diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b1b547c..131a46f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "implementation-plugin", "source": "./", "description": "Run approved implementation programs one reviewable increment at a time.", - "version": "0.1.2" + "version": "0.1.3" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 408796d..c617e0d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "implementation-plugin", "displayName": "Implementation Plugin", - "version": "0.1.2", + "version": "0.1.3", "description": "Run approved implementation programs one reviewable increment at a time.", "repository": "https://github.com/CoveMB/implementation-plugin", "skills": "./skills/" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 31447be..91e3391 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "implementation-plugin", - "version": "0.1.2", + "version": "0.1.3", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/" } diff --git a/docs/installation.md b/docs/installation.md index cfe0597..cce4651 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -258,7 +258,7 @@ Claude Code 2.1.128 or later also accepts a local `.zip` archive through the same option: ```bash -claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.2.zip +claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.3.zip ``` Neither command installs the plugin permanently. The archive must contain a @@ -266,7 +266,7 @@ valid plugin at its root. Claude Code 2.1.129 or later can also load a packaged `.zip` archive from a trusted URL for one session: ```bash -claude --plugin-url https://example.com/implementation-plugin-0.1.2.zip +claude --plugin-url https://example.com/implementation-plugin-0.1.3.zip ``` This repository does not currently publish a `.zip` archive. Do not point diff --git a/docs/maintainers.md b/docs/maintainers.md index c2aa32e..772560b 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -53,10 +53,12 @@ to reach installed users, update the synchronized package version deliberately and document the release. Do not bump versions merely to make local validation pass. -Version `0.1.2` is the current package owner for typed continuation and blocked -recovery. The frozen `tests/fixtures/program-bootstrap/v0.1.1/` tree remains -historical compatibility evidence and must not be rewritten during a version -sync. +Version `0.1.3` is the current package owner for exact regular-file Delete, +typed continuation, and blocked recovery. Synchronize the three manifest +versions, validator constant, current archive examples, and exact test +expectations without changing plugin identity or manifest field sets. The +frozen `tests/fixtures/program-bootstrap/v0.1.1/` tree remains historical +compatibility evidence and must not be rewritten during a version sync. ## Refresh platform instructions @@ -157,6 +159,12 @@ consistent, reader-document links resolve, examples retain the expected command and invocation forms, forbidden package surfaces remain blocked, and the deterministic workflow contracts pass their unit tests. +For 0.1.3, keep focused application-path coverage for descriptor-bound +same-filesystem Delete, retained quarantine receipts, v2 result-bound review and +approval, immediate and later continuation, multi-rollover tombstones, explicit +recreation, interrupted-prefix recovery, and legacy v1 reads. A native-Windows +skip must remain visible rather than becoming an unqualified platform claim. + They do not prove: - Claude runtime loading; diff --git a/docs/reference.md b/docs/reference.md index 1068dd9..6a05f3f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -43,6 +43,17 @@ observation, path dispositions, and preserved user work. New-program status cannot become authorized until this baseline and its plan-bound action authorization are durable. +Version 0.1.3 keeps accepted v1 baselines for `Create`, `Modify`, and `Preserve`. +The v2 family adds exact regular-file `Delete`, including the original file +identity and a deterministic descriptor-bound quarantine allocation. + +### Product-path result + +The ordered v2 result for every exact-file operation. A successful Delete is an +`absent` product-path state with no product digest and an exact receipt for the +retained quarantine bytes. Absence is therefore evidence-bound, not inferred +from a missing pathname. + ### Workspace binding The approved writable repository path, branch, base, current head, and recorded @@ -95,6 +106,12 @@ remain owned by Plan A. Plan B adds exact `accept-continue`, the distinct Final programs reuse the unchanged Plan A closure transaction and derive paths from `implementation-closure-storage/v1`. +Version 0.1.3 adds the setup/envelope v2 Delete family. The same ordered +product-path result is bound through execution transition, review, diff +acceptance, continuation, rollover, and discovery. An inherited absence remains +a tombstone until a successor exact plan explicitly owns `Create`; retained +quarantine evidence is not disposed of by recreation. + Every typed transaction writes controlling status last and adopts only byte-identical prefixes. A divergent prefix stops for recovery without cleanup. @@ -104,7 +121,7 @@ before relying on an earlier state. ## Approval modes Approval modes define routine interruption policy. New-model typed dispositions -in version 0.1.2 always offer `accept-stop` and conditionally offer exact +in version 0.1.3 always offer `accept-stop` and conditionally offer exact `accept-continue` for one satisfied successor. Modes do not grant action authority or automatic successor rollover. Legacy `approval:full` and `approval:full-diff` modes retain their automatic acceptance behavior. @@ -153,6 +170,8 @@ The workflow stops instead of guessing when it finds: - more than one possible program or workspace; - a source, plan, approval, status, brief, handoff, or packet digest mismatch; - a branch, base, head, path, or pre-existing-work observation that has drifted; +- an unsafe, protected, changed, cross-device, symlinked, or unsupported Delete + source or quarantine allocation; - an active or conflicted Git operation; - a requested transition that is not legal from the current state; - missing, expired, revoked, rejected, ambiguous, or mismatched authority; @@ -177,6 +196,12 @@ The bundled scripts can validate schemas, exact bindings, digests, declared state transitions, file constraints, deterministic packet structure, and specific local command evidence supplied to them. +For Delete, they can establish exact regular-file path removal by a local +same-filesystem rename into retained quarantine and verify the resulting +tombstone chain. They do not perform secure erasure, dispose of quarantine, +prove requirement-level terminal evidence, or close the program; those latter +contracts remain PLUG-002 work. + They do not prove that a reviewer was genuinely independent, a human approval was well informed, a design is semantically correct, a live service behaves as expected, or an external action occurred. Those claims still need evidence from diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md new file mode 100644 index 0000000..ad3b725 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -0,0 +1,426 @@ +# PLUG-001 Typed Delete Support Implementation Plan + +> **For implementers:** Execute task by task with strict RED-GREEN sequencing and preserve all legacy bytes. + +**Goal:** Add truthful, fail-closed support for an exact regular-file `Delete` operation from setup through accepted result, successor rollover, and fresh discovery. + +**Boundary:** PLUG-001 owns Delete setup, activation, exact-plan parsing, baseline and execution validation, protected-path enforcement, the no-data-loss bound-file quarantine transition that makes the product path absent, typed review/diff acceptance, result-bound approval, cumulative rollover, and retry/recovery discovery. It does not own chain-wide requirement attribution, requirement-specific result evidence, later-increment semantic invalidation, quarantine disposal, or complete-chain closure. + +**PLUG-002 dependency:** Terminal closure is not independently truthful until PLUG-002 adds machine-bound requirement ownership and later-increment invalidation evidence across the accepted chain, then authorizes the final disposition of retained quarantine bytes. The PLUG-001 replay ends after the Delete result is accepted, rolled into a successor, and rediscovered as resumable with quarantine intact. Do not add closure fields, requirement-result schemas, path-overlap heuristics, quarantine disposal, or fabricated ownership to make this plan appear terminal. + +**Compatibility:** Existing manifest/status v1 and v2, plus manifest-v3 programs using setup/envelope v1, retain their exact schemas, prompts, ordering, errors, and persisted bytes. PLUG-001 adds a nested v2 family only for manifest-v3 programs selecting setup/envelope v2 from sequence zero. + +**Tech stack:** Python 3 standard library, frozen dataclasses, canonical JSON/SHA-256, `unittest`, temporary Git repositories, and existing atomic/no-overwrite/status-last writers. + +--- + +## Implementation Kickoff Gate + +Before implementation: + +```bash +rtk git branch --show-current +rtk git status --short --branch +rtk git merge-base --is-ancestor b5eb689e780f48b218b807a4691f0994474e4178 HEAD +rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD +rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD +rtk shasum -a 256 docs/superpowers/plans/2026-09-05-delete-operation-support.md +``` + +Proceed only when the branch is `repair/delete-operation-support-narrowed`, the tree is clean, `b5eb689e780f48b218b807a4691f0994474e4178` is an ancestor of `HEAD`, and the aggregate candidate-to-`HEAD` diff contains only this plan. The implementation prompt must supply the final plan SHA-256 externally and the computed digest must match it. + +The plan deliberately does not embed its own digest. Do not infer the expected digest from a parent commit, earlier review, or this prose. + +## Stable Constraints + +- Use `rtk` for every repository command. +- Preserve user-owned staged, unstaged, untracked, and committed work. +- Route by exact schema pairs, never optional-field presence or whether an increment has a non-empty Delete section. +- A Delete-capable program uses its nested v2 family from the first increment; earlier increments have an empty ordered Delete section. +- Delete targets are normalized repository-relative regular files owned by the exact plan. Directories, symlinks, hard links, special files, missing deletion baselines, external paths, protected paths, and pre-existing user work are unsupported. +- Delete means accepted absence of the exact product path with `sha256: null`, bound to a manifest-owned quarantine receipt that preserves the removed bytes; it is not a secure-erasure claim. Never encode it as Modify, Preserve, omission, an empty digest, or a fabricated digest. +- `authorized` requires the exact baseline file. `implementing` permits that file or its bound absence. `reviewing`, acceptance, rollover, and later states require absence. +- Delete remains inside approved local `modify-workspace` authority. It grants no generic destructive-operation, cleanup, migration, Git, publication, deployment, provider, or external-state authority. +- Keep public plan preparation/materialization signatures unchanged. +- Preserve exact-prefix adoption, compare-and-swap, no-overwrite publication, immutable ledgers, and status-last ordering. +- Reuse existing review packets and nonfinal handoffs; do not invent a handoff addendum. +- Add no generic operations framework, Move/Rename, Replace, directory deletion, automatic restore, staging engine, or manifest/status v4. +- Record unrelated observations without enlarging this plan. + +## Confirmed PLUG-001 Defects + +1. `program_setup.py` permits only Create, Modify, and Preserve. +2. The exact-plan parser can absorb an unversioned `### Delete` section into Modify. +3. Baseline and execution validation require mutable paths to remain present; accepted results require a string digest. +4. `program_activation.py::advance_execution_state(...)` emits only transition v1 with `product_delta_sha256`. +5. Discovery validates generic state before several owned prefixes, misclassifying exact setup-v2 retries. +6. Path-shape and final-`Path` checks do not mechanically exclude normal/linked-worktree Git metadata, program/control paths, or ancestor/final swaps. +7. A final identity check followed by `os.unlink(name, dir_fd=parent_fd)` is still name-bound. A concurrent rename-and-replacement can make it irreversibly unlink an unvalidated replacement, and post-unlink checks detect the loss too late. PLUG-001 must instead atomically rename into a same-filesystem manifest-owned quarantine, validate the moved identity, and never unlink user bytes. +8. Product-result-bearing approvals and rollover actions use legacy delta fields; producers and readers need exact versioned families. +9. Rollover writes a review packet, handoff, successor brief, rollover record, and status. No v2 handoff-addendum producer exists. + +## File Map + +### Create + +- `tests/test_delete_operation_lifecycle.py` — production-writer replay through accepted Delete rollover and discovery. + +### Modify + +- `skills/implementing-staged-plans/scripts/program_setup.py` +- `skills/implementing-staged-plans/scripts/repository_preparation.py` +- `skills/implementing-staged-plans/scripts/program_activation.py` +- `skills/implementing-staged-plans/scripts/execution_discipline.py` +- `skills/implementing-staged-plans/scripts/review_coordination.py` +- `skills/implementing-staged-plans/scripts/program_review.py` +- `skills/implementing-staged-plans/scripts/diff_disposition.py` +- `skills/implementing-staged-plans/scripts/program_continuation.py` +- `skills/implementing-staged-plans/scripts/program_rollover.py` +- `skills/implementing-staged-plans/scripts/program_authority.py` +- `skills/implementing-staged-plans/scripts/program_discovery.py` +- `skills/implementing-staged-plans/scripts/state_authority.py` +- `tests/program_bootstrap_support.py` +- focused tests named per task +- canonical references, docs, and version owners named in Task 5 + +No PLUG-002 requirement-evidence or closure file is in scope. + +--- + +### Task 1: Add the Delete-Capable Setup Family and Own Sequence-Zero Discovery + +**Files:** + +- Modify: setup, activation, program-authority, discovery, and state-authority scripts +- Modify: `tests/program_bootstrap_support.py` +- Test: `tests/test_program_setup.py`, `tests/test_program_activation.py`, `tests/test_program_authority.py`, `tests/test_program_discovery.py`, `tests/test_state_authority.py` + +**Contract:** + +```python +SETUP_SEMANTICS_SCHEMA_V2 = "implementation-program-setup-semantics/v2" +OPERATION_ENVELOPE_SCHEMA_V2 = "implementation-operation-envelope/v2" +SETUP_RECAP_SCHEMA_V2 = "implementation-program-setup-recap/v2" +SETUP_RECAP_CHECKPOINT_SCHEMA_V2 = "implementation-program-setup-recap-checkpoint/v2" +SETUP_DECISION_ADAPTER_SCHEMA_V2 = "setup-approval-decision/v2" +SETUP_ACTIVATION_SCHEMA_V2 = "setup-activation-decision/v2" + +SUPPORTED_OPERATIONS_V1 = ("Create", "Modify", "Preserve") +SUPPORTED_OPERATIONS_V2 = ("Create", "Modify", "Delete", "Preserve") +DELETE_CONTENT_DISPOSITIONS = frozenset( + {"migrated", "obsolete", "intentional-discard"} +) +``` + +A Delete allocation requires one exact path, `accepted_state == "absent"`, one allowed content disposition, a non-empty rationale, program ownership, and collision `existing` or `accepted-predecessor`. `accepted-predecessor` requires a same-path Create allocation in a strict transitive predecessor; its Delete baseline must still observe a present safe file. Reject Delete-only fields on other operations. + +#### Step 1: Write RED tests + +Prove setup/envelope v2 accepts well-formed Delete; v1 rejects it with unchanged bytes; mixed families fail before publication; recap/checkpoint/adapter/activation select the exact family; the production writer emits activation v2; and setup-v1/v2 records cannot substitute for one another. Interrupt sequence-zero activation after each record: byte-exact prefixes are retry-ready, while mixed/reordered/changed started prefixes are activation-recovery-required before generic routing. + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_activation tests.test_program_authority tests.test_program_discovery tests.test_state_authority -v +``` + +Expected RED: setup rejects Delete and has no v2 activation family. + +#### Step 2: Implement and verify GREEN + +Dispatch setup validation, recap, adapter, activation writer, authority readers, and discovery from the exact setup/envelope pair. Run the same command. Do not alter setup-v1 constructors or fixtures. + +--- + +### Task 2: Add Descriptor-Bound Delete, Exact Plan/Baseline/Result, and Transition v2 + +**Files:** + +- Modify: repository-preparation, activation, discovery, and state-authority scripts +- Test: `tests/test_repository_preparation.py`, `tests/test_program_activation.py`, `tests/test_approval_checkpoint.py`, `tests/test_program_discovery.py`, `tests/test_state_authority.py` + +**Contracts:** + +```python +EXACT_FILE_MAP_SCHEMA_V2 = "implementation-exact-file-map/v2" +EXECUTION_BASELINE_SCHEMA_V2 = "implementation-execution-baseline/v2" +PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" +EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2" +DELETE_QUARANTINE_RECEIPT_SCHEMA_V1 = "implementation-delete-quarantine-receipt/v1" + +@dataclass(frozen=True) +class WorkspacePathSnapshot: + path: str + exists: bool + sha256: str | None + mode: str | None + device: int | None + inode: int | None + link_count: int | None + +@dataclass(frozen=True) +class DeleteQuarantineReceipt: + schema_version: str + program_id: str + program_revision: int + increment_id: str + path: str + baseline_sha256: str + device: int + inode: int + quarantine_path: str + quarantine_sha256: str + final_state: str +``` + +V2 exact maps have ordered Create, Modify, Delete, and Preserve sections. Unversioned Delete fails explicitly. V2 result order is operation-section then exact-map order; v1 keeps lexical ordering and bytes. + +#### Step 1: Write RED tests + +Test v2 parsing including empty Delete; unversioned Delete rejection; present regular-file baseline; manifest-owned quarantine allocation; typed absent/null-digest result with its exact quarantine-receipt binding; authorized/implementing/reviewing/accepted rules; transition-v2 product-result fields without `product_delta_sha256`; family-specific seed/adoption/recovery; and production output through fresh authority/discovery. + +Use normal and linked worktrees. Reject lexical `.git`, Git directory/common directory, conventional/actual program roots, manifest control paths, symlinked ancestors/finals, protected identity aliases, hard links, directories, special files, and ancestor/final/content swaps. Require the quarantine root and receipt to be manifest-owned protected control paths, and reject caller-selected or symlinked quarantine locations. Allow `.github`, `.gitignore`, and ordinary names containing `git`. + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_repository_preparation tests.test_program_activation tests.test_approval_checkpoint tests.test_program_discovery tests.test_state_authority -v +``` + +Expected RED: no v2 map/baseline/result/transition or protected descriptor path exists. + +#### Step 2: Implement one descriptor-relative identity path + +From a fresh repository inspection, normalize one relative POSIX path; reject absolute/dot/backslash/Git/control paths; open workspace and ancestors with `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC` and `dir_fd`; open final with `O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC`; match descriptors to parent-relative no-follow stats and protected identities; require regular type and `st_nlink == 1`; hash the held final descriptor; compare pre/post `fstat`; and revalidate the held chain. Only descriptor-relative `ENOENT` means absence. + +No setup-v2 authorization may use `Path.resolve()`, `is_file()`, `read_bytes()`, or a separate check-then-open target. + +The product-path transition uses production `quarantine_bound_regular_file(...)`, never test-side `Path.unlink()` and never `os.unlink()` on product or quarantine bytes. During plan materialization, allocate a deterministic per-target quarantine entry and receipt beneath the current increment's manifest-owned storage. The quarantine directory is created as a private regular directory, is included in required future lifecycle writes, and is added to the protection context so it can never be a product Delete target. The entry name derives from the canonical program/revision/increment/path/baseline binding; callers cannot choose it. + +At mutation time, open the quarantine directory with the same descriptor-relative no-follow rules, require its recorded owner/mode/identity, and compare its `st_dev` with the held product parent and target before changing either namespace. A mismatch, unavailable atomic rename, or `EXDEV` is a pre-mutation fail-closed stop. Require the deterministic quarantine entry and receipt to be absent, repeat the held target walk/hash, and require matching device/inode/mode/digest plus `st_nlink == 1`. Then call one same-filesystem `os.rename(source_name, quarantine_name, src_dir_fd=source_parent_fd, dst_dir_fd=quarantine_fd)`. This operation may move a raced replacement, but it cannot destroy its bytes. + +After rename, open the quarantine entry through the held quarantine descriptor and require its device/inode/mode/digest to equal the already-open validated target. Revalidate every held ancestor, require the source name to be absent, and require no replacement to have appeared. Only then persist canonical `DeleteQuarantineReceipt` bytes with no-overwrite/status-last semantics and return success. The v2 product result contains ordered `delete_quarantine_bindings` entries `{path, receipt_path, receipt_sha256}` alongside its ordered path states; its canonical digest therefore binds both the tombstone and preserved bytes. Review, approval, continuation, rollover, authority, and discovery must reproduce that binding. + +Recovery classifies the existing authorized action as the immutable intent. Source exact + empty quarantine means retry-ready; source absent + exact quarantined identity + missing receipt means receipt-adoption-ready; source absent + exact quarantine + exact receipt means resume. A pre-rename replacement moved into quarantine, a post-rename replacement at the source name, both names present, wrong quarantine bytes/identity, unexpected receipt, or missing source and quarantine is recovery-required. Never delete, overwrite, or automatically restore either name during classification. Report the exact source/quarantine identities so separately authorized recovery can preserve both byte sequences. + +If required descriptor or same-filesystem atomic-rename primitives are unavailable, fail before mutation with `descriptor-relative no-follow Delete quarantine is unsupported on this platform`. No copy fallback, cross-device move, or path fallback is allowed; legacy families do not allocate or inspect quarantine. + +#### Step 3: Implement exact baseline/result/transition and verify GREEN + +Add v2 dataclasses rather than widening v1. The product-path-states v2 digest covers both `ordered_path_states` and ordered `delete_quarantine_bindings`; every absent Delete state has exactly one matching canonical receipt, while non-Delete states have none. Pair only baseline v1 + delta v1 + transition v1, or baseline v2 + path-states-plus-quarantine v2 + transition v2. Reconstruct the pair at activation, reassessment, retry, authority, and discovery. Run the Step 1 command. + +--- + +### Task 3: Carry Typed Delete Through Review and Exact Diff Approval + +**Files:** + +- Modify: `skills/implementing-staged-plans/scripts/execution_discipline.py` +- Modify: `skills/implementing-staged-plans/scripts/review_coordination.py` +- Modify: `skills/implementing-staged-plans/scripts/program_review.py` +- Modify: `skills/implementing-staged-plans/scripts/diff_disposition.py` +- Modify: `skills/implementing-staged-plans/scripts/program_authority.py` +- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` +- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` +- Test: `tests/test_execution_discipline.py` +- Test: `tests/test_review_coordination.py` +- Test: `tests/test_program_review.py` +- Test: `tests/test_diff_disposition.py` +- Test: `tests/test_program_authority.py` +- Test: `tests/test_program_discovery.py` +- Test: `tests/test_state_authority.py` + +**Contracts:** + +- `implementation-review-evidence/v2` +- `implementation-review-packet/v2` +- `implementation-review-preparation/v2` +- `implementation-review-remediation/v2` +- `implementation-diff-disposition-binding/v2` +- `implementation-diff-disposition-command/v2` +- `implementation-approval/v3` only for setup-v2 result-bearing diff approval + +Review evidence v2 binds `{schema_version, sha256, ordered_path_states, delete_quarantine_bindings}` as `product_result`. It reopens and verifies every receipt and quarantined identity before accepting absence. It contains no requirement-result object; PLUG-002 owns that evidence. + +Exact setup-v2 accept-stop approval order: + +```python +SETUP_V2_DIFF_APPROVAL_FIELDS = ( + "schema_version", "event_id", "type", "decision", "scope", + "diff_decision", "checkpoint_id", "base_seed_sha256", + "submitted_prompt_sha256", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "increment_id", "brief_sha256", + "exact_file_plan_sha256", "approval_mode", "workspace", + "review_evidence_sha256", "review_packet_sha256", + "verification_sha256", "execution_baseline_sha256", + "product_result_schema_version", "product_result_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) +``` + +Accept-continue adds only `successor_increment_id` and `successor_authority_projection_sha256`. + +#### Step 1: Write RED tests + +Drive one quarantined absent result through production review/diff writers. Assert the exact state and receipt binding through evidence, packet, preparation, command, approval, and status. Reject reappearance, changed/reordered/omitted states or receipts, missing/wrong quarantine bytes, stale remediation, prompt mismatch, malformed/dual records, approval-v2 on setup-v2, and approval-v3 on setup-v1. Approval v3 binds the complete product-result digest and omits `accepted_product_delta_sha256`. Review/acceptance prefixes classify before generic routing; setup-v1 bytes remain exact. Repeat protected source/quarantine assessment at each entry. + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_execution_discipline tests.test_review_coordination tests.test_program_review tests.test_diff_disposition tests.test_program_authority tests.test_program_discovery tests.test_state_authority -v +``` + +Expected RED: v2 review/diff and result-bound approval do not exist. + +#### Step 2: Implement and verify GREEN + +Select v2 review/diff only from the validated v2 result. Binding, command, status, approval tuple, and seed carry its schema/digest. Reject cross-family substitution before writes; preserve v1 prompts/bytes. Run the same command. + +--- + +### Task 4: Preserve the Tombstone Through Rollover and Discovery + +**Files:** + +- Modify: `skills/implementing-staged-plans/scripts/program_continuation.py` +- Modify: `skills/implementing-staged-plans/scripts/program_rollover.py` +- Modify: `skills/implementing-staged-plans/scripts/program_authority.py` +- Modify: `skills/implementing-staged-plans/scripts/program_activation.py` +- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` +- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` +- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` +- Modify: `tests/program_bootstrap_support.py` +- Create: `tests/test_delete_operation_lifecycle.py` +- Test: `tests/test_program_continuation.py` +- Test: `tests/test_program_rollover.py` +- Test: `tests/test_multi_increment_lifecycle.py` +- Test: `tests/test_program_activation.py` +- Test: `tests/test_program_authority.py` +- Test: `tests/test_program_discovery.py` +- Test: `tests/test_state_authority.py` + +**Contracts:** + +- `implementation-accepted-state-continuation-binding/v2` +- `implementation-successor-authority-projection/v2` +- `implementation-action-authorization/v3` only for setup-v2 result-bearing rollover +- `implementation-increment-rollover/v2` +- `implementation-increment-rollover-binding/v2` +- `implementation-inherited-workspace/v2` + +Exact setup-v2 rollover action order: + +```python +SETUP_V2_ROLLOVER_ACTION_FIELDS = ( + "schema_version", "authorization_id", "decision", "actions", "scope", + "constraints", "excluded", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "current_increment_id", + "successor_increment_id", "continuation_domain", + "continuation_checkpoint_id", "accepted_status_sha256", + "accepted_status_sequence", "product_result_schema_version", + "product_result_sha256", "workspace", "submitted_prompt_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) +``` + +#### Step 1: Write RED rollover and application-path tests + +Prove immediate/later continuation retains v2 diff binding and embeds the exact result plus quarantine-receipt bindings; cumulative states replace owned paths in place and append in result order; tombstones and their quarantine receipts survive unrelated successors; only explicit Create from inherited absence recreates the product path without consuming or deleting quarantined bytes; rollover v2 copies review evidence/packet, full diff binding, unique approval-v3 binding, existing handoff, result pair, quarantine bindings, and cumulative digest; no addendum exists; action v3 uses the exact tuple and omits `accepted_product_delta_sha256`; malformed/stale/cross-family prefixes recover before later writes; and setup-v1 bytes remain exact. + +Add one replay using production writers: setup-v2 publication and activation, authorized Delete plan, `quarantine_bound_regular_file("legacy.ts")`, production review and accept-continue, completed rollover, exact inherited absent state with its receipt binding, fresh authority, and discovery `resume`. Assert the source path is absent, the manifest-owned quarantine entry retains the exact original bytes, and no unlink call occurs. The fixture never directly renames, unlinks, or hand-writes records. Negative variants cover normal and linked worktrees, cross-device preflight, caller-selected/symlinked quarantine, hard links, source or quarantine swaps before rename, source replacement after rename, changed content, crash-before-receipt adoption, and divergent recovery without data loss. + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_continuation tests.test_program_rollover tests.test_multi_increment_lifecycle tests.test_program_activation tests.test_program_authority tests.test_program_discovery tests.test_state_authority tests.test_delete_operation_lifecycle -v +``` + +Expected RED: accepted absence cannot survive current continuation/rollover. + +#### Step 2: Implement and verify GREEN + +Load accepted results only from exact review/diff/approval-v3 state. Carry the result pair and ordered quarantine bindings through continuation, projection, action-v3, grant, rollover, inherited workspace, and status. Merge without sorting: replace owned paths in place, append new paths, reject duplicates. Freshly validate present digests; for each absent tombstone, require source absence plus its exact protected quarantine receipt and bytes. Validate existing handoff and copied evidence on every completed-chain read. Classify exact source/quarantine/receipt and transaction prefixes before full authority and generic routing. + +Do not add requirement ownership or closure. Run the Step 1 command. + +--- + +### Task 5: Synchronize PLUG-001 Documentation and Package Version + +**Files:** + +- Modify: `skills/implementing-staged-plans/SKILL.md`, `skills/implementing-staged-plans/agents/openai.yaml` +- Modify: `skills/implementing-staged-plans/references/repository-preparation.md` +- Modify: `skills/implementing-staged-plans/references/execution-discipline.md` +- Modify: `skills/implementing-staged-plans/references/review-coordination.md` +- Modify: `skills/implementing-staged-plans/references/state-authorization.md` +- Modify: `skills/implementing-staged-plans/references/program-authority.md` +- Modify: `skills/implementing-staged-plans/references/program-discovery.md` +- Modify: `docs/reference.md`, `docs/workflows.md`, `docs/troubleshooting.md` +- Modify: `implementing-staged-plans-bootstrap-execution-review-runbook.md` +- Modify: `docs/installation.md`, `docs/maintainers.md` +- Modify: `.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` +- Modify: `skills/implementing-staged-plans/scripts/validate_package.py` +- Test: `tests/test_front_door_contract.py`, `tests/test_distribution_documentation.py`, `tests/test_package_validation.py` + +Set existing version owners to `0.1.3` without changing plugin identity or manifest field sets. + +Document once at canonical owners: v1 versus v2 operations; same-filesystem descriptor-bound quarantine and local authority limit; exact transition/review/diff/continuation/rollover families; approval-v3/action-v3 result bindings; Git/program/control/quarantine protection and unsupported or cross-device stops; absent results bound to retained quarantine bytes, deterministic recovery, tombstones, and explicit recreation; reuse of existing handoff; no secure-erasure claim; and PLUG-002 requirement-evidence/quarantine-disposal/closure dependency. + +#### Step 1: Update expectations and observe RED + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_front_door_contract tests.test_distribution_documentation tests.test_package_validation -v +``` + +#### Step 2: Synchronize and verify GREEN + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_front_door_contract tests.test_distribution_documentation tests.test_package_validation -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +``` + +--- + +## Final Focused Verification and Claim Gate + +Run once on the unchanged candidate: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_repository_preparation tests.test_program_activation tests.test_approval_checkpoint tests.test_execution_discipline tests.test_review_coordination tests.test_program_review tests.test_diff_disposition tests.test_program_continuation tests.test_program_rollover tests.test_multi_increment_lifecycle tests.test_program_authority tests.test_program_discovery tests.test_state_authority tests.test_delete_operation_lifecycle tests.test_front_door_contract tests.test_distribution_documentation tests.test_package_validation -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +rtk git diff --check +``` + +Record exact counts, skips, and platform limitations. Interrupted or partial output is not a pass. Do not run the unrelated full suite. + +Limit completion claims to exact regular-file product-path removal under setup/envelope v2; no-data-loss same-filesystem quarantine; typed absence bound to retained bytes through review, acceptance, rollover, and discovery; exact result-bound records; and focused legacy compatibility. + +Do not claim secure erasure, quarantine disposal, PLUG-002 requirement ownership, semantic invalidation, terminal closure, Move/Rename, Replace, directory deletion, automatic rollback, external migration, deployment, or untested-platform support. + +## Failure and Recovery + +- Before product mutation, adopt only byte-identical prefixes; divergence stops without cleanup. +- A cross-device or capability failure stops before rename and leaves the product file unchanged. +- A pre-rename replacement can be moved only into the protected deterministic quarantine slot; identity mismatch stops, preserves its bytes, and yields no receipt. +- After a matching rename, source replacement, quarantine change, or receipt interruption yields recovery-required or receipt-adoption-ready without unlinking, overwriting, or restoring either name. +- Successful product-path absence is a valid implementing partial result only with the exact quarantine receipt and retained bytes. +- Review, approval, and rollover reproduce the ordered absent state and quarantine binding. Reappearance, omission, reorder, mixed schemas, changed receipt, or changed quarantine bytes stops. +- Rollover preserves the tombstone and retained quarantine binding until a later exact Create owns the product path from inherited absence; recreation does not dispose of the quarantine. +- PLUG-001 does not dispose of quarantine or close the program; PLUG-002 must add requirement-specific accepted-chain evidence and terminal disposition first. + +## Validation Matrix + +| Requirement | Owner | Required evidence | Failure signal | +| --- | --- | --- | --- | +| Stable kickoff | Git preflight | branch, clean tree, candidate ancestry, plan-only aggregate scope, external final digest | stop | +| Setup truth | setup | exact setup/envelope v2 and Delete facts | mixed/unsupported schema | +| Discovery ownership | discovery | exact prefix retry/recovery before generic routing | misclassification | +| Protected identity | repository preparation | normal/linked Git, program/control/quarantine, link/swap coverage | protected access | +| Actual Delete | bound quarantine helper | held identity/digest, same-device atomic rename, exact receipt, absent source, retained bytes, zero unlink calls | pre-mutation stop or recovery-required | +| Plan/result truth | activation | ordered map/baseline and absent/null result | fabricated state | +| Transition | activation/authority | exact transition-v2 pair and seed | malformed family | +| Review/acceptance | review/diff | exact v2 evidence binds tombstone and quarantine receipt; approval-v3 binds its digest | stale/legacy/quarantine mismatch | +| Rollover | continuation/rollover | action-v3, existing evidence/handoff, tombstone and retained quarantine binding | addendum/stale state | +| Application path | lifecycle regression | production setup through rollover and discovery resume | hand-written state | +| Legacy compatibility | focused controls | unchanged v1 bytes and routes | drift | +| PLUG-002 boundary | docs/tests | no requirement-result or terminal closure claim | false terminal claim | +| Package | existing owners | `0.1.3`, focused tests, validator zero | mismatch | diff --git a/docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md b/docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md new file mode 100644 index 0000000..dd07b0d --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md @@ -0,0 +1,559 @@ +# Delete Activation Final Review Repairs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This planning task prohibits subagents and production repairs. Future implementation and fresh independent review require a separate execution instruction. + +**Goal:** Repair the three revalidated Delete lifecycle defects without changing v1 records, public interfaces, or the PLUG-001 ownership boundary. + +**Architecture:** Guard the existing Darwin rename-symbol initialization at its owner. Bind completed v2 rollover review bytes to both the retained disposition and its unique approval. Replace activation's substring exclusion with an exact, path-bound exception for independently classified interrupted Delete moves; all other authority issues remain blocking before a move or receipt adoption. + +**Tech Stack:** Existing Python 3 standard library, `unittest`, subprocesses, temporary Git repositories, canonical JSON/SHA-256, descriptor-relative filesystem inspection, and status-last persistence. No new dependencies, schemas, CLI switches, or production files. + +**Spec:** The bounded repair request is reproduced in the scope and acceptance criteria below. The controlling PLUG-001 contract is `docs/superpowers/plans/2026-09-05-delete-operation-support.md`, especially its Boundary, Tasks 2–4, security invariants, and completion limits. `docs/superpowers/specs/2026-08-23-program-setup-and-activation-design.md` supplies the existing setup/activation contract. Read both before execution; historical implementation checkboxes do not authorize additional work. + +## Global constraints and authority + +- Planning source: independent review task `01a07f27-0244-7191-b540-eed968f318bb`, reviewed head `a3168c367633c960534d84bb47d1632123c1ea24`, base `00c04a0f1c1ebb2cbdf890c4c4cf0334f89a344e`. +- Revalidation checkout: `/Users/CoveMB/.codex/worktrees/c034/implementation-plugin`, clean detached HEAD at that exact head. Local `origin/main` resolved to the exact base. Remote refs were not refreshed: the requested comparison is immutable and local. +- This task authorizes one new plan file and a local commit containing only that file. It does not authorize production/test edits, execution of this implementation plan, push, PR creation, review-comment replies, or subagent dispatch. +- During later execution, preserve staged, unstaged, untracked, and committed user work. Inspect branch/status before Git work; use the approved checkout, do not invent a branch, stash, reset, clean, amend, or overwrite user changes. Local implementation commits below are boundaries for the future execution authorization, not authorization supplied by this document itself. +- Commands below run from the repository root. Prefix shell commands with `rtk`; use `rtk env PYTHONDONTWRITEBYTECODE=1` for Python. Use `apply_patch` for edits. Do not write bytecode or temporary probes into the package. +- Keep setup/envelope family selection exact: `implementation-program-setup-semantics/v2` with `implementation-operation-envelope/v2`. Do not reinterpret persisted family hints. Preserve v1 constructors, canonical record bytes, field order, prompts, routes, and failure behavior. +- Keep Delete as exact normalized regular-file absence with null digest, bound to the manifest-owned receipt and retained bytes. Preserve descriptor-relative no-follow inspection, identity/mode/owner checks, hard-link/protected-path rejection, same-filesystem no-replace movement, and status-last publication. +- Preserve the precise recovery classes: `retry-ready`, `receipt-adoption-ready`, `resume`, and `recovery-required`. Never turn a malformed prefix or a divergent source/quarantine/receipt into valid recovery. +- Unsupported Delete primitives must stop with `descriptor-relative no-follow Delete quarantine is unsupported on this platform`. No path-based fallback, copy-and-delete, replacement rename, cross-device move, or new Windows/Linux Delete backend. +- No PLUG-002 requirement ownership, requirement-result evidence, later-increment semantic invalidation, complete-chain closure, quarantine disposal, secure erasure, automatic restoration, broad refactoring, API redesign, package version bump, or unrelated cleanup. +- A local simulator cannot establish native-Windows behavior. Keep the native-Windows limitation and skipped tests visible in the final receipt. Do not start hosted jobs or transmit source for external review without separate authorization. + +## Revalidation and bounded disposition + +Revalidation used unmodified production modules and temporary fixture repositories on macOS (`sys.platform == "darwin"`), Python 3.14.6. All three findings are retained, with the following limits. These are defect probes, not evidence that the future repairs pass. The full suite and package validator were not rerun for this plan-only task. + +Planning validation also parsed all seven Python code blocks and resolved every unittest selector against existing definitions or the test definitions below. The test excerpts were executed without installing them in the repository: six F2 rebinding variants were accepted unexpectedly, both F3 trigger filenames reached movement, and the F3 receipt-prefix case reached adoption. The F1 simulator initially exposed a test-harness `_winapi` import error; after preloading native standard-library backends, both platform variants failed at the intended `CDLL` `TypeError`. Only that changed excerpt was rerun. No repair was applied and no GREEN result is claimed. + +### F1 — Non-Darwin imports depend on a Darwin rename library + +**Owner:** `skills/implementing-staged-plans/scripts/state_authority.py:21–32`, module initialization; `_rename_without_replacement` at line 4582 consumes `_RENAMEATX_NP`. `program_discovery.py` imports this authority chain before choosing a program family. + +**Verified:** Module import calls `_ctypes.CDLL(None, use_errno=True)` unconditionally, before `_WINDOWS` and the named-mutex backend are selected. A fresh-process import of `program_discovery` with `ctypes.CDLL` raising the Windows loader's `TypeError` failed at that import. This affects entry-point availability independently of whether the selected program uses Delete. + +**Current primary-source check, 2026-09-08:** [CPython 3.13 Windows loader](https://github.com/python/cpython/blob/3.13/Modules/_ctypes/callproc.c#L1296-L1303) parses a Unicode library name; its POSIX loader separately permits `None`. [Python ctypes documentation](https://docs.python.org/3/library/ctypes.html#loading-shared-libraries) describes the platform-specific loaders. These support the import-risk finding; native Windows was not executed here. The simulated exception is evidence of propagation through the application import path, not a native-Windows test. + +**Narrow repair:** Initialize the optional `renameatx_np` library only on Darwin. Keep the current signature setup and unavailable-symbol fail-closed path. Do not redesign locking or assert that guarding this import implements Delete on Windows or Linux. + +### F2 — Completed v2 rollover loses review approval hash binding + +**Owner:** `skills/implementing-staged-plans/scripts/program_rollover.py`, `_validated_completed_rollover_records`, v2 branch around lines 1692–1840. Public `validated_inherited_paths` reaches it through `_validated_inherited_paths`. `validate_state_authority` and fresh-process discovery consume this chain. + +**Verified executable probe:** `_complete_delete_rollover()` produced an accepted Delete and successor using production writers. The probe changed `review_packet.changes_and_rationale` in retained evidence, rendered its matching packet through `review_coordination.render_review_packet`, and verified `validate_review_bundle(...) == []`. It updated the current file digests in both rollover-level and embedded `accepted_diff_binding` review bindings, then rebound the status's rollover-row digest. It left the approval log and disposition's approved review hashes untouched. + +Observed result: `approval_bytes_unchanged: true`, `authority_issues: []`, discovery `disposition: "resume"`, `stop_required: false`, `issues: []`. Existing tests detect changed files when their recorded digests are stale; they do not cover this coordinated rebinding. + +**Narrow repair:** Compare the freshly retained evidence/packet hashes with the exact hashes already carried by the disposition and the unique, canonically hashed approval. Apply only inside the v2 completed-history branch, for both `accept-continue` and `accept-stop` followed by later continuation. This is consistency validation inside the existing local authority model; it does not make fully rewritten local authority cryptographically unforgeable. + +### F3 — Filename text controls whether pre-mutation authority failures block + +**Owner:** `skills/implementing-staged-plans/scripts/program_activation.py`, `advance_execution_state`, lines 2200–2226. The legitimate recovery-only warning originates in `repository_preparation.validate_execution_workspace_v2`, lines 1838–1846, because persisted status is still `authorized` after an interrupted move. + +**Verified executable probe:** Create an authorized Delete through `_authorized_delete_program_with_successor()`, add one unmapped file, and call the real `advance_execution_state(root, "implementing", fresh_observation)`. + +| Unmapped filename | Raised error | Source after rejection | Quarantine | Status | +| --- | --- | --- | --- | --- | +| `unmapped.txt` | `execution workspace has unmapped dirty paths: unmapped.txt` | Present | No entry | Unchanged, authorized | +| `unmapped-Delete.txt` | Same error with that filename | Absent | Original bytes retained | Unchanged, authorized | +| `unmapped-quarantine.txt` | Same error with that filename | Absent | Original bytes retained | Unchanged, authorized | + +**Narrow repair:** Do not remove recovery support. The existing classifier independently validates exact baseline/source/quarantine/receipt state. Exempt only the complete authorized-source warning for an exact Delete path whose classification is `receipt-adoption-ready` or `resume`. Do not exempt anything for `retry-ready`, any inspection failure, generic Delete/quarantine text, or a different path. This does not promise atomicity across multiple files or automatic rollback after an actual filesystem race; it restores the pre-mutation authority barrier for observed invalid input. + +**Recovery counterprobe:** Interrupting the production receipt writer after movement and interrupting the production status writer after receipt publication each left exactly `authorized Delete source does not match baseline: legacy.ts` as the authority issue. Both exact retries reached `implementing` on the original code. These observations constrain the exception to one complete diagnostic and protect against an overbroad repair that simply rejects every interrupted move. + +## Exact change inventory + +| File | Responsibility and permitted changes | +| --- | --- | +| `skills/implementing-staged-plans/scripts/state_authority.py` | F1 only: guard existing library initialization; keep public functions and record formats unchanged. | +| `tests/test_state_authority.py` | F1 fresh-process import/legacy-reader regression; use existing mutex, unsupported-primitives, descriptor, and native-Windows tests for related coverage. | +| `skills/implementing-staged-plans/scripts/program_rollover.py` | F2 only: review-hash consistency inside `_validated_completed_rollover_records` v2 completed-chain branch. | +| `skills/implementing-staged-plans/scripts/program_activation.py` | F3 only: replace broad issue substring exclusion in `advance_execution_state`. | +| `tests/test_delete_operation_lifecycle.py` | F2 production-generated completed-chain rebinding matrix; F3 real execution-transition no-mutation and interrupted-recovery regressions. Reuse existing fixtures. | + +Read-only context: `tests/program_bootstrap_support.py`, `tests/script_module_support.py`, `tests/test_program_activation.py`, `tests/test_program_discovery.py`, `tests/test_program_rollover.py`, `tests/test_program_continuation.py`, `tests/test_multi_increment_lifecycle.py`, `repository_preparation.py`, `diff_disposition.py`, `program_continuation.py`, `program_discovery.py`, `review_coordination.py`, and `docs/maintainers.md`. Script basenames in this paragraph are under `skills/implementing-staged-plans/scripts/`. These are not additional write authority. No production helper extraction or test-fixture redesign is needed. + +## Task 1 — Isolate the Darwin-only import without changing legacy behavior + +**Files:** Modify `state_authority.py` and `tests/test_state_authority.py` from the inventory above. + +**Interfaces:** `_RENAMEATX_NP` remains a ctypes callable or `None`; `_rename_without_replacement(...) -> None` keeps its signature and current unsupported error. `validate_state_authority(program_root, observation) -> list[str]` remains unchanged. Later tasks consume no new interface. + +- [ ] **1. Record execution preflight.** Read the controlling plan/spec and this plan. Verify current checkout and proposed changes before editing: + +```bash +rtk git status --short --branch +rtk git rev-parse HEAD +rtk git diff --name-status +rtk git diff --cached --name-status +rtk proxy python3 --version +``` + +Expected: the three production owners still match the revalidated head, or only previously completed tasks in this plan have changed them. A new implementation head requires revalidation before proceeding. Preserve any unrelated changes; do not assume a clean index. Do not fetch solely for this fixed-head repair. + +- [ ] **2. Add this application import/legacy-reader regression.** Add `subprocess`, `sys`, and `textwrap` imports to `tests/test_state_authority.py` and append this test class. The subprocess prevents already-loaded modules from hiding the import defect. It mocks the loader boundary only, then executes the existing real v1 authority test and imports production entry points. `os.name` is deliberately left native; this is not a simulated Windows filesystem or mutex certification. + +```python +class PlatformImportCompatibilityTests(unittest.TestCase): + def test_non_darwin_imports_keep_legacy_authority_available(self) -> None: + script = textwrap.dedent(""" + import ctypes + import pathlib + import shutil + import subprocess + import sys + import tempfile + import unittest + from unittest import mock + + # Initialize native standard-library backends before spoofing only + # the optional application library-selection condition. + selected_platform = sys.argv[1] + sys.path.insert(0, sys.argv[2]) + with mock.patch.object(sys, "platform", selected_platform): + with mock.patch.object( + ctypes, "CDLL", + side_effect=TypeError("Windows loader requires a string"), + ) as loader: + from tests.test_state_authority import WorkspaceAndBindingTests + import program_discovery + import program_activation + suite = unittest.TestSuite([ + WorkspaceAndBindingTests( + "test_valid_state_authority_and_workspace_pass" + ) + ]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + loader.assert_not_called() + if not result.wasSuccessful(): + raise SystemExit(1) + """) + for selected_platform in ("win32", "linux"): + with self.subTest(platform=selected_platform): + completed = subprocess.run( + [sys.executable, "-c", script, selected_platform, str(SCRIPT_ROOT)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual( + completed.returncode, 0, + completed.stdout + completed.stderr, + ) +``` + +- [ ] **3. Run RED.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_state_authority.PlatformImportCompatibilityTests -v +``` + +Expected on the original head: both subtests fail because a fresh authority import invokes `CDLL` and raises `TypeError`. A missing import, broken test fixture, or collection error is not the intended RED; repair the test harness before touching production. + +- [ ] **4. Make the smallest production change.** Replace only the `_LIBC` assignment; retain `getattr(_LIBC, "renameatx_np", None)`, argtypes, restype, `_RENAME_EXCL`, and all mutex code as they are: + +```python +_LIBC = _ctypes.CDLL(None, use_errno=True) if sys.platform == "darwin" else None +``` + +No exception swallowing is required to repair the Windows claim: the unsupported platform must never call this loader. Darwin loader failures remain visible, and a missing symbol still causes the existing unsupported-operation stop. + +- [ ] **5. Run GREEN and focused compatibility.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_state_authority.PlatformImportCompatibilityTests tests.test_state_authority.WorkspaceAndBindingTests tests.test_state_authority.AtomicAuthorityWriterTests tests.test_state_authority.DescriptorRelativeWorkspacePathTests tests.test_state_authority.DeleteQuarantineTests -v +``` + +Expected on macOS: all executed tests pass; the existing native-Windows-only test is visibly skipped. The real Delete movement and collision tests exercise the preserved Darwin callable; mocks do not substitute for those passes. + +- [ ] **6. Commit this coherent change if execution authority includes local commits.** Review only these files and ensure no unrelated staged work is included: + +```bash +rtk git diff --check +rtk git diff -- skills/implementing-staged-plans/scripts/state_authority.py tests/test_state_authority.py +rtk git add -- skills/implementing-staged-plans/scripts/state_authority.py tests/test_state_authority.py +rtk git diff --cached --name-status +rtk git commit --only -m "fix: isolate Darwin Delete loader initialization" -- skills/implementing-staged-plans/scripts/state_authority.py tests/test_state_authority.py +``` + +Expected commit scope: exactly the two listed files. Record full commit ID, test command, exit status, pass count, and skips. Otherwise leave the reviewed changes uncommitted at the authorized gate. + +## Task 2 — Bind retained review files to the completed rollover approval + +**Files:** Modify `program_rollover.py` and `tests/test_delete_operation_lifecycle.py` from the inventory. + +**Interfaces:** `_validated_completed_rollover_records(program_root, status, *, allow_unbound_suffix) -> tuple[dict[str, object], ...]` keeps its existing signature and result. It raises `ValueError` on inconsistency; `_validated_inherited_paths`, `validated_inherited_paths`, `validate_state_authority`, and discovery retain their existing routing/error contracts. No new fields or writers. + +- [ ] **1. Add the complete rebinding regression below** to `DeleteOperationLifecycleTests`. Add `import copy` at the top. This starts from a real accepted rollover; negative variants alter retained artifacts only after production has generated them. Keep the existing copied-file and approval-tampering tests. + +```python +def test_completed_rollover_binds_review_hashes_to_approval_and_disposition(self): + fixture, legacy_bytes, allocation, _receipt = _complete_delete_rollover() + try: + root = fixture.program_root + status_path = root / "state/status.json" + rows_path = root / "state/rollovers.jsonl" + approvals_path = root / "state/approvals.jsonl" + original_status = json.loads(status_path.read_text()) + original_rows = [json.loads(line) for line in rows_path.read_text().splitlines()] + original_row = original_rows[-1] + evidence_path = root / original_row["review_evidence_binding"]["path"] + packet_path = root / original_row["review_packet_binding"]["path"] + originals = { + path: path.read_bytes() + for path in (status_path, rows_path, approvals_path, evidence_path, packet_path) + } + cases = ( + "replace-bundle", + "replace-bundle-and-disposition", + "disposition-evidence", + "disposition-packet", + "approval-evidence", + "approval-packet", + ) + for case in cases: + with self.subTest(case=case): + try: + status = copy.deepcopy(original_status) + rows = copy.deepcopy(original_rows) + row = rows[-1] + accepted = row["accepted_diff_binding"] + disposition = accepted["diff_disposition_binding"] + if case.startswith("replace-bundle"): + import review_coordination as coordination + + evidence = json.loads(originals[evidence_path]) + evidence["review_packet"]["changes_and_rationale"] = [ + "Replacement created after the retained approval." + ] + packet = coordination.ReviewPacket( + **coordination._tuple_fields( + evidence["review_packet"], coordination.PACKET_FIELDS + ) + ) + packet_text = coordination.render_review_packet(packet) + self.assertEqual( + coordination.validate_review_bundle(evidence, packet_text), [] + ) + evidence_path.write_bytes(ACTIVATION._canonical_json_bytes(evidence)) + packet_path.write_text(packet_text, encoding="utf-8") + for stem, path in (("review_evidence", evidence_path), + ("review_packet", packet_path)): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + row[stem + "_binding"]["sha256"] = digest + accepted[stem + "_binding"]["sha256"] = digest + if case == "replace-bundle-and-disposition": + disposition[stem + "_sha256"] = digest + self.assertEqual(approvals_path.read_bytes(), originals[approvals_path]) + elif case.startswith("disposition-"): + stem = "review_evidence" if case.endswith("evidence") else "review_packet" + disposition[stem + "_sha256"] = "0" * 64 + else: + approvals = [json.loads(line) for line in originals[approvals_path].splitlines()] + approval = next( + item for item in approvals + if item.get("event_id") == accepted["diff_approval_binding"]["event_id"] + ) + stem = "review_evidence" if case.endswith("evidence") else "review_packet" + approval[stem + "_sha256"] = "0" * 64 + + def approval_line(value): + return (json.dumps(value, ensure_ascii=False, + separators=(",", ":"), sort_keys=False) + + "\n").encode("utf-8") + + approvals_path.write_bytes(b"".join(approval_line(item) for item in approvals)) + accepted["diff_approval_binding"]["sha256"] = hashlib.sha256( + approval_line(approval) + ).hexdigest() + rows_path.write_bytes(b"".join(ACTIVATION._canonical_json_line(item) for item in rows)) + status["rollover_binding"]["rollover_sha256"] = hashlib.sha256( + ACTIVATION._canonical_json_line(row) + ).hexdigest() + status_path.write_bytes(ACTIVATION._canonical_json_bytes(status)) + observation = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, "rollover review .* approval binding mismatch"): + ROLLOVER.validated_inherited_paths(root, status, observation) + self.assertTrue(ACTIVATION.validate_state_authority(root, observation)) + discovery = run_program_discovery(fixture.repository) + self.assertTrue(discovery["stop_required"]) + self.assertNotEqual(discovery["disposition"], "resume") + self.assertEqual(repository_snapshot(fixture.repository), before) + self.assertEqual((root / allocation["entry_path"]).read_bytes(), legacy_bytes) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + finally: + for path, content in originals.items(): + path.write_bytes(content) + observation = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + self.assertEqual(ACTIVATION.validate_state_authority(root, observation), []) + self.assertEqual(run_program_discovery(fixture.repository)["disposition"], "resume") + finally: + fixture.close() +``` + +The per-case restoration above is confined to an owned temporary fixture, never a real program or user worktree. Approval byte order is preserved with `sort_keys=False`; do not use sorted object serialization to manufacture an approval hash failure. + +- [ ] **2. Run RED.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_completed_rollover_binds_review_hashes_to_approval_and_disposition -v +``` + +Expected on the unrepaired owner: `ValueError not raised` for the accepted coordinated/rebound variants. Do not accept a failure from malformed packet generation, stale row hashes, fixture paths, or approval field order as proof of this defect. + +- [ ] **3. Add the existing-authority comparisons.** In `_validated_completed_rollover_records`, within `if record_is_v2`, after validating the exact unique approval and its canonical SHA-256, and before the `accept-continue` projection check, insert: + +```python +for stem, path in ( + ("review_evidence", evidence_path), + ("review_packet", packet_path), +): + current_sha256 = sha256_file(path) + if ( + record[stem + "_binding"].get("sha256") != current_sha256 + or accepted_diff[stem + "_binding"].get("sha256") != current_sha256 + or disposition.get(stem + "_sha256") != current_sha256 + or approval.get(stem + "_sha256") != current_sha256 + ): + label = stem.replace("_", " ") + raise ValueError(f"rollover {label} approval binding mismatch") +``` + +The earlier binding validation and equality checks already establish the two binding mappings. Retain those checks, bundle validation, exact approval tuple/order, unique event match, approval digest, typed product result, projection, baseline, handoff, successor brief, cumulative tombstones, receipt/bytes validation, and unbound-suffix recovery rules. Do not modify v1 or rewrite historical approvals to make them pass. + +- [ ] **4. Run GREEN and the existing immediate/later/multiple-rollover controls.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_completed_rollover_binds_review_hashes_to_approval_and_disposition tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_completed_rollover_revalidates_copied_files_and_approval tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_production_delete_accept_continue_preserves_tombstone_and_quarantine tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_later_continuation_carries_the_exact_v2_result_and_receipt tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_second_rollover_replaces_tombstone_in_place_and_keeps_history tests.test_program_rollover -v +``` + +Expected: all tests pass; unchanged legitimate completed chains still resume, negative reads stop without writing, later `accept-stop` continuation and legacy rollover remain valid. Every bound historical row is checked, not just the latest successor's current review artifacts. + +- [ ] **5. Commit only Task 2 files when authorized.** + +```bash +rtk git diff --check +rtk git diff -- skills/implementing-staged-plans/scripts/program_rollover.py tests/test_delete_operation_lifecycle.py +rtk git add -- skills/implementing-staged-plans/scripts/program_rollover.py tests/test_delete_operation_lifecycle.py +rtk git diff --cached --name-status +rtk git commit --only -m "fix: bind completed rollover reviews to approval" -- skills/implementing-staged-plans/scripts/program_rollover.py tests/test_delete_operation_lifecycle.py +``` + +Expected commit scope: these two files and only this task's changes. Task 3 also owns this test file, so execute sequentially. + +## Task 3 — Preserve exact Delete recovery while enforcing the mutation barrier + +**Files:** Modify `program_activation.py` and `tests/test_delete_operation_lifecycle.py` from the inventory. + +**Interfaces:** `advance_execution_state(program_root, target_increment_state, observation)` continues returning `ExecutionTransitionReceipt | ExecutionTransitionReceiptV2`. Existing classifiers, mutation functions, authority readers, and their signatures remain unchanged. No global recovery flag, new validator mode, or general issue taxonomy. + +- [ ] **1. Add the real pre-mutation regression** to `DeleteOperationLifecycleTests`: + +```python +def test_unmapped_names_cannot_bypass_delete_authority_before_mutation(self): + for filename in ("unmapped.txt", "unmapped-Delete.txt", "unmapped-quarantine.txt"): + with self.subTest(filename=filename): + fixture, legacy_bytes = _authorized_delete_program_with_successor() + try: + root = fixture.program_root + (fixture.repository / filename).write_text("unmapped user work\n", encoding="utf-8") + baseline = json.loads((root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text()) + allocation = baseline["delete_quarantine_bindings"][0] + before = repository_snapshot(fixture.repository) + status_before = (root / "state/status.json").read_bytes() + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + wraps=ACTIVATION.quarantine_bound_regular_file, + ) as move, mock.patch.object( + ACTIVATION, "adopt_delete_quarantine_receipt", + wraps=ACTIVATION.adopt_delete_quarantine_receipt, + ) as adopt: + with self.assertRaisesRegex(ValueError, "unmapped dirty paths"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + move.assert_not_called() + adopt.assert_not_called() + self.assertEqual(repository_snapshot(fixture.repository), before) + self.assertEqual((root / "state/status.json").read_bytes(), status_before) + self.assertEqual((fixture.repository / "legacy.ts").read_bytes(), legacy_bytes) + self.assertFalse((root / allocation["entry_path"]).exists()) + self.assertFalse((root / allocation["receipt_path"]).exists()) + finally: + fixture.close() +``` + +- [ ] **2. Add this production-prefix recovery control** to the same class. It proves an unconditional `if state_issues: raise` is not an acceptable repair, and also checks that receipt adoption cannot bypass an unrelated issue. Fault injection stops the real writer at its persistence boundary; it does not manufacture a valid receipt or approval. + +```python +def test_exact_delete_prefix_recovery_still_blocks_unmapped_work(self): + for boundary in ("receipt", "status"): + with self.subTest(boundary=boundary): + fixture, legacy_bytes = _authorized_delete_program_with_successor() + try: + root = fixture.program_root + baseline = json.loads((root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text()) + allocation = baseline["delete_quarantine_bindings"][0] + entry_path = root / allocation["entry_path"] + receipt_path = root / allocation["receipt_path"] + status_before = (root / "state/status.json").read_bytes() + fault = mock.Mock(side_effect=RuntimeError("interrupted Delete prefix")) + patcher = ( + mock.patch.dict( + ACTIVATION.quarantine_bound_regular_file.__globals__, + {"_write_delete_receipt": fault}, + ) if boundary == "receipt" else + mock.patch.object(ACTIVATION, "atomic_replace_json", fault) + ) + with patcher: + with self.assertRaisesRegex(RuntimeError, "interrupted Delete prefix"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + self.assertEqual((root / "state/status.json").read_bytes(), status_before) + self.assertEqual(entry_path.read_bytes(), legacy_bytes) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + self.assertEqual(receipt_path.exists(), boundary == "status") + dirty_path = fixture.repository / "unmapped-quarantine.txt" + dirty_path.write_text("unmapped user work\n", encoding="utf-8") + before = repository_snapshot(fixture.repository) + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + wraps=ACTIVATION.quarantine_bound_regular_file, + ) as move, mock.patch.object( + ACTIVATION, "adopt_delete_quarantine_receipt", + wraps=ACTIVATION.adopt_delete_quarantine_receipt, + ) as adopt: + with self.assertRaisesRegex(ValueError, "unmapped dirty paths"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + move.assert_not_called() + adopt.assert_not_called() + self.assertEqual(repository_snapshot(fixture.repository), before) + # Remove only the test-created unmapped file in this disposable fixture. + dirty_path.unlink() + retained_entry = entry_path.read_bytes() + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + side_effect=AssertionError("recovery must not move again"), + ), mock.patch.object( + Path, "unlink", side_effect=AssertionError("recovery must retain bytes"), + ): + receipt = ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + self.assertEqual(receipt.increment_state, "implementing") + self.assertEqual(entry_path.read_bytes(), retained_entry) + self.assertTrue(receipt_path.is_file()) + normalized = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + self.assertEqual(ACTIVATION.validate_state_authority(root, normalized), []) + discovery = run_program_discovery(fixture.repository) + self.assertEqual(discovery["disposition"], "resume") + self.assertFalse(discovery["stop_required"]) + finally: + fixture.close() +``` + +- [ ] **3. Run RED.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_unmapped_names_cannot_bypass_delete_authority_before_mutation tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_exact_delete_prefix_recovery_still_blocks_unmapped_work -v +``` + +Expected on the unrepaired owner: trigger filenames reach the wrapped move, and an adoption-ready prefix reaches the wrapped receipt adopter. The neutral filename is a passing control. Existing post-mutation errors alone are insufficient: assert zero calls and unchanged files/status. If a failure occurs before the intended writer boundary, correct the test harness first. + +- [ ] **4. Replace the substring filter with the exact recovery exception.** In `advance_execution_state`, inside `if v2_delete_execution`, after all paths have been classified and `recovery-required` rejected, replace the current `state_issues`/`blocking_issues` block with: + +```python +state_issues = validate_state_authority(root, normalized) +# Status is still authorized after an exact interrupted move. Only its +# path-specific source warning can be explained by the classified prefix. +recoverable_source_issues = { + f"authorized Delete source does not match baseline: {relative}" + for relative, recovery in delete_recoveries.items() + if recovery.disposition in {"receipt-adoption-ready", "resume"} +} +blocking_issues = [ + issue for issue in state_issues if issue not in recoverable_source_issues +] +if blocking_issues: + raise ValueError("; ".join(blocking_issues)) +``` + +This deliberately uses equality against one existing complete diagnostic, gated by exact path and classifier state. Changing that diagnostic later makes recovery stop safely until its application regression is updated. A generalized structured-issue/API migration would add unnecessary churn for this bounded repair. Preserve classification before writes, protected contexts, no-repeat-move/adoption functions, target-state workspace assessment, action/source-gate checks, and status-last validation. Do not change discovery to hide the failures. + +- [ ] **5. Run GREEN plus recovery/security controls.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_unmapped_names_cannot_bypass_delete_authority_before_mutation tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_exact_delete_prefix_recovery_still_blocks_unmapped_work tests.test_state_authority.DeleteQuarantineTests tests.test_program_activation.ProgramActivationTests.test_every_activation_prefix_is_discovered_and_exact_retry_completes tests.test_program_activation.ProgramActivationTests.test_divergent_existing_record_is_preserved_and_requires_recovery tests.test_program_activation.ExactPlanMaterializationTests.test_execution_transitions_are_status_last_retry_safe_and_delta_bound -v +``` + +Expected: all pass on the supported local platform. All three filenames block before either product movement or receipt adoption; exact receipt/status interruptions still complete; divergent receipt/bytes, source replacement, unavailable primitives, collision, and malformed activation prefixes retain their existing safe stops. The upstream-replay recovery repaired at `a3168c3` must remain intact. + +- [ ] **6. Commit only Task 3 files when authorized.** + +```bash +rtk git diff --check +rtk git diff -- skills/implementing-staged-plans/scripts/program_activation.py tests/test_delete_operation_lifecycle.py +rtk git add -- skills/implementing-staged-plans/scripts/program_activation.py tests/test_delete_operation_lifecycle.py +rtk git diff --cached --name-status +rtk git commit --only -m "fix: restrict Delete recovery authority exceptions" -- skills/implementing-staged-plans/scripts/program_activation.py tests/test_delete_operation_lifecycle.py +``` + +## Task 4 — Verify the coherent repair and obtain one fresh independent review + +**Files:** No additional production/test changes. Review only the five-file implementation inventory and this plan. Fixes discovered in this step are limited to defects caused by these three repairs; an unrelated finding or materially different approach needs a separately agreed scope. + +**Interfaces:** No additions. Deliver a receipt tied to the exact final commit/tree and the actual test outputs. + +- [ ] **1. Make one focused requirement/DRY pass.** Check all three retained findings against the implementation; remove only newly introduced unnecessary flexibility. Ensure the diff has only the loader guard, v2 review-hash comparisons, exact recovery exclusion, and the behavior regressions. Confirm no test weakening, new schemas, fixture snapshots, generated artifacts, package metadata, unrelated cleanup, or v1 serializer changes. + +- [ ] **2. Run the full relevant suite once on the coherent tree.** The per-task commands are focused checks on changing inputs. This full suite is a separate integration obligation, not a reason to rerun successful focused commands against unchanged inputs. + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +rtk git diff --check +rtk git diff --check a3168c367633c960534d84bb47d1632123c1ea24 HEAD +rtk git status --short --branch +``` + +Expected: terminal exit 0 for the full suite and package validator, no whitespace errors, and only authorized files in the implementation diff. Report actual counts/skips; do not copy review task `01a07f27-0244-7191-b540-eed968f318bb`'s historical test counts as fresh results. A running/interrupted command is not a pass. Poll long commands in bounded waits and give concise status if they exceed 60 seconds. If inputs change after a material repair, rerun affected focused checks and the relevant integration/package check; do not rerun expensive external evaluation merely for a new commit ID with identical tested inputs. + +- [ ] **3. Handle the native-Windows evidence boundary explicitly.** If a native-Windows runner is already available and execution there is authorized, run these targeted commands there; `rtk env` avoids shell-specific environment syntax: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python -m unittest tests.test_state_authority.PlatformImportCompatibilityTests tests.test_state_authority.WorkspaceAndBindingTests tests.test_state_authority.AtomicAuthorityWriterTests.test_native_windows_compare_and_swap_replaces_closed_destination -v +rtk env PYTHONDONTWRITEBYTECODE=1 python skills/implementing-staged-plans/scripts/program_discovery.py --help +rtk env PYTHONDONTWRITEBYTECODE=1 python skills/implementing-staged-plans/scripts/program_activation.py --help +rtk env PYTHONDONTWRITEBYTECODE=1 python skills/implementing-staged-plans/scripts/validate_package.py . +``` + +Expected: native imports/legacy authority/closed-destination compare-and-swap and package checks pass. Do not run POSIX Delete success fixtures as proof of Windows Delete support. If native Windows is unavailable, report: **“Native Windows was not executed; import coverage was simulated on macOS and the native-Windows test was skipped. No native-Windows validation or Windows Delete support is claimed.”** This limitation must appear even if every local test passes. Do not provision paid/hosted infrastructure to remove the limitation without authority. + +- [ ] **4. Freeze one review scope.** Record final HEAD, full repair diff from `a3168c367633c960534d84bb47d1632123c1ea24`, aggregate context from `00c04a0f1c1ebb2cbdf890c4c4cf0334f89a344e`, dirty/index inventory, command exits/counts/skips, and package result. Do not refresh remote refs unless the later review asks about remote integration freshness. + +- [ ] **5. Obtain one fresh, bounded independent review in a separately authorized review task.** No subagent is dispatched by this plan-only task. Supply the reviewer the exact final head, these two bases, this plan, and test receipts. Require read-only review, no recursive delegation, no external source transmission, no edits/push/PR/comments, and material findings only. Ask them to independently assess: + + - F1 import order, real v1 availability, preserved Darwin no-replace behavior, and truthful platform limits. + - F2 actual retained bytes → rollover bindings → disposition → unique exact approval; both acceptance routes, historical rows, schema/order preservation, and no-write fail-closed discovery. + - F3 real product/receipt mutation timing, hostile filenames, exact interrupted-prefix adoption/resume, unchanged action/source-gate checks, and preservation of `a3168c3` activation-prefix recovery. + - Recovery/security invariants and test strength; no inference that green tests alone establish merge readiness. + +Expected: no unresolved material defects in the bounded repair. Use at most one final reviewer unless that reviewer identifies a material defect. Validate a reported defect against current code, repair within the authorized scope, rerun changed-input checks, and request fresh evidence only for the changed scope. Do not repeatedly review unchanged code. + +- [ ] **6. Report bounded completion and stop.** Give the exact three repair commits/final head, changed files, RED/GREEN evidence, full-suite/package results, native-Windows limitations, and the independent review result/head. If review or a required verification is unavailable, state that it remains outstanding; do not claim merge readiness. + +## Recovery, security, and completion criteria + +The repair is complete only when all of these hold on the final verified tree: + +1. Fresh non-Darwin import paths do not load the Darwin process library; the existing v1 authority reader remains functional. Actual native-Windows evidence, if absent, is disclosed. +2. A production-generated completed v2 rollover rejects replaced/rebound review evidence or packet whenever either retained approval or disposition hashes disagree. The direct chain reader rejects, authority reports issues, fresh discovery stops, and no retained product/quarantine/control bytes change during those reads. +3. Unmapped filename text cannot exempt unrelated authority failures before a new move or receipt adoption. Exact interrupted moves still adopt/resume, while malformed or divergent recovery states continue to stop with bytes preserved. +4. v1 canonical bytes, family-specific prompts/readers/routes, typed v2 tombstones/receipts, approval field order, unique-event checks, cumulative rollover history, action/source gates, descriptor protection, and status-last recovery remain covered by the existing relevant suite. +5. Focused checks, the final full suite, package validation, whitespace checks, and one fresh independent review have terminal, attributable results. No material findings remain unresolved. + +Recovery is preservation-first. Never “repair” a real program by rewriting its approval hashes, replacing evidence, removing a receipt, restoring the source, or deleting quarantine. Invalid retained evidence requires separately authorized reconciliation; interrupted movement uses only the existing exact classifier and no-overwrite adoption path. If a code repair is rejected, preserve the work and propose a reviewed reverse patch or separately authorized revert; never reset or discard user work. + +Completion claims are limited to these three bounded repairs and tested compatibility. They do not authorize push, PR creation, merge, release, deployment, PLUG-002, quarantine disposal, terminal closure, secure erasure, or certification of an untested platform. diff --git a/docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md b/docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md new file mode 100644 index 0000000..e613b5a --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md @@ -0,0 +1,303 @@ +# Delete Receipt Content Revalidation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This planning task permits only this new plan and its local commit; implementation and subagents require separate authorization. + +**Goal:** Reject a final observed Delete receipt whose content differs from the classifier-validated receipt before any reviewing status or authority write. + +**Architecture:** Keep `validate_execution_workspace_v2` as the binding owner. After its existing descriptor-relative receipt inspection succeeds, compare the observed digest with SHA-256 of the already validated `recovery.receipt`, encoded by the existing canonical serializer. Missing validated receipts or digest mismatches become assessment issues with no binding; the existing activation barrier rejects the transition while product-state collection remains complete. + +**Tech Stack:** Existing Python 3 standard library, `hashlib`, frozen dataclasses, canonical JSON, `unittest`, disposable Git fixtures, descriptor-relative no-follow inspection, and status-last persistence. No dependencies or public API changes. + +**Spec:** The bounded receipt-content repair request and acceptance criteria in this document implement the Delete invariants in `docs/superpowers/plans/2026-09-05-delete-operation-support.md`, especially Task 2 and its recovery matrix. Read `docs/superpowers/specs/2026-08-23-program-setup-and-activation-design.md`, `skills/implementing-staged-plans/references/repository-preparation.md`, and `skills/implementing-staged-plans/references/execution-discipline.md` for the existing authority and persistence contracts. Preserve the preceding repairs recorded in `docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md` and `docs/superpowers/plans/2026-09-08-delete-receipt-race-review-repairs.md`; their historical branches and gates grant no additional authority here. + +## Global constraints + +- Exact production parent/head: `76b8d442f2452f943cc6ce03d0c9ecbe9f4d4c7c`. Planning checkout: `/private/tmp/implementation-plugin-pr18-content-plan`; branch: `repair/delete-receipt-content-plan`. Initial HEAD matched and the worktree/index were clean. No refs were fetched; the comparison is against this immutable head, not a refreshed PR tip. +- Planning owns exactly `docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md` and its local commit. No code, test, existing-document, manifest, or version edits. Do not implement, push, update or create a PR, merge, reinstall, replay pipeFlow, reply to review, or spawn subagents. +- Later execution needs an explicit instruction identifying its checkout/branch and approved plan digest. Preserve user-owned staged, unstaged, untracked, and committed work; do not invent Git names, reset, clean, stash, amend, or overwrite unrelated work. +- Prefix every shell command with `rtk`. Commands below run from the approved repository root. Use `apply_patch` for edits and `rtk env PYTHONDONTWRITEBYTECODE=1` for Python; keep probes and fixtures out of the package. +- Existing manifest/status v1 and v2, plus manifest-v3 programs using setup/envelope v1, retain their exact schemas, prompts, ordering, errors, and persisted bytes. Keep exact supported setup/envelope-v2 selection and v2 result ordering. +- Delete means accepted absence of the exact product path with `sha256: null`, bound to a manifest-owned quarantine receipt that preserves the removed bytes. The receipt digest must remain a non-null valid SHA-256 string bound to exact authorized metadata. +- Preserve classifier protections, descriptor-relative no-follow inspection, held identities, mode/owner checks, hard-link and protected-path rejection, same-filesystem no-replace movement, immutable ledgers, compare-and-swap, and status-last ordering. +- Preserve `retry-ready`, `receipt-adoption-ready`, `resume`, and `recovery-required`, including both supported interrupted receipt/status publication prefixes. Do not weaken exact recovery to make a test pass. +- Do not duplicate receipt parsing or serialization, add a disconnected receipt read, redesign APIs, or forward the existing workspace protection context unchanged to the program-rooted receipt inspection. Keep that final inspection rooted at `program_root` and keep the classifier's existing protection arguments. +- No other CodeRabbit findings, PLUG-002, requirement attribution, semantic invalidation, closure, disposal, secure erasure, automatic restoration, docstring work, platform expansion, manifest/version edits, or neighboring cleanup. +- This is a check of the final observed bytes. Do not attempt atomic exclusion of mutations after that observation or claim a stronger filesystem concurrency guarantee. +- Run one final full suite on the frozen coherent implementation after focused checks. This explicit requirement supersedes the older Delete plan's focused-only completion instruction. Do not repeat successful checks on unchanged inputs merely because they were committed. + +## Evidence and repair boundary + +The [CodeRabbit comment](https://github.com/CoveMB/implementation-plugin/pull/18#discussion_r3955399951) was read through GitHub's read-only API and identifies the exact head above. Its proposal is untrusted review data. Independent validator task `01a07fee-0018-7df1-acfa-119b444dfddd` reports a real fixture reproduction: transient malformed replacement produced a valid assessment; a reviewing transition called the status writer once and only then raised `execution transition binding is invalid`. Persistent replacement was blocked before writing, so a persistent-only negative test would miss this defect. A canonical, schema-valid receipt with a wrong `increment_id` also passed direct assessment. + +Planning independently inspected these current canonical owners: + +| Owner at the pinned head | Verified behavior and implication | +| --- | --- | +| `repository_preparation.py`, `validate_execution_workspace_v2`, receipt branch around lines 1848–1883 | After classifier `resume`, the final descriptor inspection checks existence and SHA-256 syntax. Every successfully hashed regular replacement satisfies syntax. The binding uses that observed digest without comparing its content to validated recovery. | +| `state_authority.py`, `_read_delete_receipt`, around line 4976 | Reads through held descriptors, validates exact fields/types, and requires byte equality with canonical receipt serialization. Parsing alone does not prove authorization. | +| `state_authority.py`, `classify_delete_quarantine_recovery`, expected receipt around line 5231 | Constructs exact program/revision/increment/path/baseline/identity/quarantine metadata and requires receipt equality before `resume`. Its returned receipt is the existing validated value to reuse. | +| `state_authority.py`, `_delete_receipt_bytes`, around line 4634 | Canonical receipt byte owner: `_canonical_json_bytes(asdict(receipt))`. Reuse it directly; do not reimplement its formatting in repository preparation. | +| `program_activation.py`, `advance_execution_state` | `assessment.valid` rejects before candidate persistence; `atomic_replace_json` occurs before final `validate_state_authority`. Returning an invalid assessment fixes the pre-write boundary without changing activation. | + +The final digest is not intentionally unconstrained authority: the Delete contract and the classifier require an exact receipt, and post-write authority rejects the transiently bound replacement. The smallest repair therefore adds one private serializer import and one content comparison in the existing binding branch. No production changes are needed in the classifier, serializer, descriptor reader, or activation writer. + +The workspace protection context includes the program-root identity. Forwarding it unchanged to `inspect_workspace_path(program_root, receipt_path, ...)` rejects its own root and breaks valid receipts. Preserve the current root distinction, including the production context exercised by the valid reviewing control. + +Current primary-source check, 2026-09-08: [Python hashlib documentation](https://docs.python.org/3/library/hashlib.html#usage) confirms `hashlib.sha256(canonical_bytes).hexdigest()` computes the hexadecimal digest of those bytes. This is an in-memory comparison, with no extra filesystem observation or dependency. It relies on the existing SHA-256 integrity model; it is not an atomicity guarantee. + +## Exact future change inventory + +| File | Responsibility | +| --- | --- | +| `skills/implementing-staged-plans/scripts/repository_preparation.py` | Import the existing canonical serializer and reject absent validated receipts or final content mismatches before the binding append. | +| `tests/test_delete_operation_lifecycle.py` | Extend the existing production-backed `DeleteReceiptReinspectionTests` harness and assertions for transient malformed/wrong-metadata content and missing validated receipt. Retain all earlier controls. | + +All other files are read-only context. No new implementation or test files, shared fixture framework, or production diagnostic cleanup is needed. + +## Task 1 — Bind final observed receipt content to validated recovery + +**Interfaces:** Consume the existing `DeleteQuarantineRecovery.receipt: DeleteQuarantineReceipt | None` and `_delete_receipt_bytes(receipt: DeleteQuarantineReceipt) -> bytes`. Keep `validate_execution_workspace_v2(program_root, baseline, inspection, *, increment_state, protected_paths=(), protected_identities=()) -> ExecutionWorkspaceAssessmentV2` and `advance_execution_state(program_root, target_increment_state, observation)` unchanged. Produce a receipt-specific assessment issue and no binding for the affected Delete path on mismatch; still append every ordered `ProductPathStateV2`. + +- [ ] **1. Verify execution scope before editing.** Read this plan and its referenced contracts. The later execution instruction supplies the accepted plan digest externally; this document deliberately does not embed its own hash. + +```bash +rtk git branch --show-current +rtk git status --short --branch +rtk git rev-parse HEAD +rtk git diff --name-status 76b8d442f2452f943cc6ce03d0c9ecbe9f4d4c7c +rtk git diff --cached --name-status +rtk shasum -a 256 docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md +``` + +Expected: the approved branch and checkout, no unexplained dirt, unchanged production relative to the exact parent, and only this plan added before execution. If production changed, revalidate this bounded finding against that change before proceeding; do not refresh refs or silently widen scope. + +- [ ] **2. Add the failing application-path tests before the production edit.** Reuse the existing `DeleteReceiptReinspectionTests` class, fixture, imports, `assess`, `assert_reviewing_rejected`, and valid-receipt control. Replace only its `receipt_race` helper and `test_receipt_reinspection_failure_returns_invalid_assessment` method with the code below, then add the three new reviewing tests below. Existing missing/symlink/OSError/null/invalid-digest reviewing tests remain unchanged. + +The helper calls the real classifier and reader in the namespace where assessment looks them up. It substitutes only the timed filesystem mutation (or the explicit missing-recovery-value contract fault). Malformed and wrong-metadata replacements are real regular files hashed by the real final descriptor inspection. Restore the original inode immediately after inspection, before activation's later checks, to expose the pre-write hole. All path operations below affect only the disposable test fixture. + +```python + @contextmanager + def receipt_race(self, kind): + namespace = self.validate.__globals__ + classify = namespace["classify_delete_quarantine_recovery"] + inspect = namespace["inspect_workspace_path"] + serialize = classify.__globals__["_delete_receipt_bytes"] + read_receipt = classify.__globals__["_read_delete_receipt"] + events = [] + replacement_digest = None + with tempfile.TemporaryDirectory() as directory: + saved = Path(directory) / "receipt.json" + displaced = Path(directory) / "receipt-replacement" + + def restore_receipt(): + if saved.exists(): + if self.receipt_path.exists() or self.receipt_path.is_symlink(): + self.receipt_path.rename(displaced) + saved.rename(self.receipt_path) + + def classify_then_race(*args, **kwargs): + nonlocal replacement_digest + recovery = classify(*args, **kwargs) + if not events and recovery.disposition == "resume": + self.assertIsNotNone(recovery.receipt) + original_bytes = self.receipt_path.read_bytes() + self.assertEqual(serialize(recovery.receipt), original_bytes) + events.append("classified-resume") + if kind == "missing-recovery-receipt": + return replace(recovery, receipt=None) + if kind in { + "missing", "symlink", "malformed-content", "wrong-metadata", + }: + self.receipt_path.rename(saved) + if kind == "symlink": + self.receipt_path.symlink_to(saved) + elif kind in {"malformed-content", "wrong-metadata"}: + replacement_receipt = replace( + recovery.receipt, increment_id="UNAUTHORIZED-INCREMENT" + ) + payload = ( + b"not an authorized Delete receipt\n" + if kind == "malformed-content" + else serialize(replacement_receipt) + ) + self.receipt_path.write_bytes(payload) + replacement_digest = hashlib.sha256(payload).hexdigest() + self.assertNotEqual( + replacement_digest, + hashlib.sha256(original_bytes).hexdigest(), + ) + if kind == "wrong-metadata": + self.assertNotEqual(replacement_receipt, recovery.receipt) + self.assertEqual( + read_receipt(self.root, self.binding["receipt_path"]), + replacement_receipt, + ) + return recovery + + def inspect_receipt(root, relative, **kwargs): + if ( + Path(root) != self.root + or relative != self.binding["receipt_path"] + or events != ["classified-resume"] + ): + return inspect(root, relative, **kwargs) + events.append("reinspected") + try: + if kind == "oserror": + raise OSError("injected receipt descriptor failure") + snapshot = inspect(root, relative, **kwargs) + if replacement_digest is not None: + self.assertTrue(snapshot.exists) + self.assertEqual(snapshot.sha256, replacement_digest) + if kind == "null-sha": + return replace(snapshot, sha256=None) + if kind == "invalid-sha": + return replace(snapshot, sha256="invalid") + return snapshot + finally: + restore_receipt() + + try: + with mock.patch.dict( + namespace, + { + "classify_delete_quarantine_recovery": classify_then_race, + "inspect_workspace_path": inspect_receipt, + }, + ): + yield events + finally: + restore_receipt() + + def test_receipt_reinspection_failure_returns_invalid_assessment(self): + valid = self.assess() + self.assertTrue(valid.valid, valid.issues) + expected_states = valid.product_states.ordered_path_states + self.assertEqual(len(expected_states), 5) + self.assertEqual(expected_states[-1].operation, "Delete") + for kind in ( + "missing", "symlink", "oserror", "null-sha", "invalid-sha", + "malformed-content", "wrong-metadata", "missing-recovery-receipt", + ): + with self.subTest(kind=kind): + before = repository_snapshot(self.fixture.repository) + before_receipt = self.receipt_path.read_bytes() + with self.receipt_race(kind) as events: + assessment = self.assess() + self.assertEqual(events, ["classified-resume", "reinspected"]) + self.assertFalse(assessment.valid) + self.assertTrue(any( + issue.startswith("Delete quarantine receipt ") + for issue in assessment.issues + ), assessment.issues) + self.assertEqual(assessment.product_states.delete_quarantine_bindings, ()) + self.assertEqual( + assessment.product_states.ordered_path_states, expected_states + ) + self.assertEqual(self.receipt_path.read_bytes(), before_receipt) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertFalse((self.fixture.repository / "legacy.ts").exists()) + self.assertEqual( + (self.root / self.binding["entry_path"]).read_bytes(), + self.legacy_bytes, + ) + + def test_malformed_receipt_content_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("malformed-content") + + def test_wrong_receipt_metadata_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("wrong-metadata") + + def test_missing_recovery_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("missing-recovery-receipt") +``` + +`assert_reviewing_rejected` already calls the real transition, spies on the real `ACTIVATION.atomic_replace_json` using `wraps`, requires zero calls and a receipt-specific `ValueError`, and compares exact status bytes plus the full repository snapshot before/after. That snapshot includes all program authority records, quarantine and receipt contents, directories, and source absence. Keep its existing source/quarantine assertions and add explicit receipt byte equality by capturing `before_receipt = self.receipt_path.read_bytes()` before `receipt_race`, then asserting `self.assertEqual(self.receipt_path.read_bytes(), before_receipt)` after the context exits. Do not substitute a mocked assessment, authorization result, status candidate, or writer success. + +- [ ] **3. Observe strict RED on the unmodified production head.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteReceiptReinspectionTests -v +``` + +Expected: new malformed/wrong-metadata/missing-recovery direct cases fail because assessment is still valid; both content transition cases fail `writer.assert_not_called()` because the old code reaches persistence before its post-write authority exception. The missing-recovery transition instead fails `assertRaises(ValueError)` because the old code completes reviewing without requiring the returned receipt. Earlier five fault controls and the exact valid receipt control must still pass. Reject import, fixture, wrong-namespace, missing-output, or unrelated authority failures as RED evidence. Record command, nonzero exit, exact intended failures, count/skips, production hash, and that tests preceded the production edit. The transient content helper must record both events and prove the real reader hashed the replacement. + +- [ ] **4. Implement the minimal guard.** Add `_delete_receipt_bytes` to the existing `from state_authority import (...)` list in `repository_preparation.py`. Retain the current inspection and existing missing/type/syntax checks. Insert this `elif` immediately before the current `else: bindings.append(...)`: + +```python + elif ( + recovery.receipt is None + or receipt_snapshot.sha256 + != hashlib.sha256( + _delete_receipt_bytes(recovery.receipt) + ).hexdigest() + ): + issues.append( + f"Delete quarantine receipt does not match validated recovery: {relative}" + ) +``` + +Short-circuit on `None` before serialization. Use the observed digest in the unchanged valid binding append after equality succeeds. Do not return, raise, or `continue` here: the existing unconditional `states.append(...)` must run for this Delete, and later Preserve paths must still be assessed. Keep the outer state-specific recovery policy, final missing-binding issue, and aggregate result digest construction unchanged. + +- [ ] **5. Observe focused GREEN.** Run the Step 3 command once after the guard. Expected: all old and new class tests pass; malformed and wrong-metadata replacements have no binding, all five states remain ordered, and real reviewing transitions call no writer and preserve status/authority/source/quarantine/receipt. The unchanged valid control must still transition to reviewing, preserve the canonical digest and null Delete tombstone digest, and pass real authority validation. + +- [ ] **6. Verify supported recovery and the bounded surrounding application paths.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteOperationLifecycleTests.test_exact_delete_prefix_recovery_still_blocks_unmapped_work tests.test_state_authority.DeleteQuarantineTests tests.test_state_authority.DescriptorRelativeWorkspacePathTests tests.test_repository_preparation tests.test_program_activation -v +``` + +Expected: terminal success. The first test injects production receipt-writer and status-writer interruptions, confirms unmapped work still blocks without movement/adoption, then retries to implementing without moving again or unlinking retained bytes and passes authority/discovery. The remaining suites protect classifier validity, descriptor boundaries, legacy preparation/activation, and ordinary valid transitions. Do not add new mocks, a copied recovery parser, or broaden supported recovery dispositions. Record actual counts/skips and any native-platform gaps. + +## Final verification and commit boundary + +- [ ] **1. Make one focused requirement and DRY pass.** The production diff must consist only of the canonical serializer import and content guard. Verify absent `recovery.receipt` short-circuits; syntax/error controls remain; no binding on mismatch; ordered states still complete; activation, classifier, serializer, v1 paths, and public APIs are unchanged. Check the exact two-file future inventory, and preserve all explicit exclusions. + +- [ ] **2. Freeze the candidate, then run the final full suite once and package/diff checks.** + +```bash +rtk git rev-parse HEAD +rtk shasum -a 256 skills/implementing-staged-plans/scripts/repository_preparation.py tests/test_delete_operation_lifecycle.py +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +rtk git diff --check +rtk git diff --check 76b8d442f2452f943cc6ce03d0c9ecbe9f4d4c7c +rtk git diff --name-status 76b8d442f2452f943cc6ce03d0c9ecbe9f4d4c7c +rtk git status --short --branch +``` + +Expected: full suite and package validator exit 0, no whitespace errors, and only this plan plus the two approved implementation paths in the aggregate comparison. Capture final counts, skips, hashes, and terminal exit codes; partial output is not a pass. No changes after the frozen run unless a material defect is found; a relevant change invalidates affected evidence. Static/package checks do not establish native-Windows Delete behavior, live model behavior, installed-copy behavior, or production safety. + +- [ ] **3. Prepare one bounded final review handoff if separately authorized.** Include exact parent/HEAD, final diff and changed-file hashes, RED/GREEN receipts, complete suite/package outputs, all exclusions, and platform limitations. Limit review to this defect and regressions caused by the two-file repair. Use at most one read-only reviewer, no recursive delegation or external transmission; do not dispatch any subagent under this planning authorization. Do not call a self-review independent. If independent review is not yet authorized, report it as outstanding without blocking the already authorized plan commit. + +- [ ] **4. Commit implementation only if the future execution instruction authorizes it.** A suitable message is `Revalidate final Delete receipt content`. Verify only the two approved implementation paths are staged; preserve unrelated work. This plan's local commit does not grant implementation commit or publication authority. + +```bash +rtk git diff --check +rtk git add -- skills/implementing-staged-plans/scripts/repository_preparation.py tests/test_delete_operation_lifecycle.py +rtk git diff --cached --name-status +rtk git diff --cached --check +rtk git commit --only -m "Revalidate final Delete receipt content" -- skills/implementing-staged-plans/scripts/repository_preparation.py tests/test_delete_operation_lifecycle.py +rtk git show --format=fuller --stat HEAD +rtk git rev-parse HEAD HEAD^ +rtk git status --short --branch +``` + +- [ ] **5. Report evidence and stop.** Report the actual change, RED/GREEN observations, final suite/package exits and counts/skips, exact parent/head, review status, and any limitations. Do not push, update PR #18, reply to review, merge, reinstall, replay pipeFlow, or perform excluded work. + +## Acceptance and recovery + +Acceptance requires both transient content variants and absent validated receipt to produce an invalid direct assessment with a receipt-specific issue, no Delete binding, and complete ordered product states; the real reviewing path must reject with zero status writer calls and unchanged status, authority, source absence, retained source bytes, and original receipt bytes. Exact valid receipts and supported interrupted recovery must pass, and all five earlier reinspection controls must remain effective. Final focused/full-suite/package/diff evidence must refer to the actual implementation tree. + +For a real divergent program, preserve all evidence and retained bytes. This repair does not rewrite receipt/status hashes, restore source names, or dispose of quarantine. Existing exact classifier/adoption behavior remains the authorized recovery mechanism; divergent evidence requires separately authorized reconciliation. If a candidate repair is rejected, preserve work and prepare a reverse diff for review instead of resetting the checkout. + +## Planning verification receipts + +Planning verification records below describe the plan and unmodified production only. They are not implementation GREEN or final full-suite evidence. + +- Initial branch/HEAD/clean-state verification passed at the exact requested parent. No filesystem `AGENTS.md` was found in this worktree or its ancestor directories; the supplied instructions and `/Users/CoveMB/.codex/RTK.md` apply. GitHub comment retrieval succeeded through a read-only API call; no refs were fetched or external state changed. +- Both Python excerpts parsed successfully (the `elif` excerpt inside its required conditional context). Test methods were loaded directly from this Markdown into the existing test class in one disposable Python process; no production function or on-disk test was edited. +- Initial excerpt run: **10 test methods, 4 failures, 0 errors, 0 skips, exit 1**. Both transient content transition tests reached the real status writer exactly once and failed the zero-call assertion. Missing validated receipt completed reviewing without the required exception. All five prior transition fault controls and the valid canonical-receipt/authority control passed. +- The remaining initial failure was a plan-harness mistake: this production fixture has four Create states followed by Delete, not a trailing Preserve state. Corrected that assertion in the plan and reran only the affected direct-assessment method. **1 method, 3 intended subtest failures, 0 errors, 0 skips, exit 1**: malformed content, canonical/schema-valid wrong metadata, and missing validated receipt each still returned `valid=True`. The five earlier fault subcases passed with complete ordered-state equality. Harness failures are excluded from defect evidence. +- `rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py .` returned **Package validation passed, exit 0**. The plan is outside the runtime package surface. `rtk git diff --check` passed. All tracked files remained unchanged; SHA-256 checks confirmed the inspected repository-preparation, state-authority, activation, and lifecycle-test files still matched their initial bytes. +- The single-file/hash/staged-diff/parent verification accompanies the planning commit in the task receipt. No production GREEN, final implementation full suite, independent implementation review, native-Windows execution, installed-copy validation, or publication was performed or claimed. These remain future execution obligations. diff --git a/docs/superpowers/plans/2026-09-08-delete-receipt-race-review-repairs.md b/docs/superpowers/plans/2026-09-08-delete-receipt-race-review-repairs.md new file mode 100644 index 0000000..39b8f3c --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-delete-receipt-race-review-repairs.md @@ -0,0 +1,459 @@ +# Delete Receipt Race Review Repairs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This planning task permits only this new plan and its local commit; it prohibits implementation and subagents. Future execution requires a separate instruction. + +**Goal:** Reject failed Delete receipt reinspection before status persistence, repair the wrong-mode regression fixture, and restore the documented manifest-v3 activation order. + +**Architecture:** Keep receipt binding construction in `repository_preparation.validate_execution_workspace_v2`; convert inspection failures and absent/invalid digests into assessment issues before appending a binding. Reuse activation's existing `assessment.valid` barrier rather than adding a second writer or changing persistence. Isolate the mode test using the existing allocation helper, and correct only the runbook's ordered list. + +**Tech Stack:** Existing Python 3 standard library, `unittest`, temporary Git fixtures, descriptor-relative no-follow inspection, canonical SHA-256 results, and status-last persistence. No dependencies, schemas, public interfaces, or version changes. + +**Spec:** `docs/superpowers/plans/2026-09-05-delete-operation-support.md` supplies the PLUG-001 boundary and Delete invariants. `docs/superpowers/specs/2026-08-23-program-setup-and-activation-design.md` supplies setup/source-gate and status-last contracts. Read the current `skills/implementing-staged-plans/SKILL.md` and `skills/implementing-staged-plans/references/repository-preparation.md`; retain the preceding repairs recorded in `docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md`. Historical kickoff branches and checkboxes in those plans are context, not execution authority for this repair. + +## Global constraints + +- Reviewed production head: `b031fe442efc685f459f6b49a102767d6ede061b`; PR #18 base: `00c04a0f1c1ebb2cbdf890c4c4cf0334f89a344e`. The planning branch is `repair/delete-review-receipt-race-plan`; initial HEAD matched the reviewed head and status was clean. No remote refs were fetched; this is an immutable-head repair scope. +- Planning owns exactly this file. Do not edit production, tests, existing documents, manifests, versions, or other files during planning. Commit only this plan. Do not push, create a PR, merge, reinstall, replay pipeFlow, respond to review comments, or spawn subagents. +- Later execution must preserve all user-owned staged, unstaged, untracked, and committed work. Use the explicitly approved checkout/branch; do not invent branch names, reset, clean, stash, amend, or overwrite unrelated changes. +- Prefix every shell command with `rtk`. Commands below run from the repository root. Use `apply_patch` for edits and `rtk env PYTHONDONTWRITEBYTECODE=1` for Python; keep temporary fixtures and probes outside the package. +- Existing manifest/status v1 and v2, plus manifest-v3 programs using setup/envelope v1, retain their exact schemas, prompts, ordering, errors, and persisted bytes. The nested v2 family remains selected only by the exact supported setup/envelope pair. +- Delete means accepted absence of the exact product path with `sha256: null`, bound to a manifest-owned quarantine receipt that preserves the removed bytes. The **receipt** digest must be a valid SHA-256 string; it must never inherit the product tombstone's null-digest rule. +- Preserve normalized exact regular-file ownership, descriptor-relative no-follow inspection, held identities, mode/owner and hard-link checks, Git/program/control/quarantine protection, same-filesystem no-replace movement, immutable ledgers, compare-and-swap, and status-last ordering. +- Preserve `retry-ready`, `receipt-adoption-ready`, `resume`, and `recovery-required`, including exact interrupted-move adoption and the preceding repair's path-specific authority exceptions. Never weaken a classifier or accept divergent evidence to make a test pass. +- No PLUG-002 requirement attribution, semantic invalidation, terminal closure, quarantine disposal, automatic restoration, secure erasure, Move/Rename, Replace, directory deletion, broad refactoring, diagnostic cleanup, docstring expansion, manifest edits, or package version bump. +- This plan changes neither the supported platform set nor the filesystem concurrency model. An inspected invalid receipt must stop before persistence; these bounded checks do not claim atomic exclusion of every external mutation after the final observation. +- The requested final full suite supersedes the older PLUG-001 plan's instruction to run only its focused suite. Run one full suite after the coherent batch; do not repeat passing checks on unchanged inputs merely because a commit was created. + +## Evidence and disposition + +Independent validation task `01a07f93-ee6f-7173-a673-e6074ec93af1` supplied the eleven-claim adjudication. Its results are context, not implementation authority. Planning reread the live CodeRabbit inline comments and inspected the pinned source, classifier, descriptor reader, activation sink, fixture allocation, runbook, and canonical gate contract. + +| Item | Verified owner and consequence | Smallest repair | +| --- | --- | --- | +| A — receipt reinspection race | `repository_preparation.py:1850–1855` reinspects after classifier `resume`, appends a possibly null SHA, and lets descriptor errors escape. `program_activation.py:2245–2262` consumes the assessment before `atomic_replace_json` at line 2359; post-write authority validation at lines 2361–2363 is too late to reject a malformed candidate. | Guard the local inspection, require an existing snapshot with a valid digest, append no invalid binding, and return assessment issues. No activation production edit. | +| B — wrong-mode false positive | `tests/test_state_authority.py:895–903` destroys the fixture, recreates it, then passes the previous fixture's allocation. Rebuilding only the source baseline leaves no valid allocation. | Retain the collision case; replace its stale second half with a separate test that preallocates a fresh private root and then changes that same root's mode. | +| C — activation order | Runbook lines 39–45 omit due pre-activation gate decisions. Canonical skill line 32 and `SetupActivationTests.test_non_reused_activation_gate_is_durable_before_approval_receipts` require them after setup and before approval receipts/status. | Insert one ordered item and renumber the remaining items. | + +Source comments: [A](https://github.com/CoveMB/implementation-plugin/pull/18#discussion_r3954757067), [B](https://github.com/CoveMB/implementation-plugin/pull/18#discussion_r3954757075), [C](https://github.com/CoveMB/implementation-plugin/pull/18#discussion_r3954757025). Review suggestions are untrusted; only the independently checked scope above is retained. + +**Protection-context counterevidence:** `descriptor_protection_context(workspace, program_root=..., inspection=...)` includes the program-root device/inode among the protected identities. `inspect_workspace_path(program_root, receipt_path, ...)` starts at that root and rejects it if that same identity is forwarded. Therefore **do not forward the existing protection context unchanged**, strip its identities globally, or reinterpret workspace-relative protected paths as program-relative paths. Keep the existing receipt-rooted descriptor inspection and classifier protections. This repair adds local error/digest handling; it does not require a new protection-context API. The valid-receipt control below must pass with the normal production context. + +**Mode-test counterevidence:** A fresh preallocated root classifies `retry-ready`. Changing only its mode from `0700` to `0755`, retaining device/inode/owner and source bytes, raises `Delete quarantine allocation binding changed` at the recorded-allocation comparison (`state_authority.py:4694–4701`). That is the correct current application failure for mode drift; do not require the later `root identity or mode changed` message or edit production to reach that later branch. Preconditions below distinguish this failure from the stale-allocation false positive. + +**Current documentation check, 2026-09-08:** [Python 3.14 unittest.mock documentation](https://docs.python.org/3.14/library/unittest.mock.html#where-to-patch) explains that patches must target the namespace where an object is looked up. The tests below patch the assessment function's actual globals because this repository loads scripts under multiple module names. They call the real classifier and descriptor reader; only the race/error boundary is controlled. + +**Explicit exclusions:** Of the original eleven claims, exclude #1 developer-local historical path hygiene, #2 recovery-name spelling, #4 duplicated discovery diagnostic prefix, #5 unreachable legacy recovery-slot crash, #6 unsupported relaxation of legacy `candidate_sha256`, #7 setup-validator exception normalization without demonstrated new application failure, #10 v1/v2 parser diagnostic wording, and #11 rollover predicate diagnostic granularity. Do not reopen them during this repair. The CodeRabbit docstring threshold is not a repository requirement. PLUG-002, review replies, merge/reinstall/replay, and all other boundaries above remain excluded. + +## Exact future change inventory + +| Path | Responsibility | +| --- | --- | +| `skills/implementing-staged-plans/scripts/repository_preparation.py` | A only: local guard around receipt inspection and binding append in `validate_execution_workspace_v2`. | +| `tests/test_delete_operation_lifecycle.py` | A only: production-fixture assessment/transition regressions and valid-receipt control. | +| `tests/test_state_authority.py` | B only: remove stale wrong-mode half of collision test; add independently allocated mode-drift test. | +| `implementing-staged-plans-bootstrap-execution-review-runbook.md` | C only: manifest-v3 ordered activation list. | + +All other files are read-only context, including `program_activation.py`, `state_authority.py`, shared fixtures, existing plans, package metadata, and version owners. No new implementation files or fixture framework are needed. + +## Task 1 — Reject invalid receipt snapshots before persistence + +**Files:** The A production owner and lifecycle test file from the inventory. + +**Interfaces:** Keep `validate_execution_workspace_v2(program_root, baseline, inspection, *, increment_state, protected_paths=(), protected_identities=()) -> ExecutionWorkspaceAssessmentV2` unchanged. Invalid receipt reinspection produces `valid=False`, a receipt-specific issue, and no binding for that Delete path. Keep `advance_execution_state(program_root, target_increment_state, observation)` unchanged; its existing guard raises before the status writer. + +- [ ] **1. Verify execution scope.** Read this plan and its contracts, then inspect current state. A later execution instruction must identify the approved worktree/branch; this plan's commit alone grants no implementation authority. + +```bash +rtk git status --short --branch +rtk git rev-parse HEAD +rtk git diff --name-status b031fe442efc685f459f6b49a102767d6ede061b +rtk git diff --cached --name-status +rtk proxy python3 --version +``` + +Expected: production still matches the pinned head, or a separately authorized change has been revalidated. The only pre-implementation addition should be this plan. Preserve unexpected work and resolve a material scope conflict before edits. Do not fetch for this fixed-head comparison. + +- [ ] **2. Add these tests to `tests/test_delete_operation_lifecycle.py`.** Add `tempfile`, `contextmanager` from `contextlib`, and `replace` from `dataclasses` to the existing imports, then append this class. The fixture reaches implementing through production activation and quarantine writers; required Create outputs include the raw review reports. No status or receipt records are fabricated. + +```python +class DeleteReceiptReinspectionTests(unittest.TestCase): + def setUp(self): + self.fixture, self.legacy_bytes = _authorized_delete_program_with_successor() + self.addCleanup(self.fixture.close) + self.root = self.fixture.program_root + ACTIVATION.advance_execution_state( + self.root, "implementing", _fresh_observation(self.fixture) + ) + (self.fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(self.fixture.repository) + self.baseline = ACTIVATION.execution_baseline_v2_from_value( + json.loads( + (self.root / "increments/ARCHIVE-INDEX/execution-baseline.json") + .read_text(encoding="utf-8") + ) + ) + self.binding = self.baseline.delete_quarantine_bindings[0] + self.receipt_path = self.root / self.binding["receipt_path"] + self.validate = ACTIVATION.validate_execution_workspace_v2 + + def assess(self): + inspection = ACTIVATION.inspect_repository(self.fixture.repository, self.fixture.head) + normalized = ACTIVATION._without_owned_program_paths(self.root, inspection.observation) + return self.validate( + self.root, + self.baseline, + replace(inspection, observation=normalized), + increment_state="reviewing", + ) + + @contextmanager + def receipt_race(self, kind): + namespace = self.validate.__globals__ + classify = namespace["classify_delete_quarantine_recovery"] + inspect = namespace["inspect_workspace_path"] + events = [] + with tempfile.TemporaryDirectory() as directory: + saved = Path(directory) / "receipt.json" + displaced = Path(directory) / "receipt-link" + + def classify_then_race(*args, **kwargs): + recovery = classify(*args, **kwargs) + if not events and recovery.disposition == "resume": + events.append("classified-resume") + if kind in {"missing", "symlink"}: + self.receipt_path.rename(saved) + if kind == "symlink": + self.receipt_path.symlink_to(saved) + return recovery + + def inspect_receipt(root, relative, **kwargs): + if ( + Path(root) != self.root + or relative != self.binding["receipt_path"] + or events != ["classified-resume"] + ): + return inspect(root, relative, **kwargs) + events.append("reinspected") + try: + if kind == "oserror": + raise OSError("injected receipt descriptor failure") + snapshot = inspect(root, relative, **kwargs) + if kind == "null-sha": + return replace(snapshot, sha256=None) + if kind == "invalid-sha": + return replace(snapshot, sha256="invalid") + return snapshot + finally: + if saved.exists(): + if self.receipt_path.is_symlink(): + self.receipt_path.rename(displaced) + saved.rename(self.receipt_path) + + with mock.patch.dict( + namespace, + { + "classify_delete_quarantine_recovery": classify_then_race, + "inspect_workspace_path": inspect_receipt, + }, + ): + yield events + + def test_receipt_reinspection_failure_returns_invalid_assessment(self): + for kind in ("missing", "symlink", "oserror", "null-sha", "invalid-sha"): + with self.subTest(kind=kind): + before = repository_snapshot(self.fixture.repository) + with self.receipt_race(kind) as events: + assessment = self.assess() + self.assertEqual(events, ["classified-resume", "reinspected"]) + self.assertFalse(assessment.valid) + self.assertTrue(any( + issue.startswith("Delete quarantine receipt ") + for issue in assessment.issues + ), assessment.issues) + self.assertEqual(assessment.product_states.delete_quarantine_bindings, ()) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + + def assert_reviewing_rejected(self, kind): + status_path = self.root / "state/status.json" + before_status = status_path.read_bytes() + before = repository_snapshot(self.fixture.repository) + with self.receipt_race(kind) as events: + with mock.patch.object( + ACTIVATION, "atomic_replace_json", + wraps=ACTIVATION.atomic_replace_json, + ) as writer: + with self.assertRaises(ValueError) as raised: + ACTIVATION.advance_execution_state( + self.root, "reviewing", _fresh_observation(self.fixture) + ) + writer.assert_not_called() + self.assertIn("Delete quarantine receipt ", str(raised.exception)) + self.assertEqual(events, ["classified-resume", "reinspected"]) + self.assertEqual(status_path.read_bytes(), before_status) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertFalse((self.fixture.repository / "legacy.ts").exists()) + self.assertEqual( + (self.root / self.binding["entry_path"]).read_bytes(), + self.legacy_bytes, + ) + + def test_missing_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("missing") + + def test_symlink_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("symlink") + + def test_receipt_oserror_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("oserror") + + def test_null_receipt_sha_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("null-sha") + + def test_invalid_receipt_sha_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("invalid-sha") + + def test_valid_receipt_keeps_exact_digest_and_reviewing_transition(self): + receipt_bytes = self.receipt_path.read_bytes() + expected_digest = hashlib.sha256(receipt_bytes).hexdigest() + assessment = self.assess() + self.assertTrue(assessment.valid, assessment.issues) + expected_bindings = ({ + "path": "legacy.ts", + "receipt_path": self.binding["receipt_path"], + "receipt_sha256": expected_digest, + },) + self.assertEqual(assessment.product_states.delete_quarantine_bindings, expected_bindings) + prior = json.loads((self.root / "state/status.json").read_text()) + transition = ACTIVATION.advance_execution_state( + self.root, "reviewing", _fresh_observation(self.fixture) + ) + status = json.loads((self.root / "state/status.json").read_text()) + self.assertEqual(transition.increment_state, "reviewing") + self.assertEqual(status["state_sequence"], prior["state_sequence"] + 1) + result = status["execution_transition_binding"]["product_path_states"] + self.assertEqual(result["delete_quarantine_bindings"], list(expected_bindings)) + tombstone = next(row for row in result["ordered_path_states"] if row["path"] == "legacy.ts") + self.assertFalse(tombstone["exists"]) + self.assertIsNone(tombstone["sha256"]) + self.assertEqual(self.receipt_path.read_bytes(), receipt_bytes) + self.assertEqual((self.root / self.binding["entry_path"]).read_bytes(), self.legacy_bytes) + self.assertEqual( + ACTIVATION.validate_state_authority( + self.root, + ACTIVATION._without_owned_program_paths( + self.root, _fresh_observation(self.fixture) + ), + ), [] + ) +``` + +The transient missing/symlink races occur **after real successful classification**. The wrapper restores the original receipt after the descriptor read, preserving its bytes and identity. This ensures a later fresh authority read cannot mask the invalid candidate produced by the earlier assessment. Each transition variant gets its own fresh fixture so one bad status write cannot contaminate another RED. Direct assessment uses activation's existing owned-program-path normalization, including retained publication staging paths. Null/invalid digest cases inject only the descriptor-result boundary; they do not mock the classifier or assessment. The OSError case checks the promised assessment error contract. The valid control prevents an unconditional failure or incorrect protection-context forwarding from passing the negative tests. + +- [ ] **3. Run RED before production changes.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteReceiptReinspectionTests -v +``` + +Expected: the valid control passes. Missing/null/invalid SHA assessment cases fail because the current assessment accepts them. Unsafe symlink and OSError cases escape assessment. Transition variants fail because status is written or the error lacks the receipt-specific assessment issue; OSError escapes the expected ValueError contract. Loader errors, missing fixture outputs, or unmapped publication staging paths do not count as RED. Record actual failure text before the production edit. + +- [ ] **4. Replace only the existing receipt-snapshot-and-append block inside the `resume` branch with this code.** Reuse the module's existing `_SHA256` validator. Keep the preceding classifier and subsequent product-state assembly unchanged. + +```python + try: + receipt_snapshot = inspect_workspace_path( + program_root, + str(binding["receipt_path"]), + ) + except (OSError, ValueError) as error: + issues.append( + f"Delete quarantine receipt inspection failed: {relative} ({error})" + ) + else: + if ( + not receipt_snapshot.exists + or not isinstance(receipt_snapshot.sha256, str) + or not _SHA256.fullmatch(receipt_snapshot.sha256) + ): + issues.append( + f"Delete quarantine receipt is missing or invalid: {relative}" + ) + else: + bindings.append({ + "path": relative, + "receipt_path": binding["receipt_path"], + "receipt_sha256": receipt_snapshot.sha256, + }) +``` + +Do not use `continue` to skip the path-state append; preserve the assessment's complete ordered inventory even when invalid. Do not catch errors around the whole validator, return a fabricated valid product result, loosen parsers, add post-write rollback, or change activation. The existing final missing-binding diagnostic may accompany the new inspection issue and remains useful. + +- [ ] **5. Run GREEN and the focused application contract checks.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle.DeleteReceiptReinspectionTests tests.test_repository_preparation.ExecutionWorkspaceValidationTests tests.test_repository_preparation.ExecutionV2ContractTests -v +``` + +Expected: all executed cases pass. Failures leave exact status bytes/sequence, authority ledgers, product files, receipt bytes, and retained quarantine bytes unchanged. The valid transition persists a real receipt SHA and a null product tombstone SHA, then passes authority validation. This is one bounded production/test deliverable; inspect its diff before moving on. Keep implementation commits pending until the final verification step unless a separate execution instruction explicitly authorizes intermediate commits. + +## Task 2 — Make the wrong-mode test exercise a valid allocation + +**Files:** Only `tests/test_state_authority.py`. + +**Interfaces:** Reuse `DeleteQuarantineTests.allocate(baseline)`, `classify_delete_quarantine_recovery(...)`, and `quarantine_bound_regular_file(...)`. No production changes or new fixture helpers. + +- [ ] **1. Expose the false-positive fixture with RED.** In the current collision test's second half, insert this precondition immediately after `self.setUp()` and before creating the manual root. Keep its existing local `baseline` for this RED run. + +```python + self.assertEqual( + AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ).disposition, + "retry-ready", + ) +``` + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_state_authority.DeleteQuarantineTests.test_quarantine_destination_collision_fails_without_replacing_bytes -v +``` + +Expected RED: the supposed starting allocation is `recovery-required`, not `retry-ready`. This test-only repair has a fixture-precondition RED; do not deliberately break a working production mode check to manufacture failure. + +- [ ] **2. Preserve the collision case and replace its stale second half.** Remove that test's block from `shutil.rmtree(self.workspace)` through its final source-byte assertion, including the temporary RED precondition. Add this separate method to the same class. Its normal `setUp` supplies a fresh source and baseline; `allocate` supplies the same root later changed to `0755`. + +```python + def test_preallocated_quarantine_mode_drift_fails_before_move(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + allocation = self.allocate(baseline) + root = self.program_root / allocation.root_path + original = root.stat() + source_bytes = self.target.read_bytes() + self.assertEqual(original.st_mode & 0o777, 0o700) + self.assertEqual( + AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ).disposition, + "retry-ready", + ) + + root.chmod(0o755) + changed = root.stat() + self.assertEqual(changed.st_mode & 0o777, 0o755) + self.assertEqual( + (changed.st_dev, changed.st_ino, changed.st_uid), + (original.st_dev, original.st_ino, original.st_uid), + ) + with self.assertRaisesRegex( + ValueError, "^Delete quarantine allocation binding changed$" + ): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), source_bytes) + self.assertEqual(source_bytes, b"bytes retained by quarantine\n") + self.assertEqual( + AUTHORITY.inspect_workspace_path(self.workspace, "legacy.ts"), self.baseline + ) + self.assertFalse((self.program_root / allocation.quarantine_path).exists()) + self.assertFalse((self.program_root / allocation.receipt_path).exists()) +``` + +- [ ] **3. Run GREEN for both distinct protections and the related quarantine cases.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_state_authority.DeleteQuarantineTests -v +``` + +Expected: the collision test still preserves attacker destination bytes and source bytes. The new mode test proves valid allocation before a mode-only change, then rejection without a product move or receipt. Existing symlink, relocation/race, unsupported primitive, interruption, and no-data-loss tests remain intact. No production mode/error changes are permitted to satisfy this task. + +## Task 3 — Restore the manifest-v3 activation record order + +**Files:** Only `implementing-staged-plans-bootstrap-execution-review-runbook.md`, the ordered list under “Activate a Generated Program.” + +**Interfaces:** Documentation only. Canonical gate decisions, approval records, status fields, and first-start behavior do not change. + +- [ ] **1. Confirm the existing application gate test.** Read the canonical skill's activation paragraph and this existing test, then run it once: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup.SetupActivationTests.test_non_reused_activation_gate_is_durable_before_approval_receipts -v +``` + +Expected: PASS. It proves an unsatisfied non-reused gate leaves approval receipts empty and status awaiting approval, then a persisted gate permits activation. The defect is documentary omission, so no source-text assertion or artificial failing application test is warranted. + +- [ ] **2. Replace the four-item list with exactly this sequence.** Keep its introduction and the following semantic-handoff/legacy-v2 paragraph unchanged. + +```markdown +1. setup decision; +2. any due pre-activation gate decisions; +3. program approval; +4. workspace-selection approval; and +5. active/awaiting-first-increment status last. +``` + +- [ ] **3. Verify the narrow diff and existing documentation contracts.** + +```bash +rtk git diff -- implementing-staged-plans-bootstrap-execution-review-runbook.md +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_distribution_documentation -v +``` + +Expected: only the ordered item/numbering changes, and documentation checks pass. Manual comparison with the canonical owner verifies the wording; these checks do not themselves prove gate enforcement. + +## Final verification and bounded handoff + +- [ ] **1. Make one requirement and DRY pass.** Map A/B/C to the four-file inventory. Confirm the receipt guard cannot append null/invalid digests or skip product-state collection; the production sink is unchanged; mode drift starts from a real allocation; the runbook has the exact gate order. Remove only newly introduced unnecessary flexibility. Preserve all eight excluded claims and preceding Delete repairs without editing them. + +- [ ] **2. Run one final full suite, package validation, and whitespace/scope checks after all three tasks.** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +rtk git diff --check +rtk git diff --check b031fe442efc685f459f6b49a102767d6ede061b +rtk git diff --name-status b031fe442efc685f459f6b49a102767d6ede061b +rtk git status --short --branch +``` + +Expected: terminal exit 0 with actual counts/skips recorded, package validation success, no whitespace errors, and only this plan plus the four approved implementation paths in the aggregate diff. The full suite includes legacy v1/v2, setup/envelope-v1, typed Delete, interrupted adoption, prior activation authority exceptions, review-hash binding, rollover, and discovery coverage. Inspect results rather than copying historical counts. Do not compare/install a plugin or start hosted jobs. Disclose native-Windows or other platform tests that were skipped; local Darwin Delete tests do not establish additional platform support. + +- [ ] **3. Freeze the exact final review scope.** Record HEAD, the worktree/index diff against the reviewed head, changed-path inventory, RED/GREEN receipts, full-suite and package results, and platform limitations. No subagents are authorized by this planning task. If later execution authorizes an independent reviewer, use at most one bounded read-only final reviewer for A/B/C and regressions caused by them, with no recursive delegation or external source transmission. Otherwise leave that independent review outstanding for the user rather than silently spawning one. Do not label a self-review independent or repeatedly review unchanged code. + +- [ ] **4. Commit the coherent implementation only if the future execution instruction authorizes it.** Confirm no unrelated staged work is included; use the exact four approved paths. A suitable concise message is `Reject invalid Delete receipt bindings`. This plan authorizes no implementation commit on its own. + +```bash +rtk git diff --check +rtk git add -- skills/implementing-staged-plans/scripts/repository_preparation.py tests/test_delete_operation_lifecycle.py tests/test_state_authority.py implementing-staged-plans-bootstrap-execution-review-runbook.md +rtk git diff --cached --name-status +rtk git commit --only -m "Reject invalid Delete receipt bindings" -- skills/implementing-staged-plans/scripts/repository_preparation.py tests/test_delete_operation_lifecycle.py tests/test_state_authority.py implementing-staged-plans-bootstrap-execution-review-runbook.md +rtk git show --format=fuller --stat HEAD +rtk git rev-parse HEAD HEAD^ +rtk git status --short --branch +``` + +- [ ] **5. Report and stop at the authorized boundary.** Report exact changed paths, head/parent, RED/GREEN failures and passes, final suite/package results, independent review status, and limitations. If a required check or review is outstanding, state it. No push, PR, merge, reinstall, pipeFlow replay, comment replies, or PLUG-002 work follows automatically. + +## Recovery and acceptance criteria + +The implementation is complete only when failed reinspection returns an invalid assessment with no invalid receipt binding; reviewing transitions reject before status persistence; successful transitions retain their exact valid bindings; mode-only quarantine drift rejects while preserving the source and empty destinations; the activation list agrees with its canonical owner; and the required final checks pass on the actual candidate. + +Keep real program evidence intact on failure. Do not rewrite receipt/status hashes, dispose of quarantine, recreate product files, or clean a divergent transaction. Existing exact recovery classification/adoption remains the only previously authorized recovery mechanism; a divergent real program needs separately authorized reconciliation. For a rejected code change, preserve work and prepare a reverse diff for review instead of resetting the checkout. + +Planning verification receipts are recorded below; they are not future GREEN or full-suite results. + +### Planning verification receipts — 2026-09-08 + +- Planning ran on Darwin with Python 3.14.6. The reviewed production files remained unchanged. No filesystem `AGENTS.md` was found in this worktree or its ancestor directories; the supplied user instructions and `/Users/CoveMB/.codex/RTK.md` governed the task. +- All four Python excerpts parsed successfully using `ast.parse(textwrap.dedent(block))`. The lifecycle class was executed directly from this Markdown with the existing test module's imports plus the three specified imports, without writing a test file or modifying a production function. +- Final A excerpt run: **7 test methods, 7 assertion failures and 3 errors across the negative cases, 0 skips; valid-receipt control passed.** Each missing/null/invalid-SHA transition reached `atomic_replace_json` once, failing `writer.assert_not_called()`. Direct assessments incorrectly returned valid; symlink and injected OSError escaped the assessment. These are expected RED observations of the pinned defect, not a passing suite. +- B excerpt checks: the strengthened original fixture failed once with `recovery-required != retry-ready`; the corrected standalone fresh-allocation test passed once against unchanged production. The mode-only probe retained the same device/inode/owner and source bytes and raised `Delete quarantine allocation binding changed`. +- The existing gate application test passed: `rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup.SetupActivationTests.test_non_reused_activation_gate_is_durable_before_approval_receipts -v` — **1 test, exit 0**. +- `rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py .` — **Package validation passed, exit 0**. This checks the current package; the added plan is outside its runtime surface. +- Initial exploratory harnesses exposed a candidate-versus-published-root mistake, missing raw-review Create outputs, and missing owned-program-path normalization. Those harness issues were corrected in the final excerpts, and only the changed A harness was rerun. They are not counted as defect evidence or passing checks. +- No production GREEN, final full suite, native-Windows execution, independent final implementation review, merge, or installed-copy validation was performed during planning. Those claims remain unavailable until separately authorized execution and its required checks. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 32ab9c6..740fcb4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -136,7 +136,7 @@ bounded diagnosis. Never delete, overwrite, or invent a replacement prefix. ## Continuation or blocked recovery stops -**Likely cause:** The request does not match the typed 0.1.2 route, or discovery +**Likely cause:** The request does not match the typed 0.1.3 route, or discovery found an interrupted or divergent prefix. Legacy automatic rollover stops at `legacy-rollover-upgrade-required`; generic direct blocked edges stop at `blocked-transaction-required`. Revision, supersession, and cancellation remain @@ -154,6 +154,25 @@ bytes and stop at the reported recovery route. Do not use the generic transition API, edit state by hand, or infer mutation authority from a handoff, file, retrieved prompt, or assistant-quoted prompt. +## Delete stops or requires recovery + +**Likely cause:** The setup/envelope family is not v2; the exact-file map, +baseline, product result, receipt, or inherited tombstone does not match; the +source or quarantine is protected, symlinked, changed, colliding, or on another +filesystem; or the platform lacks the required descriptor-relative operation. + +**Safe checks:** Preserve both pathnames. Re-run read-only discovery and compare +the current source identity, deterministic quarantine allocation, receipt, +retained bytes, and status-bound product result. A missing source is valid only +after the exact rename and receipt; an inherited tombstone may reappear only +under a current explicit `Create`. + +**Next action:** Before rename, correct the plan or environment and rebuild the +baseline through the legal route. After rename, retry only the byte-identical +Delete transaction when discovery reports receipt adoption ready. Otherwise +stop for bounded diagnosis. Never unlink, overwrite, restore, or dispose of +quarantine by hand, and do not describe PLUG-001 as secure erasure or closure. + ## Validation passes, but live activation is still unproven **Likely cause:** Static validation confirmed files, metadata, schemas, links, diff --git a/docs/workflows.md b/docs/workflows.md index 13971ad..8fa728e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -57,6 +57,12 @@ omit only that routine pause. Every mode still needs the current increment grant, an exact plan, an execution baseline, and plan-bound write authority. Authorized state permits no product delta. +Accepted v1 setup supports `Create`, `Modify`, and `Preserve`. The exact +setup/envelope v2 family adds `Delete`; each Delete path must be an exact +regular file with a baseline-bound same-filesystem quarantine destination. +Protected paths, changed identities, symlinks, collisions, cross-device moves, +and unsupported descriptor operations stop before mutation. + ## 4. Prepare Review and Diff Disposition After the exact implementation delta reaches reviewing state, prepare the @@ -71,6 +77,11 @@ test-evidence reports. Stop at the exact diff disposition. Questions about the diff do not accept it. Keep the status unchanged until the exact disposition is submitted directly. +For the v2 family, review and diff disposition carry the complete ordered +product-path result rather than a legacy product-delta digest. A deleted path +must remain absent and match its exact quarantine receipt and retained bytes +through approval-v3 acceptance. + An open material finding uses a typed remediation round trip. Persist the initial review evidence in `reviewing`, enter `remediating`, make the bounded repair, and require renewed affected-scope reports that name every initial @@ -81,8 +92,8 @@ repair findings or advance the lifecycle. ## 5. Dispose the Current Diff The new-model typed diff-disposition prompt always offers `accept-stop`. It -offers `accept-continue` only when traceability names one successor and every -dependency is satisfied. Both choices persist the same Plan A acceptance prefix +offers `accept-continue` only when the [successor contract](../skills/implementing-staged-plans/references/state-authorization.md#allocate-lifecycle-writes-before-authority) +resolves the next increment. Both choices persist the same Plan A acceptance prefix and accepted status first. Already persisted legacy programs using `approval:full` or `approval:full-diff` retain automatic acceptance. The continue choice then completes its prompt-bound rollover with no second @@ -105,6 +116,11 @@ writes status last. The successor status binds manifest or inherit genesis authority. The successor exact plan allocates its own complete lifecycle paths before its baseline and write authority exist. +Delete-capable rollover uses result-bound action-v3 and reuses the existing +handoff. Its inherited workspace retains ordered product states and quarantine +receipts. A tombstone may be recreated only when that successor explicitly owns +the path as `Create`; recreation preserves historical quarantine evidence. + ## 8. Resolve a Blocked Program Only active `implementing` or `reviewing` state can enter the typed blocked @@ -116,8 +132,10 @@ separate. ## 9. Close a Final Program -Use this route only after the accepted increment is final and traceability -allocates no successor. +Use this route only after current acceptance and an explicit terminal result from +the [successor contract](../skills/implementing-staged-plans/references/state-authorization.md#allocate-lifecycle-writes-before-authority). +Unavailable selection cannot authorize closure. PLUG-002's accepted-chain and +terminal Delete closure/disposal work remains separate. ```text Use $implementing-staged-plans to prepare closure for the accepted final @@ -134,7 +152,8 @@ status last. Closure approval authorizes no commit or later action. ## Unsupported routes and mandatory stops -Version 0.1.2 adds typed successor rollover and blocked recovery while +Version 0.1.3 adds exact regular-file Delete to typed successor rollover and +blocked recovery while preserving these sink guards: - legacy automatic or caller-authored rollover returns @@ -156,6 +175,12 @@ unsafe, stale, ambiguous, or unexpected prefix is preserved and returns the corresponding recovery-required disposition. Do not delete or rewrite it as a routine recovery step. +Delete recovery is likewise non-destructive: before rename, failure leaves the +product file in place; after a matching rename, only an exact receipt may be +adopted. A changed quarantine, replacement source, or divergent receipt is +preserved for diagnosis. PLUG-001 never unlinks quarantine, claims secure +erasure, supplies PLUG-002 requirement evidence, or performs terminal closure. + ## Authority reminders - Program creation is not program activation. diff --git a/implementing-staged-plans-bootstrap-execution-review-runbook.md b/implementing-staged-plans-bootstrap-execution-review-runbook.md index 7abe157..a427ceb 100644 --- a/implementing-staged-plans-bootstrap-execution-review-runbook.md +++ b/implementing-staged-plans-bootstrap-execution-review-runbook.md @@ -1,6 +1,6 @@ # Implementing Staged Plans — Bootstrap, Execution, and Review Runbook -**Version boundary:** Plan A 0.1.1 plus Plan B 0.1.2 +**Version boundary:** Plan A 0.1.1, Plan B 0.1.2, plus PLUG-001 0.1.3 **Purpose:** Operate the implemented multi-increment lifecycle without claiming unsupported program revision, supersession, or cancellation routes. @@ -36,16 +36,20 @@ Review the source snapshot, traceability, proposed program, workspace observation, first brief, approval mode, and initial status. Then submit the one copy-ready launch prompt directly and without editing it. -Activation appends or adopts these separate typed receipts in order: +For manifest v3, activation appends or adopts these separate typed records in +order: -1. program approval; -2. workspace-selection approval; -3. first-increment grant; and -4. active/preparing status last. +1. setup decision; +2. any due pre-activation gate decisions; +3. program approval; +4. workspace-selection approval; and +5. active/awaiting-first-increment status last. -One prompt can carry the fully bound decision without collapsing the receipts. -Only the direct submission is activation authority. A generated or quoted -prompt that the user did not submit is not authority. +The returned semantic handoff is navigation only. Its direct submission in a +fresh task can append or adopt the first-increment grant and write +active/preparing status last. Existing manifest-v2 proposals retain their +byte-exact combined activation route. Only direct submission is authority; a +generated or quoted prompt that the user did not submit is not authority. ## Before Production Modification @@ -54,15 +58,25 @@ increment, user-owned work, and immutable manifest storage descriptors. The file map must include all required future lifecycle owners even though allocation does not authorize their use. +The v1 setup/envelope family supports `Create`, `Modify`, and `Preserve`. The +exact v2 family adds `Delete`. A Delete names one exact program-owned regular +file and a descriptor-bound quarantine allocation inside the selected local +repository. Git metadata, program/control paths, quarantine paths and held +identities remain protected. A symlink, collision, source change, unsupported +descriptor capability, or cross-device allocation stops before mutation. + Under `approval:standard`, stop for exact plan approval. Under pre-approve and full-increment policy, omit only the routine plan pause. Every mode still needs the status-current increment grant, execution baseline, and plan-bound action authorization. Persist the baseline and authorization before authorized status. Authorized status permits no product delta. -Implement only the exact product map. Preserve all baseline user work. Advance -to reviewing only when the observed product delta exactly matches its status -binding. +Implement only the exact product map. Preserve all baseline user work. V2 +Delete holds the source and quarantine identities, atomically renames on the +same filesystem, and does not unlink, overwrite, or restore either name. Its +typed result is `absent`, with no product digest, plus an exact receipt bound to +retained quarantine bytes. Advance to reviewing only when the observed product +result exactly matches its status binding. ## Prepare Review and Diff Disposition @@ -72,6 +86,11 @@ their identities, scopes, findings, risk predicates, product-delta binding, and fresh final verification. It creates review evidence, then the review packet, then writes verified and awaiting-diff statuses in order. +For setup/envelope v2, transition, review evidence, review packet, and diff +disposition reproduce the ordered product-path result and quarantine receipts. +The uniquely ordered approval-v3 binds that exact family; omission, reordering, +reappearance, receipt drift, quarantine-byte drift, or legacy mixing stops. + Every new-model typed exact disposition preserves the Plan A stop choice. Already persisted legacy programs using `approval:full` or `approval:full-diff` retain automatic acceptance: @@ -80,8 +99,8 @@ retain automatic acceptance: accept-stop ``` -When exactly one traceability successor is dependency-ready, the prompt also -offers `accept-continue`. Direct submission appends or adopts the exact diff +When the [successor contract](skills/implementing-staged-plans/references/state-authorization.md#allocate-lifecycle-writes-before-authority) +resolves the next increment, the prompt also offers `accept-continue`. Direct submission appends or adopts the exact diff approval and writes accepted status last. Stop ends there. Continue completes its bound rollover with no second routine checkpoint. Neither choice stages or commits files or performs an external action. @@ -94,18 +113,22 @@ rendering another exact disposition. An accepted-stop status never continues by replay. A later fresh task uses the distinct `accepted-state-continuation` prompt derived from current status and the -one canonical successor. The handoff is navigation only. Persist or adopt the -rollover action, successor grant, handoff, successor brief, rollover record, and -successor status in that order. Status last binds +one canonical successor. The existing handoff is reused as navigation only; +no addendum is authored. Persist or adopt the result-bound action-v3, successor +grant, handoff, successor brief, rollover record, and successor status in that +order. Status last binds `current_increment_authority_binding` and leaves the manifest byte-identical. ## Authorize a Successor Increment Every successor repeats Plan A's exact-plan allocation and materialization -contract. The successor baseline uses the existing `inherited_paths` field only -for accepted product bytes proven by the canonical rollover chain and owned as -`Modify` or `Preserve`. It keeps those bytes separate from user-work baselines. -All first-increment and frozen 0.1.1 baselines retain `inherited_paths: []`. +contract. The successor baseline uses `inherited_paths` only for ordered product +states proven by the canonical rollover chain and exactly owned by the current +plan. An inherited Delete remains an absent tombstone with its quarantine +receipt. Only an explicit successor `Create` may recreate that path; it removes +the active receipt binding but preserves historical quarantine evidence. Keep +inherited state separate from user-work baselines. All first-increment and +frozen 0.1.1 baselines retain `inherited_paths: []`. ## Resolve a Blocked Program @@ -117,8 +140,10 @@ recorded prior states with status last. ## Close a Final Program -Closure is available only when the accepted increment is final and no successor -is allocated. Resolve the reconciliation and packet paths from +Closure requires current acceptance and explicit terminal resolution under the +successor contract; unavailable selection never means finality. This eligibility +check leaves PLUG-002's accepted-chain and terminal Delete closure/disposal +requirements unchanged. Resolve the reconciliation and packet paths from `implementation-closure-storage/v1`, and require both paths under the accepted exact plan's `Create` map. @@ -149,9 +174,9 @@ After interruption: ## Unsupported routes -Version 0.1.2 implements typed successor rollover and blocked recovery. It does -not implement program revision, supersession, or cancellation, and it does not -reactivate legacy automatic rollover. +Version 0.1.3 implements exact regular-file Delete, typed successor rollover, +and blocked recovery. It does not implement program revision, supersession, or +cancellation, and it does not reactivate legacy automatic rollover. | Requested operation | Mandatory result | | --- | --- | @@ -164,9 +189,14 @@ These are persistence-sink guards, not advisory prose. Preserve accepted legacy state and historical terminal records as readable evidence. Do not edit status or invoke a generic transition to bypass the stop. +PLUG-001 preserves quarantine and has no secure-erasure claim. It does not own +requirement-specific accepted-chain evidence, quarantine disposal, or terminal closure. +PLUG-002 must add and authorize all three before any disposal or final +Delete-completion claim. + ## Review checklist -Before accepting the 0.1.2 candidate, verify: +Before accepting the 0.1.3 candidate, verify: 1. the source, branch, HEAD, workspace, and dirty-state bindings are current; 2. every product change appears in the exact plan and execution baseline; @@ -174,18 +204,20 @@ Before accepting the 0.1.2 candidate, verify: 4. required raw reviews, findings, dispositions, and final verification match the accepted delta; 5. `accept-stop` remains byte-compatible and `accept-continue` appears only for - one dependency-ready successor; + the canonical successor; 6. accepted-state continuation uses a distinct prompt and status-current grant; 7. blocked recovery restores only sink-recorded prior states; 8. Plan A closure is final-only and uses only manifest-derived paths; 9. interruption tests cover every durable write boundary; 10. unsupported mutation calls preserve every repository byte; and -11. no test or static document check is described as proof of live provider, - deployment, accessibility, human-review, or production behavior. +11. every Delete remains bound to an exact absent result, retained quarantine + bytes, receipt, rollover tombstone, and explicit-recreation rule; and +12. no test or static document check is described as proof of live provider, + deployment, accessibility, human-review, or production behavior. ## Verification commands -Run focused checks while implementing each task. On the final unchanged 0.1.2 +Run focused checks while implementing each task. On the final unchanged 0.1.3 candidate, run package validation and the full deterministic suite exactly once, then obtain one bounded independent material review. A review finding is repair authority only when the controller validates it as material and in scope. diff --git a/skills/implementing-staged-plans/SKILL.md b/skills/implementing-staged-plans/SKILL.md index 7c42f03..b66246d 100644 --- a/skills/implementing-staged-plans/SKILL.md +++ b/skills/implementing-staged-plans/SKILL.md @@ -39,17 +39,21 @@ Derive the current exact-file plan and execution baseline from the manifest, sta Materialize the execution baseline and action authorization before status becomes authorized. `authorized` permits no product delta. Advance only through the typed execution transition, preserving user-owned work and every exact plan disposition. +The accepted v1 operation family supports `Create`, `Modify`, and `Preserve`; exact setup/envelope v2 supports `Create`, `Modify`, and `Delete` plus `Preserve`. Route Delete only through the [repository preparation](references/repository-preparation.md) descriptor-bound quarantine contract and the [execution discipline](references/execution-discipline.md) typed absence and recovery rules. Unsupported platforms, cross-device movement, protected paths, or changed identities stop before mutation. + For manifest/status v3, satisfy each immutable source-defined gate only at its declared owning boundary. The setup answer may satisfy a gate only when the manifest explicitly declares setup reuse. Every other response writes nothing; do not infer a gate from prose or move it to another transaction. ## Prepare Review and Diff Disposition At reviewing state, use typed review preparation to load the three exact-plan-allocated raw reports, validate findings and risk predicates, bind the accepted product delta, create review evidence and the review packet, and persist verified then awaiting-diff status. Status is last at each boundary, and exact partial prefixes are retryable. +For setup/envelope v2, bind the ordered product-path result—including each Delete tombstone and quarantine receipt—through transition, review, diff disposition, and the uniquely ordered approval-v3 family. Never downgrade that result to a legacy product-delta record. + Review preparation stops at the exact diff-disposition prompt. Questions or discussion do not accept the candidate. Acceptance grants no closure, commit, push, pull request, publication, deployment, or external action. ## Dispose the Current Diff -The exact `accept-stop` choice is always available for new-model typed dispositions and preserves Plan A bytes. Already persisted legacy programs using `approval:full` or `approval:full-diff` retain automatic acceptance. When traceability identifies exactly one successor whose dependencies are satisfied, the same prompt may also offer exact `accept-continue`. Direct submission persists or adopts the diff-acceptance prefix and accepted status first. `accept-continue` then completes its bound successor rollover with no second routine checkpoint; it does not grant any later or external action. +The exact `accept-stop` choice is always available for new-model typed dispositions and preserves Plan A bytes. Already persisted legacy programs using `approval:full` or `approval:full-diff` retain automatic acceptance. When the [successor contract](references/state-authorization.md#allocate-lifecycle-writes-before-authority) resolves the next increment, the same prompt may also offer exact `accept-continue`. Direct submission persists or adopts the diff-acceptance prefix and accepted status first. `accept-continue` then completes its bound successor rollover with no second routine checkpoint; it does not grant any later or external action. ## Continue an Accepted Program @@ -57,9 +61,11 @@ Replaying `accept-stop` only recovers or reports the same accepted-stop state. A ## Authorize a Successor Increment -Validate the current accepted projection, canonical rollover chain, successor dependencies, accepted product bytes, workspace, and prompt before writing. Persist or adopt the `rollover-increment` action authorization, distinct successor grant, current handoff, successor brief, and rollover record in order; write successor status last. Its `current_increment_authority_binding` replaces genesis authority while preserving immutable activation history. +Validate the current accepted projection, canonical rollover chain and [successor contract](references/state-authorization.md#allocate-lifecycle-writes-before-authority), accepted product bytes, workspace, and prompt before writing. Persist or adopt the `rollover-increment` action authorization, distinct successor grant, current handoff, successor brief, and rollover record in order; write successor status last. Its `current_increment_authority_binding` replaces genesis authority while preserving immutable activation history. + +Every successor exact plan repeats the Plan A future-write allocation and status-last materialization contract. Its execution baseline uses `inherited_paths` only for validated accepted product states with exact current-plan ownership; user-work baselines remain separate. -Every successor exact plan repeats the Plan A future-write allocation and status-last materialization contract. Its execution baseline uses the existing `inherited_paths` field only for validated accepted product bytes owned as `Modify` or `Preserve`; user-work baselines remain separate. +The v2 continuation and rollover families use result-bound action-v3 authority, reuse the existing handoff, and preserve an absent tombstone plus retained quarantine bytes. Only an explicit later `Create` may recreate that path. PLUG-001 makes no secure-erasure or quarantine-disposal claim; PLUG-002 must supply requirement evidence, disposal authority, and terminal closure semantics before any such action. ## Resolve a Blocked Program @@ -67,7 +73,7 @@ Only the typed blocked transaction may enter or leave blocked state. Entry is le ## Close a Final Program -An accepted final increment with no traceability-allocated successor may use typed closure preparation. Resolve both paths from `implementation-closure-storage/v1`, require exact-plan `Create` allocation, reconcile every requirement, validate accepted review and fresh verification, create reconciliation then packet, and write awaiting-closure status last. +Current acceptance and an explicit terminal result from the [successor contract](references/state-authorization.md#allocate-lifecycle-writes-before-authority) are required for typed closure preparation and approval/retry. Unavailable selection never implies finality. Resolve both paths from `implementation-closure-storage/v1`, require exact-plan `Create` allocation, reconcile every requirement, validate accepted review and fresh verification, create reconciliation then packet, and write awaiting-closure status last. Render one exact closure-only prompt. Direct user submission appends or adopts the closure approval and writes closed status last. Closure performs no later action and grants none. diff --git a/skills/implementing-staged-plans/agents/openai.yaml b/skills/implementing-staged-plans/agents/openai.yaml index d2264f2..dda2864 100644 --- a/skills/implementing-staged-plans/agents/openai.yaml +++ b/skills/implementing-staged-plans/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Implementing Staged Plans" - short_description: "Create, continue, or recover implementation programs." - default_prompt: "Use $implementing-staged-plans to create, activate, continue, or recover a repository-backed implementation program." + short_description: "Create, continue, recover, or delete through staged programs." + default_prompt: "Use $implementing-staged-plans to create, activate, continue, recover, or execute an exact-file Delete in a repository-backed implementation program." policy: allow_implicit_invocation: false diff --git a/skills/implementing-staged-plans/references/continuity-closure.md b/skills/implementing-staged-plans/references/continuity-closure.md index 4b1f214..9654098 100644 --- a/skills/implementing-staged-plans/references/continuity-closure.md +++ b/skills/implementing-staged-plans/references/continuity-closure.md @@ -48,13 +48,13 @@ Version `0.1.2` keeps the Plan A accept-stop bytes unchanged and adds two explic The legacy caller-authored rollover writer remains quarantined at `legacy-rollover-upgrade-required`; accepted legacy automatic modes never grant successor authority. Only the typed prompt-bound routes below can persist new rollover bytes. -Validate the complete prompt, status-current projection, canonical successor, dependencies, workspace, and accepted product bytes before writing. Persist or adopt the `rollover-increment` authorization, distinct successor grant, current handoff, successor brief, and rollover record in that order; replace successor status last. Every durable prefix is discoverable and retryable with the same prompt. Divergent bytes are preserved and require the matching continuation recovery route. +Validate the complete prompt, status-current projection, [canonical successor](state-authorization.md#allocate-lifecycle-writes-before-authority), workspace, and accepted product bytes before writing. Persist or adopt the `rollover-increment` authorization, distinct successor grant, current handoff, successor brief, and rollover record in that order; replace successor status last. Every durable prefix is discoverable and retryable with the same prompt. Divergent bytes are preserved and require the matching continuation recovery route. Successor execution baselines use the existing `inherited_paths` field. Each inherited path must come from the canonical rollover chain, match the accepted product bytes, have exactly one baseline, be owned as `Modify` or `Preserve`, and remain separate from user-work baselines. First-increment baselines remain byte-compatible with `inherited_paths: []`. ## Reconcile a Program -Only after the final increment is accepted, account for every atomic requirement exactly once with an allowed disposition and evidence. Validate every accepted increment, review packet, addendum, approved amendment, decision, owned deferral, later-invalidation check, and material-finding disposition. Require fresh successful program-level commands completed after all contributing evidence and reassess architecture, documentation, operations, and recovery. +Only after current acceptance and explicit terminal resolution under the [successor contract](state-authorization.md#allocate-lifecycle-writes-before-authority), account for every atomic requirement exactly once with an allowed disposition and evidence. Validate every accepted increment, review packet, addendum, approved amendment, decision, owned deferral, later-invalidation check, and material-finding disposition. Require fresh successful program-level commands completed after all contributing evidence and reassess architecture, documentation, operations, and recovery. For a new-model final first increment, use `program_closure.py`. It resolves both closure paths from the immutable manifest descriptor and requires both paths under the accepted exact plan's `Create` disposition. It does not create a handoff, successor brief, rollover, or later-action authority. A first-increment closure binds the accepted review packet; addendum coverage becomes mandatory only when accepted rollover history exists. @@ -66,7 +66,7 @@ Render a deterministic packet bound to the exact reconciliation digest. Include Final-increment acceptance leaves the program active. Moving to `awaiting-closure-approval` requires exact manifest-owned reconciliation and packet paths, matching digests, validated readiness, and zero blocking counts. Moving to `closed` requires one explicit `program-closure-approval` record bound to both exact digests. -New-model preparation creates or adopts the canonical reconciliation, then the packet, and replaces status last. Exact partial prefixes are retryable. Changed files, unsafe paths, nonfinal allocation, stale accepted product bytes, or divergent prefixes are preserved and require typed recovery. The exact closure prompt appends or adopts the closure approval and replaces status with `closed` last. Replaying that prompt can only recover or report the same closure; it cannot authorize a commit or any consequential action. +New-model preparation creates or adopts the canonical reconciliation, then the packet, and replaces status last. Exact partial prefixes are retryable. Changed files, unsafe paths, nonterminal or unavailable resolution, stale accepted product bytes, or divergent prefixes are preserved and require typed recovery. The exact closure prompt appends or adopts the closure approval and replaces status with `closed` last. Replaying that prompt can only recover or report the same closure; it cannot authorize a commit or any consequential action. ## Decide a Later Action diff --git a/skills/implementing-staged-plans/references/execution-discipline.md b/skills/implementing-staged-plans/references/execution-discipline.md index 3b6177d..39c8524 100644 --- a/skills/implementing-staged-plans/references/execution-discipline.md +++ b/skills/implementing-staged-plans/references/execution-discipline.md @@ -8,7 +8,13 @@ Load the manifest-owned current status, brief, preparation, exact-file plan, exe Before the execution baseline exists, repository dirt must equal the normalized launch observation. After it exists, validate product paths by disposition and lifecycle state: `authorized` permits no product delta; `implementing` permits any subset of declared Create and Modify work; `reviewing` and later require every declared Create path to exist and every declared Modify path to differ from its baseline while Preserve remains byte-identical. Reject new staged, conflicted, unmapped, deleted, unsafe, or changed user-owned paths. -For successor increments, validate every nonempty `inherited_paths` entry against the canonical rollover chain, matching accepted product bytes, and exact `Modify` or `Preserve` ownership. Inherited accepted history is not user-owned dirt and must not be merged into `user_work_baselines`. First-increment and frozen legacy baselines remain byte-compatible with `inherited_paths: []`. +For successor increments, validate every nonempty `inherited_paths` entry against the canonical rollover chain, matching accepted product states, and exact current-plan ownership. Inherited accepted history is not user-owned dirt and must not be merged into `user_work_baselines`. First-increment and frozen legacy baselines remain byte-compatible with `inherited_paths: []`. + +## Delete execution and recovery + +Execute v2 `Delete` only for the exact regular-file source and descriptor-bound quarantine slot recorded in the live baseline. Hold and recheck both identities, require the same-filesystem device binding, and atomically rename source to quarantine. The operation does not unlink, overwrite, or restore either name. Success is a typed `absent` product result with a null product digest, an exact quarantine receipt, and retained quarantine bytes; it carries no secure-erasure claim. + +Recovery is deterministic and non-destructive. Before rename, changed or unsupported state stops with the product file intact. After a matching rename, exact receipt adoption may complete the result; a source replacement, quarantine change, or conflicting receipt stops recovery and preserves both names. A later exact `Create` may explicitly recreate the inherited tombstone, but it does not dispose of the retained quarantine or its historical receipt. ## Meaningful test-first evidence @@ -24,7 +30,7 @@ Reuse accepted preparation validators instead of copying their ownership or over ## Semantic-surface coverage -Inventory every created or renamed symbol, command, test or fixture, heading, schema or identifier, and every created or generated path exactly once. Delegate contextual naming and compatibility decisions to repository preparation. A physical path rename is unsupported because Create/Modify/Preserve has no deletion or typed old/new migration disposition; it requires a future approved migration contract. Reject coordinate-shaped planning names unless a specific implementation-governance artifact or durable domain concept owns them. Existing public, persisted, generated, or external names require an explicit compatibility or migration disposition. +Inventory every created or renamed symbol, command, test or fixture, heading, schema or identifier, and every created or generated path exactly once. Delegate contextual naming and compatibility decisions to repository preparation. A physical Move, Rename, or Replace remains unsupported: `Delete` removes one exact regular-file product path into retained quarantine and is not an old/new migration disposition. Reject coordinate-shaped planning names unless a specific implementation-governance artifact or durable domain concept owns them. Existing public, persisted, generated, or external names require an explicit compatibility or migration disposition. ## Bounded approach autonomy diff --git a/skills/implementing-staged-plans/references/program-authority.md b/skills/implementing-staged-plans/references/program-authority.md index fb91174..8572c24 100644 --- a/skills/implementing-staged-plans/references/program-authority.md +++ b/skills/implementing-staged-plans/references/program-authority.md @@ -61,10 +61,18 @@ Organize atomic requirements into reviewable outcomes with explicit acceptance, Every requirement must be allocated. A group-level allocation may guide preparation, but it cannot substitute for the source-located atomic inventory required for a machine-completeness claim. +For manifest v3, approve the increment list in serial execution order and set `first_increment_id` to its first entry. Dependencies constrain that order; requirement allocations describe participation and may skip intervening increments. Validate these bindings through the canonical [successor and lifecycle allocation contract](state-authorization.md#allocate-lifecycle-writes-before-authority). + ## Elaborate progressively Make the current outcome exact enough to execute and review. Preserve later outcomes semantically while deferring repository-specific file choices. When new evidence changes an approved outcome, acceptance condition, sequence, public contract, authority, or risk posture, stop for a recorded program amendment. Ordinary implementation detail may be elaborated within approved bounds. +## Select one operation family + +The exact setup pair owns the available product operations. `implementation-program-setup-semantics/v1` with `implementation-operation-envelope/v1` supports `Create`, `Modify`, and `Preserve`. `implementation-program-setup-semantics/v2` with `implementation-operation-envelope/v2` adds `Delete`; never mix either member of the v1 and v2 pairs or reinterpret accepted v1 bytes. + +A v2 `Delete` allocation names one exact program-owned regular-file path, an `absent` accepted state, a supported content disposition (`migrated`, `obsolete`, or `intentional-discard`), and a rationale. It cannot collide with another allocation. When the path is created by an earlier increment, that `Create` must be its strict accepted predecessor; otherwise the live descriptor-bound baseline must establish the file before Delete authorization. + ## Revise without rewriting history A new source or program revision receives new immutable paths and digests. Its traceability declares the prior source, program, traceability, and accepted evidence records it preserves. Validate every declared prior digest. diff --git a/skills/implementing-staged-plans/references/program-discovery.md b/skills/implementing-staged-plans/references/program-discovery.md index e62174a..867f062 100644 --- a/skills/implementing-staged-plans/references/program-discovery.md +++ b/skills/implementing-staged-plans/references/program-discovery.md @@ -14,7 +14,7 @@ Every repository, manifest, program root, and manifest-owned logical role must r - A manifest-v3 proposal at sequence zero uses proposal validation. An empty proposal returns `program-setup-ready`; an exact setup-only or partial non-reused pre-activation source-gate prefix returns `source-gate-approval-ready` with the first missing gate's canonical recap; an exact complete gate or derived approval prefix returns `program-activation-retry-ready`. Completed sequence one returns `first-increment-start-ready` for the semantic fresh-task handoff. A v2 proposal retains `program-activation-ready` and its exact launch-prompt recovery route. Out-of-order, duplicate, unrelated, stale, mixed-family, or divergent prefixes fail closed. - Approved new-program status selects approved validation. Discovery inspects manifest-allocated plan, baseline, review, acceptance, closure, approval, authorization, and grant prefixes before applying generic state rejection. Exact controlled prefixes route to the corresponding `*-retry-ready` transaction; unsafe, malformed, unexpected, or state-incompatible artifacts stop without repair. - A new-model accepted status with an exact `accept-stop` binding returns `accepted-stop`. Closure files are a retryable preparation prefix. Complete closure preparation returns `closure-approval-ready`; an exact approval prefix returns `closure-approval-retry-ready`. Closed and superseded programs are non-controlling terminal history. -- A byte-exact immediate rollover prefix returns `increment-continuation-retry-ready` before navigation or `increment-rollover-retry-ready` after navigation begins. A later accepted-state prefix returns the corresponding `accepted-state-*` route. The same exact prompt adopts the prefix; divergence returns the domain-specific continuation recovery route. A completed successor status resumes normally. +- A byte-exact immediate rollover prefix returns `increment-continuation-retry-ready` before navigation or `increment-rollover-retry-ready` after navigation begins. A later accepted-state prefix returns the corresponding `accepted-state-*` route. The same exact prompt adopts the prefix only when the [successor contract](state-authorization.md#allocate-lifecycle-writes-before-authority) still agrees; divergence returns the domain-specific continuation recovery route. Unavailable selection never routes to closure. A completed successor status resumes normally. - A valid sink-authored blocked status returns `blocked-recovery-ready` and reports only its recorded prior program and increment states. An exact resolution action or ledger prefix returns `blocked-resolution-retry-ready`; a completed resumed status returns ordinary `resume`. Malformed context, changed bound evidence, out-of-order records, or divergent prefix bytes return `blocked-recovery-required` without repair. - Classify caller intent with `classify_requested_program_operation`. The implemented front door supports `create`, `activate`, and typed `continue` routing, including exact continuation and blocked-recovery prefixes. A live `revise` or `supersede` intent returns `program-revision-workflow-required`; `cancel` and every other mutation return `unsupported-program-mutation`. Classification is pure and always stops before any unsupported live write. - One valid legacy `active` or `blocked` program: select it, inspect its bound workspace afresh, validate program and state authority, and build resume expectations from the manifest, persisted status, and fresh observation. An accepted legacy automatic mode stops at `legacy-rollover-upgrade-required` before successor writes. Do not request the original documentation-plan path. @@ -26,6 +26,12 @@ An explicit valid manifest takes precedence over convention and instruction cand The legacy caller-authored rollover writer is quarantined at its persistence entry point and always returns `legacy-rollover-upgrade-required`. Accepted legacy state remains readable; historical closed and superseded records remain terminal evidence. Read compatibility does not reactivate an unsafe writer. +## Delete continuation evidence + +For setup/envelope v2, discovery validates the exact `implementation-accepted-state-continuation-binding/v2`, successor projection, uniquely ordered approval-v3, result-bound action-v3, `implementation-increment-rollover/v2`, rollover binding, and inherited-workspace families before classifying a retry or resumed successor. It rechecks the copied review evidence, packet, accepted result, existing handoff, successor brief, baseline, quarantine receipts, and cumulative inherited digest rather than trusting status alone. + +An inherited Delete remains an `absent` tombstone bound to retained quarantine bytes and its historical receipt. It may become present only when the current successor exact plan owns that path as explicit `Create`; unrelated reappearance fails closed before review or rollover writes. Repeated Delete replaces the current inherited state in place, and explicit `Create` removes only the active receipt binding while preserving historical quarantine evidence. PLUG-001 never disposes of quarantine or declares terminal closure. + ## Resume Evidence Build expected program, source, semantic, status, increment, workspace, plan, and dirty-state bindings independently from the discovered manifest and fresh Git observation. Compare submitted resume evidence with those expectations. Structural bundle validation may validate a submitted record, but it must not present a record-versus-itself comparison as repository-backed resume validation. diff --git a/skills/implementing-staged-plans/references/repository-preparation.md b/skills/implementing-staged-plans/references/repository-preparation.md index 1ebc218..487b218 100644 --- a/skills/implementing-staged-plans/references/repository-preparation.md +++ b/skills/implementing-staged-plans/references/repository-preparation.md @@ -54,16 +54,22 @@ Coordinate-shaped names such as a phase, task, step, wave, sprint, priority, or ## Exact-File Plan Contract -Create the exact-file plan just in time from current repository evidence. Bind the program, revision, increment, source, program and semantic digests, workspace path, branch, base and head, and preparation evidence. Include global constraints; requirements and acceptance; non-empty create, modify, and preserve maps; interfaces; semantic naming inventory; test-first or alternative verification slices; exact commands and expected evidence; review predicates; logical commit boundaries; rollback and recovery; risks, exclusions, and amendment rules; and the required approval gate. +Create the exact-file plan just in time from current repository evidence. Bind the program, revision, increment, source, program and semantic digests, workspace path, branch, base and head, and preparation evidence. Include global constraints; requirements and acceptance; the schema-appropriate operation maps; interfaces; semantic naming inventory; test-first or alternative verification slices; exact commands and expected evidence; review predicates; logical commit boundaries; rollback and recovery; risks, exclusions, and amendment rules; and the required approval gate. -The plan contains exactly one `## File map`, followed by exactly one ordered `### Create`, `### Modify`, and `### Preserve`. Each entry is a normalized repository-relative POSIX path in one backticked bullet. Paths cannot escape the repository, traverse `.` or `..`, use backslashes, or appear in more than one disposition. +The plan contains exactly one `## File map`. The v1 family has one ordered `### Create`, `### Modify`, and `### Preserve`; the v2 family has one ordered `### Create`, `### Modify`, `### Delete`, and `### Preserve`. Each entry is a normalized repository-relative POSIX path in one backticked bullet. Paths cannot escape the repository, traverse `.` or `..`, use backslashes, or appear in more than one disposition. For new-model programs, compare the parsed map with the manifest-derived lifecycle requirements before any plan, baseline, approval, authorization, review, acceptance, rollover, blocked-resolution, or closure sink writes. Final increments allocate closure files and no successor navigation. Nonfinal increments with one traceability successor allocate the current handoff and successor brief and no closure files. Allocation records ownership; it does not grant write authority. -Repeat this full allocation for every successor exact plan. A successor execution baseline may populate only the existing `inherited_paths` field, and only from a canonical rollover chain whose accepted product bytes match one-for-one with `Modify` or `Preserve` plan ownership. Keep inherited accepted history separate from pre-existing user-work baselines. First-increment baselines retain `inherited_paths: []`. +Repeat this full allocation for every successor exact plan. A successor execution baseline may populate only the existing `inherited_paths` field, and only from a canonical rollover chain whose accepted product states match one-for-one with exact `Create`, `Modify`, `Delete`, or `Preserve` ownership. Keep inherited accepted history separate from pre-existing user-work baselines. First-increment baselines retain `inherited_paths: []`. Reject a missing, symlinked, stale, digest-mismatched, or structurally incomplete plan. Before the first plan write, validate every manifest-derived future lifecycle allocation, every product path disposition, the status-current increment grant, and the complete execution-baseline and action-authorization candidates. Bind pre-existing user work separately so it cannot be claimed as Create or Modify. A content-valid plan is not write authority. +## Delete quarantine boundary + +For each v2 `Delete`, derive the quarantine path from the immutable manifest descriptor and bind it in `implementation-execution-baseline/v2`. Repository preparation protects Git metadata, program and control files, every descriptor-bound quarantine path, and their held identities from product ownership or overlap. Quarantine authority is local to the selected repository and workspace; it grants no deletion, cleanup, disposal, or external-storage authority. + +The source must be a single regular non-symlink file. The descriptor-bound quarantine root must be a private directory with its exact held identity and mode; its destination and receipt slots must be absent before execution. Source and quarantine root need an exact same-filesystem binding so the sink can use a descriptor-relative, no-follow atomic rename. A cross-device path, unsupported descriptor capability, protected path or identity, occupied slot, symlink, missing source, or changed baseline stops before product mutation. The destination becomes retained evidence only after the rename; this preparation contract never treats it as temporary scratch space. + ## Approval and Action Gate Before production changes, always require the status-current increment grant, validated exact plan, execution baseline, and separate plan-bound action authorization naming the requested writes and verification. Standard mode additionally requires its exact prompt-bound plan-approval event. Pre-approve and full-increment omit only that routine plan question and do not invent an approval event. Approval mode controls interruption and diff acceptance; it never removes write ownership, baseline validation, action authority, or the user diff decision. diff --git a/skills/implementing-staged-plans/references/review-coordination.md b/skills/implementing-staged-plans/references/review-coordination.md index 02f6e93..e155029 100644 --- a/skills/implementing-staged-plans/references/review-coordination.md +++ b/skills/implementing-staged-plans/references/review-coordination.md @@ -34,6 +34,12 @@ Reconcile only after all initial reports are persisted. An open material finding Final verification must complete after all repairs and reconciled reviews. Record exact commands, integer exit codes, concise results, completion times, verified paths, and a candidate digest. Reject duplicate commands, nonzero or boolean exits, sensitive result text, stale timestamps, and unresolved material findings. A prior successful run is not fresh evidence after repair. +## Delete result family + +When setup/envelope v2 selects Delete-capable execution, review uses `implementation-review-preparation/v2`, `implementation-review-evidence/v2`, and `implementation-review-packet/v2`. Each reproduces the ordered `implementation-product-path-states/v2` result and digest: an accepted deleted path stays `absent` and remains bound to the exact quarantine receipt and retained bytes. Reappearance, omission, reorder, mixed schema, receipt drift, or quarantine-byte drift fails closed. + +Diff disposition then uses `implementation-diff-disposition-command/v2` and `implementation-diff-disposition-binding/v2`. The uniquely ordered `implementation-approval/v3` acceptance binds that exact result, transition, review evidence, packet, and disposition. It cannot fall back to a legacy product-delta or approval family. + ## Packet data and rendering Build packet data from the reconciled structured evidence and render it deterministically. The packet must include identity and outcome; changes and rationale; program context; files by purpose; human review order; requirements and acceptance; exact commands and results; baseline failures; execution evidence; reviewer roles, findings, and dispositions; repairs and renewed verification; deviations and amendments; human judgment; edge cases and manual checks; implications; residual risks and deferred work; recovery; workspace and logical boundaries; and current state and next action. diff --git a/skills/implementing-staged-plans/references/state-authorization.md b/skills/implementing-staged-plans/references/state-authorization.md index 00475af..51f8cf3 100644 --- a/skills/implementing-staged-plans/references/state-authorization.md +++ b/skills/implementing-staged-plans/references/state-authorization.md @@ -33,7 +33,7 @@ Approval modes control routine interruption, diff acceptance, and continuation o - Legacy `approval:full-diff` may accept one verified, packet-bound current-increment diff automatically; it does not continue to another increment. - Legacy `approval:full` may automatically accept one verified, packet-bound current-increment diff; it does not continue to another increment. -`approval:full-diff` and `approval:full` are dual-read compatibility modes for already persisted legacy programs only. New-model proposal construction, bootstrap, and launch reject either mode before every write. New programs accept only `approval:standard`, `approval:pre-approve`, or `approval:full-increment`; version `0.1.2` continues only through an exact typed continuation prompt. +`approval:full-diff` and `approval:full` are dual-read compatibility modes for already persisted legacy programs only. New-model proposal construction, bootstrap, and launch reject either mode before every write. New programs accept only `approval:standard`, `approval:pre-approve`, or `approval:full-increment`; version `0.1.3` continues only through an exact typed continuation prompt. For both legacy modes, automatic behavior ends with acceptance of the current increment. A successor requires the typed continuation route; neither legacy mode supplies successor authority. @@ -41,7 +41,7 @@ An omitted mode defaults to `approval:full-increment` only while constructing ne ## Bind approvals exactly -A mechanically relied-on approval must use `implementation-approval/v1`, be approved, and bind the program and revision, source and program digests, semantic digest, increment, brief and plan digests, approval mode, and workspace path, branch, base, and head. Require the expected event type and exact event identifier for the transition. +`implementation-approval/v1` and `implementation-action-authorization/v1` are legacy-only. Manifest-v3 setup and product-delta transactions use `implementation-approval/v2` and `implementation-action-authorization/v2`; manifest v3 rejects the v1 schemas. Setup/envelope v2 result-bound diff acceptance advances to approval v3 as described below. Every mechanically relied-on approval must be approved and match the exact schema, event type, identifier, and transaction-owned program, source, increment, plan, workspace, prompt, result, and authority fields required by its family. Rejected, stale, schema-less, differently scoped, or conflicting records do not grant approval. More than one exact match is ambiguous and fails closed. Historical records remain evidence but cannot authorize a new mechanically checked transition. @@ -55,25 +55,31 @@ Selection records repository identity, path, branch, base and head, pre-existing ## Authorize actions separately -Use `decide_action_authorization` for the requested action and scope. A valid `implementation-action-authorization/v1` grant must bind the same program, source, semantic, increment, mode, brief, plan, and workspace tuple. The exact action and scope must appear in the grant. +Use the schema-specific typed sink for the requested action and scope. The generic `decide_action_authorization` helper checks accepted legacy `implementation-action-authorization/v1` records. Manifest-v3 ordinary action grants use `implementation-action-authorization/v2`; only the setup/envelope v2 result-bound rollover uses `implementation-action-authorization/v3`. Every grant must bind its family-specific program, source, semantic, increment, mode, brief, plan, workspace, result, and prior-authority tuple, and must name the exact action and scope. Approval policies contain no action grants. Approval of any mode never authorizes a commit, pull request, merge, publication, release, deployment, migration, destructive operation, provider mutation, or external-state change. Expired, rejected, revoked, stale, schema-less, or conflicting grants fail closed. ## Allocate lifecycle writes before authority -For a new-model exact plan, derive the required control-plane paths with `required_future_lifecycle_writes`. The resolver accepts the program root, selected workspace root, and status-current increment identifier. It derives paths only from manifest logical roles, immutable increment and closure storage descriptors, status-current identity, and traceability. +For a new-model exact plan, derive the required control-plane paths with `required_future_lifecycle_writes`. It uses manifest-owned paths, status-current identity, immutable allocations, and validated completed rollover history. Allocation is prospective: it models the boundary after current acceptance without accepting the increment, satisfying a source gate, or granting a write. -The file map must classify approvals, status, action authorizations, increment grants, rollovers, and block resolutions as `Modify`. It must classify the current execution baseline, review evidence, and review packet as `Create`. A traceability-allocated successor adds the current handoff and successor brief as `Create`; a final increment instead adds the manifest-derived reconciliation and closure packet. These alternatives are mutually exclusive. +`program_authority.resolve_increment_successor` owns successor eligibility. For manifest v3, both supported setup/envelope pairs use the approved `setup_semantics.increments` list as serial order. `first_increment_id` must name its first entry; unique dependencies must name earlier entries. Requirement allocations are ordered subsequences, may be sparse or disjoint, and must preserve exact reciprocal membership. The accepted completed-history projection must equal the full prefix preceding current. Select the next list entry; never select another ready node or infer adjacency from requirement participation. -`validate_required_managed_file_map` rejects a missing or misclassified required path. Extra product paths remain subject to repository ownership and action-authorization checks. Declaring a managed path allocates ownership only. In particular, declaring rollover, block-resolution, closure, approval, or status paths grants no permission to write them. +Manifest v1/v2 keeps legacy allocation adjacency: one candidate requires all its allocation predecessors accepted. Multiple candidates, malformed allocations/history, or outstanding disjoint work without a safe immediate order are unavailable. Legacy terminal status requires the entire allocation universe covered by validated history plus current. Successful legacy records retain their serialization. + +The internal result is explicitly `successor`, `terminal`, or `unavailable`. Only `successor` allocates the current handoff and exact next brief as `Create`; only `terminal` allocates reconciliation and closure packet as `Create`. Unavailable raises before any write set is returned and never implies finality. Closure preparation and direct approval/retry independently require terminal resolution. Completed v3 rollover edges use the pure resolver; the program adapter must not recurse through lifecycle allocation. Only exact interrupted-rollover inspection/retry may tolerate one unbound suffix, which supplies no accepted-history authority. + +The common file map classifies approvals, status, action authorizations, increment grants, rollovers, and block resolutions as `Modify`, and current execution baseline, review evidence, and review packet as `Create`. Setup activation remains `Preserve` and its source-gate ledger remains `Modify`. `validate_program_lifecycle_file_map` rejects missing or misclassified required paths and writes to inactive closure, handoff, or future-brief alternatives. Legitimate `Preserve` and product paths retain their ownership/baseline checks. Declaring any lifecycle path grants no permission to write it. Incompatible existing plans or transaction prefixes are preserved for the matching recovery route; no automatic migration is authorized. ## Persist one transition Before `apply_state_transition`, revalidate authority and provide a `TransitionRequest` with the expected status digest and sequence, target program and increment states, exact transition event, schema-appropriate authority, and the controlling action scope in its evidence. +For setup/envelope v2, `implementation-execution-baseline/v2` and `implementation-execution-transition/v2` replace the legacy product-delta scalar with an ordered `implementation-product-path-states/v2` result. A Delete entry must be `absent`, have no product digest, and bind exactly one `implementation-delete-quarantine-receipt/v1`; current source absence, quarantine identity, and retained bytes must still match. Review and acceptance use that exact result and its digest. + Legacy `implementation-program-status/v1` transitions keep their existing action-authorization contract. New v2 status is dual-read and records an explicit authority union. The exact approval-driven edges are program approval to active, standard-mode plan approval to authorized, diff approval to accepted, and closure approval to closed. Those governance transitions rely on the matching approved event and do not falsely claim `modify-workspace` authority. Every other declared state change still requires an exact live `modify-workspace` authorization. -New-model diff acceptance uses [`diff_disposition.py`](../scripts/diff_disposition.py), not the generic transition sink. Its acyclic base seed binds the prior status, review evidence and packet, final verification, exact plan, execution baseline, and accepted product delta. It derives the checkpoint, then approval event, then accepted status. The accepted `implementation-diff-disposition-binding/v1` excludes its own status digest and submitted-prompt digest. Accept-stop remains byte-compatible and grants no successor action. When one allocated successor has satisfied dependencies, the rendered disposition may also contain an exact accept-and-continue prompt. The front-door coordinator persists acceptance first and delegates its status-last successor suffix to [`program_rollover.py`](../scripts/program_rollover.py). +New-model diff acceptance uses [`diff_disposition.py`](../scripts/diff_disposition.py), not the generic transition sink. Its acyclic base seed binds the prior status, review evidence and packet, final verification, exact plan, execution baseline, and family-specific accepted product state. It derives the checkpoint, then approval event, then accepted status. Product-delta diff acceptance keeps `implementation-diff-disposition-binding/v1`; manifest-v3 pairs it with `implementation-approval/v2`, while legacy pairs it with `implementation-approval/v1`. Setup/envelope v2 Delete diff acceptance uses `implementation-diff-disposition-binding/v2` with `implementation-approval/v3`, and its result-bound rollover uses `implementation-action-authorization/v3`. Accept-stop remains byte-compatible and grants no successor action. When the canonical contract resolves a successor, the rendered disposition may also contain an exact accept-and-continue prompt. The front-door coordinator persists acceptance first and delegates its status-last successor suffix to [`program_rollover.py`](../scripts/program_rollover.py). Rollover registers `rollover-increment` as `explicit-local`. Its authorization, successor grant, handoff, successor brief, rollover record, and successor status form an ordered, retry-safe persistence sequence, with status last. Status retains immutable activation history, replaces the status-current grant with the distinct successor grant in `current_increment_authority_binding`, binds the canonical rollover and inherited workspace, and clears prior plan, execution-baseline, review, diff, and closure bindings. The manifest is never rewritten. diff --git a/skills/implementing-staged-plans/scripts/approval_checkpoint.py b/skills/implementing-staged-plans/scripts/approval_checkpoint.py index 9cb44eb..f14aa9f 100644 --- a/skills/implementing-staged-plans/scripts/approval_checkpoint.py +++ b/skills/implementing-staged-plans/scripts/approval_checkpoint.py @@ -23,7 +23,7 @@ apply_state_transition, atomic_append_json_line, required_future_lifecycle_writes, - validate_required_managed_file_map, + validate_program_lifecycle_file_map, validate_state_authority, ) @@ -464,7 +464,7 @@ def validate_plan_managed_writes( ) except (OSError, UnicodeError, ValueError) as error: return [str(error)] - return validate_required_managed_file_map(file_map, required) + return validate_program_lifecycle_file_map(program_root, workspace_root, increment_id, file_map, required) def _preflight_live_plan_approval( diff --git a/skills/implementing-staged-plans/scripts/continuity_closure.py b/skills/implementing-staged-plans/scripts/continuity_closure.py index 2dc8ff6..34e678f 100644 --- a/skills/implementing-staged-plans/scripts/continuity_closure.py +++ b/skills/implementing-staged-plans/scripts/continuity_closure.py @@ -705,46 +705,24 @@ def select_unique_satisfied_successor( current_increment_id: str, accepted_increment_ids: set[str] | frozenset[str], ) -> tuple[str | None, str]: - """Select one directly allocated successor whose dependencies are accepted.""" - if not _nonempty(current_increment_id): - raise ValueError("current increment id is required") + """Compatibility adapter for accepted legacy allocation boundaries.""" + from program_authority import resolve_increment_successor + if not isinstance(accepted_increment_ids, (set, frozenset)) or not all( _nonempty(item) for item in accepted_increment_ids ): raise ValueError("accepted increment ids must be a string set") - normalized: list[tuple[str, ...]] = [] - candidates: set[str] = set() - for requirement in atomic_requirements: - if not isinstance(requirement, Mapping): - raise ValueError("atomic requirement must be an object") - assigned = requirement.get("assigned_increments") - if ( - not isinstance(assigned, list) - or not assigned - or not all(_nonempty(item) for item in assigned) - or len(assigned) != len(set(assigned)) - ): - raise ValueError( - "atomic requirement assigned_increments must be unique strings" - ) - allocation = tuple(assigned) - normalized.append(allocation) - if current_increment_id in allocation: - successor_index = allocation.index(current_increment_id) + 1 - if successor_index < len(allocation): - candidates.add(allocation[successor_index]) - if not candidates: + if current_increment_id not in accepted_increment_ids: + return None, "current increment must be accepted" + resolution = resolve_increment_successor( + {"schema_version": "implementation-program-manifest/v1"}, + atomic_requirements, + current_increment_id, + tuple(accepted_increment_ids - {current_increment_id}), + ) + if resolution.kind == "terminal": return None, "no allocated successor" - if len(candidates) != 1: - return None, "multiple allocated successors" - successor = next(iter(candidates)) - for allocation in normalized: - if successor not in allocation: - continue - dependencies = allocation[: allocation.index(successor)] - if any(item not in accepted_increment_ids for item in dependencies): - return None, "successor dependencies are unsatisfied" - return successor, "" + return resolution.successor_increment_id, resolution.reason def evaluate_continuation(candidate: ConversationAssessment) -> tuple[bool, tuple[str, ...]]: diff --git a/skills/implementing-staged-plans/scripts/diff_disposition.py b/skills/implementing-staged-plans/scripts/diff_disposition.py index e15fb29..e447ff8 100644 --- a/skills/implementing-staged-plans/scripts/diff_disposition.py +++ b/skills/implementing-staged-plans/scripts/diff_disposition.py @@ -26,6 +26,8 @@ from repository_preparation import inspect_repository from state_authority import ( APPROVAL_SCHEMA, + EXECUTION_TRANSITION_SCHEMA_V2, + PRODUCT_PATH_STATES_SCHEMA_V2, RepositoryObservation, TransitionRequest, apply_state_transition, @@ -37,6 +39,23 @@ DIFF_DISPOSITION_BINDING_SCHEMA = "implementation-diff-disposition-binding/v1" DIFF_DISPOSITION_COMMAND_SCHEMA = "implementation-diff-disposition-command/v1" +DIFF_DISPOSITION_BINDING_SCHEMA_V2 = "implementation-diff-disposition-binding/v2" +DIFF_DISPOSITION_COMMAND_SCHEMA_V2 = "implementation-diff-disposition-command/v2" +APPROVAL_SCHEMA_V3 = "implementation-approval/v3" +SETUP_V2_DIFF_APPROVAL_FIELDS = ( + "schema_version", "event_id", "type", "decision", "scope", + "diff_decision", "checkpoint_id", "base_seed_sha256", + "submitted_prompt_sha256", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "increment_id", "brief_sha256", + "exact_file_plan_sha256", "approval_mode", "workspace", + "review_evidence_sha256", "review_packet_sha256", + "verification_sha256", "execution_baseline_sha256", + "product_result_schema_version", "product_result_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) @dataclass(frozen=True) @@ -171,6 +190,26 @@ def build_diff_acceptance_candidate( if not isinstance(verification, dict): raise ValueError("review evidence final verification is missing") verification_sha256 = _sha256_bytes(_canonical_json_bytes(verification)) + is_v2_result = ( + is_setup_program + and execution_transition.get("schema_version") == EXECUTION_TRANSITION_SCHEMA_V2 + ) + product_result = None + if is_v2_result: + from repository_preparation import product_path_states_v2_from_value + + try: + product_result = product_path_states_v2_from_value(evidence["product_result"]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("v2 review evidence product_result is invalid") from error + if ( + evidence.get("schema_version") != "implementation-review-evidence/v2" + or product_result.sha256 != execution_transition.get("product_path_states_sha256") + or evidence.get("product_result") != execution_transition.get("product_path_states") + ): + raise ValueError("v2 review product result does not match execution transition") + elif not is_setup_program and execution_transition.get("schema_version") == EXECUTION_TRANSITION_SCHEMA_V2: + raise ValueError("execution v2 transition requires setup v2") accepted_product_delta_sha256 = execution_transition.get( "product_delta_sha256" ) @@ -186,8 +225,14 @@ def build_diff_acceptance_candidate( "verification_sha256": verification_sha256, "exact_file_plan_sha256": status["approved_exact_file_plan_sha256"], "execution_baseline_sha256": baseline_binding["sha256"], - "accepted_product_delta_sha256": accepted_product_delta_sha256, } + if is_v2_result: + base_seed.update( + product_result_schema_version=PRODUCT_PATH_STATES_SCHEMA_V2, + product_result_sha256=product_result.sha256, + ) + else: + base_seed["accepted_product_delta_sha256"] = accepted_product_delta_sha256 base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) checkpoint_id = _identifier( "diff-checkpoint", {"base_seed_sha256": base_seed_sha256} @@ -200,7 +245,10 @@ def build_diff_acceptance_candidate( }, ) disposition_binding = { - "schema_version": DIFF_DISPOSITION_BINDING_SCHEMA, + "schema_version": ( + DIFF_DISPOSITION_BINDING_SCHEMA_V2 + if is_v2_result else DIFF_DISPOSITION_BINDING_SCHEMA + ), **base_seed, "base_seed_sha256": base_seed_sha256, "checkpoint_id": checkpoint_id, @@ -232,7 +280,10 @@ def build_diff_acceptance_candidate( accepted_status["source_gate_satisfaction"] = gate_satisfaction accepted_status_bytes = _canonical_json_bytes(accepted_status) command = { - "schema_version": DIFF_DISPOSITION_COMMAND_SCHEMA, + "schema_version": ( + DIFF_DISPOSITION_COMMAND_SCHEMA_V2 + if is_v2_result else DIFF_DISPOSITION_COMMAND_SCHEMA + ), "decision": "accept-stop", "base_seed_sha256": base_seed_sha256, "checkpoint_id": checkpoint_id, @@ -246,7 +297,8 @@ def build_diff_acceptance_candidate( brief = status["brief_binding"] approval_record = { "schema_version": ( - "implementation-approval/v2" if is_setup_program else APPROVAL_SCHEMA + APPROVAL_SCHEMA_V3 if is_v2_result else + ("implementation-approval/v2" if is_setup_program else APPROVAL_SCHEMA) ), "event_id": approval_event_id, "type": "increment-diff-approval", @@ -276,8 +328,14 @@ def build_diff_acceptance_candidate( "review_packet_sha256": packet_binding["sha256"], "verification_sha256": verification_sha256, "execution_baseline_sha256": baseline_binding["sha256"], - "accepted_product_delta_sha256": accepted_product_delta_sha256, } + if is_v2_result: + approval_record.update( + product_result_schema_version=PRODUCT_PATH_STATES_SCHEMA_V2, + product_result_sha256=product_result.sha256, + ) + else: + approval_record["accepted_product_delta_sha256"] = accepted_product_delta_sha256 if is_setup_program: setup_binding = status.get("setup_activation_binding") increment_authority = status.get("current_increment_authority_binding") @@ -296,7 +354,19 @@ def build_diff_acceptance_candidate( increment_grant_sha256=increment_authority["grant_sha256"], source_gate_satisfaction=gate_satisfaction, ) - approval_bytes = _canonical_json_line(approval_record) + approval_bytes = ( + ( + json.dumps( + approval_record, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + if is_v2_result + else _canonical_json_line(approval_record) + ) return DiffAcceptanceCandidate( base_seed_sha256=base_seed_sha256, checkpoint_id=checkpoint_id, @@ -349,7 +419,12 @@ def render_diff_disposition_prompt(program_root: Path) -> str: def _persist_diff_acceptance_candidate( root: Path, candidate: DiffAcceptanceCandidate ) -> DiffDispositionReceipt: - parse_exact_prompt(candidate.prompt, DIFF_DISPOSITION_COMMAND_SCHEMA) + command_schema = ( + DIFF_DISPOSITION_COMMAND_SCHEMA_V2 + if candidate.approval_record.get("schema_version") == APPROVAL_SCHEMA_V3 + else DIFF_DISPOSITION_COMMAND_SCHEMA + ) + parse_exact_prompt(candidate.prompt, command_schema) manifest, manifest_issues = load_json_object(root / "manifest.json") if manifest is None: raise ValueError("; ".join(manifest_issues)) @@ -404,10 +479,21 @@ def _append_or_adopt_approval( if record.get("event_id") == candidate.approval_event_id ] if matches: + expected_line = candidate.approval_bytes + if candidate.approval_record.get("schema_version") == APPROVAL_SCHEMA_V3: + expected_line = ( + json.dumps( + candidate.approval_record, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") if ( len(matches) != 1 or matches[0] != candidate.approval_record - or not path.read_bytes().endswith(candidate.approval_bytes) + or not path.read_bytes().endswith(expected_line) ): raise ValueError("increment-acceptance-recovery-required: conflicting approval") return True @@ -421,7 +507,13 @@ def _append_or_adopt_approval( for record in records ): raise ValueError("increment-acceptance-recovery-required: conflicting approval") - atomic_append_json_line(path, candidate.approval_record, sha256_file(path)) + atomic_append_json_line( + path, + candidate.approval_record, + sha256_file(path), + preserve_field_order=candidate.approval_record.get("schema_version") + == APPROVAL_SCHEMA_V3, + ) return False @@ -504,6 +596,10 @@ def persist_diff_disposition( if ( status.get("current_increment_state") == "preparing" and isinstance(status.get("rollover_binding"), dict) + ) or ( + status.get("current_increment_state") == "accepted" + and isinstance(status.get("diff_disposition_binding"), dict) + and status["diff_disposition_binding"].get("decision") == "accept-continue" ): from program_rollover import persist_increment_rollover diff --git a/skills/implementing-staged-plans/scripts/program_activation.py b/skills/implementing-staged-plans/scripts/program_activation.py index a559e4b..57419d9 100644 --- a/skills/implementing-staged-plans/scripts/program_activation.py +++ b/skills/implementing-staged-plans/scripts/program_activation.py @@ -22,11 +22,13 @@ sha256_file, ) from program_setup import ( - SETUP_ACTIVATION_SCHEMA, STATUS_SCHEMA_V3, + OPERATION_ENVELOPE_SCHEMA_V2, SOURCE_GATE_SATISFACTION_SCHEMA, + SETUP_SEMANTICS_SCHEMA_V2, derive_identifier, render_increment_start_handoff, + setup_family_contract, setup_semantic_identity, source_gate_satisfaction, validate_increment_start_intent, @@ -39,23 +41,35 @@ validate_submitted_program_launch_prompt, ) from repository_preparation import ( + ExactFileMap, + ExactFileMapV2, REQUIRED_PLAN_SECTIONS, _section_body, _validate_plan_naming_table, execution_baseline_from_value, + execution_baseline_v2_from_value, + product_path_states_v2_value, inspect_repository, parse_exact_file_map, + parse_exact_file_map_v2, validate_execution_workspace, + validate_execution_workspace_v2, ) from state_authority import ( ACTION_AUTHORIZATION_SCHEMA, APPROVAL_SCHEMA, - ExactFileMap, RepositoryObservation, + WorkspacePathSnapshot, atomic_append_json_line, atomic_replace_json, + adopt_delete_quarantine_receipt, + classify_delete_quarantine_recovery, + delete_quarantine_allocation, + descriptor_protection_context, + inspect_workspace_path, + quarantine_bound_regular_file, required_future_lifecycle_writes, - validate_required_managed_file_map, + validate_program_lifecycle_file_map, validate_state_authority, ) from task_prompt import parse_exact_prompt, render_exact_prompt @@ -69,8 +83,11 @@ "implementation-current-increment-authority-binding/v1" ) EXECUTION_BASELINE_SCHEMA = "implementation-execution-baseline/v1" +EXECUTION_BASELINE_SCHEMA_V2 = "implementation-execution-baseline/v2" +PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" PLAN_PREPARATION_SCHEMA = "implementation-exact-plan-preparation/v1" EXECUTION_TRANSITION_SCHEMA = "implementation-execution-transition/v1" +EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2" @dataclass(frozen=True) @@ -114,6 +131,15 @@ class ExecutionTransitionReceipt: recovered: bool +@dataclass(frozen=True) +class ExecutionTransitionReceiptV2: + prior_state: str + increment_state: str + status_sha256: str + product_path_states: dict[str, object] + recovered: bool + + @dataclass(frozen=True) class _PlanCandidate: plan_path: Path @@ -580,7 +606,7 @@ def _build_v3_setup_record( }, ) base: dict[str, object] = { - "schema_version": SETUP_ACTIVATION_SCHEMA, + "schema_version": setup_family_contract(manifest)["activation_schema"], "program_id": manifest["program_id"], "program_revision": manifest["program_revision"], "source_binding": source, @@ -1037,7 +1063,7 @@ def _validate_plan_candidate_text( manifest: dict[str, object], status: dict[str, object], observation: RepositoryObservation, - file_map: ExactFileMap, + file_map: ExactFileMap | ExactFileMapV2, ) -> list[str]: issues: list[str] = [] if len([line for line in markdown.splitlines() if line.startswith("# ")]) != 1: @@ -1106,6 +1132,74 @@ def _path_baselines( return baselines +def _v2_path_baselines( + root: Path, + workspace_root: Path, + file_map: ExactFileMapV2, + manifest: dict[str, object], + increment_id: str, + current_increment_authority_binding: dict[str, object] | None = None, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + """Capture v2 snapshots and allocate manifest-owned Delete storage.""" + workspace = Path(workspace_root) + control_prefix = Path(os.path.relpath(os.fspath(root), os.fspath(workspace))).as_posix() + if control_prefix == ".." or control_prefix.startswith("../"): + raise ValueError("program root must be inside the selected workspace") + path_baselines: list[dict[str, object]] = [] + bindings: list[dict[str, object]] = [] + for operation, paths in ( + ("Create", file_map.create), + ("Modify", file_map.modify), + ("Delete", file_map.delete), + ("Preserve", file_map.preserve), + ): + for relative in paths: + if relative == control_prefix or relative.startswith(control_prefix + "/"): + continue + snapshot = inspect_workspace_path( + workspace, + relative, + protected_paths=tuple( + path for path in (*protected_paths, control_prefix) if path + ), + protected_identities=protected_identities, + ) + if operation == "Delete": + if not snapshot.exists: + raise ValueError(f"Delete path must be a present regular file: {relative}") + allocation = delete_quarantine_allocation( + root, + workspace, + relative, + { + "increment_id": increment_id, + "current_increment_authority_binding": current_increment_authority_binding, + **asdict(snapshot), + }, + ) + snapshot_value = asdict(snapshot) + path_baselines.append({"path": relative, "disposition": operation, "snapshot": snapshot_value}) + bindings.append({ + "path": relative, + "root_path": allocation.root_path, + "root_owner": allocation.root_owner, + "root_mode": allocation.root_mode, + "root_device": allocation.root_device, + "root_inode": allocation.root_inode, + "entry_path": allocation.quarantine_path, + "receipt_path": allocation.receipt_path, + }) + continue + if operation == "Create" and snapshot.exists: + raise ValueError(f"Create path already exists: {relative}") + if operation in {"Modify", "Preserve"} and not snapshot.exists: + raise ValueError(f"{operation} path is missing: {relative}") + path_baselines.append({"path": relative, "disposition": operation, "snapshot": asdict(snapshot)}) + return path_baselines, bindings + + def _user_work_baselines( root: Path, observation: RepositoryObservation, @@ -1131,7 +1225,7 @@ def _user_work_baselines( if relative == control_prefix or relative.startswith(control_prefix + "/"): continue categories.setdefault(relative, set()).add(category) - claimed = set(file_map.create) | set(file_map.modify) + claimed = set(file_map.create) | set(file_map.modify) | set(getattr(file_map, "delete", ())) overlap = claimed & set(categories) if overlap: raise ValueError( @@ -1177,6 +1271,27 @@ def _stable_status_fields(status: dict[str, object]) -> dict[str, object]: return stable +def _parse_exact_file_map_for_manifest( + manifest: dict[str, object], markdown: str +) -> ExactFileMap | ExactFileMapV2: + setup_semantics = manifest.get("setup_semantics") + setup_operation_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + if ( + manifest.get("schema_version") == SETUP_PROGRAM_MANIFEST_SCHEMA + and isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") == SETUP_SEMANTICS_SCHEMA_V2 + and isinstance(setup_operation_envelope, dict) + and setup_operation_envelope.get("schema_version") + == OPERATION_ENVELOPE_SCHEMA_V2 + ): + return parse_exact_file_map_v2(markdown) + return parse_exact_file_map(markdown) + + def _build_plan_candidate( root: Path, plan_bytes: bytes, @@ -1212,11 +1327,22 @@ def _build_plan_candidate( markdown = plan_bytes.decode("utf-8") except UnicodeDecodeError as error: raise ValueError("exact-file plan must be UTF-8") from error - file_map = parse_exact_file_map(markdown) + file_map = _parse_exact_file_map_for_manifest(manifest, markdown) + v2_protected_paths: tuple[str, ...] = () + v2_protected_identities: tuple[tuple[int, int], ...] = () + if isinstance(file_map, ExactFileMapV2): + protection_inspection = inspect_repository( + Path(observation.path), observation.base_commit + ) + v2_protected_paths, v2_protected_identities = descriptor_protection_context( + Path(observation.path), + program_root=root, + inspection=protection_inspection, + ) required = required_future_lifecycle_writes( root, Path(observation.path), str(status["current_increment_id"]) ) - managed_issues = validate_required_managed_file_map(file_map, required) + managed_issues = validate_program_lifecycle_file_map(root, Path(observation.path), str(status["current_increment_id"]), file_map, required) if isinstance(status.get("rollover_binding"), dict): from program_rollover import validated_inherited_paths @@ -1242,11 +1368,14 @@ def _build_plan_candidate( (requirement.path, requirement.disposition) for requirement in required } - for operation, paths in ( + envelope_operations = [ ("Create", file_map.create), ("Modify", file_map.modify), - ("Preserve", file_map.preserve), - ): + ] + if isinstance(file_map, ExactFileMapV2): + envelope_operations.append(("Delete", file_map.delete)) + envelope_operations.append(("Preserve", file_map.preserve)) + for operation, paths in envelope_operations: for path in paths: if (path, operation) in managed: continue @@ -1274,25 +1403,43 @@ def _build_plan_candidate( ) continue allocation = matches[0] - workspace_path = Path(observation.path) / path - if workspace_path.is_symlink(): - actual_facts = ("symlink", "symlink", None, "existing") - elif not workspace_path.exists(): - actual_facts = ("absent", "none", None, "none") - elif workspace_path.is_file(): - file_status = workspace_path.stat() - actual_facts = ( - "regular-file", - "hard-link" if file_status.st_nlink > 1 else "none", - "100755" if file_status.st_mode & 0o111 else "100644", - ( - "accepted-predecessor" - if path in inherited_set - else "existing" - ), - ) + if isinstance(file_map, ExactFileMapV2): + try: + observed = inspect_workspace_path( + Path(observation.path), + path, + protected_paths=v2_protected_paths, + protected_identities=v2_protected_identities, + ) + except ValueError: + observed = None + if observed is None: + actual_facts = ("unsafe", "none", None, "existing") + elif not observed.exists: + actual_facts = ("absent", "none", None, "none") + else: + actual_facts = ( + "regular-file", + "none" if observed.link_count == 1 else "hard-link", + "100755" if observed.mode and int(observed.mode, 8) & 0o111 else "100644", + "accepted-predecessor" if path in inherited_set else "existing", + ) else: - actual_facts = ("unsupported", "none", None, "existing") + workspace_path = Path(observation.path) / path + if workspace_path.is_symlink(): + actual_facts = ("symlink", "symlink", None, "existing") + elif not workspace_path.exists(): + actual_facts = ("absent", "none", None, "none") + elif workspace_path.is_file(): + file_status = workspace_path.stat() + actual_facts = ( + "regular-file", + "hard-link" if file_status.st_nlink > 1 else "none", + "100755" if file_status.st_mode & 0o111 else "100644", + "accepted-predecessor" if path in inherited_set else "existing", + ) + else: + actual_facts = ("unsupported", "none", None, "existing") expected_facts = ( allocation.get("file_kind"), allocation.get("link_kind"), @@ -1338,7 +1485,30 @@ def _build_plan_candidate( file_map, inherited_paths=inherited_paths, ) - path_baselines = _path_baselines(root, Path(observation.path), file_map) + delete_quarantine_bindings: list[dict[str, object]] = [] + if isinstance(file_map, ExactFileMapV2): + path_baselines, delete_quarantine_bindings = _v2_path_baselines( + root, + Path(observation.path), + file_map, + manifest, + str(status["current_increment_id"]), + status.get("current_increment_authority_binding"), + v2_protected_paths, + v2_protected_identities, + ) + else: + path_baselines = _path_baselines(root, Path(observation.path), file_map) + if isinstance(file_map, ExactFileMapV2): + required = required_future_lifecycle_writes( + root, + Path(observation.path), + str(status["current_increment_id"]), + delete_quarantine_bindings=delete_quarantine_bindings, + ) + managed_issues = validate_program_lifecycle_file_map(root, Path(observation.path), str(status["current_increment_id"]), file_map, required) + if managed_issues: + raise ValueError("; ".join(sorted(set(managed_issues)))) baseline_observation = replace( observation, staged_paths=tuple( @@ -1355,7 +1525,11 @@ def _build_plan_candidate( ), ) baseline = { - "schema_version": EXECUTION_BASELINE_SCHEMA, + "schema_version": ( + EXECUTION_BASELINE_SCHEMA_V2 + if isinstance(file_map, ExactFileMapV2) + else EXECUTION_BASELINE_SCHEMA + ), "program_id": manifest["program_id"], "program_revision": manifest["program_revision"], "increment_id": status["current_increment_id"], @@ -1366,10 +1540,42 @@ def _build_plan_candidate( "workspace_observation": _observation_value(baseline_observation), "file_map": asdict(file_map), "path_baselines": path_baselines, + **({"delete_quarantine_bindings": delete_quarantine_bindings} if isinstance(file_map, ExactFileMapV2) else {}), + **( + { + "protected_control_allocations": sorted( + { + *( + path + for path in ( + *file_map.create, + *file_map.modify, + *file_map.delete, + *file_map.preserve, + ) + if path.startswith( + Path(os.path.relpath(os.fspath(root), os.fspath(observation.path))).as_posix() + + "/" + ) + ), + *(binding["root_path"] for binding in delete_quarantine_bindings), + *(binding["entry_path"] for binding in delete_quarantine_bindings), + *(binding["receipt_path"] for binding in delete_quarantine_bindings), + } + ) + } + if isinstance(file_map, ExactFileMapV2) + else {} + ), "user_work_baselines": user_work_baselines, "inherited_paths": list(inherited_paths), } - execution_baseline_from_value(baseline) + if isinstance(file_map, ExactFileMapV2): + from repository_preparation import execution_baseline_v2_from_value + + execution_baseline_v2_from_value(baseline) + else: + execution_baseline_from_value(baseline) baseline_bytes = _canonical_json_bytes(baseline) baseline_sha256 = _sha256_bytes(baseline_bytes) file_map_sha256 = _sha256_bytes(_canonical_json_bytes(asdict(file_map))) @@ -1888,7 +2094,7 @@ def advance_execution_state( program_root: Path, target_increment_state: str, observation: RepositoryObservation, -) -> ExecutionTransitionReceipt: +) -> ExecutionTransitionReceipt | ExecutionTransitionReceiptV2: """Advance authorized execution through implementing and reviewing.""" root = Path(program_root) fresh = inspect_repository(Path(observation.path), observation.base_commit) @@ -1898,6 +2104,11 @@ def advance_execution_state( fresh.observation, "workspace observation changed before execution transition", ) + protected_paths, protected_identities = descriptor_protection_context( + Path(normalized.path), + program_root=root, + inspection=fresh, + ) manifest, manifest_issues = load_json_object(root / "manifest.json") if manifest is None: raise ValueError("; ".join(manifest_issues)) @@ -1925,18 +2136,145 @@ def advance_execution_state( baseline_value, baseline_issues = load_json_object(baseline_path) if baseline_value is None: raise ValueError("; ".join(baseline_issues)) - baseline = execution_baseline_from_value(baseline_value) - assessment = validate_execution_workspace( - root, - baseline, - replace(fresh, observation=normalized), - increment_state=target_increment_state, + is_v2_baseline = ( + isinstance(baseline_value, dict) + and baseline_value.get("schema_version") == EXECUTION_BASELINE_SCHEMA_V2 + ) + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + is_v2_setup = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") == SETUP_SEMANTICS_SCHEMA_V2 + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") == OPERATION_ENVELOPE_SCHEMA_V2 + ) + if is_v2_baseline != is_v2_setup: + raise ValueError("execution v2 baseline/setup/envelope family mismatch") + baseline = ( + execution_baseline_v2_from_value(baseline_value) + if is_v2_baseline + else execution_baseline_from_value(baseline_value) + ) + v2_delete_execution = is_v2_baseline and current_state == "authorized" and target_increment_state == "implementing" + delete_recoveries: dict[str, object] = {} + delete_baselines: dict[str, dict[str, object]] = {} + if v2_delete_execution: + baseline_by_path = {item["path"]: item for item in baseline.path_baselines} + binding_by_path = {item["path"]: item for item in baseline.delete_quarantine_bindings} + for relative in baseline.file_map.delete: + item = baseline_by_path[relative] + binding = binding_by_path[relative] + snapshot = item["snapshot"] + if not isinstance(snapshot, dict): + snapshot = asdict(snapshot) + quarantine_baseline = { + "program_id": baseline.program_id, + "program_revision": baseline.program_revision, + "increment_id": baseline.increment_id, + **snapshot, + "quarantine_root_path": binding["root_path"], + "quarantine_root_device": binding["root_device"], + "quarantine_root_inode": binding["root_inode"], + "quarantine_root_mode": binding["root_mode"], + "quarantine_root_owner": binding["root_owner"], + "current_increment_authority_binding": baseline.current_increment_authority_binding, + } + recovery = classify_delete_quarantine_recovery( + root, + Path(normalized.path), + relative, + quarantine_baseline, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + delete_recoveries[relative] = recovery + delete_baselines[relative] = quarantine_baseline + if recovery.disposition == "recovery-required": + raise ValueError( + f"Delete quarantine recovery-required: {relative}" + ) + state_issues = validate_state_authority(root, normalized) + # Status is still authorized after an exact interrupted move. Only its + # path-specific source warning can be explained by the classified prefix. + recoverable_source_issues = { + f"authorized Delete source does not match baseline: {relative}" + for relative, recovery in delete_recoveries.items() + if recovery.disposition in {"receipt-adoption-ready", "resume"} + } + blocking_issues = [ + issue for issue in state_issues if issue not in recoverable_source_issues + ] + if blocking_issues: + raise ValueError("; ".join(blocking_issues)) + for relative in baseline.file_map.delete: + recovery = delete_recoveries[relative] + quarantine_baseline = delete_baselines[relative] + if recovery.disposition == "retry-ready": + quarantine_bound_regular_file( + root, + Path(normalized.path), + relative, + quarantine_baseline, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + elif recovery.disposition == "receipt-adoption-ready": + adopt_delete_quarantine_receipt( + root, + Path(normalized.path), + relative, + quarantine_baseline, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + elif recovery.disposition != "resume": + raise ValueError( + f"Delete quarantine recovery-required: {relative}" + ) + assessment = ( + validate_execution_workspace_v2( + root, + baseline, + replace(fresh, observation=normalized), + increment_state=target_increment_state, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + if is_v2_baseline + else validate_execution_workspace( + root, + baseline, + replace(fresh, observation=normalized), + increment_state=target_increment_state, + ) ) if not assessment.valid: raise ValueError("; ".join(assessment.issues)) if current_state == target_increment_state: transition = status.get("execution_transition_binding") + if is_v2_baseline: + product_value = transition.get("product_path_states") if isinstance(transition, dict) else None + expected_product = product_path_states_v2_value(assessment.product_states) + if ( + not isinstance(transition, dict) + or transition.get("schema_version") != EXECUTION_TRANSITION_SCHEMA_V2 + or transition.get("target_increment_state") != target_increment_state + or product_value != expected_product + or transition.get("product_path_states_sha256") != assessment.product_states.sha256 + ): + raise ValueError("execution-transition-recovery-required: status binding differs") + return ExecutionTransitionReceiptV2( + prior_state=str(transition["prior_increment_state"]), + increment_state=target_increment_state, + status_sha256=sha256_file(status_path), + product_path_states=expected_product, + recovered=True, + ) if ( not isinstance(transition, dict) or transition.get("schema_version") != EXECUTION_TRANSITION_SCHEMA @@ -1956,9 +2294,10 @@ def advance_execution_state( raise ValueError( f"illegal execution transition {current_state!r} -> {target_increment_state!r}" ) - state_issues = validate_state_authority(root, normalized) - if state_issues: - raise ValueError("; ".join(state_issues)) + if not v2_delete_execution: + state_issues = validate_state_authority(root, normalized) + if state_issues: + raise ValueError("; ".join(state_issues)) execution_authorization = status.get("execution_authorization") if not isinstance(execution_authorization, dict): raise ValueError("execution authorization binding is required") @@ -1978,19 +2317,20 @@ def advance_execution_state( f"increment:{status['current_increment_id']}", ) prior_sha256 = sha256_file(status_path) - event_id = _identifier( - "execution-transition", - { - "program_id": status["program_id"], - "program_revision": status["program_revision"], - "increment_id": status["current_increment_id"], - "prior_status_sha256": prior_sha256, - "prior_increment_state": current_state, - "target_increment_state": target_increment_state, - "product_delta_sha256": assessment.product_delta_sha256, - "authorization_id": authorization_id, - }, - ) + event_seed = { + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "increment_id": status["current_increment_id"], + "prior_status_sha256": prior_sha256, + "prior_increment_state": current_state, + "target_increment_state": target_increment_state, + "authorization_id": authorization_id, + } + if is_v2_baseline: + event_seed["product_path_states_sha256"] = assessment.product_states.sha256 + else: + event_seed["product_delta_sha256"] = assessment.product_delta_sha256 + event_id = _identifier("execution-transition", event_seed) new_status = dict(status) new_status.update( state_sequence=int(status["state_sequence"]) + 1, @@ -2005,7 +2345,19 @@ def advance_execution_state( "event_id": event_id, "authorization_id": authorization_id, }, - execution_transition_binding={ + execution_transition_binding=( + { + "schema_version": EXECUTION_TRANSITION_SCHEMA_V2, + "event_id": event_id, + "authorization_id": authorization_id, + "prior_increment_state": current_state, + "target_increment_state": target_increment_state, + "prior_status_sha256": prior_sha256, + "product_path_states_sha256": assessment.product_states.sha256, + "product_path_states": product_path_states_v2_value(assessment.product_states), + } + if is_v2_baseline + else { "schema_version": EXECUTION_TRANSITION_SCHEMA, "event_id": event_id, "authorization_id": authorization_id, @@ -2013,7 +2365,8 @@ def advance_execution_state( "target_increment_state": target_increment_state, "prior_status_sha256": prior_sha256, "product_delta_sha256": assessment.product_delta_sha256, - }, + } + ), ) if transition_gate_satisfaction is not None: new_status["source_gate_satisfaction"] = transition_gate_satisfaction @@ -2022,6 +2375,14 @@ def advance_execution_state( validation_issues = validate_state_authority(root, normalized) if validation_issues: raise ValueError("; ".join(validation_issues)) + if is_v2_baseline: + return ExecutionTransitionReceiptV2( + prior_state=current_state, + increment_state=target_increment_state, + status_sha256=sha256_file(status_path), + product_path_states=product_path_states_v2_value(assessment.product_states), + recovered=False, + ) return ExecutionTransitionReceipt( prior_state=current_state, increment_state=target_increment_state, diff --git a/skills/implementing-staged-plans/scripts/program_authority.py b/skills/implementing-staged-plans/scripts/program_authority.py index be49649..8da3e5d 100644 --- a/skills/implementing-staged-plans/scripts/program_authority.py +++ b/skills/implementing-staged-plans/scripts/program_authority.py @@ -11,10 +11,10 @@ import sys import tempfile import unicodedata -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass from pathlib import Path, PurePosixPath -from typing import Any +from typing import Any, Literal MANIFEST_NAME = "manifest.json" @@ -74,9 +74,12 @@ SETUP_AUTHORITY_RECORD_SCHEMAS = frozenset( { "implementation-approval/v2", + "implementation-approval/v3", "implementation-action-authorization/v2", + "implementation-action-authorization/v3", "implementation-increment-grant/v2", "setup-activation-decision/v1", + "setup-activation-decision/v2", "source-gate-decision/v1", } ) @@ -180,6 +183,210 @@ def _string_list(value: object, *, non_empty: bool = False) -> bool: ) +def _safe_increment_id(value: object) -> bool: + return ( + _is_non_empty_string(value) + and value not in {".", ".."} + and not any(character in value for character in ("/", "\\", "\0")) + ) + + +@dataclass(frozen=True) +class SuccessorResolution: + kind: Literal["successor", "terminal", "unavailable"] + successor_increment_id: str | None + reason: str + + def __post_init__(self) -> None: + valid = ( + self.kind == "successor" + and _safe_increment_id(self.successor_increment_id) + and self.reason == "" + ) or ( + self.kind == "terminal" + and self.successor_increment_id is None + and self.reason == "" + ) or ( + self.kind == "unavailable" + and self.successor_increment_id is None + and _is_non_empty_string(self.reason) + ) + if not valid: + raise ValueError("invalid successor resolution") + + +def _validated_requirement_allocations( + atomic_requirements: Sequence[Mapping[str, object]], +) -> dict[str, tuple[str, ...]]: + if not isinstance(atomic_requirements, (list, tuple)) or not atomic_requirements: + raise ValueError("atomic requirements must be non-empty") + allocations: dict[str, tuple[str, ...]] = {} + for requirement in atomic_requirements: + if not isinstance(requirement, Mapping): + raise ValueError("atomic requirement must be an object") + requirement_id = requirement.get("id") + if not _is_non_empty_string(requirement_id) or requirement_id in allocations: + raise ValueError("atomic requirement IDs must be unique non-empty strings") + assigned = requirement.get("assigned_increments") + if ( + not isinstance(assigned, list) + or not assigned + or not all(_safe_increment_id(item) for item in assigned) + or len(assigned) != len(set(assigned)) + ): + raise ValueError( + "atomic requirement assigned_increments must be unique safe strings" + ) + allocations[requirement_id] = tuple(assigned) + return allocations + + +def _program_setup_module(): + try: + return importlib.import_module("program_setup") + except ModuleNotFoundError as error: + if error.name != "program_setup": + raise + script_root = str(Path(__file__).resolve().parent) + sys.path.insert(0, script_root) + try: + return importlib.import_module("program_setup") + finally: + sys.path.remove(script_root) + + +def validated_increment_schedule( + manifest: Mapping[str, object], + atomic_requirements: Sequence[Mapping[str, object]], +) -> tuple[str, ...]: + """Validate the approved v3 serial order and reciprocal allocations.""" + if manifest.get("schema_version") != SETUP_PROGRAM_MANIFEST_SCHEMA: + raise ValueError("approved increment schedule requires manifest v3") + _program_setup_module().setup_family_contract(manifest) + semantics = manifest["setup_semantics"] + increments = semantics.get("increments") + if not isinstance(increments, list) or not increments: + raise ValueError("setup increments must be a non-empty list") + schedule: list[str] = [] + for increment in increments: + if not isinstance(increment, Mapping) or not _safe_increment_id( + increment.get("increment_id") + ): + raise ValueError("setup increment ID must be one safe path segment") + increment_id = increment["increment_id"] + if increment_id in schedule: + raise ValueError("setup increment IDs must be unique") + dependencies = increment.get("depends_on") + if ( + not isinstance(dependencies, list) + or not all(_safe_increment_id(item) for item in dependencies) + or len(dependencies) != len(set(dependencies)) + or any(item not in schedule for item in dependencies) + ): + raise ValueError("setup increment dependency graph is invalid") + schedule.append(increment_id) + if semantics.get("first_increment_id") != schedule[0]: + raise ValueError("setup first increment must head the approved schedule") + allocations = _validated_requirement_allocations(atomic_requirements) + positions = {increment_id: index for index, increment_id in enumerate(schedule)} + allocated_ids = {item for assigned in allocations.values() for item in assigned} + if set(schedule) != allocated_ids: + raise ValueError("setup increments do not cover exact traceability allocation") + for assigned in allocations.values(): + if any( + positions[left] >= positions[right] + for left, right in zip(assigned, assigned[1:]) + ): + raise ValueError("requirement allocation must follow the approved schedule") + for increment in increments: + expected = [ + requirement_id + for requirement_id, assigned in allocations.items() + if increment["increment_id"] in assigned + ] + if increment.get("requirement_ids") != expected: + raise ValueError( + f"setup increment {increment['increment_id']} requirement allocation mismatch" + ) + return tuple(schedule) + + +def resolve_increment_successor( + manifest: Mapping[str, object], + atomic_requirements: Sequence[Mapping[str, object]], + current_increment_id: str, + accepted_before_current: tuple[str, ...], +) -> SuccessorResolution: + """Model the boundary after current acceptance; never grant acceptance.""" + try: + if not isinstance(manifest, Mapping): + raise ValueError("successor manifest must be an object") + if not _safe_increment_id(current_increment_id): + raise ValueError("current increment ID must be one safe path segment") + if ( + not isinstance(accepted_before_current, tuple) + or not all(_safe_increment_id(item) for item in accepted_before_current) + or len(accepted_before_current) != len(set(accepted_before_current)) + or current_increment_id in accepted_before_current + ): + raise ValueError("accepted history must be unique and exclude current") + schema = manifest.get("schema_version") + if not isinstance(schema, str): + raise ValueError("unsupported successor manifest schema") + if schema == SETUP_PROGRAM_MANIFEST_SCHEMA: + schedule = validated_increment_schedule(manifest, atomic_requirements) + if current_increment_id not in schedule: + raise ValueError("current increment is absent from approved schedule") + index = schedule.index(current_increment_id) + if accepted_before_current != schedule[:index]: + raise ValueError( + "accepted history must equal the exact approved schedule prefix" + ) + # Schedule validation requires dependencies strictly earlier; the exact + # prefix supplies those dependencies for current and its next entry. + successor = schedule[index + 1] if index + 1 < len(schedule) else None + elif schema in { + "implementation-program-manifest/v1", NEW_PROGRAM_MANIFEST_SCHEMA + }: + allocations = _validated_requirement_allocations(atomic_requirements) + universe = {item for assigned in allocations.values() for item in assigned} + if current_increment_id not in universe: + raise ValueError("current increment is absent from traceability allocation") + accepted = set(accepted_before_current) | {current_increment_id} + if not accepted <= universe: + raise ValueError("accepted history contains unknown increment IDs") + candidates = { + assigned[index + 1] + for assigned in allocations.values() + for index, item in enumerate(assigned[:-1]) + if item == current_increment_id + } + if len(candidates) > 1: + raise ValueError("multiple allocated successors") + successor = next(iter(candidates), None) + if successor is None: + if accepted != universe: + raise ValueError( + "outstanding allocated work has no safe legacy successor" + ) + else: + if successor in accepted: + raise ValueError("successor is already accepted") + for assigned in allocations.values(): + if successor in assigned and any( + item not in accepted + for item in assigned[:assigned.index(successor)] + ): + raise ValueError("successor dependencies are unsatisfied") + else: + raise ValueError("unsupported successor manifest schema") + if successor is not None: + return SuccessorResolution("successor", successor, "") + return SuccessorResolution("terminal", None, "") + except ValueError as error: + return SuccessorResolution("unavailable", None, str(error)) + + def sha256_file(path: Path) -> str: """Return the SHA-256 digest of a file without interpreting its bytes.""" digest = hashlib.sha256() @@ -1117,21 +1324,7 @@ def _validate_new_manifest_contract( issues.extend(closure_issues) if manifest_schema == SETUP_PROGRAM_MANIFEST_SCHEMA: try: - setup_module = importlib.import_module("program_setup") - except ModuleNotFoundError as error: - if error.name != "program_setup": - issues.append(str(error)) - setup_module = None - else: - script_root = str(Path(__file__).resolve().parent) - sys.path.insert(0, script_root) - try: - setup_module = importlib.import_module("program_setup") - except (ImportError, OSError, TypeError, ValueError) as nested_error: - issues.append(str(nested_error)) - setup_module = None - finally: - sys.path.remove(script_root) + setup_module = _program_setup_module() except (ImportError, OSError, TypeError, ValueError) as error: issues.append(str(error)) setup_module = None @@ -1571,6 +1764,31 @@ def validate_program_authority( ): issues.append("setup-activation decision record is required") if manifest_schema == SETUP_PROGRAM_MANIFEST_SCHEMA: + setup_semantics = manifest.get("setup_semantics") + operation_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + setup_v2_family = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(operation_envelope, dict) + and operation_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) + if not setup_v2_family and any( + record.get("schema_version") == "implementation-approval/v3" + for record in approvals + ): + issues.append("setup-v1 rejects v3 approval records") + if not setup_v2_family and any( + record.get("schema_version") + == "implementation-action-authorization/v3" + for record in new_ledgers.get("action_authorizations", []) + ): + issues.append("setup-v1 rejects v3 action authorization records") if any( record.get("schema_version") == "implementation-approval/v1" for record in approvals diff --git a/skills/implementing-staged-plans/scripts/program_closure.py b/skills/implementing-staged-plans/scripts/program_closure.py index 9eb206c..0c89384 100644 --- a/skills/implementing-staged-plans/scripts/program_closure.py +++ b/skills/implementing-staged-plans/scripts/program_closure.py @@ -27,6 +27,7 @@ ) from program_authority import ( SETUP_PROGRAM_MANIFEST_SCHEMA, + SuccessorResolution, load_json_lines, load_json_object, resolve_managed_path, @@ -45,7 +46,7 @@ APPROVAL_SCHEMA, RepositoryObservation, TransitionRequest, - _traceability_successor, + resolve_program_successor, apply_state_transition, atomic_append_json_line, validate_state_authority, @@ -179,10 +180,27 @@ def _closure_preconditions( } -def _validate_preconditions(value: dict[str, object]) -> None: +def _require_terminal_successor(resolution: SuccessorResolution) -> None: + if not isinstance(resolution, SuccessorResolution): + raise ValueError("closure requires an explicit terminal successor resolution") + if ( + resolution.kind != "terminal" + or resolution.successor_increment_id is not None + or resolution.reason + ): + raise ValueError( + "closure requires terminal successor resolution: " + f"{resolution.reason or resolution.successor_increment_id}" + ) + + +def _validate_preconditions( + value: dict[str, object], *, successor_resolution: SuccessorResolution +) -> None: + _require_terminal_successor(successor_resolution) issues: list[str] = [] if value.get("successor_id") is not None: - issues.append("accepted increment is nonfinal because traceability allocates a successor") + issues.append("accepted increment is nonfinal because a successor remains") if value.get("paths_allocated", True) is not True: issues.append("manifest-owned closure paths lack exact-plan Create allocation") for field, label in ( @@ -347,6 +365,8 @@ def build_closure_preparation( ) not in {"active", "awaiting-closure-approval"}: raise ValueError("closure preparation requires an accepted active final increment") increment_id = str(status["current_increment_id"]) + successor_resolution = resolve_program_successor(root, manifest, status) + _require_terminal_successor(successor_resolution) paths = _increment_paths(root, manifest, increment_id) closure_paths = resolve_program_closure_paths(root) file_map = parse_exact_file_map(paths["plan"].read_text(encoding="utf-8")) @@ -358,7 +378,7 @@ def build_closure_preparation( paths_allocated = all(path in file_map.create for path in closure_relatives) traceability, _traceability_path = _load_role(root, manifest, "traceability") - successor_id = _traceability_successor(traceability, increment_id) + successor_id = successor_resolution.successor_increment_id ( requirement_ids, bare_dispositions, @@ -416,7 +436,7 @@ def build_closure_preparation( unowned_deferrals=unowned_deferrals, verification_is_fresh=verification_is_fresh, ) - _validate_preconditions(preconditions) + _validate_preconditions(preconditions, successor_resolution=successor_resolution) evidence_paths = tuple( dict.fromkeys( @@ -665,6 +685,7 @@ def build_closure_command_candidate( ) status, status_path = _load_role(root, manifest, "status") state = status.get("program_state") + _require_terminal_successor(resolve_program_successor(root, manifest, status)) if state not in {"awaiting-closure-approval", "closed"}: raise ValueError("closure approval requires awaiting-closure-approval status") closure = status.get("closure_binding") diff --git a/skills/implementing-staged-plans/scripts/program_continuation.py b/skills/implementing-staged-plans/scripts/program_continuation.py index bfc5f8b..1aac2bb 100644 --- a/skills/implementing-staged-plans/scripts/program_continuation.py +++ b/skills/implementing-staged-plans/scripts/program_continuation.py @@ -5,6 +5,7 @@ import argparse import hashlib +import json import sys from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass, replace @@ -12,11 +13,14 @@ from diff_disposition import ( DIFF_DISPOSITION_BINDING_SCHEMA, + DIFF_DISPOSITION_BINDING_SCHEMA_V2, DIFF_DISPOSITION_COMMAND_SCHEMA, + DIFF_DISPOSITION_COMMAND_SCHEMA_V2, + APPROVAL_SCHEMA_V3, + SETUP_V2_DIFF_APPROVAL_FIELDS, DiffAcceptanceCandidate, _render_accept_continue_envelope, ) -from continuity_closure import select_unique_satisfied_successor from program_activation import ( _canonical_json_bytes, _canonical_json_line, @@ -24,24 +28,40 @@ _without_owned_program_paths, ) from program_authority import ( + SETUP_PROGRAM_MANIFEST_SCHEMA, load_json_lines, load_json_object, resolve_managed_path, sha256_file, ) from repository_preparation import ( + EXECUTION_BASELINE_SCHEMA_V2, + PRODUCT_PATH_STATES_SCHEMA_V2, execution_baseline_from_value, + execution_baseline_v2_from_value, inspect_repository, + product_path_states_v2_from_value, + product_path_states_v2_value, validate_execution_workspace, + validate_execution_workspace_v2, ) from state_authority import RepositoryObservation from task_prompt import parse_exact_prompt, render_exact_prompt SUCCESSOR_PROJECTION_SCHEMA = "implementation-successor-authority-projection/v1" +SUCCESSOR_PROJECTION_SCHEMA_V2 = "implementation-successor-authority-projection/v2" ACCEPTED_STATE_CONTINUATION_SCHEMA = ( "implementation-accepted-state-continuation-binding/v1" ) +ACCEPTED_STATE_CONTINUATION_SCHEMA_V2 = ( + "implementation-accepted-state-continuation-binding/v2" +) +SETUP_V2_CONTINUE_APPROVAL_FIELDS = ( + *SETUP_V2_DIFF_APPROVAL_FIELDS, + "successor_increment_id", + "successor_authority_projection_sha256", +) @dataclass(frozen=True) @@ -56,6 +76,8 @@ class ContinuationExtension: successor_increment_id: str successor_brief_bytes: bytes accepted_product_delta: tuple[ProductDeltaPath, ...] + accepted_product_result: Mapping[str, object] | None + inherited_workspace: Mapping[str, object] checkpoint_id: str rollover_authorization_id: str successor_grant_id: str @@ -83,6 +105,29 @@ class ContinuationCommand: allowed_conditional_action_ceiling: tuple[str, ...] +@dataclass(frozen=True) +class ContinuationCommandV2: + schema_version: str + base_seed_sha256: str + checkpoint_id: str + rollover_authorization_id: str + successor_grant_id: str + accepted_status_sha256: str + accepted_status_sequence: int + program_id: str + program_revision: int + current_increment_id: str + successor_increment_id: str + successor_brief_sha256: str + product_result_schema_version: str + product_result_sha256: str + accepted_product_result: Mapping[str, object] + successor_approval_mode: str + selected_workspace: Mapping[str, object] + inherited_workspace: Mapping[str, object] + allowed_conditional_action_ceiling: tuple[str, ...] + + def _sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() @@ -106,27 +151,6 @@ def _load_role( return value, path -def _accepted_increment_ids( - root: Path, - status: Mapping[str, object], - *, - allow_unbound_rollover_suffix: bool, -) -> set[str]: - current_increment_id = status.get("current_increment_id") - if not isinstance(current_increment_id, str) or not current_increment_id: - raise ValueError("status current increment is required") - from program_rollover import _validated_completed_rollover_records - - completed = _validated_completed_rollover_records( - root, - status, - allow_unbound_suffix=allow_unbound_rollover_suffix, - ) - accepted = {current_increment_id} - accepted.update(str(record["current_increment_id"]) for record in completed) - return accepted - - def _successor_selection( root: Path, manifest: dict[str, object], @@ -134,24 +158,15 @@ def _successor_selection( *, allow_unbound_rollover_suffix: bool = False, ) -> tuple[str | None, str]: - traceability, _ = _load_role(root, manifest, "traceability") - requirements = traceability.get("atomic_requirements") - if not isinstance(requirements, list): - raise ValueError("traceability atomic_requirements must be a list") - current = status.get("current_increment_id") - if not isinstance(current, str) or not current: - raise ValueError("status current increment is required") - for requirement in requirements: - assigned = requirement.get("assigned_increments") if isinstance(requirement, dict) else None - if not isinstance(assigned, list): - raise ValueError("traceability assigned_increments must be a list") - accepted = _accepted_increment_ids( - root, - status, + from state_authority import resolve_program_successor + + resolution = resolve_program_successor( + root, manifest, status, allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, ) - return select_unique_satisfied_successor(requirements, current, accepted) - + if resolution.kind == "terminal": + return None, "no allocated successor" + return resolution.successor_increment_id, resolution.reason def continuation_unavailability_reason( program_root: Path, @@ -176,7 +191,7 @@ def _live_product_delta( root: Path, status: dict[str, object], observation: RepositoryObservation, -) -> tuple[tuple[ProductDeltaPath, ...], str]: +) -> tuple[tuple[ProductDeltaPath, ...] | dict[str, object], str]: baseline_binding = status.get("execution_baseline_binding") if not isinstance(baseline_binding, dict): raise ValueError("execution baseline binding is required") @@ -190,12 +205,34 @@ def _live_product_delta( baseline_value, baseline_issues = load_json_object(baseline_path) if baseline_value is None: raise ValueError("; ".join(baseline_issues)) - baseline = execution_baseline_from_value(baseline_value) inspection = inspect_repository(Path(observation.path), observation.base_commit) inspection = replace( inspection, observation=_without_owned_program_paths(root, inspection.observation), ) + if baseline_value.get("schema_version") == EXECUTION_BASELINE_SCHEMA_V2: + baseline = execution_baseline_v2_from_value(baseline_value) + assessment = validate_execution_workspace_v2( + root, + baseline, + inspection, + increment_state=str(status.get("current_increment_state")), + ) + if not assessment.valid: + raise ValueError("; ".join(assessment.issues)) + product_result = product_path_states_v2_value(assessment.product_states) + expected = status.get("execution_transition_binding") + if ( + not isinstance(expected, dict) + or expected.get("schema_version") + != "implementation-execution-transition/v2" + or expected.get("product_path_states") != product_result + or expected.get("product_path_states_sha256") + != product_result["sha256"] + ): + raise ValueError("live accepted product result changed") + return product_result, str(product_result["sha256"]) + baseline = execution_baseline_from_value(baseline_value) assessment = validate_execution_workspace( root, baseline, @@ -221,6 +258,203 @@ def _live_product_delta( return product_delta, assessment.product_delta_sha256 +def _accepted_v2_diff_binding( + root: Path, + acceptance: DiffAcceptanceCandidate, + status: dict[str, object], + product_result: Mapping[str, object], + *, + require_persisted_approval: bool, +) -> dict[str, object]: + """Require one exact approval-v3 over the reviewed v2 product result.""" + result = product_path_states_v2_from_value(dict(product_result)) + disposition = ( + status.get("diff_disposition_binding") + if require_persisted_approval + else acceptance.accepted_status.get("diff_disposition_binding") + ) + evidence_binding = status.get("review_evidence_binding") + packet_binding = status.get("review_packet_binding") + transition = status.get("execution_transition_binding") + if ( + not isinstance(disposition, dict) + or disposition.get("schema_version") != DIFF_DISPOSITION_BINDING_SCHEMA_V2 + or disposition.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or disposition.get("product_result_sha256") != result.sha256 + or "accepted_product_delta_sha256" in disposition + or not isinstance(evidence_binding, dict) + or not isinstance(packet_binding, dict) + or evidence_binding.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or evidence_binding.get("product_result_sha256") != result.sha256 + or packet_binding.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or packet_binding.get("product_result_sha256") != result.sha256 + or not isinstance(transition, dict) + or transition.get("product_path_states") != dict(product_result) + or disposition.get("review_evidence_sha256") + != evidence_binding.get("sha256") + or disposition.get("review_packet_sha256") != packet_binding.get("sha256") + ): + raise ValueError("accepted v2 diff binding does not match reviewed product") + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + approval_path, approval_issues = resolve_managed_path( + root, roles.get("approvals"), role="logical role approvals" + ) + if approval_path is None: + raise ValueError("; ".join(approval_issues)) + approvals, load_issues = load_json_lines(approval_path) + if approvals is None: + raise ValueError("; ".join(load_issues)) + matches = [ + record + for record in approvals + if record.get("event_id") == disposition.get("approval_event_id") + and record.get("type") == "increment-diff-approval" + ] + if require_persisted_approval and len(matches) != 1: + raise ValueError("accepted v2 diff approval must exist exactly once") + expected_approval = ( + matches[0] if require_persisted_approval else acceptance.approval_record + ) + if ( + expected_approval.get("schema_version") != APPROVAL_SCHEMA_V3 + or tuple(expected_approval) + != ( + SETUP_V2_CONTINUE_APPROVAL_FIELDS + if disposition.get("decision") == "accept-continue" + else SETUP_V2_DIFF_APPROVAL_FIELDS + ) + or expected_approval.get("diff_decision") != disposition.get("decision") + or expected_approval.get("base_seed_sha256") + != disposition.get("base_seed_sha256") + or expected_approval.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or expected_approval.get("product_result_sha256") != result.sha256 + or "accepted_product_delta_sha256" in expected_approval + ): + raise ValueError("accepted v2 diff approval does not match product result") + if require_persisted_approval and disposition.get("decision") == "accept-continue": + projection = disposition.get("successor_authority_projection") + if ( + expected_approval.get("successor_increment_id") + != disposition.get("successor_increment_id") + or not isinstance(projection, dict) + or expected_approval.get("successor_authority_projection_sha256") + != _sha256_bytes(_canonical_json_bytes(projection)) + ): + raise ValueError("accepted v2 continuation approval is invalid") + approval_bytes = ( + json.dumps( + expected_approval, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + return { + "diff_disposition_binding": dict(disposition), + "diff_approval_binding": { + "event_id": expected_approval["event_id"], + "sha256": _sha256_bytes(approval_bytes), + }, + "execution_transition_binding": dict(transition), + "review_evidence_binding": dict(evidence_binding), + "review_packet_binding": dict(packet_binding), + } + + +def _merge_inherited_workspace_v2( + status: Mapping[str, object], + selected_workspace: Mapping[str, object], + product_result: Mapping[str, object], +) -> dict[str, object]: + """Project cumulative path state without reordering untouched history.""" + parsed = product_path_states_v2_from_value(dict(product_result)) + prior = status.get("inherited_workspace_binding") + prior_states: list[dict[str, object]] = [] + prior_receipts: list[dict[str, object]] = [] + if prior is not None: + if ( + not isinstance(prior, Mapping) + or prior.get("schema_version") != "implementation-inherited-workspace/v2" + or not isinstance(prior.get("inherited_path_states"), list) + or not isinstance(prior.get("delete_quarantine_bindings"), list) + ): + raise ValueError("prior inherited workspace v2 binding is invalid") + prior_states = [dict(item) for item in prior["inherited_path_states"]] + prior_receipts = [dict(item) for item in prior["delete_quarantine_bindings"]] + current_states = [ + {key: value for key, value in asdict(item).items() if key != "operation"} + for item in parsed.ordered_path_states + ] + current_by_path = {str(item["path"]): item for item in current_states} + if len(current_by_path) != len(current_states): + raise ValueError("accepted product result contains duplicate paths") + prior_paths = [item.get("path") for item in prior_states] + if ( + any(not isinstance(path, str) for path in prior_paths) + or len(set(prior_paths)) != len(prior_paths) + ): + raise ValueError("prior inherited workspace contains duplicate paths") + merged_states: list[dict[str, object]] = [] + for item in prior_states: + path = str(item["path"]) + merged_states.append(current_by_path.pop(path, item)) + merged_states.extend( + item for item in current_states if item["path"] in current_by_path + ) + current_receipts = [dict(item) for item in parsed.delete_quarantine_bindings] + receipt_paths = [item.get("receipt_path") for item in prior_receipts] + receipt_target_paths = [item.get("path") for item in prior_receipts] + if ( + any(not isinstance(path, str) for path in receipt_paths) + or len(set(receipt_paths)) != len(receipt_paths) + or any(not isinstance(path, str) for path in receipt_target_paths) + or len(set(receipt_target_paths)) != len(receipt_target_paths) + ): + raise ValueError("prior inherited workspace contains duplicate receipts") + current_receipts_by_path = { + str(item["path"]): item for item in current_receipts + } + if len(current_receipts_by_path) != len(current_receipts): + raise ValueError("accepted product result duplicates a quarantine receipt") + current_state_paths = {str(item["path"]) for item in current_states} + merged_receipts = [ + current_receipts_by_path.pop(str(item["path"]), item) + for item in prior_receipts + if str(item.get("path")) not in current_state_paths + or str(item.get("path")) in current_receipts_by_path + ] + merged_receipts.extend( + item + for item in current_receipts + if item["path"] in current_receipts_by_path + ) + cumulative = { + "inherited_path_states": merged_states, + "delete_quarantine_bindings": merged_receipts, + } + return { + "schema_version": "implementation-inherited-workspace/v2", + "selected_workspace": dict(selected_workspace), + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": parsed.sha256, + "accepted_product_result": dict(product_result), + **cumulative, + "inherited_path_states_sha256": _sha256_bytes( + _canonical_json_bytes(cumulative) + ), + } + + def _successor_brief_bytes( traceability: dict[str, object], status: dict[str, object], @@ -274,7 +508,7 @@ def _continuation_inputs( dict[str, object], str, bytes, - tuple[ProductDeltaPath, ...], + tuple[ProductDeltaPath, ...] | dict[str, object], str, dict[str, object], tuple[str, ...], @@ -309,14 +543,27 @@ def _continuation_inputs( ) if any(persisted != observed for _label, persisted, observed in selected_pairs): raise ValueError("selected workspace changed before continuation") - product_delta, product_delta_sha256 = _live_product_delta( + accepted_product, accepted_product_sha256 = _live_product_delta( root, status, observation ) - inherited_workspace = { - "selected_workspace": selected_workspace, - "accepted_product_delta": [asdict(item) for item in product_delta], - "accepted_product_delta_sha256": product_delta_sha256, - } + if isinstance(accepted_product, dict): + _accepted_v2_diff_binding( + root, + acceptance, + status, + accepted_product, + require_persisted_approval=status.get("current_increment_state") + == "accepted", + ) + inherited_workspace = _merge_inherited_workspace_v2( + status, selected_workspace, accepted_product + ) + else: + inherited_workspace = { + "selected_workspace": selected_workspace, + "accepted_product_delta": [asdict(item) for item in accepted_product], + "accepted_product_delta_sha256": accepted_product_sha256, + } authority = status.get("current_increment_authority_binding") if not isinstance(authority, dict): raise ValueError("status-current increment authority is required") @@ -350,8 +597,8 @@ def _continuation_inputs( workspace, successor, brief_bytes, - product_delta, - product_delta_sha256, + accepted_product, + accepted_product_sha256, inherited_workspace, tuple(allowed), ) @@ -396,6 +643,46 @@ def _immediate_base_seed( } +def _immediate_base_seed_v2( + acceptance: DiffAcceptanceCandidate, + *, + successor_increment_id: str, + successor_brief_sha256: str, + product_result_sha256: str, + successor_approval_mode: str, + selected_workspace: Mapping[str, object], + workspace_selection_sha256: str, + inherited_workspace_sha256: str, + allowed_conditional_action_ceiling: tuple[str, ...], +) -> dict[str, object]: + binding = acceptance.accepted_status["diff_disposition_binding"] + return { + "schema_domain": SUCCESSOR_PROJECTION_SCHEMA_V2, + "program_id": binding["program_id"], + "program_revision": binding["program_revision"], + "current_increment_id": binding["increment_id"], + "successor_increment_id": successor_increment_id, + "prior_status_sha256": binding["prior_status_sha256"], + "prior_status_sequence": binding["prior_status_sequence"], + "decision": "accept-continue", + "review_evidence_sha256": binding["review_evidence_sha256"], + "review_packet_sha256": binding["review_packet_sha256"], + "verification_sha256": binding["verification_sha256"], + "exact_file_plan_sha256": binding["exact_file_plan_sha256"], + "execution_baseline_sha256": binding["execution_baseline_sha256"], + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": product_result_sha256, + "successor_brief_sha256": successor_brief_sha256, + "successor_approval_mode": successor_approval_mode, + "selected_workspace": dict(selected_workspace), + "workspace_selection_sha256": workspace_selection_sha256, + "inherited_workspace_sha256": inherited_workspace_sha256, + "allowed_conditional_action_ceiling": list( + allowed_conditional_action_ceiling + ), + } + + def _build_continuation_extension( program_root: Path, acceptance: DiffAcceptanceCandidate, @@ -420,10 +707,10 @@ def _build_continuation_extension( _manifest, status, _workspace, - successor, + input_successor, brief_bytes, - product_delta, - product_delta_sha256, + accepted_product, + accepted_product_sha256, inherited_workspace, allowed, ) = _continuation_inputs( @@ -432,6 +719,8 @@ def _build_continuation_extension( observation, allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, ) + if input_successor != successor: + raise ValueError("accepted continuation successor changed") roles = manifest["logical_roles"] workspace_path, workspace_path_issues = resolve_managed_path( root, roles.get("workspace"), role="logical role workspace" @@ -441,17 +730,31 @@ def _build_continuation_extension( selected_workspace = inherited_workspace["selected_workspace"] brief_sha256 = _sha256_bytes(brief_bytes) inherited_sha256 = _sha256_bytes(_canonical_json_bytes(inherited_workspace)) - base_seed = _immediate_base_seed( - acceptance, - successor_increment_id=successor, - successor_brief_sha256=brief_sha256, - accepted_product_delta_sha256=product_delta_sha256, - successor_approval_mode=str(status["approval_mode"]), - selected_workspace=selected_workspace, - workspace_selection_sha256=sha256_file(workspace_path), - inherited_workspace_sha256=inherited_sha256, - allowed_conditional_action_ceiling=allowed, - ) + is_v2_result = isinstance(accepted_product, dict) + if is_v2_result: + base_seed = _immediate_base_seed_v2( + acceptance, + successor_increment_id=successor, + successor_brief_sha256=brief_sha256, + product_result_sha256=accepted_product_sha256, + successor_approval_mode=str(status["approval_mode"]), + selected_workspace=selected_workspace, + workspace_selection_sha256=sha256_file(workspace_path), + inherited_workspace_sha256=inherited_sha256, + allowed_conditional_action_ceiling=allowed, + ) + else: + base_seed = _immediate_base_seed( + acceptance, + successor_increment_id=successor, + successor_brief_sha256=brief_sha256, + accepted_product_delta_sha256=accepted_product_sha256, + successor_approval_mode=str(status["approval_mode"]), + selected_workspace=selected_workspace, + workspace_selection_sha256=sha256_file(workspace_path), + inherited_workspace_sha256=inherited_sha256, + allowed_conditional_action_ceiling=allowed, + ) base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) checkpoint_id = _identifier( "diff-checkpoint", {"base_seed_sha256": base_seed_sha256} @@ -481,7 +784,11 @@ def _build_continuation_extension( }, ) projection = { - "schema_version": SUCCESSOR_PROJECTION_SCHEMA, + "schema_version": ( + SUCCESSOR_PROJECTION_SCHEMA_V2 + if is_v2_result + else SUCCESSOR_PROJECTION_SCHEMA + ), "program_id": status["program_id"], "program_revision": status["program_revision"], "current_increment_id": status["current_increment_id"], @@ -491,7 +798,19 @@ def _build_continuation_extension( "checkpoint_id": checkpoint_id, "approval_event_id": approval_event_id, "successor_brief_sha256": brief_sha256, - "accepted_product_delta_sha256": product_delta_sha256, + **( + { + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": accepted_product_sha256, + "accepted_product_result": accepted_product, + "delete_quarantine_bindings": accepted_product[ + "delete_quarantine_bindings" + ], + "inherited_workspace": inherited_workspace, + } + if is_v2_result + else {"accepted_product_delta_sha256": accepted_product_sha256} + ), "successor_approval_mode": status["approval_mode"], "selected_workspace": selected_workspace, "workspace_selection_sha256": sha256_file(workspace_path), @@ -503,7 +822,11 @@ def _build_continuation_extension( return ContinuationExtension( successor_increment_id=successor, successor_brief_bytes=brief_bytes, - accepted_product_delta=product_delta, + accepted_product_delta=( + () if is_v2_result else accepted_product + ), + accepted_product_result=(accepted_product if is_v2_result else None), + inherited_workspace=inherited_workspace, checkpoint_id=checkpoint_id, rollover_authorization_id=rollover_authorization_id, successor_grant_id=successor_grant_id, @@ -517,42 +840,51 @@ def build_continuation_extension( observation: RepositoryObservation, ) -> ContinuationExtension | None: """Derive a continuation extension only for one satisfied successor.""" + unbound_history_reason = "unbound rollover history is not lifecycle authority" try: - return _build_continuation_extension( + extension = _build_continuation_extension( program_root, acceptance, observation, allow_unbound_rollover_suffix=False, ) except ValueError as error: - if str(error) != "unbound rollover history is not lifecycle authority": + if str(error) != unbound_history_reason: raise - from program_rollover import inspect_increment_rollover + else: + if extension is not None: + return extension + if continuation_unavailability_reason( + program_root, acceptance + ) != unbound_history_reason: + return None - inspection = inspect_increment_rollover(program_root, observation) - if ( - inspection.issues - or inspection.disposition - not in { - "increment-rollover-retry-ready", - "accepted-state-rollover-retry-ready", - } - or inspection.completed_steps - != ( - "action-authorization", - "successor-grant", - "handoff", - "successor-brief", - "rollover-record", - ) - ): - raise error - return _build_continuation_extension( - program_root, - acceptance, - observation, - allow_unbound_rollover_suffix=True, + from program_rollover import inspect_increment_rollover + + inspection = inspect_increment_rollover(program_root, observation) + if ( + inspection.issues + or inspection.disposition + not in { + "increment-rollover-retry-ready", + "accepted-state-rollover-retry-ready", + } + or inspection.completed_steps + != ( + "action-authorization", + "successor-grant", + "handoff", + "successor-brief", + "rollover-record", ) + ): + return None + return _build_continuation_extension( + program_root, + acceptance, + observation, + allow_unbound_rollover_suffix=True, + ) def successor_projection_sha256(projection: Mapping[str, object]) -> str: @@ -567,26 +899,47 @@ def build_accept_continue_candidate( if extension is None: raise ValueError("accept-continue requires one satisfied successor") projection = dict(extension.successor_projection) - base_seed = _immediate_base_seed( - acceptance, - successor_increment_id=extension.successor_increment_id, - successor_brief_sha256=str(projection["successor_brief_sha256"]), - accepted_product_delta_sha256=str( - projection["accepted_product_delta_sha256"] - ), - successor_approval_mode=str(projection["successor_approval_mode"]), - selected_workspace=projection["selected_workspace"], - workspace_selection_sha256=str(projection["workspace_selection_sha256"]), - inherited_workspace_sha256=str(projection["inherited_workspace_sha256"]), - allowed_conditional_action_ceiling=tuple( - str(item) for item in projection["allowed_conditional_action_ceiling"] - ), - ) + is_v2_result = projection.get("schema_version") == SUCCESSOR_PROJECTION_SCHEMA_V2 + if is_v2_result: + base_seed = _immediate_base_seed_v2( + acceptance, + successor_increment_id=extension.successor_increment_id, + successor_brief_sha256=str(projection["successor_brief_sha256"]), + product_result_sha256=str(projection["product_result_sha256"]), + successor_approval_mode=str(projection["successor_approval_mode"]), + selected_workspace=projection["selected_workspace"], + workspace_selection_sha256=str(projection["workspace_selection_sha256"]), + inherited_workspace_sha256=str(projection["inherited_workspace_sha256"]), + allowed_conditional_action_ceiling=tuple( + str(item) + for item in projection["allowed_conditional_action_ceiling"] + ), + ) + else: + base_seed = _immediate_base_seed( + acceptance, + successor_increment_id=extension.successor_increment_id, + successor_brief_sha256=str(projection["successor_brief_sha256"]), + accepted_product_delta_sha256=str( + projection["accepted_product_delta_sha256"] + ), + successor_approval_mode=str(projection["successor_approval_mode"]), + selected_workspace=projection["selected_workspace"], + workspace_selection_sha256=str(projection["workspace_selection_sha256"]), + inherited_workspace_sha256=str(projection["inherited_workspace_sha256"]), + allowed_conditional_action_ceiling=tuple( + str(item) for item in projection["allowed_conditional_action_ceiling"] + ), + ) base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) checkpoint_id = extension.checkpoint_id approval_event_id = str(projection["approval_event_id"]) binding = { - "schema_version": DIFF_DISPOSITION_BINDING_SCHEMA, + "schema_version": ( + DIFF_DISPOSITION_BINDING_SCHEMA_V2 + if is_v2_result + else DIFF_DISPOSITION_BINDING_SCHEMA + ), **{ key: value for key, value in acceptance.accepted_status[ @@ -609,9 +962,22 @@ def build_accept_continue_candidate( "successor_brief_sha256": projection["successor_brief_sha256"], "rollover_action_authorization_id": extension.rollover_authorization_id, "successor_grant_id": extension.successor_grant_id, - "inherited_product_delta_sha256": projection[ - "accepted_product_delta_sha256" - ], + **( + { + "inherited_product_result_schema_version": projection[ + "product_result_schema_version" + ], + "inherited_product_result_sha256": projection[ + "product_result_sha256" + ], + } + if is_v2_result + else { + "inherited_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ] + } + ), "successor_authority_projection": projection, } accepted_status = dict(acceptance.accepted_status) @@ -623,7 +989,11 @@ def build_accept_continue_candidate( accepted_status["diff_disposition_binding"] = binding accepted_status_bytes = _canonical_json_bytes(accepted_status) command = { - "schema_version": DIFF_DISPOSITION_COMMAND_SCHEMA, + "schema_version": ( + DIFF_DISPOSITION_COMMAND_SCHEMA_V2 + if is_v2_result + else DIFF_DISPOSITION_COMMAND_SCHEMA + ), "decision": "accept-continue", "base_seed_sha256": base_seed_sha256, "checkpoint_id": checkpoint_id, @@ -705,7 +1075,7 @@ def _build_accepted_state_command( program_root: Path, *, allow_unbound_rollover_suffix: bool = False, -) -> ContinuationCommand: +) -> ContinuationCommand | ContinuationCommandV2: root = Path(program_root) manifest, manifest_issues = load_json_object(root / "manifest.json") if manifest is None: @@ -743,15 +1113,26 @@ def _build_accepted_state_command( ) projection = dict(extension.successor_projection) selected_workspace = projection["selected_workspace"] - inherited_workspace = { - "selected_workspace": selected_workspace, - "accepted_product_delta": [asdict(item) for item in extension.accepted_product_delta], - "accepted_product_delta_sha256": projection[ - "accepted_product_delta_sha256" - ], - } + is_v2_result = projection.get("schema_version") == SUCCESSOR_PROJECTION_SCHEMA_V2 + inherited_workspace = ( + dict(extension.inherited_workspace) + if is_v2_result + else { + "selected_workspace": selected_workspace, + "accepted_product_delta": [ + asdict(item) for item in extension.accepted_product_delta + ], + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + } + ) base_seed = { - "schema_domain": ACCEPTED_STATE_CONTINUATION_SCHEMA, + "schema_domain": ( + ACCEPTED_STATE_CONTINUATION_SCHEMA_V2 + if is_v2_result + else ACCEPTED_STATE_CONTINUATION_SCHEMA + ), "accepted_status_sha256": sha256_file(status_path), "accepted_status_sequence": status["state_sequence"], "program_id": status["program_id"], @@ -759,9 +1140,21 @@ def _build_accepted_state_command( "current_increment_id": status["current_increment_id"], "successor_increment_id": extension.successor_increment_id, "successor_brief_sha256": projection["successor_brief_sha256"], - "accepted_product_delta_sha256": projection[ - "accepted_product_delta_sha256" - ], + **( + { + "product_result_schema_version": projection[ + "product_result_schema_version" + ], + "product_result_sha256": projection["product_result_sha256"], + "accepted_product_result": projection["accepted_product_result"], + } + if is_v2_result + else { + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ] + } + ), "successor_approval_mode": projection["successor_approval_mode"], "selected_workspace": selected_workspace, "workspace_selection_sha256": sha256_file(workspace_path), @@ -790,6 +1183,31 @@ def _build_accepted_state_command( "rollover_authorization_id": rollover_authorization_id, }, ) + if is_v2_result: + return ContinuationCommandV2( + schema_version=ACCEPTED_STATE_CONTINUATION_SCHEMA_V2, + base_seed_sha256=base_seed_sha256, + checkpoint_id=checkpoint_id, + rollover_authorization_id=rollover_authorization_id, + successor_grant_id=successor_grant_id, + accepted_status_sha256=sha256_file(status_path), + accepted_status_sequence=int(status["state_sequence"]), + program_id=str(status["program_id"]), + program_revision=int(status["program_revision"]), + current_increment_id=str(status["current_increment_id"]), + successor_increment_id=extension.successor_increment_id, + successor_brief_sha256=str(projection["successor_brief_sha256"]), + product_result_schema_version=PRODUCT_PATH_STATES_SCHEMA_V2, + product_result_sha256=str(projection["product_result_sha256"]), + accepted_product_result=dict(projection["accepted_product_result"]), + successor_approval_mode=str(projection["successor_approval_mode"]), + selected_workspace=selected_workspace, + inherited_workspace=inherited_workspace, + allowed_conditional_action_ceiling=tuple( + str(item) + for item in projection["allowed_conditional_action_ceiling"] + ), + ) return ContinuationCommand( schema_version=ACCEPTED_STATE_CONTINUATION_SCHEMA, base_seed_sha256=base_seed_sha256, @@ -834,9 +1252,9 @@ def render_accepted_state_continuation_prompt(program_root: Path) -> str: def validate_submitted_continuation_prompt( program_root: Path, submitted_prompt: str, -) -> ContinuationCommand: - parse_exact_prompt(submitted_prompt, ACCEPTED_STATE_CONTINUATION_SCHEMA) +) -> ContinuationCommand | ContinuationCommandV2: expected = _build_accepted_state_command(program_root) + parse_exact_prompt(submitted_prompt, expected.schema_version) if render_exact_prompt(asdict(expected)) != submitted_prompt: raise ValueError("submitted accepted-state continuation prompt is stale") return expected @@ -845,12 +1263,12 @@ def validate_submitted_continuation_prompt( def _validate_submitted_continuation_prompt_for_rollover_retry( program_root: Path, submitted_prompt: str, -) -> ContinuationCommand: - parse_exact_prompt(submitted_prompt, ACCEPTED_STATE_CONTINUATION_SCHEMA) +) -> ContinuationCommand | ContinuationCommandV2: expected = _build_accepted_state_command( program_root, allow_unbound_rollover_suffix=True, ) + parse_exact_prompt(submitted_prompt, expected.schema_version) if render_exact_prompt(asdict(expected)) != submitted_prompt: raise ValueError("submitted accepted-state continuation prompt is stale") return expected diff --git a/skills/implementing-staged-plans/scripts/program_discovery.py b/skills/implementing-staged-plans/scripts/program_discovery.py index c59b0ea..4637386 100644 --- a/skills/implementing-staged-plans/scripts/program_discovery.py +++ b/skills/implementing-staged-plans/scripts/program_discovery.py @@ -34,7 +34,11 @@ render_program_launch_prompt, validate_submitted_program_launch_prompt, ) -from program_setup import inspect_sequence_zero_activation_prefix +from program_setup import ( + SETUP_ACTIVATION_SCHEMA_V2, + inspect_sequence_zero_activation_prefix, + setup_family_contract, +) from program_review import build_review_preparation from diff_disposition import build_diff_acceptance_candidate from program_closure import ( @@ -1196,6 +1200,11 @@ def _load_new_candidate( for issue in state_issue_set ): recovery_route = "plan-materialization-recovery-required" + elif ( + (program_state, increment_state) == ("active", "authorized") + and any("Delete" in issue or "quarantine" in issue for issue in state_issue_set) + ): + recovery_route = "execution-transition-recovery-required" elif (program_state, increment_state) in { ("active", "implementing"), ("active", "reviewing"), @@ -1342,7 +1351,7 @@ def _load_setup_candidate( status_sha256=sha256_file(status_path), status_sequence=sequence, ) - _, setup_path_issues = resolve_managed_path( + setup_path, setup_path_issues = resolve_managed_path( root, roles.get("setup_activation_decision"), role="logical role setup_activation_decision", @@ -1363,6 +1372,10 @@ def _load_setup_candidate( role="logical role action_authorizations", ) issues.extend(action_path_issues) + rollovers_path, rollover_path_issues = resolve_managed_path( + root, roles.get("rollovers"), role="logical role rollovers" + ) + issues.extend(rollover_path_issues) if issues: return candidate, None, tuple( f"{display_path}: {issue}" for issue in sorted(set(issues)) @@ -1370,17 +1383,36 @@ def _load_setup_candidate( approvals, approval_issues = load_json_lines(approvals_path) grants, grant_issues = load_json_lines(grants_path) actions, action_issues = load_json_lines(actions_path) - issues.extend([*approval_issues, *grant_issues, *action_issues]) - if approvals is None or grants is None or actions is None: + rollovers, rollover_issues = load_json_lines(rollovers_path) + issues.extend( + [*approval_issues, *grant_issues, *action_issues, *rollover_issues] + ) + if ( + approvals is None + or grants is None + or actions is None + or rollovers is None + ): return candidate, None, tuple( f"{display_path}: {issue}" for issue in sorted(set(issues)) ) program_state = status.get("program_state") increment_state = status.get("current_increment_state") + setup_exists = bool(setup_path and (setup_path.exists() or setup_path.is_symlink())) if sequence == 0: prefix = inspect_sequence_zero_activation_prefix(root) - issues.extend(str(issue) for issue in prefix.get("issues", [])) + prefix_issues = [str(issue) for issue in prefix.get("issues", [])] prefix_state = prefix.get("state") + try: + setup_v2 = ( + setup_family_contract(manifest).get("activation_schema") + == SETUP_ACTIVATION_SCHEMA_V2 + ) + except ValueError: + setup_v2 = False + if prefix_state == "invalid" and setup_v2 and setup_exists: + return candidate, "program-activation-recovery-required", () + issues.extend(prefix_issues) if prefix_state == "pristine": issues.extend( validate_program_authority( @@ -1416,10 +1448,115 @@ def _load_setup_candidate( Path(selected["path"]), selected["base_commit"] ).observation, ) - issues.extend(validate_state_authority(root, observation)) + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + setup_v2 = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) + authority_root = root.resolve() + has_rollover_prefix = ( + bool(rollovers) + or isinstance(status.get("rollover_binding"), dict) + or any(action.get("actions") == ["rollover-increment"] for action in actions) + or (increment_state == "accepted" and isinstance(status.get("diff_disposition_binding"), dict)) + ) + if has_rollover_prefix: + from program_rollover import inspect_increment_rollover + + rollover = inspect_increment_rollover( + authority_root, observation + ) + if rollover.disposition is not None and ( + rollover.disposition != "resume" or rollover.issues + ): + return candidate, rollover.disposition, () + authority_issues = validate_state_authority( + authority_root, observation + ) + if setup_v2 and any( + "Delete" in issue or "quarantine" in issue + for issue in authority_issues + ): + return candidate, "execution-transition-recovery-required", () + ledgers = { + "approvals": approvals, + "increment_grants": grants, + "action_authorizations": actions, + } + transaction_files, transaction_issues = _inspect_transaction_files( + authority_root, manifest, status + ) + for prefix_disposition in ( + _exact_plan_prefix_disposition( + authority_root, + manifest, + status, + ledgers, + transaction_files, + ), + _exact_closure_prefix_disposition( + authority_root, + manifest, + status, + ledgers, + transaction_files, + ), + _exact_acceptance_prefix_disposition( + authority_root, manifest, status, ledgers + ), + ): + if prefix_disposition is not None: + return candidate, prefix_disposition, () + review_prefix_disposition = _exact_review_prefix_disposition( + authority_root, manifest, status, transaction_files + ) + if review_prefix_disposition not in {None, "resume"}: + return candidate, review_prefix_disposition, () + issues.extend( + f"{display_path}: {issue}" for issue in transaction_issues + ) + issues.extend(authority_issues) except (KeyError, OSError, TypeError, ValueError) as error: issues.append(str(error)) if issues: + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + setup_v2 = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) + if ( + setup_v2 + and increment_state in { + "authorized", + "implementing", + "reviewing", + "remediating", + "verified", + "awaiting-diff-approval", + "change-requested", + "accepted", + } + and any("Delete" in issue or "quarantine" in issue for issue in issues) + ): + return candidate, "execution-transition-recovery-required", () return candidate, None, tuple( f"{display_path}: {issue}" for issue in sorted(set(issues)) ) @@ -1774,13 +1911,36 @@ def _single_bootstrap_prefix_disposition( and status.get("program_state") == "awaiting-program-approval" and status.get("current_increment_state") == "not-started" ): + setup_path, setup_path_issues = resolve_managed_path( + target, + logical_roles.get("setup_activation_decision"), + role="logical role setup_activation_decision", + require_file=False, + ) + if setup_path is None: + return ( + "proposal-publication-recovery-required", + tuple(setup_path_issues), + ) + try: + setup_v2 = ( + setup_family_contract(committed_manifest).get( + "activation_schema" + ) + == SETUP_ACTIVATION_SCHEMA_V2 + ) + except ValueError: + setup_v2 = False prefix = inspect_sequence_zero_activation_prefix(target) prefix_issues = tuple( str(issue) for issue in prefix.get("issues", []) ) if prefix.get("state") == "invalid" or prefix_issues: return ( - "proposal-publication-recovery-required", + "program-activation-recovery-required" + if setup_v2 + and (setup_path.exists() or setup_path.is_symlink()) + else "proposal-publication-recovery-required", prefix_issues or ("v3 activation prefix is invalid",), ) activation_started = prefix.get("state") != "pristine" @@ -2040,6 +2200,36 @@ def _load_candidate( return None, None, (f"{display_path}: repository observation failed: {error}",) state_issues = validate_state_authority(program_root, observation) if state_issues: + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + setup_v2 = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) + if ( + setup_v2 + and status.get("current_increment_state") + in { + "authorized", + "implementing", + "reviewing", + "remediating", + "verified", + "awaiting-diff-approval", + "change-requested", + "accepted", + } + and any("Delete" in issue or "quarantine" in issue for issue in state_issues) + ): + return candidate, "execution-transition-recovery-required", () return None, None, tuple(f"{display_path}: {issue}" for issue in state_issues) source_binding = manifest["source_binding"] diff --git a/skills/implementing-staged-plans/scripts/program_review.py b/skills/implementing-staged-plans/scripts/program_review.py index 10a92f0..7b21b79 100644 --- a/skills/implementing-staged-plans/scripts/program_review.py +++ b/skills/implementing-staged-plans/scripts/program_review.py @@ -23,14 +23,21 @@ RepositoryInspection, _section_body, execution_baseline_from_value, + execution_baseline_v2_from_value, inspect_repository, parse_exact_file_map, + parse_exact_file_map_v2, + product_path_states_v2_from_value, + product_path_states_v2_value, validate_execution_workspace, + validate_execution_workspace_v2, ) from review_coordination import ( RAW_REVIEW_REPORT_SCHEMA, REVIEW_EVIDENCE_SCHEMA, + REVIEW_EVIDENCE_SCHEMA_V2, REVIEW_PACKET_SCHEMA, + REVIEW_PACKET_SCHEMA_V2, CommandResult, FinalVerification, ReviewFinding, @@ -48,12 +55,15 @@ TransitionRequest, apply_state_transition, atomic_replace_json, + descriptor_protection_context, validate_state_authority, ) REVIEW_PREPARATION_SCHEMA = "implementation-review-preparation/v1" REVIEW_REMEDIATION_SCHEMA = "implementation-review-remediation/v1" +REVIEW_PREPARATION_SCHEMA_V2 = "implementation-review-preparation/v2" +REVIEW_REMEDIATION_SCHEMA_V2 = "implementation-review-remediation/v2" @dataclass(frozen=True) @@ -252,14 +262,43 @@ def _reconciled_review_history( remediation_binding: dict[str, object] | None, current_reports: tuple[ReviewReport, ...], current_findings: tuple[ReviewFinding, ...], + *, + expected_schema: str, ) -> tuple[tuple[ReviewReport, ...], tuple[ReviewFinding, ...]]: if remediation_binding is None: return current_reports, current_findings - if remediation_binding.get("schema_version") != REVIEW_REMEDIATION_SCHEMA: + remediation_schema = remediation_binding.get("schema_version") + if remediation_schema not in { + REVIEW_REMEDIATION_SCHEMA, + REVIEW_REMEDIATION_SCHEMA_V2, + }: raise ValueError("review remediation binding has unsupported schema") + if remediation_schema != expected_schema: + raise ValueError("review remediation family does not match product result") + if remediation_schema == REVIEW_REMEDIATION_SCHEMA_V2: + try: + product_result = product_path_states_v2_from_value( + remediation_binding["initial_product_result"] + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("review remediation initial product result is invalid") from error + if product_result.sha256 != remediation_binding.get( + "initial_product_result_sha256" + ): + raise ValueError("review remediation initial product result digest mismatch") initial_reports = _stored_review_reports( remediation_binding.get("initial_reports") ) + if ( + remediation_schema == REVIEW_REMEDIATION_SCHEMA_V2 + and any( + report.reviewed_candidate_sha256 != product_result.sha256 + for report in initial_reports + ) + ): + raise ValueError( + "review remediation initial reports do not match initial product result" + ) initial_findings = _stored_review_findings( remediation_binding.get("initial_findings") ) @@ -331,6 +370,7 @@ def _build_report_bundle( program_revision: int, increment_id: str, remediation_binding: dict[str, object] | None = None, + product_result: dict[str, object] | None = None, ) -> tuple[dict[str, object], ReviewPacket]: raw_values, current_reports, current_findings = _load_current_report_inputs( workspace, @@ -341,7 +381,14 @@ def _build_report_bundle( increment_id, ) reports, findings = _reconciled_review_history( - remediation_binding, current_reports, current_findings + remediation_binding, + current_reports, + current_findings, + expected_schema=( + REVIEW_REMEDIATION_SCHEMA_V2 + if product_result is not None + else REVIEW_REMEDIATION_SCHEMA + ), ) architecture = raw_values["architecture"] @@ -394,7 +441,11 @@ def _build_report_bundle( for item in reports ) packet = ReviewPacket( - schema_version=REVIEW_PACKET_SCHEMA, + schema_version=( + REVIEW_PACKET_SCHEMA_V2 + if product_result is not None + else REVIEW_PACKET_SCHEMA + ), candidate_sha256=candidate_sha256, identity_and_outcome=(f"reviewed candidate {candidate_sha256}",), changes_and_rationale=tuple(f"reviewed {path}" for path in product_paths), @@ -428,7 +479,11 @@ def _build_report_bundle( ), ) bundle = { - "schema_version": REVIEW_EVIDENCE_SCHEMA, + "schema_version": ( + REVIEW_EVIDENCE_SCHEMA_V2 + if product_result is not None + else REVIEW_EVIDENCE_SCHEMA + ), "risk_predicates": [asdict(item) for item in predicates], "reports": [asdict(item) for item in reports], "findings": [asdict(item) for item in findings], @@ -443,6 +498,8 @@ def _build_report_bundle( "final_verification": asdict(verification), "review_packet": asdict(packet), } + if product_result is not None: + bundle["product_result"] = product_result return bundle, packet @@ -457,7 +514,10 @@ def _review_workspace_context( root, manifest, str(status["current_increment_id"]) ) plan_markdown = paths["plan"].read_text(encoding="utf-8") - file_map = parse_exact_file_map(plan_markdown) + try: + file_map = parse_exact_file_map(plan_markdown) + except ValueError: + file_map = parse_exact_file_map_v2(plan_markdown) workspace = Path(inspection.observation.path).resolve() review_outputs = tuple( paths[key].resolve(strict=False).relative_to(workspace).as_posix() @@ -472,12 +532,34 @@ def _review_workspace_context( baseline_value, baseline_issues = load_json_object(paths["baseline"]) if baseline_value is None: raise ValueError("; ".join(baseline_issues)) - baseline = execution_baseline_from_value(baseline_value) - increment_state = assessment_state or str(status["current_increment_state"]) - assessment = validate_execution_workspace( - root, baseline, inspection, - increment_state=increment_state, + is_v2 = ( + isinstance(baseline_value, dict) + and baseline_value.get("schema_version") + == "implementation-execution-baseline/v2" + ) + baseline = ( + execution_baseline_v2_from_value(baseline_value) + if is_v2 + else execution_baseline_from_value(baseline_value) ) + increment_state = assessment_state or str(status["current_increment_state"]) + if is_v2: + protected_paths, protected_identities = descriptor_protection_context( + Path(inspection.observation.path), program_root=root, inspection=inspection + ) + assessment = validate_execution_workspace_v2( + root, + baseline, + inspection, + increment_state=increment_state, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + else: + assessment = validate_execution_workspace( + root, baseline, inspection, + increment_state=increment_state, + ) if not assessment.valid: raise ValueError("; ".join(assessment.issues)) return paths, raw_paths, assessment @@ -553,20 +635,26 @@ def build_review_remediation( observation, assessment_state=None, ) + is_v2 = hasattr(assessment, "product_states") + product_result = ( + product_path_states_v2_value(assessment.product_states) if is_v2 else None + ) + candidate_sha256 = assessment.product_states.sha256 if is_v2 else assessment.product_delta_sha256 state = status.get("current_increment_state") if state == "remediating": binding = status.get("review_remediation_binding") - initial_product_delta_sha256 = ( - binding.get("initial_product_delta_sha256") + initial_product_sha256 = ( + binding.get("initial_product_result_sha256" if is_v2 else "initial_product_delta_sha256") if isinstance(binding, dict) else None ) if ( not isinstance(binding, dict) - or binding.get("schema_version") != REVIEW_REMEDIATION_SCHEMA + or binding.get("schema_version") + != (REVIEW_REMEDIATION_SCHEMA_V2 if is_v2 else REVIEW_REMEDIATION_SCHEMA) or not isinstance(binding.get("unresolved_finding_ids"), list) - or not isinstance(initial_product_delta_sha256, str) - or re.fullmatch(r"[0-9a-f]{64}", initial_product_delta_sha256) + or not isinstance(initial_product_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", initial_product_sha256) is None ): raise ValueError("review-remediation-recovery-required: status binding differs") @@ -576,7 +664,7 @@ def build_review_remediation( return ReviewRemediationCandidate( remediating_status=status, remediating_status_bytes=_canonical_json_bytes(status), - product_delta_sha256=initial_product_delta_sha256, + product_delta_sha256=initial_product_sha256, unresolved_finding_ids=tuple(binding["unresolved_finding_ids"]), ) if state != "reviewing": @@ -585,18 +673,25 @@ def build_review_remediation( if state_issues: raise ValueError("; ".join(state_issues)) transition = status.get("execution_transition_binding") - if ( + if is_v2: + if ( + not isinstance(transition, dict) + or transition.get("target_increment_state") != "reviewing" + or transition.get("product_path_states_sha256") != candidate_sha256 + or transition.get("product_path_states") != product_result + ): + raise ValueError("review product path states do not match reviewing status") + elif ( not isinstance(transition, dict) or transition.get("target_increment_state") != "reviewing" - or transition.get("product_delta_sha256") - != assessment.product_delta_sha256 + or transition.get("product_delta_sha256") != candidate_sha256 ): raise ValueError("review product delta does not match reviewing status") workspace = Path(normalized.path) raw_values, reports, findings = _load_current_report_inputs( workspace, raw_paths, - assessment.product_delta_sha256, + candidate_sha256, str(status["program_id"]), int(status["program_revision"]), str(status["current_increment_id"]), @@ -635,15 +730,17 @@ def build_review_remediation( for report in reports ] binding = { - "schema_version": REVIEW_REMEDIATION_SCHEMA, + "schema_version": REVIEW_REMEDIATION_SCHEMA_V2 if is_v2 else REVIEW_REMEDIATION_SCHEMA, "prior_status_sha256": prior_sha256, "prior_status_sequence": prior_sequence, - "initial_product_delta_sha256": assessment.product_delta_sha256, + ("initial_product_result_sha256" if is_v2 else "initial_product_delta_sha256"): candidate_sha256, "initial_reports": [asdict(report) for report in reports], "initial_findings": [asdict(finding) for finding in findings], "raw_report_bindings": raw_bindings, "unresolved_finding_ids": list(unresolved), } + if is_v2: + binding["initial_product_result"] = product_result authorization_id = status["execution_authorization"]["authorization_id"] event_id = _identifier("review-remediation", binding) remediating = dict(status) @@ -661,8 +758,8 @@ def build_review_remediation( "authorization_id": authorization_id, }, review_binding={ - "schema_version": REVIEW_REMEDIATION_SCHEMA, - "candidate_sha256": assessment.product_delta_sha256, + "schema_version": REVIEW_REMEDIATION_SCHEMA_V2 if is_v2 else REVIEW_REMEDIATION_SCHEMA, + "candidate_sha256": candidate_sha256, "unresolved_material_findings": len(unresolved), "finding_ids": list(unresolved), }, @@ -671,7 +768,7 @@ def build_review_remediation( return ReviewRemediationCandidate( remediating_status=remediating, remediating_status_bytes=_canonical_json_bytes(remediating), - product_delta_sha256=assessment.product_delta_sha256, + product_delta_sha256=candidate_sha256, unresolved_finding_ids=unresolved, ) @@ -727,28 +824,42 @@ def return_review_to_reviewing( ) = _review_transaction_context( root, observation, assessment_state="reviewing" ) + is_v2 = hasattr(assessment, "product_states") + product_result = ( + product_path_states_v2_value(assessment.product_states) if is_v2 else None + ) + candidate_sha256 = assessment.product_states.sha256 if is_v2 else assessment.product_delta_sha256 binding = status.get("review_remediation_binding") if not isinstance(binding, dict) or binding.get( "schema_version" - ) != REVIEW_REMEDIATION_SCHEMA: + ) != (REVIEW_REMEDIATION_SCHEMA_V2 if is_v2 else REVIEW_REMEDIATION_SCHEMA): raise ValueError("review remediation binding is required") unresolved = tuple(str(item) for item in binding["unresolved_finding_ids"]) state = status.get("current_increment_state") transition = status.get("execution_transition_binding") - if ( - state == "reviewing" - and isinstance(transition, dict) + transition_matches = ( + isinstance(transition, dict) and transition.get("prior_increment_state") == "remediating" - and transition.get("product_delta_sha256") - == assessment.product_delta_sha256 - ): + and ( + ( + is_v2 + and transition.get("product_path_states_sha256") == candidate_sha256 + and transition.get("product_path_states") == product_result + ) + or ( + not is_v2 + and transition.get("product_delta_sha256") == candidate_sha256 + ) + ) + ) + if state == "reviewing" and transition_matches: state_issues = validate_state_authority(root, normalized) if state_issues: raise ValueError("; ".join(state_issues)) return ReviewRemediationReceipt( increment_state="reviewing", status_sha256=sha256_file(status_path), - product_delta_sha256=assessment.product_delta_sha256, + product_delta_sha256=candidate_sha256, unresolved_finding_ids=unresolved, recovered=True, ) @@ -757,16 +868,24 @@ def return_review_to_reviewing( state_issues = validate_state_authority(root, normalized) if state_issues: raise ValueError("; ".join(state_issues)) - product_paths = tuple(str(item["path"]) for item in assessment.product_delta) + product_paths = tuple( + str(item["path"]) + for item in ( + product_result["ordered_path_states"] + if is_v2 + else assessment.product_delta + ) + ) bundle, packet = _build_report_bundle( Path(normalized.path), raw_paths, - assessment.product_delta_sha256, + candidate_sha256, product_paths, str(status["program_id"]), int(status["program_revision"]), str(status["current_increment_id"]), binding, + product_result, ) packet_text = render_review_packet(packet) review_issues = validate_review_bundle(bundle, packet_text) @@ -783,7 +902,7 @@ def return_review_to_reviewing( "prior_status_sha256": prior_sha256, "prior_increment_state": "remediating", "target_increment_state": "reviewing", - "product_delta_sha256": assessment.product_delta_sha256, + ("product_path_states_sha256" if is_v2 else "product_delta_sha256"): candidate_sha256, "authorization_id": authorization_id, "review_remediation_sha256": remediation_sha256, } @@ -803,18 +922,28 @@ def return_review_to_reviewing( "authorization_id": authorization_id, }, execution_transition_binding={ - "schema_version": "implementation-execution-transition/v1", + "schema_version": ( + "implementation-execution-transition/v2" if is_v2 + else "implementation-execution-transition/v1" + ), "event_id": event_id, "authorization_id": authorization_id, "prior_increment_state": "remediating", "target_increment_state": "reviewing", "prior_status_sha256": prior_sha256, - "product_delta_sha256": assessment.product_delta_sha256, + **( + { + "product_path_states_sha256": candidate_sha256, + "product_path_states": product_result, + } + if is_v2 + else {"product_delta_sha256": candidate_sha256} + ), "review_remediation_sha256": remediation_sha256, }, review_binding={ - "schema_version": REVIEW_REMEDIATION_SCHEMA, - "candidate_sha256": assessment.product_delta_sha256, + "schema_version": REVIEW_REMEDIATION_SCHEMA_V2 if is_v2 else REVIEW_REMEDIATION_SCHEMA, + "candidate_sha256": candidate_sha256, "unresolved_material_findings": 0, "finding_ids": list(unresolved), }, @@ -827,7 +956,7 @@ def return_review_to_reviewing( return ReviewRemediationReceipt( increment_state="reviewing", status_sha256=sha256_file(status_path), - product_delta_sha256=assessment.product_delta_sha256, + product_delta_sha256=candidate_sha256, unresolved_finding_ids=unresolved, recovered=False, ) @@ -870,17 +999,35 @@ def build_review_preparation( ) workspace = Path(normalized.path) transition = status.get("execution_transition_binding") - if ( - not isinstance(transition, dict) - or transition.get("target_increment_state") != "reviewing" - or transition.get("product_delta_sha256") != assessment.product_delta_sha256 - ): - raise ValueError("review product delta does not match reviewing status") - product_paths = tuple(str(item["path"]) for item in assessment.product_delta) + is_v2_assessment = hasattr(assessment, "product_states") + if is_v2_assessment: + product_result = product_path_states_v2_value(assessment.product_states) + product_candidate_sha256 = assessment.product_states.sha256 + if ( + not isinstance(transition, dict) + or transition.get("target_increment_state") != "reviewing" + or transition.get("product_path_states_sha256") + != product_candidate_sha256 + or transition.get("product_path_states") != product_result + ): + raise ValueError("review product path states do not match reviewing status") + product_paths = tuple( + str(item["path"]) for item in product_result["ordered_path_states"] + ) + else: + if ( + not isinstance(transition, dict) + or transition.get("target_increment_state") != "reviewing" + or transition.get("product_delta_sha256") != assessment.product_delta_sha256 + ): + raise ValueError("review product delta does not match reviewing status") + product_result = None + product_candidate_sha256 = assessment.product_delta_sha256 + product_paths = tuple(str(item["path"]) for item in assessment.product_delta) bundle, packet = _build_report_bundle( workspace, raw_paths, - assessment.product_delta_sha256, + product_candidate_sha256, product_paths, str(status["program_id"]), int(status["program_revision"]), @@ -890,6 +1037,7 @@ def build_review_preparation( if isinstance(status.get("review_remediation_binding"), dict) else None ), + product_result, ) packet_text = render_review_packet(packet) review_issues = validate_review_bundle(bundle, packet_text) @@ -899,17 +1047,26 @@ def build_review_preparation( packet_bytes = packet_text.encode("utf-8") evidence_sha256 = _sha256_bytes(evidence_bytes) packet_sha256 = _sha256_bytes(packet_bytes) + preparation_schema = ( + REVIEW_PREPARATION_SCHEMA_V2 if is_v2_assessment else REVIEW_PREPARATION_SCHEMA + ) seed = { - "schema_version": REVIEW_PREPARATION_SCHEMA, + "schema_version": preparation_schema, "program_id": status["program_id"], "program_revision": status["program_revision"], "increment_id": increment_id, "prior_status_sha256": prior_sha256, "prior_status_sequence": prior_sequence, - "product_delta_sha256": assessment.product_delta_sha256, "evidence_sha256": evidence_sha256, "packet_sha256": packet_sha256, } + if is_v2_assessment: + seed.update( + product_result_schema_version=product_result["schema_version"], + product_result_sha256=product_candidate_sha256, + ) + else: + seed["product_delta_sha256"] = product_candidate_sha256 binding = { **seed, "evidence_path": paths["evidence"].relative_to(root).as_posix(), @@ -935,12 +1092,28 @@ def build_review_preparation( review_evidence_binding={ "path": binding["evidence_path"], "sha256": evidence_sha256, - "candidate_sha256": assessment.product_delta_sha256, + "candidate_sha256": product_candidate_sha256, + **( + { + "product_result_schema_version": product_result["schema_version"], + "product_result_sha256": product_candidate_sha256, + } + if is_v2_assessment + else {} + ), }, review_packet_binding={ "path": binding["packet_path"], "sha256": packet_sha256, - "candidate_sha256": assessment.product_delta_sha256, + "candidate_sha256": product_candidate_sha256, + **( + { + "product_result_schema_version": product_result["schema_version"], + "product_result_sha256": product_candidate_sha256, + } + if is_v2_assessment + else {} + ), }, ) verified_bytes = _canonical_json_bytes(verified) diff --git a/skills/implementing-staged-plans/scripts/program_rollover.py b/skills/implementing-staged-plans/scripts/program_rollover.py index 0d2d308..4b03845 100644 --- a/skills/implementing-staged-plans/scripts/program_rollover.py +++ b/skills/implementing-staged-plans/scripts/program_rollover.py @@ -6,6 +6,7 @@ import argparse import hashlib import json +import os import sys from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass @@ -31,16 +32,23 @@ ) from program_setup import source_gate_satisfaction from repository_preparation import ( + EXECUTION_BASELINE_SCHEMA_V2, + PRODUCT_PATH_STATES_SCHEMA_V2, ExactFileMap, execution_baseline_from_value, + execution_baseline_v2_from_value, inspect_repository, + product_path_states_v2_from_value, ) from state_authority import ( ACTION_AUTHORIZATION_SCHEMA, ManagedWriteRequirement, RepositoryObservation, atomic_append_json_line, + classify_delete_quarantine_recovery, + inspect_workspace_path, required_future_lifecycle_writes, + validate_program_lifecycle_file_map, validate_state_authority, ) @@ -48,6 +56,23 @@ ROLLOVER_RECORD_SCHEMA = "implementation-increment-rollover/v1" ROLLOVER_BINDING_SCHEMA = "implementation-increment-rollover-binding/v1" INHERITED_WORKSPACE_SCHEMA = "implementation-inherited-workspace/v1" +ROLLOVER_RECORD_SCHEMA_V2 = "implementation-increment-rollover/v2" +ROLLOVER_BINDING_SCHEMA_V2 = "implementation-increment-rollover-binding/v2" +INHERITED_WORKSPACE_SCHEMA_V2 = "implementation-inherited-workspace/v2" +ACTION_AUTHORIZATION_SCHEMA_V3 = "implementation-action-authorization/v3" +SETUP_V2_ROLLOVER_ACTION_FIELDS = ( + "schema_version", "authorization_id", "decision", "actions", "scope", + "constraints", "excluded", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "current_increment_id", + "successor_increment_id", "continuation_domain", + "continuation_checkpoint_id", "accepted_status_sha256", + "accepted_status_sequence", "product_result_schema_version", + "product_result_sha256", "workspace", "submitted_prompt_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) @dataclass(frozen=True) @@ -232,28 +257,19 @@ def _required_increment_rollover_writes( if extension is None or extension.successor_increment_id != successor_increment_id: raise ValueError("requested successor is not uniquely allocated and satisfied") return required_future_lifecycle_writes( - root, Path(workspace_root), str(status["current_increment_id"]) + root, Path(workspace_root), str(status["current_increment_id"]), + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, ) def _validate_rollover_file_map( + program_root: Path, + workspace_root: Path, + increment_id: str, file_map: ExactFileMap, required: Sequence[ManagedWriteRequirement], ) -> None: - actual = { - path: disposition - for disposition, paths in ( - ("Create", file_map.create), - ("Modify", file_map.modify), - ("Preserve", file_map.preserve), - ) - for path in paths - } - issues = [ - f"rollover allocation {item.path} must be {item.disposition}" - for item in required - if actual.get(item.path) != item.disposition - ] + issues = validate_program_lifecycle_file_map(program_root, workspace_root, increment_id, file_map, required) if issues: raise ValueError("; ".join(issues)) @@ -404,14 +420,19 @@ def _build_rollover_candidate( baseline_value, baseline_issues = load_json_object(baseline_path) if baseline_value is None: raise ValueError("; ".join(baseline_issues)) - baseline = execution_baseline_from_value(baseline_value) + is_v2_result = baseline_value.get("schema_version") == EXECUTION_BASELINE_SCHEMA_V2 + baseline = ( + execution_baseline_v2_from_value(baseline_value) + if is_v2_result + else execution_baseline_from_value(baseline_value) + ) required = _required_increment_rollover_writes( root, Path(normalized.path), successor_increment_id, allow_unbound_rollover_suffix=True, ) - _validate_rollover_file_map(baseline.file_map, required) + _validate_rollover_file_map(root, Path(normalized.path), current_increment_id, baseline.file_map, required) prompt_sha256 = _sha256_bytes(submitted_prompt.encode("utf-8")) prior_status_sha256 = sha256_file(status_path) @@ -427,28 +448,56 @@ def _build_rollover_candidate( ) selected_workspace = dict(accepted_command.selected_workspace) inherited_workspace = dict(accepted_command.inherited_workspace) - accepted_product_delta_sha256 = ( - accepted_command.accepted_product_delta_sha256 - ) + if is_v2_result: + accepted_product_result = dict( + accepted_command.accepted_product_result + ) + accepted_product_sha256 = accepted_command.product_result_sha256 + else: + accepted_product_delta_sha256 = ( + accepted_command.accepted_product_delta_sha256 + ) allowed_actions = list( accepted_command.allowed_conditional_action_ceiling ) else: selected_workspace = dict(projection["selected_workspace"]) - inherited_workspace = { - "selected_workspace": selected_workspace, - "accepted_product_delta": [ - asdict(item) for item in extension.accepted_product_delta - ], - "accepted_product_delta_sha256": projection[ - "accepted_product_delta_sha256" - ], - } - accepted_product_delta_sha256 = str( - projection["accepted_product_delta_sha256"] - ) + if is_v2_result: + inherited_workspace = dict(extension.inherited_workspace) + accepted_product_result = dict(extension.accepted_product_result) + accepted_product_sha256 = str(projection["product_result_sha256"]) + else: + inherited_workspace = { + "selected_workspace": selected_workspace, + "accepted_product_delta": [ + asdict(item) for item in extension.accepted_product_delta + ], + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + } + accepted_product_delta_sha256 = str( + projection["accepted_product_delta_sha256"] + ) allowed_actions = list(projection["allowed_conditional_action_ceiling"]) + if is_v2_result: + parsed_product_result = product_path_states_v2_from_value( + accepted_product_result + ) + if parsed_product_result.sha256 != accepted_product_sha256: + raise ValueError("rollover product result binding is invalid") + from diff_disposition import build_diff_acceptance_candidate + from program_continuation import _accepted_v2_diff_binding + + accepted_diff_binding = _accepted_v2_diff_binding( + root, + build_diff_acceptance_candidate(root, normalized), + status, + accepted_product_result, + require_persisted_approval=True, + ) + source = status["source_binding"] program = status["program_binding"] action_gate_satisfaction = None @@ -477,9 +526,13 @@ def _build_rollover_candidate( raise ValueError("v3 rollover authority is incomplete") action_record = { "schema_version": ( - "implementation-action-authorization/v2" - if is_setup_program - else ACTION_AUTHORIZATION_SCHEMA + ACTION_AUTHORIZATION_SCHEMA_V3 + if is_v2_result + else ( + "implementation-action-authorization/v2" + if is_setup_program + else ACTION_AUTHORIZATION_SCHEMA + ) ), "authorization_id": authorization_id, "decision": "authorized", @@ -512,7 +565,16 @@ def _build_rollover_candidate( "continuation_checkpoint_id": checkpoint_id, "accepted_status_sha256": prior_status_sha256, "accepted_status_sequence": prior_status_sequence, - "accepted_product_delta_sha256": accepted_product_delta_sha256, + **( + { + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": accepted_product_sha256, + } + if is_v2_result + else { + "accepted_product_delta_sha256": accepted_product_delta_sha256 + } + ), "workspace": selected_workspace, "submitted_prompt_sha256": prompt_sha256, } @@ -528,7 +590,19 @@ def _build_rollover_candidate( increment_grant_sha256=prior_authority["grant_sha256"], source_gate_satisfaction=action_gate_satisfaction, ) - action_bytes = _canonical_json_line(action_record) + action_bytes = ( + ( + json.dumps( + action_record, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + if is_v2_result + else _canonical_json_line(action_record) + ) action_sha256 = _sha256_bytes(action_bytes) brief_binding = { "path": successor_brief_path.relative_to(root).as_posix(), @@ -558,6 +632,20 @@ def _build_rollover_candidate( "workspace_selection_sha256": sha256_file(workspace_path), "allowed_conditional_actions": allowed_actions, "submitted_prompt_sha256": prompt_sha256, + **( + { + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": accepted_product_sha256, + "delete_quarantine_bindings": accepted_product_result[ + "delete_quarantine_bindings" + ], + "inherited_path_states_sha256": inherited_workspace[ + "inherited_path_states_sha256" + ], + } + if is_v2_result + else {} + ), } if is_setup_program: grant_record.update( @@ -597,7 +685,9 @@ def _build_rollover_candidate( }, ) rollover_record = { - "schema_version": ROLLOVER_RECORD_SCHEMA, + "schema_version": ( + ROLLOVER_RECORD_SCHEMA_V2 if is_v2_result else ROLLOVER_RECORD_SCHEMA + ), "rollover_id": rollover_id, "continuation_domain": domain, "continuation_checkpoint_id": checkpoint_id, @@ -617,10 +707,32 @@ def _build_rollover_candidate( "sha256": _sha256_bytes(handoff_bytes), }, "successor_brief_binding": brief_binding, - "accepted_product_delta": [ - asdict(item) for item in extension.accepted_product_delta - ], - "accepted_product_delta_sha256": accepted_product_delta_sha256, + **( + { + "review_evidence_binding": dict( + status["review_evidence_binding"] + ), + "review_packet_binding": dict(status["review_packet_binding"]), + "execution_baseline_binding": dict(baseline_binding), + "accepted_diff_binding": accepted_diff_binding, + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": accepted_product_sha256, + "accepted_product_result": accepted_product_result, + "delete_quarantine_bindings": accepted_product_result[ + "delete_quarantine_bindings" + ], + "inherited_path_states_sha256": inherited_workspace[ + "inherited_path_states_sha256" + ], + } + if is_v2_result + else { + "accepted_product_delta": [ + asdict(item) for item in extension.accepted_product_delta + ], + "accepted_product_delta_sha256": accepted_product_delta_sha256, + } + ), "selected_workspace": selected_workspace, "inherited_workspace": inherited_workspace, "prior_increment_authority_binding": status[ @@ -629,20 +741,21 @@ def _build_rollover_candidate( } rollover_bytes = _canonical_json_line(rollover_record) rollover_sha256 = _sha256_bytes(rollover_bytes) - prior_inherited_binding = status.get("inherited_workspace_binding", {}) - if not isinstance(prior_inherited_binding, Mapping): - raise ValueError("prior inherited workspace inventory is invalid") - prior_inherited = prior_inherited_binding.get("inherited_paths", []) - if not isinstance(prior_inherited, list) or not all( - isinstance(item, str) for item in prior_inherited - ): - raise ValueError("prior inherited workspace inventory is invalid") - cumulative_inherited_paths = sorted( - { - *prior_inherited, - *(item.path for item in extension.accepted_product_delta), - } - ) + if not is_v2_result: + prior_inherited_binding = status.get("inherited_workspace_binding", {}) + if not isinstance(prior_inherited_binding, Mapping): + raise ValueError("prior inherited workspace inventory is invalid") + prior_inherited = prior_inherited_binding.get("inherited_paths", []) + if not isinstance(prior_inherited, list) or not all( + isinstance(item, str) for item in prior_inherited + ): + raise ValueError("prior inherited workspace inventory is invalid") + cumulative_inherited_paths = sorted( + { + *prior_inherited, + *(item.path for item in extension.accepted_product_delta), + } + ) successor_status = dict(status) for field in ( "approved_exact_file_plan_sha256", @@ -689,7 +802,11 @@ def _build_rollover_candidate( ), }, rollover_binding={ - "schema_version": ROLLOVER_BINDING_SCHEMA, + "schema_version": ( + ROLLOVER_BINDING_SCHEMA_V2 + if is_v2_result + else ROLLOVER_BINDING_SCHEMA + ), "rollover_id": rollover_id, "rollover_sha256": rollover_sha256, "continuation_domain": domain, @@ -703,13 +820,31 @@ def _build_rollover_candidate( "successor_grant_id": grant_id, "successor_grant_sha256": grant_sha256, "submitted_prompt_sha256": prompt_sha256, + **( + { + "product_result_schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "product_result_sha256": accepted_product_sha256, + "delete_quarantine_bindings": accepted_product_result[ + "delete_quarantine_bindings" + ], + "inherited_path_states_sha256": inherited_workspace[ + "inherited_path_states_sha256" + ], + } + if is_v2_result + else {} + ), }, - inherited_workspace_binding={ - "schema_version": INHERITED_WORKSPACE_SCHEMA, - "workspace_selection_sha256": sha256_file(workspace_path), - "accepted_product_delta_sha256": accepted_product_delta_sha256, - "inherited_paths": cumulative_inherited_paths, - }, + inherited_workspace_binding=( + inherited_workspace + if is_v2_result + else { + "schema_version": INHERITED_WORKSPACE_SCHEMA, + "workspace_selection_sha256": sha256_file(workspace_path), + "accepted_product_delta_sha256": accepted_product_delta_sha256, + "inherited_paths": cumulative_inherited_paths, + } + ), previous_state={ "schema_version": status["schema_version"], "state_sequence": prior_status_sequence, @@ -760,6 +895,7 @@ def _append_or_adopt_record( *, identifier_field: str, label: str, + preserve_field_order: bool = False, ) -> bool: records, issues = load_json_lines(path) if records is None: @@ -770,7 +906,12 @@ def _append_or_adopt_record( if len(matches) != 1 or matches[0] != record: raise ValueError(f"continuation-recovery-required: divergent {label}") return True - atomic_append_json_line(path, record, sha256_file(path)) + atomic_append_json_line( + path, + record, + sha256_file(path), + preserve_field_order=preserve_field_order, + ) return False @@ -857,14 +998,46 @@ def _preflight_rollover_history( ) if isinstance(status.get("rollover_binding"), Mapping): inherited = status.get("inherited_workspace_binding") - if ( - not isinstance(inherited, Mapping) - or inherited.get("inherited_paths") != list(inherited_paths) - ): + if not isinstance(inherited, Mapping): + raise ValueError("rollover inherited workspace inventory mismatch") + if inherited.get("schema_version") == INHERITED_WORKSPACE_SCHEMA_V2: + if ( + not completed + or inherited != completed[-1].get("inherited_workspace") + or [ + item.get("path") + for item in inherited.get("inherited_path_states", []) + if isinstance(item, Mapping) + ] + != list(inherited_paths) + ): + raise ValueError("rollover inherited workspace inventory mismatch") + elif inherited.get("inherited_paths") != list(inherited_paths): raise ValueError("rollover inherited workspace inventory mismatch") candidate_inherited = candidate.successor_status.get( "inherited_workspace_binding" ) + if ( + isinstance(candidate_inherited, Mapping) + and candidate_inherited.get("schema_version") + == INHERITED_WORKSPACE_SCHEMA_V2 + ): + cumulative = { + "inherited_path_states": candidate_inherited.get( + "inherited_path_states" + ), + "delete_quarantine_bindings": candidate_inherited.get( + "delete_quarantine_bindings" + ), + } + if ( + not isinstance(cumulative["inherited_path_states"], list) + or not isinstance(cumulative["delete_quarantine_bindings"], list) + or candidate_inherited.get("inherited_path_states_sha256") + != _sha256_bytes(_canonical_json_bytes(cumulative)) + ): + raise ValueError("rollover candidate inherited workspace is invalid") + return current_delta = candidate.rollover_record.get("accepted_product_delta") if not isinstance(candidate_inherited, Mapping) or not isinstance( current_delta, list @@ -918,6 +1091,10 @@ def record(label: str, was_adopted: bool) -> None: candidate.action_record, identifier_field="authorization_id", label="rollover action authorization", + preserve_field_order=( + candidate.action_record.get("schema_version") + == ACTION_AUTHORIZATION_SCHEMA_V3 + ), ), ) if ( @@ -1104,10 +1281,35 @@ def inspect_increment_rollover( return IncrementRolloverInspection(None, None, (), tuple(manifest_issues)) status, _ = _load_role_object(root, manifest, "status") if ( - status.get("current_increment_state") == "preparing" + status.get("current_increment_state") != "accepted" and isinstance(status.get("rollover_binding"), dict) ): - return IncrementRolloverInspection(None, "resume", ("successor-status",), ()) + binding = status["rollover_binding"] + domain = str(binding.get("continuation_domain", "immediate")) + try: + _validated_inherited_paths( + root, + status, + observation, + allow_unbound_suffix=False, + ) + except (KeyError, OSError, TypeError, ValueError) as error: + return IncrementRolloverInspection( + domain, + _recovery_disposition(domain), + ("successor-status",), + (str(error),), + ) + return IncrementRolloverInspection( + domain, + ( + "resume" + if status.get("current_increment_state") == "preparing" + else None + ), + ("successor-status",), + (), + ) if status.get("current_increment_state") != "accepted": return IncrementRolloverInspection(None, None, (), ()) binding = status.get("diff_disposition_binding") @@ -1207,6 +1409,26 @@ def validated_inherited_paths( ) +def _validate_rollover_file_binding( + root: Path, + binding: object, + *, + label: str, +) -> Path: + if not isinstance(binding, Mapping): + raise ValueError(f"rollover {label} binding is invalid") + path, path_issues = resolve_managed_path( + root, + binding.get("path"), + role=f"rollover {label}", + ) + if path is None: + raise ValueError("; ".join(path_issues)) + if binding.get("sha256") != sha256_file(path): + raise ValueError(f"rollover {label} digest changed") + return path + + def _validated_completed_rollover_records( program_root: Path, status: Mapping[str, object], @@ -1249,13 +1471,32 @@ def _validated_completed_rollover_records( grant_path = _load_role_path(root, manifest, "increment_grants") action_path = _load_role_path(root, manifest, "action_authorizations") + approval_path = _load_role_path(root, manifest, "approvals") grants, grant_issues = load_json_lines(grant_path) actions, action_issues = load_json_lines(action_path) + approvals, approval_issues = load_json_lines(approval_path) if grants is None: raise ValueError("; ".join(grant_issues)) if actions is None: raise ValueError("; ".join(action_issues)) + if approvals is None: + raise ValueError("; ".join(approval_issues)) is_setup_program = status.get("schema_version") == "implementation-program-status/v3" + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, Mapping) + else None + ) + is_setup_v2 = ( + is_setup_program + and isinstance(setup_semantics, Mapping) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, Mapping) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) activation = ( status.get("setup_activation_binding") if is_setup_program @@ -1330,8 +1571,16 @@ def _validated_completed_rollover_records( ), ) expected_current: str | None = None + accepted_prefix: tuple[str, ...] = () + if manifest.get("schema_version") == SETUP_PROGRAM_MANIFEST_SCHEMA: + from program_authority import resolve_increment_successor + + traceability, _ = _load_role_object(root, manifest, "traceability") for index, record in enumerate(completed): - if record.get("schema_version") != ROLLOVER_RECORD_SCHEMA: + record_is_v2 = record.get("schema_version") == ROLLOVER_RECORD_SCHEMA_V2 + if record.get("schema_version") != ( + ROLLOVER_RECORD_SCHEMA_V2 if is_setup_v2 else ROLLOVER_RECORD_SCHEMA + ): raise ValueError("rollover chain contains an unsupported record") current = record.get("current_increment_id") successor = record.get("successor_increment_id") @@ -1348,6 +1597,13 @@ def _validated_completed_rollover_records( raise ValueError("rollover chain increment authority is invalid") if index and current != expected_current: raise ValueError("rollover chain is not contiguous") + if manifest.get("schema_version") == SETUP_PROGRAM_MANIFEST_SCHEMA: + resolution = resolve_increment_successor( + manifest, traceability.get("atomic_requirements"), current, accepted_prefix + ) + if resolution.kind != "successor" or resolution.successor_increment_id != successor: + raise ValueError(f"rollover successor disagrees with approved schedule: {resolution.reason or successor}") + accepted_prefix += (current,) if record.get("prior_increment_authority_binding") != expected_authority: raise ValueError("rollover chain prior increment authority is invalid") matching_actions = [ @@ -1362,9 +1618,13 @@ def _validated_completed_rollover_records( if ( action.get("schema_version") != ( - "implementation-action-authorization/v2" - if is_setup_program - else ACTION_AUTHORIZATION_SCHEMA + ACTION_AUTHORIZATION_SCHEMA_V3 + if is_setup_v2 + else ( + "implementation-action-authorization/v2" + if is_setup_program + else ACTION_AUTHORIZATION_SCHEMA + ) ) or action.get("decision") != "authorized" or action.get("actions") != ["rollover-increment"] @@ -1372,10 +1632,48 @@ def _validated_completed_rollover_records( or action.get("program_revision") != status.get("program_revision") or action.get("current_increment_id") != current or action.get("successor_increment_id") != successor - or _sha256_bytes(_canonical_json_line(action)) + or _sha256_bytes( + ( + ( + json.dumps( + action, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + if record_is_v2 + else _canonical_json_line(action) + ) + ) != record.get("rollover_authorization_sha256") ): raise ValueError("rollover chain action authority is invalid") + if record_is_v2 and ( + tuple(action) != SETUP_V2_ROLLOVER_ACTION_FIELDS + or action.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or action.get("product_result_sha256") + != record.get("product_result_sha256") + or action.get("continuation_domain") + != record.get("continuation_domain") + or action.get("continuation_checkpoint_id") + != record.get("continuation_checkpoint_id") + or action.get("accepted_status_sha256") + != record.get("accepted_status_sha256") + or action.get("accepted_status_sequence") + != record.get("accepted_status_sequence") + or action.get("workspace") != record.get("selected_workspace") + or action.get("submitted_prompt_sha256") + != record.get("submitted_prompt_sha256") + or action.get("increment_grant_id") + != expected_authority.get("grant_id") + or action.get("increment_grant_sha256") + != expected_authority.get("grant_sha256") + or "accepted_product_delta_sha256" in action + ): + raise ValueError("rollover chain action v3 binding is invalid") matching_grants = [ grant for grant in grants @@ -1399,6 +1697,209 @@ def _validated_completed_rollover_records( or grant_sha256 != record.get("successor_grant_sha256") ): raise ValueError("rollover chain successor grant authority is invalid") + if record_is_v2: + try: + result = product_path_states_v2_from_value( + record["accepted_product_result"] + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("rollover chain product result is invalid") from error + inherited = record.get("inherited_workspace") + evidence_path = _validate_rollover_file_binding( + root, + record.get("review_evidence_binding"), + label="review evidence", + ) + packet_path = _validate_rollover_file_binding( + root, + record.get("review_packet_binding"), + label="review packet", + ) + _validate_rollover_file_binding( + root, + record.get("execution_baseline_binding"), + label="execution baseline", + ) + _validate_rollover_file_binding( + root, + record.get("handoff_binding"), + label="handoff", + ) + _validate_rollover_file_binding( + root, + record.get("successor_brief_binding"), + label="successor brief", + ) + evidence, evidence_issues = load_json_object(evidence_path) + if evidence is None: + raise ValueError("; ".join(evidence_issues)) + try: + packet_markdown = packet_path.read_text(encoding="utf-8") + from review_coordination import validate_review_bundle + + bundle_issues = validate_review_bundle(evidence, packet_markdown) + except (OSError, UnicodeError, TypeError, ValueError) as error: + raise ValueError("rollover review evidence is invalid") from error + if bundle_issues: + raise ValueError( + "rollover review evidence is invalid: " + + "; ".join(bundle_issues) + ) + accepted_diff = record.get("accepted_diff_binding") + if not isinstance(accepted_diff, Mapping) or set(accepted_diff) != { + "diff_disposition_binding", + "diff_approval_binding", + "execution_transition_binding", + "review_evidence_binding", + "review_packet_binding", + }: + raise ValueError("rollover accepted diff binding is invalid") + disposition = accepted_diff.get("diff_disposition_binding") + approval_binding = accepted_diff.get("diff_approval_binding") + transition = accepted_diff.get("execution_transition_binding") + if ( + accepted_diff.get("review_evidence_binding") + != record.get("review_evidence_binding") + or accepted_diff.get("review_packet_binding") + != record.get("review_packet_binding") + or not isinstance(disposition, Mapping) + or disposition.get("schema_version") + != "implementation-diff-disposition-binding/v2" + or disposition.get("decision") + not in {"accept-stop", "accept-continue"} + or disposition.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or disposition.get("product_result_sha256") != result.sha256 + or "accepted_product_delta_sha256" in disposition + or not isinstance(transition, Mapping) + or transition.get("schema_version") + != "implementation-execution-transition/v2" + or transition.get("product_path_states") + != record.get("accepted_product_result") + or transition.get("product_path_states_sha256") != result.sha256 + or not isinstance(approval_binding, Mapping) + or approval_binding.get("event_id") + != disposition.get("approval_event_id") + ): + raise ValueError("rollover accepted diff binding is invalid") + matching_approvals = [ + approval + for approval in approvals + if approval.get("event_id") == approval_binding.get("event_id") + and approval.get("type") == "increment-diff-approval" + ] + if len(matching_approvals) != 1: + raise ValueError("rollover diff approval must exist exactly once") + approval = matching_approvals[0] + from diff_disposition import ( + APPROVAL_SCHEMA_V3, + SETUP_V2_DIFF_APPROVAL_FIELDS, + ) + from program_continuation import SETUP_V2_CONTINUE_APPROVAL_FIELDS + + approval_sha256 = _sha256_bytes( + ( + json.dumps( + approval, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + ) + if ( + approval.get("schema_version") != APPROVAL_SCHEMA_V3 + or tuple(approval) + != ( + SETUP_V2_CONTINUE_APPROVAL_FIELDS + if disposition.get("decision") == "accept-continue" + else SETUP_V2_DIFF_APPROVAL_FIELDS + ) + or approval.get("diff_decision") + != disposition.get("decision") + or approval.get("base_seed_sha256") + != disposition.get("base_seed_sha256") + or approval.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or approval.get("product_result_sha256") != result.sha256 + or approval_sha256 != approval_binding.get("sha256") + or "accepted_product_delta_sha256" in approval + ): + raise ValueError("rollover diff approval binding is invalid") + for stem, path in ( + ("review_evidence", evidence_path), + ("review_packet", packet_path), + ): + current_sha256 = sha256_file(path) + if ( + record[stem + "_binding"].get("sha256") != current_sha256 + or accepted_diff[stem + "_binding"].get("sha256") != current_sha256 + or disposition.get(stem + "_sha256") != current_sha256 + or approval.get(stem + "_sha256") != current_sha256 + ): + label = stem.replace("_", " ") + raise ValueError(f"rollover {label} approval binding mismatch") + if disposition.get("decision") == "accept-continue": + projection = disposition.get("successor_authority_projection") + if ( + not isinstance(projection, Mapping) + or disposition.get("successor_increment_id") != successor + or approval.get("successor_increment_id") != successor + or approval.get("successor_authority_projection_sha256") + != _sha256_bytes(_canonical_json_bytes(dict(projection))) + ): + raise ValueError("rollover diff approval projection is invalid") + cumulative = { + "inherited_path_states": ( + inherited.get("inherited_path_states") + if isinstance(inherited, Mapping) + else None + ), + "delete_quarantine_bindings": ( + inherited.get("delete_quarantine_bindings") + if isinstance(inherited, Mapping) + else None + ), + } + if ( + result.sha256 != record.get("product_result_sha256") + or record.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or record.get("delete_quarantine_bindings") + != list(result.delete_quarantine_bindings) + or not isinstance(inherited, Mapping) + or inherited.get("schema_version") + != INHERITED_WORKSPACE_SCHEMA_V2 + or inherited.get("product_result_sha256") != result.sha256 + or inherited.get("accepted_product_result") + != record.get("accepted_product_result") + or inherited.get("inherited_path_states_sha256") + != _sha256_bytes(_canonical_json_bytes(cumulative)) + or record.get("inherited_path_states_sha256") + != inherited.get("inherited_path_states_sha256") + or grant.get("product_result_sha256") != result.sha256 + or grant.get("delete_quarantine_bindings") + != list(result.delete_quarantine_bindings) + or grant.get("inherited_path_states_sha256") + != inherited.get("inherited_path_states_sha256") + or grant.get("continuation_domain") + != record.get("continuation_domain") + or grant.get("continuation_checkpoint_id") + != record.get("continuation_checkpoint_id") + or grant.get("rollover_authorization_id") + != record.get("rollover_authorization_id") + or grant.get("predecessor_increment_authority_binding") + != record.get("prior_increment_authority_binding") + or grant.get("predecessor_status_sha256") + != record.get("accepted_status_sha256") + or grant.get("predecessor_status_sequence") + != record.get("accepted_status_sequence") + or grant.get("submitted_prompt_sha256") + != record.get("submitted_prompt_sha256") + or "handoff_addendum_binding" in record + ): + raise ValueError("rollover chain v2 result binding is invalid") expected_authority = { "schema_version": ( "implementation-current-increment-authority-binding/v2" @@ -1421,6 +1922,38 @@ def _validated_completed_rollover_records( or status.get("current_increment_authority_binding") != expected_authority ): raise ValueError("rollover chain does not reach status-current authority") + latest = completed[-1] + if latest.get("schema_version") == ROLLOVER_RECORD_SCHEMA_V2: + expected_binding = { + "schema_version": ROLLOVER_BINDING_SCHEMA_V2, + "rollover_id": latest.get("rollover_id"), + "rollover_sha256": _sha256_bytes(_canonical_json_line(latest)), + "continuation_domain": latest.get("continuation_domain"), + "continuation_checkpoint_id": latest.get("continuation_checkpoint_id"), + "prior_status_sha256": latest.get("accepted_status_sha256"), + "prior_status_sequence": latest.get("accepted_status_sequence"), + "current_increment_id": latest.get("current_increment_id"), + "successor_increment_id": latest.get("successor_increment_id"), + "rollover_authorization_id": latest.get("rollover_authorization_id"), + "rollover_authorization_sha256": latest.get( + "rollover_authorization_sha256" + ), + "successor_grant_id": latest.get("successor_grant_id"), + "successor_grant_sha256": latest.get("successor_grant_sha256"), + "submitted_prompt_sha256": latest.get("submitted_prompt_sha256"), + "product_result_schema_version": latest.get( + "product_result_schema_version" + ), + "product_result_sha256": latest.get("product_result_sha256"), + "delete_quarantine_bindings": latest.get( + "delete_quarantine_bindings" + ), + "inherited_path_states_sha256": latest.get( + "inherited_path_states_sha256" + ), + } + if binding != expected_binding: + raise ValueError("status rollover v2 binding is invalid") return tuple(completed) @@ -1438,7 +1971,209 @@ def _validated_inherited_paths( ) if not records: return () - root = Path(program_root) + root = Path(program_root).resolve() + if records[-1].get("schema_version") == ROLLOVER_RECORD_SCHEMA_V2: + inherited = status.get("inherited_workspace_binding") + if ( + not isinstance(inherited, Mapping) + or inherited.get("schema_version") != INHERITED_WORKSPACE_SCHEMA_V2 + or inherited != records[-1].get("inherited_workspace") + ): + raise ValueError("status inherited workspace v2 binding is invalid") + states = inherited.get("inherited_path_states") + receipts = inherited.get("delete_quarantine_bindings") + if not isinstance(states, list) or not isinstance(receipts, list): + raise ValueError("inherited workspace v2 inventory is invalid") + state_paths = [ + item.get("path") if isinstance(item, Mapping) else None + for item in states + ] + receipt_paths = [ + item.get("path") if isinstance(item, Mapping) else None + for item in receipts + ] + if ( + any(not isinstance(path, str) or not path for path in state_paths) + or len(set(state_paths)) != len(state_paths) + or any(not isinstance(path, str) or not path for path in receipt_paths) + or len(set(receipt_paths)) != len(receipt_paths) + ): + raise ValueError("inherited workspace v2 inventory is duplicated") + cumulative = { + "inherited_path_states": states, + "delete_quarantine_bindings": receipts, + } + if inherited.get("inherited_path_states_sha256") != _sha256_bytes( + _canonical_json_bytes(cumulative) + ): + raise ValueError("inherited workspace v2 digest mismatch") + + workspace = Path(observation.path).resolve() + program_relative = Path(os.path.relpath(root, workspace)).as_posix() + protected_paths = ( + (program_relative,) + if program_relative not in {"", "."} + else () + ) + current_operations: dict[str, str] = {} + current_baseline_binding = status.get("execution_baseline_binding") + if isinstance(current_baseline_binding, Mapping): + current_baseline_path, current_baseline_path_issues = ( + resolve_managed_path( + root, + current_baseline_binding.get("path"), + role="current execution baseline", + ) + ) + if current_baseline_path is None: + raise ValueError("; ".join(current_baseline_path_issues)) + if current_baseline_binding.get("sha256") != sha256_file( + current_baseline_path + ): + raise ValueError("current execution baseline digest mismatch") + current_baseline_value, current_baseline_issues = load_json_object( + current_baseline_path + ) + if current_baseline_value is None: + raise ValueError("; ".join(current_baseline_issues)) + current_baseline = execution_baseline_v2_from_value( + current_baseline_value + ) + current_operations = { + path: operation + for operation, paths in ( + ("Create", current_baseline.file_map.create), + ("Modify", current_baseline.file_map.modify), + ("Delete", current_baseline.file_map.delete), + ("Preserve", current_baseline.file_map.preserve), + ) + for path in paths + } + active_receipts = { + str(item["path"]): item + for item in receipts + if isinstance(item, Mapping) + } + for item in states: + if not isinstance(item, Mapping) or set(item) != { + "path", + "exists", + "sha256", + "mode", + "device", + "inode", + "link_count", + }: + raise ValueError("inherited workspace v2 path state is invalid") + relative = str(item["path"]) + observed = inspect_workspace_path( + workspace, + relative, + protected_paths=protected_paths, + ) + expected = { + "path": relative, + "exists": item["exists"], + "sha256": item["sha256"], + "mode": item["mode"], + "device": item["device"], + "inode": item["inode"], + "link_count": item["link_count"], + } + operation = current_operations.get(relative) + change_is_explicitly_owned = ( + item["exists"] and operation in {"Modify", "Delete"} + ) or (not item["exists"] and operation == "Create") + if not change_is_explicitly_owned and asdict(observed) != expected: + raise ValueError( + f"inherited accepted product bytes changed: {relative}" + ) + if not item["exists"] and relative not in active_receipts: + raise ValueError( + f"inherited Delete receipt binding is missing: {relative}" + ) + + for record in records: + if record.get("schema_version") != ROLLOVER_RECORD_SCHEMA_V2: + continue + baseline_binding = record.get("execution_baseline_binding") + if not isinstance(baseline_binding, Mapping): + raise ValueError("rollover execution baseline binding is invalid") + baseline_path, baseline_path_issues = resolve_managed_path( + root, + baseline_binding.get("path"), + role="rollover execution baseline", + ) + if baseline_path is None: + raise ValueError("; ".join(baseline_path_issues)) + if baseline_binding.get("sha256") != sha256_file(baseline_path): + raise ValueError("rollover execution baseline digest mismatch") + baseline_value, baseline_issues = load_json_object(baseline_path) + if baseline_value is None: + raise ValueError("; ".join(baseline_issues)) + baseline = execution_baseline_v2_from_value(baseline_value) + baseline_states = { + str(item["path"]): item["snapshot"] + for item in baseline.path_baselines + } + baseline_deletes = { + str(item["path"]): item + for item in baseline.delete_quarantine_bindings + } + result = product_path_states_v2_from_value( + record.get("accepted_product_result") + ) + for receipt_binding in result.delete_quarantine_bindings: + relative = str(receipt_binding["path"]) + snapshot = baseline_states.get(relative) + allocation = baseline_deletes.get(relative) + if snapshot is None or allocation is None: + raise ValueError("rollover Delete baseline binding is invalid") + receipt_snapshot = inspect_workspace_path( + root, + str(receipt_binding["receipt_path"]), + ) + quarantine_snapshot = inspect_workspace_path( + root, + str(allocation["entry_path"]), + ) + if ( + not receipt_snapshot.exists + or receipt_snapshot.sha256 + != receipt_binding["receipt_sha256"] + or not quarantine_snapshot.exists + or quarantine_snapshot.sha256 != snapshot.sha256 + or quarantine_snapshot.mode != snapshot.mode + or quarantine_snapshot.device != snapshot.device + or quarantine_snapshot.inode != snapshot.inode + or quarantine_snapshot.link_count != 1 + ): + raise ValueError( + f"rollover Delete quarantine binding changed: {relative}" + ) + recovery = classify_delete_quarantine_recovery( + root, + workspace, + relative, + { + "increment_id": baseline.increment_id, + **asdict(snapshot), + **allocation, + "quarantine_root_path": allocation["root_path"], + "quarantine_root_device": allocation["root_device"], + "quarantine_root_inode": allocation["root_inode"], + "quarantine_root_mode": allocation["root_mode"], + "quarantine_root_owner": allocation["root_owner"], + }, + protected_paths=protected_paths, + require_status_current=False, + ) + if recovery.issues or recovery.receipt is None: + raise ValueError( + f"rollover Delete quarantine receipt is invalid: {relative}" + ) + return tuple(str(path) for path in state_paths) + latest: dict[str, str] = {} for record in records: delta = record.get("accepted_product_delta") diff --git a/skills/implementing-staged-plans/scripts/program_setup.py b/skills/implementing-staged-plans/scripts/program_setup.py index 28b05fb..cc3eca9 100644 --- a/skills/implementing-staged-plans/scripts/program_setup.py +++ b/skills/implementing-staged-plans/scripts/program_setup.py @@ -22,12 +22,28 @@ MANIFEST_SCHEMA_V3 = "implementation-program-manifest/v3" STATUS_SCHEMA_V3 = "implementation-program-status/v3" -SETUP_SEMANTICS_SCHEMA = "implementation-program-setup-semantics/v1" -OPERATION_ENVELOPE_SCHEMA = "implementation-operation-envelope/v1" -SETUP_RECAP_SCHEMA = "implementation-program-setup-recap/v1" -SETUP_RECAP_CHECKPOINT_SCHEMA = "implementation-program-setup-recap-checkpoint/v1" -SETUP_DECISION_ADAPTER_SCHEMA = "setup-approval-decision/v1" -SETUP_ACTIVATION_SCHEMA = "setup-activation-decision/v1" +SETUP_SEMANTICS_SCHEMA_V1 = "implementation-program-setup-semantics/v1" +SETUP_SEMANTICS_SCHEMA_V2 = "implementation-program-setup-semantics/v2" +OPERATION_ENVELOPE_SCHEMA_V1 = "implementation-operation-envelope/v1" +OPERATION_ENVELOPE_SCHEMA_V2 = "implementation-operation-envelope/v2" +SETUP_RECAP_SCHEMA_V1 = "implementation-program-setup-recap/v1" +SETUP_RECAP_SCHEMA_V2 = "implementation-program-setup-recap/v2" +SETUP_RECAP_CHECKPOINT_SCHEMA_V1 = ( + "implementation-program-setup-recap-checkpoint/v1" +) +SETUP_RECAP_CHECKPOINT_SCHEMA_V2 = ( + "implementation-program-setup-recap-checkpoint/v2" +) +SETUP_DECISION_ADAPTER_SCHEMA_V1 = "setup-approval-decision/v1" +SETUP_DECISION_ADAPTER_SCHEMA_V2 = "setup-approval-decision/v2" +SETUP_ACTIVATION_SCHEMA_V1 = "setup-activation-decision/v1" +SETUP_ACTIVATION_SCHEMA_V2 = "setup-activation-decision/v2" +SETUP_SEMANTICS_SCHEMA = SETUP_SEMANTICS_SCHEMA_V1 +OPERATION_ENVELOPE_SCHEMA = OPERATION_ENVELOPE_SCHEMA_V1 +SETUP_RECAP_SCHEMA = SETUP_RECAP_SCHEMA_V1 +SETUP_RECAP_CHECKPOINT_SCHEMA = SETUP_RECAP_CHECKPOINT_SCHEMA_V1 +SETUP_DECISION_ADAPTER_SCHEMA = SETUP_DECISION_ADAPTER_SCHEMA_V1 +SETUP_ACTIVATION_SCHEMA = SETUP_ACTIVATION_SCHEMA_V1 INCREMENT_START_INTENT_SCHEMA = "increment-start-intent/v1" SOURCE_GATE_DEFINITION_SCHEMA = "source-gate-definition/v1" SOURCE_GATE_RECAP_SCHEMA = "source-gate-recap/v1" @@ -35,7 +51,30 @@ SOURCE_GATE_DECISION_SCHEMA = "source-gate-decision/v1" SOURCE_GATE_SATISFACTION_SCHEMA = "source-gate-satisfaction/v1" DIRECT_USER_PROVENANCE = "direct-user-message" -SUPPORTED_OPERATIONS = ("Create", "Modify", "Preserve") +SUPPORTED_OPERATIONS_V1 = ("Create", "Modify", "Preserve") +SUPPORTED_OPERATIONS_V2 = ("Create", "Modify", "Delete", "Preserve") +SUPPORTED_OPERATIONS = SUPPORTED_OPERATIONS_V1 +DELETE_CONTENT_DISPOSITIONS = frozenset( + {"migrated", "obsolete", "intentional-discard"} +) +_SETUP_FAMILY_CONTRACTS = { + (SETUP_SEMANTICS_SCHEMA_V1, OPERATION_ENVELOPE_SCHEMA_V1): { + "recap_schema": SETUP_RECAP_SCHEMA_V1, + "checkpoint_schema": SETUP_RECAP_CHECKPOINT_SCHEMA_V1, + "adapter_schema": SETUP_DECISION_ADAPTER_SCHEMA_V1, + "activation_schema": SETUP_ACTIVATION_SCHEMA_V1, + "renderer_version": 1, + "supported_operations": SUPPORTED_OPERATIONS_V1, + }, + (SETUP_SEMANTICS_SCHEMA_V2, OPERATION_ENVELOPE_SCHEMA_V2): { + "recap_schema": SETUP_RECAP_SCHEMA_V2, + "checkpoint_schema": SETUP_RECAP_CHECKPOINT_SCHEMA_V2, + "adapter_schema": SETUP_DECISION_ADAPTER_SCHEMA_V2, + "activation_schema": SETUP_ACTIVATION_SCHEMA_V2, + "renderer_version": 2, + "supported_operations": SUPPORTED_OPERATIONS_V2, + }, +} SUPPORTED_GATE_TRIGGERS = ( "before-program-activation", "before-increment-start", @@ -165,6 +204,34 @@ def _exact_fields(value: object, expected: Sequence[str], label: str) -> list[st return issues +def setup_family_contract( + manifest: Mapping[str, object], +) -> Mapping[str, object]: + """Select one setup family solely from its exact semantics/envelope pair.""" + semantics = manifest.get("setup_semantics") + envelope = ( + semantics.get("operation_envelope") + if isinstance(semantics, Mapping) + else None + ) + setup_schema = ( + semantics.get("schema_version") if isinstance(semantics, Mapping) else None + ) + envelope_schema = ( + envelope.get("schema_version") if isinstance(envelope, Mapping) else None + ) + contract = ( + _SETUP_FAMILY_CONTRACTS.get((setup_schema, envelope_schema)) + if isinstance(setup_schema, str) and isinstance(envelope_schema, str) + else None + ) + if contract is None: + raise ValueError( + "setup semantics and operation envelope schemas must be an exact supported pair" + ) + return contract + + def _safe_relative_path(value: object) -> bool: if not _is_text(value) or "\\" in str(value): return False @@ -358,8 +425,11 @@ def validate_setup_semantics(program_root: Path) -> list[str]: issues.extend(_exact_fields(semantics, expected_semantic_fields, "setup_semantics")) if not isinstance(semantics, dict): return sorted(set(issues)) - if semantics.get("schema_version") != SETUP_SEMANTICS_SCHEMA: - issues.append("setup_semantics schema_version mismatch") + try: + family = setup_family_contract(manifest) + except ValueError as error: + family = None + issues.append(str(error)) semantic_identity = value_sha256(semantics) if manifest.get("setup_semantics_sha256") != semantic_identity: issues.append("setup_semantics digest mismatch") @@ -535,57 +605,13 @@ def validate_setup_semantics(program_root: Path) -> list[str]: issues.append(f"{label} {field} must be a suitable string list") if not _is_text(increment.get("intended_outcome")): issues.append(f"{label} intended_outcome is required") - if len(increment_ids) != len(set(increment_ids)): - issues.append("setup increment IDs must be unique") - positions = { - increment_id: index for index, increment_id in enumerate(increment_ids) - } - dependency_graph_invalid = False - for index, increment in enumerate(increments): - dependencies = ( - increment.get("depends_on") if isinstance(increment, dict) else None - ) - if not _text_list(dependencies): - dependency_graph_invalid = True - continue - if len(dependencies) != len(set(dependencies)) or any( - dependency not in positions or positions[dependency] >= index - for dependency in dependencies - ): - dependency_graph_invalid = True - if dependency_graph_invalid: - issues.append("setup increment dependency graph is invalid") - if semantics.get("first_increment_id") not in increment_ids: - issues.append("setup first increment must be allocated") - atomic_requirements = ( - traceability.get("atomic_requirements") - if isinstance(traceability, dict) - else None - ) - if isinstance(atomic_requirements, list): - traceability_increment_ids = { - increment_id - for requirement in atomic_requirements - if isinstance(requirement, dict) - for increment_id in requirement.get("assigned_increments", []) - if isinstance(increment_id, str) - } - if set(increment_ids) != traceability_increment_ids: - issues.append("setup increments do not cover exact traceability allocation") - for increment in increments if isinstance(increments, list) else []: - if not isinstance(increment, dict): - continue - expected_requirement_ids = [ - str(requirement.get("id")) - for requirement in atomic_requirements - if isinstance(requirement, dict) - and increment.get("increment_id") - in requirement.get("assigned_increments", []) - ] - if increment.get("requirement_ids") != expected_requirement_ids: - issues.append( - f"setup increment {increment.get('increment_id')} requirement allocation mismatch" - ) + from program_authority import validated_increment_schedule + + try: + validated_increment_schedule(manifest, traceability.get("atomic_requirements")) + except ValueError as error: + issues.append(str(error)) + for index, definition in enumerate( manifest.get("source_gate_definitions", []) if isinstance(manifest.get("source_gate_definitions"), list) @@ -628,40 +654,56 @@ def validate_setup_semantics(program_root: Path) -> list[str]: ) ) if isinstance(envelope, dict): - if envelope.get("schema_version") != OPERATION_ENVELOPE_SCHEMA: - issues.append("operation envelope schema mismatch") - if envelope.get("supported_operations") != list(SUPPORTED_OPERATIONS): - issues.append("operation envelope must support exactly Create/Modify/Preserve") + supported_operations = ( + family.get("supported_operations") if family is not None else () + ) + if envelope.get("supported_operations") != list(supported_operations): + expected_operations = "/".join(str(item) for item in supported_operations) + issues.append( + "operation envelope must support exactly " + expected_operations + ) allocations = envelope.get("allocations") if not isinstance(allocations, list) or not allocations: issues.append("operation envelope allocations must be non-empty") else: seen_allocations: set[tuple[str, str, str]] = set() + allocation_values: list[dict[str, object]] = [] + base_allocation_fields = ( + "kind", + "path", + "operation", + "increment_ids", + "inclusions", + "exclusions", + "ownership", + "protected", + "user_work", + "file_kind", + "link_kind", + "mode", + "collision", + ) for index, allocation in enumerate(allocations): label = f"operation allocation {index}" + is_delete = ( + allocation.get("operation") == "Delete" + if isinstance(allocation, dict) + else False + ) expected = ( - "kind", - "path", - "operation", - "increment_ids", - "inclusions", - "exclusions", - "ownership", - "protected", - "user_work", - "file_kind", - "link_kind", - "mode", - "collision", + (*base_allocation_fields, "accepted_state", "content_disposition", "rationale") + if is_delete and supported_operations == SUPPORTED_OPERATIONS_V2 + else base_allocation_fields ) issues.extend(_exact_fields(allocation, expected, label)) if not isinstance(allocation, dict): continue + allocation_values.append(allocation) if allocation.get("kind") not in {"exact-path", "bounded-path-class"}: issues.append(f"{label} kind is unsupported") if not _safe_relative_path(allocation.get("path")): issues.append(f"{label} path is unsafe") - if allocation.get("operation") not in SUPPORTED_OPERATIONS: + if allocation.get("operation") not in supported_operations: issues.append(f"{label} operation is unsupported") allocated = allocation.get("increment_ids") if not _text_list(allocated, nonempty=True) or any( @@ -685,6 +727,78 @@ def validate_setup_semantics(program_root: Path) -> list[str]: if key in seen_allocations: issues.append("operation envelope contains duplicate allocation") seen_allocations.add(key) + if is_delete and supported_operations == SUPPORTED_OPERATIONS_V2: + if allocation.get("kind") != "exact-path": + issues.append("Delete allocation kind must be exact-path") + if allocation.get("accepted_state") != "absent": + issues.append( + "Delete allocation accepted_state must be absent" + ) + if ( + allocation.get("content_disposition") + not in DELETE_CONTENT_DISPOSITIONS + ): + issues.append( + "Delete allocation content_disposition is unsupported" + ) + if not _is_text(allocation.get("rationale")): + issues.append("Delete allocation rationale is required") + if allocation.get("ownership") != "program": + issues.append("Delete allocation ownership must be program") + if allocation.get("collision") not in { + "existing", + "accepted-predecessor", + }: + issues.append("Delete allocation collision is unsupported") + + strict_predecessors: dict[str, set[str]] = {} + for increment in increments if isinstance(increments, list) else []: + if not isinstance(increment, dict): + continue + increment_id = increment.get("increment_id") + dependencies = increment.get("depends_on") + if not isinstance(increment_id, str) or not isinstance( + dependencies, list + ): + continue + inherited: set[str] = set() + for dependency in dependencies: + if isinstance(dependency, str): + inherited.add(dependency) + inherited.update(strict_predecessors.get(dependency, set())) + strict_predecessors[increment_id] = inherited + create_allocations = [ + allocation + for allocation in allocation_values + if allocation.get("operation") == "Create" + and allocation.get("kind") == "exact-path" + and _text_list(allocation.get("increment_ids"), nonempty=True) + ] + for allocation in allocation_values: + if not ( + allocation.get("operation") == "Delete" + and allocation.get("collision") == "accepted-predecessor" + and supported_operations == SUPPORTED_OPERATIONS_V2 + ): + continue + delete_increment_ids = allocation.get("increment_ids") + has_create_predecessor = isinstance(delete_increment_ids, list) and all( + any( + create.get("path") == allocation.get("path") + and any( + create_increment_id + in strict_predecessors.get(str(delete_increment_id), set()) + for create_increment_id in create.get("increment_ids", []) + if isinstance(create_increment_id, str) + ) + for create in create_allocations + ) + for delete_increment_id in delete_increment_ids + ) + if not has_create_predecessor: + issues.append( + "Delete allocation accepted-predecessor lacks a same-path Create in a strict predecessor" + ) for field in ("protections", "exclusions", "external_boundaries", "material_risks"): if not _text_list(semantics.get(field)): @@ -815,6 +929,13 @@ def render_setup_recap(program_root: Path) -> str: if allocation["mode"] is not None else "not applicable" ) + delete_disposition = ( + f"; accepted state: {allocation['accepted_state']}; " + f"content disposition: {allocation['content_disposition']}; " + f"rationale: {allocation['rationale']}" + if allocation["operation"] == "Delete" + else "" + ) lines.append( f"- {allocation['operation']} {scope_label} for " + ", ".join(allocation["increment_ids"]) @@ -823,6 +944,7 @@ def render_setup_recap(program_root: Path) -> str: + f"mode: {mode}; inclusions: {inclusions}; exclusions: {exclusions}; " + f"protected: {'yes' if allocation['protected'] else 'no'}; " + f"user work: {'yes' if allocation['user_work'] else 'no'}" + + delete_disposition ) lines.extend(["", "Source-defined gates:"]) definitions = manifest["source_gate_definitions"] @@ -866,11 +988,12 @@ def setup_recap_checkpoint( ) -> dict[str, object]: root = Path(program_root) manifest = _load_manifest(root) + family = setup_family_contract(manifest) rendered = render_setup_recap(root) if recap is None else recap value: dict[str, object] = { - "schema_version": SETUP_RECAP_CHECKPOINT_SCHEMA, - "renderer_schema": SETUP_RECAP_SCHEMA, - "renderer_version": 1, + "schema_version": family["checkpoint_schema"], + "renderer_schema": family["recap_schema"], + "renderer_version": family["renderer_version"], "semantic_decision_identity": setup_semantic_identity(manifest), "presented_integrity_identity": _presented_integrity(root, manifest), "recap_sha256": _bytes_sha256(rendered.encode("utf-8")), @@ -901,12 +1024,14 @@ def adapt_setup_decision( checkpoint: Mapping[str, object] | None = None, ) -> dict[str, object]: root = Path(program_root) + manifest = _load_manifest(root) + family = setup_family_contract(manifest) expected_checkpoint = setup_recap_checkpoint(root) if checkpoint is not None and dict(checkpoint) != expected_checkpoint: raise ValueError("stale setup recap checkpoint") decision = _classify_direct_answer(response, role, provenance) base: dict[str, object] = { - "schema_version": SETUP_DECISION_ADAPTER_SCHEMA, + "schema_version": family["adapter_schema"], "semantic_decision_identity": expected_checkpoint[ "semantic_decision_identity" ], @@ -941,9 +1066,17 @@ def validate_setup_decision( ), "setup decision adapter", ) - if decision.get("schema_version") != SETUP_DECISION_ADAPTER_SCHEMA: + root = Path(program_root) + try: + family = setup_family_contract(_load_manifest(root)) + except ValueError as error: + family = None + issues.append(str(error)) + if family is None or decision.get("schema_version") != family.get( + "adapter_schema" + ): issues.append("setup decision adapter schema mismatch") - expected_checkpoint = setup_recap_checkpoint(Path(program_root)) + expected_checkpoint = setup_recap_checkpoint(root) if decision.get("recap_checkpoint") != expected_checkpoint: issues.append("setup decision recap binding mismatch") if decision.get("semantic_decision_identity") != expected_checkpoint.get( @@ -1259,7 +1392,8 @@ def _setup_activation_record( program_root: Path, manifest: Mapping[str, object] ) -> tuple[dict[str, object], Path]: record, path = _load_role(program_root, manifest, "setup_activation_decision") - if record.get("schema_version") != SETUP_ACTIVATION_SCHEMA or not _is_text( + expected_schema = setup_family_contract(manifest)["activation_schema"] + if record.get("schema_version") != expected_schema or not _is_text( record.get("decision_id") ): raise ValueError("setup-activation decision record is invalid") @@ -1492,12 +1626,16 @@ def _setup_activation_record_issues( decision_id = setup_base.pop("decision_id", None) semantics = manifest.get("setup_semantics") try: + expected_activation_schema = setup_family_contract(manifest)[ + "activation_schema" + ] expected_checkpoint = setup_recap_checkpoint(root) except ValueError as error: + expected_activation_schema = None expected_checkpoint = None issues.append(str(error)) if ( - setup.get("schema_version") != SETUP_ACTIVATION_SCHEMA + setup.get("schema_version") != expected_activation_schema or setup.get("program_id") != manifest.get("program_id") or setup.get("program_revision") != manifest.get("program_revision") or setup.get("source_binding") != manifest.get("source_binding") @@ -1647,10 +1785,13 @@ def inspect_sequence_zero_activation_prefix( root = Path(program_root) try: manifest = _load_manifest(root) + setup_family = setup_family_contract(manifest) status, status_path = _load_role(root, manifest, "status") traceability, _ = _load_role(root, manifest, "traceability") - approvals, _ = _load_role(root, manifest, "approvals", json_lines=True) - gates, _ = _load_role( + approvals, approvals_path = _load_role( + root, manifest, "approvals", json_lines=True + ) + gates, gates_path = _load_role( root, manifest, "source_gate_decisions", json_lines=True ) grants, _ = _load_role(root, manifest, "increment_grants", json_lines=True) @@ -1691,6 +1832,19 @@ def inspect_sequence_zero_activation_prefix( if setup is None: issues.extend(setup_issues) return {"state": "invalid", "issues": sorted(set(issues))} + if setup_family.get("activation_schema") == SETUP_ACTIVATION_SCHEMA_V2: + if setup_path.read_bytes() != canonical_json_bytes(setup): + issues.append("setup-v2 activation decision bytes are not canonical") + for label, records, ledger_path in ( + ("approval", approvals, approvals_path), + ("source-gate", gates, gates_path), + ): + expected_bytes = b"".join( + canonical_identity_bytes(dict(record)) + b"\n" + for record in records + ) + if ledger_path.read_bytes() != expected_bytes: + issues.append(f"setup-v2 {label} prefix bytes are not canonical") issues.extend(_setup_activation_record_issues(root, manifest, setup)) if setup.get("proposal_status_sha256") != sha256_file(status_path): issues.append("setup-activation proposal status digest mismatch") diff --git a/skills/implementing-staged-plans/scripts/repository_preparation.py b/skills/implementing-staged-plans/scripts/repository_preparation.py index c65f3e6..5b63de0 100644 --- a/skills/implementing-staged-plans/scripts/repository_preparation.py +++ b/skills/implementing-staged-plans/scripts/repository_preparation.py @@ -25,8 +25,15 @@ from state_authority import ( ActionBinding, ExactFileMap, + ExactFileMapV2, RepositoryObservation, + WorkspacePathSnapshot, + _delete_receipt_bytes, decide_action_authorization, + classify_delete_quarantine_recovery, + delete_quarantine_allocation, + descriptor_protection_context, + inspect_workspace_path, validate_state_authority, ) @@ -34,6 +41,8 @@ REPOSITORY_INSPECTION_SCHEMA = "implementation-repository-inspection/v1" EVIDENCE_RECORD_SCHEMA = "implementation-evidence-record/v1" EXECUTION_BASELINE_SCHEMA = "implementation-execution-baseline/v1" +EXECUTION_BASELINE_SCHEMA_V2 = "implementation-execution-baseline/v2" +PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" MATERIAL_EVIDENCE_PREDICATES = frozenset( { @@ -173,6 +182,58 @@ class ExecutionWorkspaceAssessment: product_delta_sha256: str +@dataclass(frozen=True) +class ExecutionBaselineV2: + schema_version: str + program_id: str + program_revision: int + increment_id: str + exact_file_plan_sha256: str + current_increment_authority_binding: dict[str, object] + workspace_observation: dict[str, object] + file_map: ExactFileMapV2 + path_baselines: tuple[dict[str, object], ...] + delete_quarantine_bindings: tuple[dict[str, object], ...] + protected_control_allocations: tuple[str, ...] + user_work_baselines: tuple[UserWorkBaseline, ...] + inherited_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class ProductPathStateV2: + path: str + operation: str + exists: bool + sha256: str | None + mode: str | None + device: int | None + inode: int | None + link_count: int | None + + +@dataclass(frozen=True) +class ProductPathStatesV2: + schema_version: str + program_id: str + program_revision: int + increment_id: str + ordered_path_states: tuple[ProductPathStateV2, ...] + delete_quarantine_bindings: tuple[dict[str, object], ...] + sha256: str + + +@dataclass(frozen=True) +class ExecutionWorkspaceAssessmentV2: + valid: bool + issues: tuple[str, ...] + product_states: ProductPathStatesV2 + + @property + def product_delta_sha256(self) -> str: + """Compatibility accessor; v2 authority binds product states by sha256.""" + return self.product_states.sha256 + + @dataclass(frozen=True) class DriftContext: previous: RepositoryInspection @@ -939,7 +1000,9 @@ def _normalized_file_map_path(raw_path: str) -> str: return raw_path -def parse_exact_file_map(markdown: str) -> ExactFileMap: +def _parse_exact_file_map( + markdown: str, *, supported_delete: bool +) -> ExactFileMap | ExactFileMapV2: """Parse exactly one disposition-aware file map from an exact plan.""" file_map_matches = list( re.finditer(r"^## File map\s*$", markdown, flags=re.MULTILINE) @@ -951,13 +1014,23 @@ def parse_exact_file_map(markdown: str) -> ExactFileMap: end = start + next_heading.start() if next_heading else len(markdown) body = markdown[start:end] headings = list( - re.finditer(r"^### (Create|Modify|Preserve)\s*$", body, flags=re.MULTILINE) + re.finditer( + r"^### (Create|Modify|Delete|Preserve)\s*$", + body, + flags=re.MULTILINE, + ) ) - if tuple(match.group(1) for match in headings) != ( - "Create", - "Modify", - "Preserve", - ): + heading_names = tuple(match.group(1) for match in headings) + if supported_delete: + if heading_names != ("Create", "Modify", "Delete", "Preserve"): + raise ValueError( + "exact-file map must contain one ordered Create, Modify, Delete, and Preserve section" + ) + elif heading_names == ("Create", "Modify", "Preserve"): + pass + elif "Delete" in heading_names: + raise ValueError("Delete section requires setup-v2 exact-file map") + else: raise ValueError( "exact-file map must contain one ordered Create, Modify, and Preserve section" ) @@ -982,9 +1055,16 @@ def parse_exact_file_map(markdown: str) -> ExactFileMap: raise ValueError(f"exact-file map path is duplicated: {path}") seen.add(path) paths.append(path) - if not paths: + if not paths and disposition != "Delete": raise ValueError(f"exact-file map {disposition} section must not be empty") parsed[disposition] = tuple(paths) + if supported_delete: + return ExactFileMapV2( + create=parsed["Create"], + modify=parsed["Modify"], + delete=parsed["Delete"], + preserve=parsed["Preserve"], + ) return ExactFileMap( create=parsed["Create"], modify=parsed["Modify"], @@ -992,6 +1072,316 @@ def parse_exact_file_map(markdown: str) -> ExactFileMap: ) +def parse_exact_file_map(markdown: str) -> ExactFileMap: + """Parse the v1 exact-file map family.""" + parsed = _parse_exact_file_map(markdown, supported_delete=False) + if isinstance(parsed, ExactFileMapV2): + raise AssertionError("v1 exact-file maps must not produce v2 results") + return parsed + + +def parse_exact_file_map_v2(markdown: str) -> ExactFileMapV2: + """Parse the v2 exact-file map family.""" + parsed = _parse_exact_file_map(markdown, supported_delete=True) + if isinstance(parsed, ExactFileMap): + raise AssertionError("v2 exact-file maps must not produce v1 results") + return parsed + + +def _snapshot_from_value(value: object, *, expected_path: str) -> WorkspacePathSnapshot: + if not isinstance(value, dict): + raise ValueError("execution baseline v2 snapshot is invalid") + try: + path = _normalized_file_map_path(str(value["path"])) + snapshot = WorkspacePathSnapshot( + path=path, + exists=value["exists"], + sha256=value.get("sha256"), + mode=value.get("mode"), + device=value.get("device"), + inode=value.get("inode"), + link_count=value.get("link_count"), + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("execution baseline v2 snapshot is invalid") from error + if path != expected_path or not isinstance(snapshot.exists, bool): + raise ValueError("execution baseline v2 snapshot is invalid") + if snapshot.exists: + if ( + not isinstance(snapshot.sha256, str) + or not _SHA256.fullmatch(snapshot.sha256) + or not isinstance(snapshot.mode, str) + or not isinstance(snapshot.device, int) + or not isinstance(snapshot.inode, int) + or snapshot.link_count != 1 + ): + raise ValueError("execution baseline v2 snapshot is invalid") + elif any( + value is not None + for value in (snapshot.sha256, snapshot.mode, snapshot.device, snapshot.inode, snapshot.link_count) + ): + raise ValueError("absent execution baseline v2 snapshot must have null facts") + return snapshot + + +def _delete_binding_from_value(value: object, *, expected_path: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("execution baseline v2 Delete binding is invalid") + required = ( + "path", "root_path", "root_owner", "root_mode", "root_device", + "root_inode", "entry_path", "receipt_path", + ) + if any(field not in value for field in required): + raise ValueError("execution baseline v2 Delete binding is invalid") + path = _normalized_file_map_path(str(value["path"])) + if path != expected_path: + raise ValueError("execution baseline v2 Delete binding is invalid") + for field in ("root_path", "entry_path", "receipt_path"): + _normalized_file_map_path(str(value[field])) + if not all(isinstance(value[field], int) and not isinstance(value[field], bool) for field in ("root_owner", "root_device", "root_inode")): + raise ValueError("execution baseline v2 Delete binding is invalid") + if not isinstance(value["root_mode"], str): + raise ValueError("execution baseline v2 Delete binding is invalid") + return {str(key): value[key] for key in value} + + +def execution_baseline_v2_from_value(value: object) -> ExecutionBaselineV2: + """Parse the immutable descriptor-bound execution baseline v2 contract.""" + if not isinstance(value, dict) or value.get("schema_version") != EXECUTION_BASELINE_SCHEMA_V2: + raise ValueError("unsupported execution baseline schema") + try: + file_map_value = value["file_map"] + path_values = value["path_baselines"] + user_values = value["user_work_baselines"] + inherited_values = value["inherited_paths"] + if not isinstance(file_map_value, dict) or not isinstance(path_values, list): + raise TypeError("baseline inventory") + file_map = ExactFileMapV2( + create=tuple(_normalized_file_map_path(item) for item in file_map_value["create"]), + modify=tuple(_normalized_file_map_path(item) for item in file_map_value["modify"]), + delete=tuple(_normalized_file_map_path(item) for item in file_map_value["delete"]), + preserve=tuple(_normalized_file_map_path(item) for item in file_map_value["preserve"]), + ) + path_baselines: list[dict[str, object]] = [] + for item in path_values: + if not isinstance(item, dict): + raise TypeError("path baseline") + path = _normalized_file_map_path(str(item["path"])) + disposition = str(item["disposition"]) + if disposition not in {"Create", "Modify", "Delete", "Preserve"}: + raise ValueError("execution baseline v2 disposition is invalid") + snapshot = _snapshot_from_value(item["snapshot"], expected_path=path) + path_baselines.append({"path": path, "disposition": disposition, "snapshot": snapshot}) + authority = value["current_increment_authority_binding"] + workspace = value["workspace_observation"] + if not isinstance(authority, dict) or not isinstance(workspace, dict) or not isinstance(user_values, list) or not isinstance(inherited_values, list): + raise TypeError("binding") + user_baselines = tuple( + UserWorkBaseline(path=str(item["path"]), categories=tuple(item["categories"]), sha256=item.get("sha256")) + for item in user_values if isinstance(item, dict) + ) + bindings_value = value["delete_quarantine_bindings"] + if not isinstance(bindings_value, list): + raise TypeError("Delete bindings") + bindings = tuple(_delete_binding_from_value(item, expected_path=str(item["path"])) for item in bindings_value) + protected_values = value.get("protected_control_allocations", []) + if not isinstance(protected_values, list): + raise TypeError("protected control allocations") + baseline = ExecutionBaselineV2( + schema_version=str(value["schema_version"]), + program_id=str(value["program_id"]), + program_revision=int(value["program_revision"]), + increment_id=str(value["increment_id"]), + exact_file_plan_sha256=str(value["exact_file_plan_sha256"]), + current_increment_authority_binding=dict(authority), + workspace_observation=dict(workspace), + file_map=file_map, + path_baselines=tuple(path_baselines), + delete_quarantine_bindings=bindings, + protected_control_allocations=tuple(_normalized_file_map_path(path) for path in protected_values), + user_work_baselines=user_baselines, + inherited_paths=tuple(_normalized_file_map_path(path) for path in inherited_values), + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("execution baseline structure is invalid") from error + map_sections = ( + *baseline.file_map.create, + *baseline.file_map.modify, + *baseline.file_map.delete, + *baseline.file_map.preserve, + ) + if len(set(map_sections)) != len(map_sections): + raise ValueError("execution baseline v2 file map is duplicated") + expected_paths = set(file_map.create) | set(file_map.modify) | set(file_map.delete) | set(file_map.preserve) + actual_paths = {item["path"] for item in baseline.path_baselines} + if actual_paths != expected_paths - set(baseline.protected_control_allocations): + raise ValueError("execution baseline v2 path inventory does not match its file map") + if len(baseline.path_baselines) != len(expected_paths - set(baseline.protected_control_allocations)): + raise ValueError("execution baseline v2 path inventory is duplicated") + expected_order = [ + path + for path in map_sections + if path not in set(baseline.protected_control_allocations) + ] + if [item["path"] for item in baseline.path_baselines] != expected_order: + raise ValueError("execution baseline v2 path inventory is not in operation order") + expected_dispositions = {path: operation for operation, paths in (("Create", file_map.create), ("Modify", file_map.modify), ("Delete", file_map.delete), ("Preserve", file_map.preserve)) for path in paths} + if any(expected_dispositions[item["path"]] != item["disposition"] for item in baseline.path_baselines): + raise ValueError("execution baseline v2 path disposition mismatch") + if ( + [item["path"] for item in baseline.delete_quarantine_bindings] + != list(file_map.delete) + ): + raise ValueError("each Delete path requires exactly one quarantine binding") + protected_allocations = set(baseline.protected_control_allocations) + for binding in baseline.delete_quarantine_bindings: + if not { + binding["root_path"], + binding["entry_path"], + binding["receipt_path"], + }.issubset(protected_allocations): + raise ValueError( + "execution baseline v2 quarantine allocations must be protected" + ) + snapshots = {item["path"]: item["snapshot"] for item in baseline.path_baselines} + for binding in baseline.delete_quarantine_bindings: + snapshot = snapshots[binding["path"]] + seed = { + "program_id": baseline.program_id, + "program_revision": baseline.program_revision, + "increment_id": baseline.increment_id, + "path": binding["path"], + "baseline_sha256": snapshot.sha256, + "device": snapshot.device, + "inode": snapshot.inode, + "mode": snapshot.mode, + "link_count": snapshot.link_count, + } + digest = hashlib.sha256( + (json.dumps(seed, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8") + ).hexdigest() + if ( + binding["entry_path"] != f'{binding["root_path"]}/delete-{digest}.bin' + or binding["receipt_path"] != f'{binding["root_path"]}/delete-{digest}.receipt.json' + ): + raise ValueError("execution baseline v2 Delete binding is not deterministic") + return baseline + + +def product_path_states_v2_from_value(value: object) -> ProductPathStatesV2: + """Parse and verify typed product path states and Delete receipt bindings.""" + if not isinstance(value, dict) or value.get("schema_version") != PRODUCT_PATH_STATES_SCHEMA_V2: + raise ValueError("unsupported product path states schema") + try: + raw_states = value["ordered_path_states"] + raw_bindings = value["delete_quarantine_bindings"] + if not isinstance(raw_states, list) or not isinstance(raw_bindings, list): + raise TypeError("product path state inventory") + states: list[ProductPathStateV2] = [] + wire_exact = True + if set(value) != { + "schema_version", + "sha256", + "ordered_path_states", + "delete_quarantine_bindings", + }: + raise ValueError("product path states fields are invalid") + for raw in raw_states: + if not isinstance(raw, dict): + raise TypeError("product path state") + if set(raw) != { + "path", + "exists", + "sha256", + "mode", + "device", + "inode", + "link_count", + }: + raise ValueError("product path state fields are invalid") + path = _normalized_file_map_path(str(raw["path"])) + operation = "Delete" if any( + isinstance(binding, dict) and binding.get("path") == path + for binding in raw_bindings + ) else "" + if operation not in {"Create", "Modify", "Delete", "Preserve", ""}: + raise ValueError("product path state operation is invalid") + state = ProductPathStateV2(path, operation, raw["exists"], raw.get("sha256"), raw.get("mode"), raw.get("device"), raw.get("inode"), raw.get("link_count")) + if not isinstance(state.exists, bool): + raise ValueError("product path state exists is invalid") + if state.exists and ( + not isinstance(state.sha256, str) + or not _SHA256.fullmatch(state.sha256) + or not isinstance(state.mode, str) + or not isinstance(state.device, int) + or isinstance(state.device, bool) + or not isinstance(state.inode, int) + or isinstance(state.inode, bool) + or not isinstance(state.link_count, int) + or isinstance(state.link_count, bool) + or state.link_count != 1 + ): + raise ValueError("product path state facts are invalid") + if not state.exists and any(fact is not None for fact in (state.sha256, state.mode, state.device, state.inode, state.link_count)): + raise ValueError("absent product path state must have null facts") + if operation == "Delete" and state.exists: + raise ValueError("Delete product path state must be absent") + states.append(state) + if len({state.path for state in states}) != len(states): + raise ValueError("product path state inventory is duplicated") + bindings = tuple(dict(item) for item in raw_bindings if isinstance(item, dict)) + if len(bindings) != len(raw_bindings): + raise ValueError("product Delete binding is invalid") + for item in bindings: + if set(item) != {"path", "receipt_path", "receipt_sha256"} or not isinstance(item["receipt_sha256"], str) or not _SHA256.fullmatch(item["receipt_sha256"]): + raise ValueError("product Delete binding is invalid") + _normalized_file_map_path(str(item["path"])) + _normalized_file_map_path(str(item["receipt_path"])) + delete_paths = [state.path for state in states if state.operation == "Delete"] + if [item["path"] for item in bindings] != delete_paths: + raise ValueError("product Delete bindings do not match Delete states") + digest_value = value["sha256"] + if not isinstance(digest_value, str) or not _SHA256.fullmatch(digest_value): + raise ValueError("product path states digest is invalid") + canonical = {"ordered_path_states": [ + ({key: item for key, item in asdict(state).items() if key != "operation"} if wire_exact else asdict(state)) + for state in states + ], "delete_quarantine_bindings": list(bindings)} + expected_digest = hashlib.sha256(json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")).hexdigest() + if digest_value != expected_digest: + raise ValueError("product path states digest mismatch") + except (KeyError, TypeError, ValueError) as error: + if str(error) in {"product path states digest mismatch", "product path state facts are invalid", "Delete product path state must be absent", "absent product path state must have null facts"}: + raise + raise ValueError("product path states structure is invalid") from error + return ProductPathStatesV2(str(value["schema_version"]), str(value.get("program_id", "")), int(value.get("program_revision", 0)), str(value.get("increment_id", "")), tuple(states), bindings, digest_value) + + +def product_path_states_v2_value(value: ProductPathStatesV2) -> dict[str, object]: + """Return the exact four-field v2 product-result wire value.""" + states = [ + { + key: item + for key, item in asdict(state).items() + if key != "operation" + } + for state in value.ordered_path_states + ] + canonical = { + "ordered_path_states": states, + "delete_quarantine_bindings": list(value.delete_quarantine_bindings), + } + digest = hashlib.sha256( + json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + ).hexdigest() + return { + "schema_version": PRODUCT_PATH_STATES_SCHEMA_V2, + "sha256": digest, + "ordered_path_states": states, + "delete_quarantine_bindings": list(value.delete_quarantine_bindings), + } + + def execution_baseline_from_value(value: object) -> ExecutionBaseline: """Parse the persisted execution baseline into its typed contract.""" if not isinstance(value, dict): @@ -1290,6 +1680,227 @@ def validate_execution_workspace( ) +def _product_state_digest(states: Sequence[ProductPathStateV2], bindings: Sequence[dict[str, object]]) -> str: + value = { + "ordered_path_states": [ + {key: item for key, item in asdict(state).items() if key != "operation"} + for state in states + ], + "delete_quarantine_bindings": list(bindings), + } + return hashlib.sha256( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + ).hexdigest() + + +def validate_execution_workspace_v2( + program_root: Path, + baseline: ExecutionBaselineV2, + inspection: RepositoryInspection, + *, + increment_state: str, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> ExecutionWorkspaceAssessmentV2: + """Validate v2 product paths and immutable Delete quarantine state.""" + workspace = Path(inspection.observation.path) + issues: list[str] = [] + states: list[ProductPathStateV2] = [] + bindings: list[dict[str, object]] = [] + baseline_by_path = {item["path"]: item for item in baseline.path_baselines} + delete_by_path = {item["path"]: item for item in baseline.delete_quarantine_bindings} + control_prefix = _relative_control_prefix(Path(program_root), workspace) + context_paths, context_identities = descriptor_protection_context( + workspace, + program_root=Path(program_root), + inspection=inspection, + extra_paths=tuple( + path for path in (control_prefix, *protected_paths) if path + ), + ) + protected_paths = context_paths + protected_identities = tuple( + dict.fromkeys((*context_identities, *protected_identities)) + ) + + recorded = baseline.workspace_observation + for field, actual in ( + ("repository", inspection.observation.repository), + ("path", inspection.observation.path), + ("branch", inspection.observation.branch), + ("base_commit", inspection.observation.base_commit), + ("head_commit", inspection.observation.head_commit), + ): + if recorded.get(field) != actual: + issues.append(f"execution workspace {field} drift") + if not inspection.selected_base_is_ancestor: + issues.append("execution workspace selected base is no longer an ancestor") + if inspection.observation.active_git_operation != recorded.get("active_git_operation"): + issues.append("execution workspace active Git operation changed") + inherited_paths = set(baseline.inherited_paths) + current_dirty = _without_control_paths( + (*inspection.observation.staged_paths, *inspection.observation.modified_paths, *inspection.observation.untracked_paths, *inspection.observation.conflicted_paths), + control_prefix, + ) + recorded_dirty = _without_control_paths( + (*tuple(recorded.get("staged_paths", ())), *tuple(recorded.get("modified_paths", ())), *tuple(recorded.get("untracked_paths", ())), *tuple(recorded.get("conflicted_paths", ()))), + control_prefix, + ) + product_paths = set(baseline.file_map.create) | set(baseline.file_map.modify) | set(baseline.file_map.delete) | inherited_paths + unexpected_dirty = current_dirty - recorded_dirty - product_paths + if unexpected_dirty: + issues.append("execution workspace has unmapped dirty paths: " + ", ".join(sorted(unexpected_dirty))) + if recorded_dirty - current_dirty: + issues.append("execution workspace no longer preserves pre-existing user work: " + ", ".join(sorted(recorded_dirty - current_dirty))) + user_paths = {item.path for item in baseline.user_work_baselines} + if user_paths != recorded_dirty: + issues.append("execution baseline user-work inventory does not match launch dirt") + for item in baseline.user_work_baselines: + try: + actual_user = inspect_workspace_path( + workspace, + item.path, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + except ValueError: + issues.append(f"pre-existing user work path is unsafe: {item.path}") + continue + if actual_user.sha256 != item.sha256: + issues.append(f"pre-existing user work changed: {item.path}") + + def snapshot(path: str) -> WorkspacePathSnapshot: + return inspect_workspace_path( + workspace, + path, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + + for operation, paths in ( + ("Create", baseline.file_map.create), + ("Modify", baseline.file_map.modify), + ("Delete", baseline.file_map.delete), + ("Preserve", baseline.file_map.preserve), + ): + for relative in paths: + if relative == control_prefix or relative.startswith(control_prefix + "/"): + continue + item = baseline_by_path.get(relative) + if item is None: + issues.append(f"execution baseline is missing product path: {relative}") + continue + try: + actual = snapshot(relative) + except ValueError as error: + issues.append(f"execution path is unsafe: {relative} ({error})") + actual = WorkspacePathSnapshot(relative, False, None, None, None, None, None) + expected = item["snapshot"] + expected_value = expected if isinstance(expected, WorkspacePathSnapshot) else _snapshot_from_value(expected, expected_path=relative) + changed = actual != expected_value + if operation == "Create": + if increment_state == "authorized" and actual.exists: + issues.append(f"authorized workspace already created path: {relative}") + elif increment_state in {"reviewing", "verified", "awaiting-diff-approval", "accepted"} and not actual.exists: + issues.append(f"reviewing workspace is missing Create path: {relative}") + elif operation == "Modify": + if not actual.exists: + issues.append(f"execution workspace deleted Modify path: {relative}") + elif increment_state == "authorized" and changed: + issues.append(f"authorized workspace changed Modify path: {relative}") + elif increment_state in {"reviewing", "verified", "awaiting-diff-approval", "accepted"} and not changed: + issues.append(f"reviewing workspace has unchanged Modify path: {relative}") + elif operation == "Preserve" and changed: + issues.append(f"preserved path changed: {relative}") + elif operation == "Delete": + binding = delete_by_path[relative] + auth = { + "increment_id": baseline.increment_id, + **asdict(expected_value), + **binding, + "quarantine_root_path": binding["root_path"], + "quarantine_root_device": binding["root_device"], + "quarantine_root_inode": binding["root_inode"], + "quarantine_root_mode": binding["root_mode"], + "quarantine_root_owner": binding["root_owner"], + } + try: + recovery = classify_delete_quarantine_recovery( + program_root, + workspace, + relative, + auth, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + except (OSError, ValueError) as error: + issues.append(f"Delete recovery inspection failed: {relative} ({error})") + recovery = None + if increment_state == "authorized": + if recovery is None or recovery.disposition != "retry-ready": + issues.append(f"authorized Delete source does not match baseline: {relative}") + if not actual.exists or changed: + issues.append(f"authorized Delete source does not match baseline: {relative}") + elif increment_state == "implementing": + allowed_partial = {"retry-ready", "receipt-adoption-ready", "resume"} + if recovery is None or recovery.disposition not in allowed_partial: + issues.append(f"authorized Delete source does not match baseline: {relative}") + elif increment_state in {"reviewing", "verified", "awaiting-diff-approval", "accepted"}: + if recovery is None or recovery.disposition != "resume": + issues.append(f"reviewing Delete lacks exact quarantine receipt: {relative}") + if recovery is not None and recovery.disposition == "resume": + try: + receipt_snapshot = inspect_workspace_path( + program_root, + str(binding["receipt_path"]), + ) + except (OSError, ValueError) as error: + issues.append( + f"Delete quarantine receipt inspection failed: {relative} ({error})" + ) + else: + if ( + not receipt_snapshot.exists + or not isinstance(receipt_snapshot.sha256, str) + or not _SHA256.fullmatch(receipt_snapshot.sha256) + ): + issues.append( + f"Delete quarantine receipt is missing or invalid: {relative}" + ) + elif ( + recovery.receipt is None + or receipt_snapshot.sha256 + != hashlib.sha256( + _delete_receipt_bytes(recovery.receipt) + ).hexdigest() + ): + issues.append( + f"Delete quarantine receipt does not match validated recovery: {relative}" + ) + else: + bindings.append({ + "path": relative, + "receipt_path": binding["receipt_path"], + "receipt_sha256": receipt_snapshot.sha256, + }) + states.append(ProductPathStateV2(relative, operation, actual.exists, actual.sha256, actual.mode, actual.device, actual.inode, actual.link_count)) + + for path in baseline.file_map.delete: + if not any(binding["path"] == path for binding in bindings): + if increment_state not in {"authorized", "implementing"}: + issues.append(f"Delete quarantine receipt binding is missing: {path}") + product = ProductPathStatesV2( + PRODUCT_PATH_STATES_SCHEMA_V2, + baseline.program_id, + baseline.program_revision, + baseline.increment_id, + tuple(states), + tuple(bindings), + _product_state_digest(states, bindings), + ) + return ExecutionWorkspaceAssessmentV2(not issues, tuple(sorted(set(issues))), product) + + def _validate_plan_naming_table(markdown: str) -> list[str]: body = _section_body(markdown, "Semantic naming inventory") rows = [line for line in body.splitlines() if line.strip().startswith("|")] diff --git a/skills/implementing-staged-plans/scripts/review_coordination.py b/skills/implementing-staged-plans/scripts/review_coordination.py index 827acdb..b4e8ad1 100644 --- a/skills/implementing-staged-plans/scripts/review_coordination.py +++ b/skills/implementing-staged-plans/scripts/review_coordination.py @@ -21,13 +21,17 @@ validate_recovery_domains, ) from repository_preparation import ( + PRODUCT_PATH_STATES_SCHEMA_V2, SemanticNameRecord, + product_path_states_v2_from_value, validate_semantic_naming_inventory, ) REVIEW_EVIDENCE_SCHEMA = "implementation-review-evidence/v1" REVIEW_PACKET_SCHEMA = "implementation-review-packet/v1" +REVIEW_EVIDENCE_SCHEMA_V2 = "implementation-review-evidence/v2" +REVIEW_PACKET_SCHEMA_V2 = "implementation-review-packet/v2" RAW_REVIEW_REPORT_SCHEMA = "implementation-raw-review-report/v1" REQUIRED_REVIEW_SCOPES = ("requirements", "architecture", "test-evidence") RISK_REVIEW_SCOPES = MappingProxyType( @@ -732,7 +736,7 @@ def _command_summaries(verification: FinalVerification) -> tuple[str, ...]: def _validate_packet_shape(packet: ReviewPacket) -> list[str]: issues: list[str] = [] - if packet.schema_version != REVIEW_PACKET_SCHEMA: + if packet.schema_version not in {REVIEW_PACKET_SCHEMA, REVIEW_PACKET_SCHEMA_V2}: issues.append("review packet has unsupported schema") if _SHA256.fullmatch(packet.candidate_sha256 or "") is None: issues.append("review packet candidate binding is invalid") @@ -782,6 +786,16 @@ def render_review_packet(packet: ReviewPacket) -> str: if issues: raise ValueError("; ".join(issues)) sections = ["# Review Packet"] + if packet.schema_version == REVIEW_PACKET_SCHEMA_V2: + sections.append( + "\n".join( + ( + f"Packet schema: {REVIEW_PACKET_SCHEMA_V2}", + f"Product result schema: {PRODUCT_PATH_STATES_SCHEMA_V2}", + f"Product result SHA-256: {packet.candidate_sha256}", + ) + ) + ) for field in PACKET_FIELDS: bullets = "\n".join(f"- {item}" for item in getattr(packet, field)) sections.append(f"## {PACKET_HEADINGS[field]}\n\n{bullets}") @@ -802,9 +816,62 @@ def validate_review_bundle( bundle: Mapping[str, object], packet_markdown: str ) -> list[str]: """Compose review, execution, recovery, verification, and packet validation.""" + if bundle.get("schema_version") == REVIEW_EVIDENCE_SCHEMA_V2: + issues: list[str] = [] + if "requirement_result" in bundle: + issues.append("v2 review evidence must not contain requirement_result") + try: + product_result = product_path_states_v2_from_value(bundle["product_result"]) + except (KeyError, TypeError, ValueError): + issues.append("v2 review evidence product_result is invalid") + else: + final_verification = bundle.get("final_verification") + if ( + not isinstance(final_verification, dict) + or product_result.sha256 != final_verification.get("candidate_sha256") + ): + issues.append("v2 review product result is not bound to final verification") + packet_value = bundle.get("review_packet") + if ( + not isinstance(packet_value, dict) + or packet_value.get("schema_version") != REVIEW_PACKET_SCHEMA_V2 + ): + issues.append("v2 review evidence requires a v2 review packet") + legacy = dict(bundle) + legacy.pop("product_result", None) + legacy["schema_version"] = REVIEW_EVIDENCE_SCHEMA + legacy_packet_markdown = packet_markdown + if isinstance(packet_value, dict): + legacy_packet_value = { + **packet_value, + "schema_version": REVIEW_PACKET_SCHEMA, + } + legacy["review_packet"] = legacy_packet_value + try: + packet = ReviewPacket( + **_tuple_fields(packet_value, PACKET_FIELDS) + ) + if render_review_packet(packet) != packet_markdown: + issues.append( + "persisted review packet does not equal deterministic rendering" + ) + legacy_packet = ReviewPacket( + **_tuple_fields(legacy_packet_value, PACKET_FIELDS) + ) + legacy_packet_markdown = render_review_packet(legacy_packet) + except (KeyError, TypeError, ValueError): + issues.append("v2 review packet is structurally invalid") + issues.extend(validate_review_bundle(legacy, legacy_packet_markdown)) + return sorted(set(issues)) issues: list[str] = [] if bundle.get("schema_version") != REVIEW_EVIDENCE_SCHEMA: issues.append("review evidence has unsupported schema") + packet_value = bundle.get("review_packet") + if ( + isinstance(packet_value, dict) + and packet_value.get("schema_version") != REVIEW_PACKET_SCHEMA + ): + issues.append("v1 review evidence requires a v1 review packet") unknown_fields = sorted(set(bundle).difference(BUNDLE_FIELDS)) missing_fields = sorted(BUNDLE_FIELDS.difference(bundle)) if unknown_fields: diff --git a/skills/implementing-staged-plans/scripts/state_authority.py b/skills/implementing-staged-plans/scripts/state_authority.py index bea9b7f..2a48f9e 100644 --- a/skills/implementing-staged-plans/scripts/state_authority.py +++ b/skills/implementing-staged-plans/scripts/state_authority.py @@ -3,6 +3,7 @@ import argparse import ctypes as _ctypes +import errno import hashlib import json import os @@ -14,9 +15,23 @@ from contextvars import ContextVar from dataclasses import asdict, dataclass, replace from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any +_ORIGINAL_OS_RENAME = os.rename +_LIBC = _ctypes.CDLL(None, use_errno=True) if sys.platform == "darwin" else None +_RENAMEATX_NP = getattr(_LIBC, "renameatx_np", None) +if _RENAMEATX_NP is not None: + _RENAMEATX_NP.argtypes = [ + _ctypes.c_int, + _ctypes.c_char_p, + _ctypes.c_int, + _ctypes.c_char_p, + _ctypes.c_uint, + ] + _RENAMEATX_NP.restype = _ctypes.c_int +_RENAME_EXCL = 0x00000004 + try: import fcntl as _fcntl except ImportError: # pragma: no cover - exercised by the Windows backend @@ -44,9 +59,11 @@ NEW_PROGRAM_MANIFEST_SCHEMA, PROPOSAL_VALIDATION_MODE, SETUP_PROGRAM_MANIFEST_SCHEMA, + SuccessorResolution, load_json_lines, load_json_object, resolve_managed_path, + resolve_increment_successor, resolve_program_closure_paths, sha256_file, validate_program_authority, @@ -60,6 +77,34 @@ WORKSPACE_SCHEMA = "implementation-workspace/v1" WORKSPACE_SCHEMA_V2 = "implementation-workspace/v2" WORKSPACE_SCHEMAS = frozenset({WORKSPACE_SCHEMA, WORKSPACE_SCHEMA_V2}) +EXACT_FILE_MAP_SCHEMA_V2 = "implementation-exact-file-map/v2" +EXECUTION_BASELINE_SCHEMA_V2 = "implementation-execution-baseline/v2" +PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" +EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2" +APPROVAL_SCHEMA_V3 = "implementation-approval/v3" +SETUP_V2_DIFF_APPROVAL_FIELDS = ( + "schema_version", "event_id", "type", "decision", "scope", + "diff_decision", "checkpoint_id", "base_seed_sha256", + "submitted_prompt_sha256", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "increment_id", "brief_sha256", + "exact_file_plan_sha256", "approval_mode", "workspace", + "review_evidence_sha256", "review_packet_sha256", + "verification_sha256", "execution_baseline_sha256", + "product_result_schema_version", "product_result_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) +SETUP_V2_CONTINUE_APPROVAL_FIELDS = ( + *SETUP_V2_DIFF_APPROVAL_FIELDS, + "successor_increment_id", + "successor_authority_projection_sha256", +) +DELETE_QUARANTINE_RECEIPT_SCHEMA_V1 = "implementation-delete-quarantine-receipt/v1" +DESCRIPTOR_RELATIVE_DELETE_UNSUPPORTED = ( + "descriptor-relative no-follow Delete quarantine is unsupported on this platform" +) APPROVAL_SCHEMA = "implementation-approval/v1" ACTION_AUTHORIZATION_SCHEMA = "implementation-action-authorization/v1" SETUP_ONLY_STATUS_SCHEMAS = frozenset( @@ -71,6 +116,7 @@ "implementation-increment-grant/v2", "implementation-setup-activation-status-binding/v1", "setup-activation-decision/v1", + "setup-activation-decision/v2", "source-gate-decision/v1", "source-gate-satisfaction/v1", } @@ -235,6 +281,62 @@ class ExactFileMap: preserve: tuple[str, ...] +@dataclass(frozen=True) +class ExactFileMapV2: + create: tuple[str, ...] + modify: tuple[str, ...] + delete: tuple[str, ...] + preserve: tuple[str, ...] + + +@dataclass(frozen=True) +class WorkspacePathSnapshot: + path: str + exists: bool + sha256: str | None + mode: str | None + device: int | None + inode: int | None + link_count: int | None + + +@dataclass(frozen=True) +class DeleteQuarantineReceipt: + schema_version: str + program_id: str + program_revision: int + increment_id: str + path: str + baseline_sha256: str + device: int + inode: int + quarantine_path: str + quarantine_sha256: str + final_state: str + + +@dataclass(frozen=True) +class DeleteQuarantineAllocation: + root_path: str + quarantine_path: str + receipt_path: str + quarantine_name: str + receipt_name: str + root_device: int | None + root_inode: int | None + root_mode: str | None + root_owner: int | None + + +@dataclass(frozen=True) +class DeleteQuarantineRecovery: + disposition: str + source: WorkspacePathSnapshot + quarantine: WorkspacePathSnapshot + receipt: DeleteQuarantineReceipt | None + issues: tuple[str, ...] + + def _workspace_relative_path(workspace_root: Path, path: Path) -> str: workspace = Path(workspace_root).resolve() resolved = Path(path).resolve(strict=False) @@ -258,44 +360,74 @@ def _nested_schema_versions(value: object) -> set[str]: return schemas -def _traceability_successor( - traceability: dict[str, object], increment_id: str -) -> str | None: - atomic_requirements = traceability.get("atomic_requirements") - if not isinstance(atomic_requirements, list): - raise ValueError("traceability atomic_requirements must be a list") - current_found = False - candidates: set[str] = set() - for requirement in atomic_requirements: - assigned = ( - requirement.get("assigned_increments") - if isinstance(requirement, dict) - else None +def resolve_program_successor( + program_root: Path, + manifest: dict[str, object], + status: dict[str, object], + *, + allow_unbound_rollover_suffix: bool = False, +) -> SuccessorResolution: + """Project validated completed history without granting current acceptance.""" + from program_rollover import _validated_completed_rollover_records + + try: + root = Path(program_root) + current = status.get("current_increment_id") + authority = status.get("current_increment_authority_binding") + if isinstance(authority, dict) and authority.get("increment_id") != current: + raise ValueError("successor status-current authority mismatch") + traceability_path, issues = resolve_managed_path( + root, + manifest.get("logical_roles", {}).get("traceability"), + role="logical role traceability", ) - if ( - not isinstance(assigned, list) - or not assigned - or not all(isinstance(candidate, str) and candidate for candidate in assigned) - or len(assigned) != len(set(assigned)) - ): - raise ValueError( - "traceability assigned_increments must be unique strings" - ) - if increment_id not in assigned: - continue - current_found = True - successor_index = assigned.index(increment_id) + 1 - if successor_index < len(assigned): - candidates.add(assigned[successor_index]) - if not current_found: - raise ValueError("current increment is absent from traceability allocation") - return next(iter(candidates)) if len(candidates) == 1 else None + if traceability_path is None: + raise ValueError("; ".join(issues)) + traceability, issues = load_json_object(traceability_path) + if traceability is None: + raise ValueError("; ".join(issues)) + completed = _validated_completed_rollover_records( + root, status, allow_unbound_suffix=allow_unbound_rollover_suffix + ) + return resolve_increment_successor( + manifest, + traceability.get("atomic_requirements"), + current, + tuple(record["current_increment_id"] for record in completed), + ) + except (OSError, ValueError) as error: + return SuccessorResolution("unavailable", None, str(error)) + + +def _allocated_lifecycle_file( + root: Path, + descriptor: dict[str, object], + field: str, + increment_id: str | None = None, +) -> Path: + storage_root = descriptor.get("root") + filename = descriptor.get(field) + if not isinstance(storage_root, str) or not isinstance(filename, str): + raise ValueError(f"lifecycle storage root and {field} must be strings") + relative = ( + f"{storage_root}/{filename}" if increment_id is None + else f"{storage_root}/{increment_id}/{filename}" + ) + path, issues = resolve_managed_path( + root, relative, role=f"allocated lifecycle {field}", require_file=False + ) + if path is None: + raise ValueError("; ".join(issues)) + return path def required_future_lifecycle_writes( program_root: Path, workspace_root: Path, increment_id: str, + *, + delete_quarantine_bindings: Sequence[dict[str, object]] = (), + allow_unbound_rollover_suffix: bool = False, ) -> tuple[ManagedWriteRequirement, ...]: """Derive disposition-aware current and future control-plane allocations.""" if ( @@ -353,23 +485,8 @@ def required_future_lifecycle_writes( ) ) - increment_root = increment_storage.get("root") - if not isinstance(increment_root, str): - raise ValueError("increment storage root must be a string") - def allocated_increment_file(target_increment: str, field: str) -> Path: - filename = increment_storage.get(field) - if not isinstance(filename, str): - raise ValueError(f"increment storage {field} must be a string") - path, path_issues = resolve_managed_path( - root, - f"{increment_root}/{target_increment}/{filename}", - role=f"allocated increment {field}", - require_file=False, - ) - if path is None: - raise ValueError("; ".join(path_issues)) - return path + return _allocated_lifecycle_file(root, increment_storage, field, target_increment) for field in ( "execution_baseline_filename", @@ -385,16 +502,24 @@ def allocated_increment_file(target_increment: str, field: str) -> Path: ) ) - traceability_path, path_issues = resolve_managed_path( - root, logical_roles.get("traceability"), role="logical role traceability" + status_path, status_issues = resolve_managed_path( + root, logical_roles.get("status"), role="logical role status" + ) + if status_path is None: + raise ValueError("; ".join(status_issues)) + status, status_issues = load_json_object(status_path) + if status is None: + raise ValueError("; ".join(status_issues)) + if status.get("current_increment_id") != increment_id: + raise ValueError("lifecycle allocation must match status current increment") + resolution = resolve_program_successor( + root, manifest, status, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, ) - if traceability_path is None: - raise ValueError("; ".join(path_issues)) - traceability, traceability_issues = load_json_object(traceability_path) - if traceability is None: - raise ValueError("; ".join(traceability_issues)) - successor = _traceability_successor(traceability, increment_id) - if successor is not None: + if resolution.kind == "unavailable": + raise ValueError(f"successor allocation unavailable: {resolution.reason}") + successor = resolution.successor_increment_id + if resolution.kind == "successor": requirements.extend( ( ManagedWriteRequirement( @@ -414,26 +539,27 @@ def allocated_increment_file(target_increment: str, field: str) -> Path: ) ) else: - closure_root = closure_storage.get("root") - if not isinstance(closure_root, str): - raise ValueError("closure storage root must be a string") for field in ("reconciliation_filename", "packet_filename"): - filename = closure_storage.get(field) - if not isinstance(filename, str): - raise ValueError(f"closure storage {field} must be a string") - path, path_issues = resolve_managed_path( - root, - f"{closure_root}/{filename}", - role=f"allocated closure {field}", - require_file=False, - ) - if path is None: - raise ValueError("; ".join(path_issues)) + path = _allocated_lifecycle_file(root, closure_storage, field) requirements.append( ManagedWriteRequirement( _workspace_relative_path(workspace_root, path), "Create" ) ) + for binding in delete_quarantine_bindings: + if not isinstance(binding, dict): + raise ValueError("Delete quarantine binding must be an object") + for field in ("root_path", "entry_path", "receipt_path"): + value = binding.get(field) + if not isinstance(value, str): + raise ValueError("Delete quarantine binding paths must be strings") + _descriptor_relative_path(value) + requirements.append( + ManagedWriteRequirement( + _workspace_relative_path(workspace_root, root / value), + "Control", + ) + ) return tuple(sorted(requirements, key=lambda item: (item.path, item.disposition))) @@ -447,8 +573,13 @@ def validate_required_managed_file_map( "Modify": set(file_map.modify), "Preserve": set(file_map.preserve), } + delete_paths = getattr(file_map, "delete", None) + if delete_paths is not None: + declared["Delete"] = set(delete_paths) issues: list[str] = [] for requirement in required: + if requirement.disposition == "Control": + continue if requirement.disposition not in declared: issues.append( f"unsupported managed-write disposition {requirement.disposition!r}" @@ -474,6 +605,64 @@ def validate_required_managed_file_map( return sorted(set(issues)) +def validate_program_lifecycle_file_map( + program_root: Path, + workspace_root: Path, + increment_id: str, + file_map: ExactFileMap, + required: Sequence[ManagedWriteRequirement], +) -> list[str]: + """Reject writes to inactive lifecycle alternatives as well as missing paths.""" + issues = validate_required_managed_file_map(file_map, required) + root = Path(program_root) + try: + manifest, load_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(load_issues)) + traceability_path, load_issues = resolve_managed_path( + root, manifest["logical_roles"].get("traceability"), + role="logical role traceability", + ) + if traceability_path is None: + raise ValueError("; ".join(load_issues)) + traceability, load_issues = load_json_object(traceability_path) + if traceability is None: + raise ValueError("; ".join(load_issues)) + increment_storage = manifest["increment_storage"] + reserved = [ + _allocated_lifecycle_file(root, manifest["closure_storage"], field) + for field in ("reconciliation_filename", "packet_filename") + ] + reserved.append( + _allocated_lifecycle_file(root, increment_storage, "handoff_filename", increment_id) + ) + allocated_ids = { + item for requirement in traceability["atomic_requirements"] + for item in requirement["assigned_increments"] + } + reserved.extend( + _allocated_lifecycle_file(root, increment_storage, "brief_filename", item) + for item in allocated_ids if item != increment_id + ) + reserved_paths = { + _workspace_relative_path(workspace_root, path) for path in reserved + } + allowed = {(item.path, item.disposition) for item in required} + for disposition, paths in ( + ("Create", file_map.create), + ("Modify", file_map.modify), + ("Delete", getattr(file_map, "delete", ())), + ): + for path in paths: + if path in reserved_paths and (path, disposition) not in allowed: + issues.append( + f"inactive lifecycle allocation cannot be {disposition}: {path}" + ) + except (OSError, KeyError, TypeError, ValueError) as error: + issues.append(str(error)) + return sorted(set(issues)) + + def validate_required_managed_writes( managed_paths: Sequence[str], required_paths: Sequence[str] ) -> list[str]: @@ -1223,6 +1412,7 @@ def _validate_setup_program_state( program_root: Path, manifest: dict[str, object], status: dict[str, object], + observation: RepositoryObservation | None = None, ) -> list[str]: """Validate the two v3 bootstrap states and their exact authority family.""" issues: list[str] = [] @@ -1497,6 +1687,12 @@ def _validate_setup_program_state( if not isinstance(disposition, dict): issues.append("v3 accepted status lacks diff disposition binding") else: + expected_diff_approval_schema = ( + APPROVAL_SCHEMA_V3 + if disposition.get("schema_version") + == "implementation-diff-disposition-binding/v2" + else "implementation-approval/v2" + ) approvals_path, approval_path_issues = resolve_managed_path( program_root, logical_roles.get("approvals"), @@ -1521,7 +1717,7 @@ def _validate_setup_program_state( diff_matches and ( diff_matches[0].get("schema_version") - != "implementation-approval/v2" + != expected_diff_approval_schema or diff_matches[0].get("source_gate_satisfaction") != diff_gate or diff_matches[0].get("increment_grant_id") @@ -1529,6 +1725,185 @@ def _validate_setup_program_state( ) ): issues.append("v3 diff approval authority binding mismatch") + elif expected_diff_approval_schema == APPROVAL_SCHEMA_V3: + approval = diff_matches[0] + diff_decision = disposition.get("decision") + raw_approval = None + try: + for line in approvals_path.read_text(encoding="utf-8").splitlines(): + candidate_record = json.loads(line) + if ( + isinstance(candidate_record, dict) + and candidate_record.get("event_id") + == disposition.get("approval_event_id") + ): + raw_approval = candidate_record + break + except (OSError, UnicodeError, json.JSONDecodeError): + raw_approval = None + if ( + raw_approval is None + or tuple(raw_approval) + != ( + SETUP_V2_CONTINUE_APPROVAL_FIELDS + if diff_decision == "accept-continue" + else SETUP_V2_DIFF_APPROVAL_FIELDS + ) + ): + issues.append("v3 diff approval fields or order mismatch") + if ( + approval.get("product_result_schema_version") + != disposition.get("product_result_schema_version") + or approval.get("product_result_sha256") + != disposition.get("product_result_sha256") + or "accepted_product_delta_sha256" in approval + ): + issues.append("v3 diff approval product result binding mismatch") + expected_approval_values = { + "schema_version": APPROVAL_SCHEMA_V3, + "event_id": disposition.get("approval_event_id"), + "type": "increment-diff-approval", + "decision": "approved", + "scope": [ + ( + "accept the bound current increment and continue " + "to the bound successor" + if diff_decision == "accept-continue" + else "accept the bound current increment and stop" + ) + ], + "diff_decision": diff_decision, + "checkpoint_id": disposition.get("checkpoint_id"), + "base_seed_sha256": disposition.get("base_seed_sha256"), + "program_id": status.get("program_id"), + "program_revision": status.get("program_revision"), + "source_id": status.get("source_binding", {}).get("source_id") + if isinstance(status.get("source_binding"), dict) + else None, + "source_sha256": status.get("source_binding", {}).get("sha256") + if isinstance(status.get("source_binding"), dict) + else None, + "program_sha256": status.get("program_binding", {}).get("sha256") + if isinstance(status.get("program_binding"), dict) + else None, + "semantic_requirements_sha256": status.get("program_binding", {}).get( + "semantic_requirements_sha256" + ) + if isinstance(status.get("program_binding"), dict) + else None, + "increment_id": status.get("current_increment_id"), + "brief_sha256": status.get("brief_binding", {}).get("sha256") + if isinstance(status.get("brief_binding"), dict) + else None, + "exact_file_plan_sha256": status.get( + "approved_exact_file_plan_sha256" + ), + "approval_mode": status.get("approval_mode"), + "review_evidence_sha256": status.get( + "review_evidence_binding", {} + ).get("sha256") + if isinstance(status.get("review_evidence_binding"), dict) + else None, + "review_packet_sha256": status.get( + "review_packet_binding", {} + ).get("sha256") + if isinstance(status.get("review_packet_binding"), dict) + else None, + "execution_baseline_sha256": baseline_binding.get("sha256") + if isinstance(baseline_binding, dict) + else None, + "setup_activation_decision_id": status.get( + "setup_activation_binding", {} + ).get("setup_activation_decision_id") + if isinstance(status.get("setup_activation_binding"), dict) + else None, + "setup_activation_decision_sha256": status.get( + "setup_activation_binding", {} + ).get("setup_activation_decision_sha256") + if isinstance(status.get("setup_activation_binding"), dict) + else None, + "increment_grant_id": authority.get("grant_id"), + "increment_grant_sha256": authority.get("grant_sha256"), + } + workspace_binding = status.get("workspace_binding") + if observation is not None: + expected_approval_values["workspace"] = { + "path": observation.path, + "branch": observation.branch, + "base_commit": observation.base_commit, + "head_commit": observation.head_commit, + } + evidence_binding = status.get("review_evidence_binding") + if isinstance(evidence_binding, dict): + evidence_path, _ = resolve_managed_path( + program_root, + evidence_binding.get("path"), + role="status v2 review evidence", + ) + if evidence_path is not None: + evidence_value, _ = load_json_object(evidence_path) + if isinstance(evidence_value, dict): + final_verification = evidence_value.get( + "final_verification" + ) + expected_approval_values["verification_sha256"] = hashlib.sha256( + _canonical_json_bytes(final_verification) + ).hexdigest() + try: + from task_prompt import render_exact_prompt + + command = { + "schema_version": "implementation-diff-disposition-command/v2", + "decision": diff_decision, + "base_seed_sha256": disposition.get("base_seed_sha256"), + "checkpoint_id": disposition.get("checkpoint_id"), + "approval_event_id": disposition.get("approval_event_id"), + "accepted_status_sha256": hashlib.sha256( + _canonical_json_bytes(status) + ).hexdigest(), + } + if diff_decision == "accept-continue": + command["successor_authority_projection"] = ( + disposition.get("successor_authority_projection") + ) + else: + for field in ( + "program_id", "program_revision", "increment_id", + "prior_status_sha256", "prior_status_sequence", + "decision", "review_evidence_sha256", + "review_packet_sha256", "verification_sha256", + "exact_file_plan_sha256", "execution_baseline_sha256", + "product_result_schema_version", "product_result_sha256", + ): + if field in disposition: + command[field] = disposition[field] + expected_approval_values["submitted_prompt_sha256"] = hashlib.sha256( + render_exact_prompt(command).encode("utf-8") + ).hexdigest() + except (ImportError, KeyError, TypeError, ValueError): + expected_approval_values["submitted_prompt_sha256"] = None + expected_approval_values["source_gate_satisfaction"] = diff_gate + if diff_decision == "accept-continue": + projection = disposition.get( + "successor_authority_projection" + ) + expected_approval_values["successor_increment_id"] = ( + disposition.get("successor_increment_id") + ) + expected_approval_values[ + "successor_authority_projection_sha256" + ] = ( + hashlib.sha256( + _canonical_json_bytes(projection) + ).hexdigest() + if isinstance(projection, dict) + else None + ) + if any( + approval.get(key) != expected + for key, expected in expected_approval_values.items() + ): + issues.append("v3 diff approval deterministic binding mismatch") if effective_program_state == "closed": command = status.get("closure_command_binding") closure_gate = None @@ -1753,7 +2128,9 @@ def validate_state( ) except ImportError as error: issues.append(str(error)) - issues.extend(_validate_setup_program_state(program_root, manifest, status)) + issues.extend( + _validate_setup_program_state(program_root, manifest, status, observation) + ) return sorted(set(issues)) if manifest.get("schema_version") == NEW_PROGRAM_MANIFEST_SCHEMA: if status.get("program_state") == "blocked" or status.get( @@ -2063,6 +2440,75 @@ def decide_action_authorization( return AuthorizationDecision(True, authorization_id, ()) +def _product_v2_result_matches_baseline(baseline: object, product: object) -> bool: + """Reject self-consistent v2 results whose paths or operations leave the baseline.""" + protected = set(getattr(baseline, "protected_control_allocations", ())) + expected: list[tuple[str, str]] = [] + for operation, paths in ( + ("Create", getattr(baseline.file_map, "create", ())), + ("Modify", getattr(baseline.file_map, "modify", ())), + ("Delete", getattr(baseline.file_map, "delete", ())), + ("Preserve", getattr(baseline.file_map, "preserve", ())), + ): + expected.extend((path, operation) for path in paths if path not in protected) + states = getattr(product, "ordered_path_states", ()) + if [state.path for state in states] != [path for path, _ in expected]: + return False + expected_by_path = dict(expected) + baseline_by_path = {item["path"]: item for item in baseline.path_baselines} + for state in states: + operation = expected_by_path[state.path] + if operation == "Delete": + if state.operation != "Delete" or state.exists: + return False + continue + if state.operation not in {"", operation}: + return False + expected_snapshot = baseline_by_path[state.path]["snapshot"] + if operation == "Preserve" and ( + state.exists != expected_snapshot.exists + or state.sha256 != expected_snapshot.sha256 + or state.mode != expected_snapshot.mode + or state.device != expected_snapshot.device + or state.inode != expected_snapshot.inode + or state.link_count != expected_snapshot.link_count + ): + return False + if operation == "Modify" and not state.exists: + return False + binding_by_path = {item["path"]: item for item in baseline.delete_quarantine_bindings} + for binding in getattr(product, "delete_quarantine_bindings", ()): + expected_binding = binding_by_path.get(binding.get("path")) + if expected_binding is None or binding.get("receipt_path") != expected_binding.get("receipt_path"): + return False + return True + + +def _valid_v2_remediation_initial_result(binding: object) -> bool: + """Bind typed remediation history to its original reviewed candidate.""" + if not isinstance(binding, dict): + return False + try: + from repository_preparation import product_path_states_v2_from_value + + product = product_path_states_v2_from_value( + binding["initial_product_result"] + ) + except (ImportError, KeyError, TypeError, ValueError): + return False + reports = binding.get("initial_reports") + return ( + product.sha256 == binding.get("initial_product_result_sha256") + and isinstance(reports, list) + and bool(reports) + and all( + isinstance(report, dict) + and report.get("reviewed_candidate_sha256") == product.sha256 + for report in reports + ) + ) + + def validate_state_authority( program_root: Path, observation: RepositoryObservation ) -> list[str]: @@ -2180,10 +2626,20 @@ def validate_state_authority( REPOSITORY_INSPECTION_SCHEMA, RepositoryInspection, execution_baseline_from_value, + execution_baseline_v2_from_value, validate_execution_workspace, + validate_execution_workspace_v2, + ) + is_v2_baseline = ( + isinstance(baseline_value, dict) + and baseline_value.get("schema_version") + == EXECUTION_BASELINE_SCHEMA_V2 + ) + baseline = ( + execution_baseline_v2_from_value(baseline_value) + if is_v2_baseline + else execution_baseline_from_value(baseline_value) ) - - baseline = execution_baseline_from_value(baseline_value) except (ImportError, ValueError) as error: issues.append(str(error)) else: @@ -2201,19 +2657,49 @@ def validate_state_authority( "current_increment_authority_binding" ): issues.append("execution baseline grant binding mismatch") - inspection = RepositoryInspection( - schema_version=REPOSITORY_INSPECTION_SCHEMA, - observation=observation, - git_directory="", - git_common_directory="", - selected_base_is_ancestor=True, - status_format="porcelain-v2-z", + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + is_v2_setup = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" ) - assessment = validate_execution_workspace( - root, - baseline, - inspection, - increment_state=str(status["current_increment_state"]), + if is_v2_baseline != is_v2_setup: + issues.append("execution v2 baseline/setup/envelope family mismatch") + from repository_preparation import inspect_repository + + inspection = inspect_repository( + Path(observation.path), observation.base_commit + ) + inspection = replace(inspection, observation=observation) + protected_paths, protected_identities = descriptor_protection_context( + Path(observation.path), + program_root=root, + inspection=inspection, + ) + assessment = ( + validate_execution_workspace_v2( + root, + baseline, + inspection, + increment_state=str(status["current_increment_state"]), + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + if is_v2_baseline + else validate_execution_workspace( + root, + baseline, + inspection, + increment_state=str(status["current_increment_state"]), + ) ) issues.extend(assessment.issues) execution_transition = status.get( @@ -2248,7 +2734,49 @@ def validate_state_authority( if isinstance(execution_authorization, dict) else None ) - transition_valid = ( + if is_v2_baseline: + from repository_preparation import product_path_states_v2_value + + expected_product = product_path_states_v2_value(assessment.product_states) + persisted_product = execution_transition.get("product_path_states") if isinstance(execution_transition, dict) else None + persisted_product_valid = False + if persisted_product is not None: + try: + from repository_preparation import product_path_states_v2_from_value + + parsed_product = product_path_states_v2_from_value(persisted_product) + persisted_product_valid = ( + parsed_product.sha256 + == execution_transition.get("product_path_states_sha256") + and _product_v2_result_matches_baseline(baseline, parsed_product) + ) + except (ImportError, ValueError): + persisted_product_valid = False + transition_valid = ( + isinstance(execution_transition, dict) + and execution_transition.get("schema_version") == EXECUTION_TRANSITION_SCHEMA_V2 + and execution_transition.get("prior_increment_state") in allowed_prior_states + and execution_transition.get("target_increment_state") == expected_target + and execution_transition.get("authorization_id") == authorization_id + and isinstance(execution_transition.get("prior_status_sha256"), str) + and len(execution_transition["prior_status_sha256"]) == 64 + and persisted_product_valid + and ( + current_increment_state in { + "implementing", + "remediating", + } + or ( + execution_transition.get("product_path_states_sha256") + == assessment.product_states.sha256 + and execution_transition.get("product_path_states") + == expected_product + ) + ) + and "product_delta_sha256" not in execution_transition + ) + else: + transition_valid = ( isinstance(execution_transition, dict) and execution_transition.get("schema_version") == "implementation-execution-transition/v1" @@ -2286,7 +2814,7 @@ def validate_state_authority( ) == assessment.product_delta_sha256 ) - ) + ) if transition_valid: event_seed = { "program_id": status["program_id"], @@ -2303,11 +2831,17 @@ def validate_state_authority( "prior_increment_state" ], "target_increment_state": expected_target, - "product_delta_sha256": execution_transition[ - "product_delta_sha256" - ], "authorization_id": authorization_id, } + event_seed[ + "product_path_states_sha256" + if is_v2_baseline + else "product_delta_sha256" + ] = execution_transition[ + "product_path_states_sha256" + if is_v2_baseline + else "product_delta_sha256" + ] if ( execution_transition.get( "prior_increment_state" @@ -2330,7 +2864,8 @@ def validate_state_authority( "execution transition binding is invalid" ) if ( - status.get("current_increment_state") + not is_v2_baseline + and status.get("current_increment_state") in { "reviewing", "verified", @@ -2371,13 +2906,24 @@ def validate_state_authority( post_remediation_transition and isinstance(remediation_history, dict) and remediation_history.get("schema_version") - == "implementation-review-remediation/v1" + == ( + "implementation-review-remediation/v2" + if is_v2_baseline + else "implementation-review-remediation/v1" + ) and execution_transition.get( "review_remediation_sha256" ) == hashlib.sha256( _canonical_json_bytes(remediation_history) ).hexdigest() + and ( + _valid_v2_remediation_initial_result( + remediation_history + ) + if is_v2_baseline + else True + ) ) if not remediation_history_valid: issues.append( @@ -2399,17 +2945,57 @@ def validate_state_authority( if isinstance(remediation, dict) else None ) + remediation_v2 = ( + isinstance(remediation, dict) + and remediation.get("schema_version") + == "implementation-review-remediation/v2" + ) + initial_product_result = ( + remediation.get("initial_product_result") + if remediation_v2 + else None + ) + initial_result_valid = False + if remediation_v2: + initial_result_valid = ( + _valid_v2_remediation_initial_result(remediation) + and isinstance(execution_transition, dict) + and execution_transition.get( + "product_path_states" + ) + == initial_product_result + and execution_transition.get( + "product_path_states_sha256" + ) + == remediation.get( + "initial_product_result_sha256" + ) + ) remediation_valid = ( isinstance(remediation, dict) and remediation.get("schema_version") - == "implementation-review-remediation/v1" - and isinstance( - initial_product_delta_sha256, str + == ( + "implementation-review-remediation/v2" + if is_v2_baseline + else "implementation-review-remediation/v1" ) - and len(initial_product_delta_sha256) == 64 - and all( - character in "0123456789abcdef" - for character in initial_product_delta_sha256 + and ( + ( + remediation_v2 + and initial_result_valid + and isinstance( + remediation.get("initial_product_result_sha256"), str + ) + ) + or ( + not remediation_v2 + and isinstance(initial_product_delta_sha256, str) + and len(initial_product_delta_sha256) == 64 + and all( + character in "0123456789abcdef" + for character in initial_product_delta_sha256 + ) + ) ) and isinstance(unresolved_finding_ids, list) and bool(unresolved_finding_ids) @@ -2419,9 +3005,17 @@ def validate_state_authority( ) and isinstance(review_binding, dict) and review_binding.get("schema_version") - == "implementation-review-remediation/v1" + == ( + "implementation-review-remediation/v2" + if remediation_v2 + else "implementation-review-remediation/v1" + ) and review_binding.get("candidate_sha256") - == initial_product_delta_sha256 + == ( + remediation.get("initial_product_result_sha256") + if remediation_v2 + else initial_product_delta_sha256 + ) and review_binding.get( "unresolved_material_findings" ) @@ -2522,6 +3116,24 @@ def validate_state_authority( if not isinstance(storage, dict): issues.append("review storage descriptor is missing") else: + is_v2_review_preparation = ( + review_preparation.get("schema_version") + == "implementation-review-preparation/v2" + ) + if is_v2_review_preparation != is_v2_baseline: + issues.append( + "review preparation family does not match execution family" + ) + if is_v2_review_preparation and ( + review_preparation.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or not isinstance( + review_preparation.get("product_result_sha256"), str + ) + ): + issues.append( + "v2 review preparation product result binding mismatch" + ) expected_paths = { "evidence": ( f"{storage.get('root')}/{status.get('current_increment_id')}/" @@ -2532,6 +3144,7 @@ def validate_state_authority( f"{storage.get('review_packet_filename')}" ), } + resolved_review_paths: dict[str, Path] = {} for label, binding in ( ("evidence", evidence_binding), ("packet", packet_binding), @@ -2545,22 +3158,165 @@ def validate_state_authority( role=f"status review {label} binding", ) issues.extend(path_issues) + if path is not None: + resolved_review_paths[label] = path if path is not None and binding.get("sha256") != sha256_file(path): issues.append(f"review {label} digest mismatch") - if binding.get("candidate_sha256") != review_preparation.get( - "product_delta_sha256" + if binding.get("sha256") != review_preparation.get( + f"{label}_sha256" + ): + issues.append( + f"review {label} digest does not match review preparation" + ) + if is_v2_review_preparation and ( + binding.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or binding.get("product_result_sha256") + != review_preparation.get("product_result_sha256") ): + issues.append( + f"v2 review {label} product result binding mismatch" + ) + if ( + path is not None + and review_preparation.get("schema_version") + == "implementation-review-preparation/v2" + and label == "evidence" + ): + evidence_value, evidence_issues = load_json_object(path) + issues.extend(evidence_issues) + try: + from repository_preparation import product_path_states_v2_from_value + + parsed_result = product_path_states_v2_from_value( + evidence_value["product_result"] + ) + transition_value = status.get( + "execution_transition_binding" + ) + if ( + evidence_value.get("schema_version") + != "implementation-review-evidence/v2" + or "requirement_result" in evidence_value + or parsed_result.sha256 + != review_preparation.get("product_result_sha256") + or not isinstance(transition_value, dict) + or transition_value.get("product_path_states") + != evidence_value.get("product_result") + ): + issues.append("v2 review evidence product result binding mismatch") + except (KeyError, TypeError, ValueError): + issues.append("v2 review evidence product result is invalid") + expected_candidate = ( + review_preparation.get("product_result_sha256") + if review_preparation.get("schema_version") + == "implementation-review-preparation/v2" + else review_preparation.get("product_delta_sha256") + ) + if binding.get("candidate_sha256") != expected_candidate: issues.append(f"review {label} candidate binding mismatch") + if ( + is_v2_review_preparation + and set(resolved_review_paths) == {"evidence", "packet"} + ): + evidence_value, evidence_issues = load_json_object( + resolved_review_paths["evidence"] + ) + issues.extend(evidence_issues) + try: + packet_markdown = resolved_review_paths[ + "packet" + ].read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + issues.append(f"review packet could not be read: {error}") + else: + if evidence_value is not None: + try: + from review_coordination import ( + validate_review_bundle, + ) + + issues.extend( + f"review bundle: {issue}" + for issue in validate_review_bundle( + evidence_value, packet_markdown + ) + ) + except (ImportError, TypeError, ValueError) as error: + issues.append( + f"review bundle validation failed: {error}" + ) if status.get("current_increment_state") == "accepted": disposition = status.get("diff_disposition_binding") + setup_semantics = manifest.get("setup_semantics") + setup_envelope = ( + setup_semantics.get("operation_envelope") + if isinstance(setup_semantics, dict) + else None + ) + setup_v2_family = ( + isinstance(setup_semantics, dict) + and setup_semantics.get("schema_version") + == "implementation-program-setup-semantics/v2" + and isinstance(setup_envelope, dict) + and setup_envelope.get("schema_version") + == "implementation-operation-envelope/v2" + ) + execution_transition = status.get("execution_transition_binding") + review_preparation = status.get("review_preparation_binding") + review_evidence = status.get("review_evidence_binding") + transition_v2_family = ( + isinstance(execution_transition, dict) + and execution_transition.get("schema_version") + == EXECUTION_TRANSITION_SCHEMA_V2 + and isinstance(execution_transition.get("product_path_states"), dict) + and execution_transition["product_path_states"].get("schema_version") + == PRODUCT_PATH_STATES_SCHEMA_V2 + ) + review_v2_family = ( + isinstance(review_preparation, dict) + and review_preparation.get("schema_version") + == "implementation-review-preparation/v2" + and isinstance(review_evidence, dict) + and review_evidence.get("product_result_schema_version") + == PRODUCT_PATH_STATES_SCHEMA_V2 + ) + is_v2_disposition = ( + isinstance(disposition, dict) + and disposition.get("schema_version") + == "implementation-diff-disposition-binding/v2" + ) + if not ( + setup_v2_family + == transition_v2_family + == review_v2_family + == is_v2_disposition + ): + issues.append( + "accepted status family does not match controlling setup family" + ) + if is_v2_disposition and ( + not isinstance(disposition, dict) + or disposition.get("product_result_schema_version") + != PRODUCT_PATH_STATES_SCHEMA_V2 + or not isinstance(execution_transition, dict) + or execution_transition.get("product_path_states_sha256") + != disposition.get("product_result_sha256") + ): + issues.append("accepted status v2 product result family binding is invalid") transition_authority = status.get("transition_authority") program_state = status.get("program_state") diff_transition_is_current = program_state == "active" if ( not isinstance(disposition, dict) or disposition.get("schema_version") - != "implementation-diff-disposition-binding/v1" - or disposition.get("decision") != "accept-stop" + != ( + "implementation-diff-disposition-binding/v2" + if is_v2_disposition + else "implementation-diff-disposition-binding/v1" + ) + or disposition.get("decision") + not in {"accept-stop", "accept-continue"} or disposition.get("exact_file_plan_sha256") != status.get("approved_exact_file_plan_sha256") or disposition.get("execution_baseline_sha256") @@ -2569,13 +3325,24 @@ def validate_state_authority( if isinstance(baseline_binding, dict) else None ) - or disposition.get("accepted_product_delta_sha256") - != ( - status.get("execution_transition_binding", {}).get( - "product_delta_sha256" + or ( + disposition.get("product_result_sha256") + != ( + status.get("execution_transition_binding", {}).get( + "product_path_states_sha256" + ) + if isinstance(status.get("execution_transition_binding"), dict) + else None + ) + if is_v2_disposition + else disposition.get("accepted_product_delta_sha256") + != ( + status.get("execution_transition_binding", {}).get( + "product_delta_sha256" + ) + if isinstance(status.get("execution_transition_binding"), dict) + else None ) - if isinstance(status.get("execution_transition_binding"), dict) - else None ) or ( diff_transition_is_current @@ -2610,7 +3377,8 @@ def validate_state_authority( == disposition.get("approval_event_id") and record.get("type") == "increment-diff-approval" and record.get("decision") == "approved" - and record.get("diff_decision") == "accept-stop" + and record.get("diff_decision") + == disposition.get("decision") and record.get("base_seed_sha256") == disposition.get("base_seed_sha256") ] @@ -2619,6 +3387,16 @@ def validate_state_authority( issues.append( "accepted status requires one exact diff approval" ) + elif matches[0].get("schema_version") != ( + APPROVAL_SCHEMA_V3 + if is_v2_disposition + else ( + "implementation-approval/v2" + if manifest.get("schema_version") == SETUP_PROGRAM_MANIFEST_SCHEMA + else APPROVAL_SCHEMA + ) + ): + issues.append("accepted status diff approval schema mismatch") if program_state in {"awaiting-closure-approval", "closed"}: issues.extend(_validate_closure_readiness(root, manifest, status)) preparation = status.get("closure_preparation_binding") @@ -3015,21 +3793,1575 @@ def _sha256_descriptor(descriptor: int) -> str: return digest.hexdigest() -def _windows_mutex_name(path: Path) -> str: - normalized = os.path.normcase(os.path.abspath(path)) - digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - return f"Local\\implementation-staged-plans-{digest}" +def _descriptor_relative_path(value: str) -> tuple[str, ...]: + """Normalize one target without allowing path traversal or Git metadata.""" + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError("descriptor-relative path must be a relative POSIX path") + path = PurePosixPath(value) + if ( + path.is_absolute() + or path.as_posix() != value + or any(part in {"", ".", ".."} for part in path.parts) + or ".git" in path.parts + ): + raise ValueError(f"descriptor-relative path is unsafe: {value!r}") + return path.parts -def _windows_api_error(operation: str) -> OSError: - error_code = _ctypes.get_last_error() - return OSError(error_code, f"{operation}: {_ctypes.FormatError(error_code)}") +def _descriptor_identity(value: object) -> tuple[int, int]: + if ( + not isinstance(value, (tuple, list)) + or len(value) != 2 + or not all(isinstance(part, int) and not isinstance(part, bool) for part in value) + ): + raise ValueError("protected identity must be a (device, inode) pair") + return int(value[0]), int(value[1]) -def _acquire_advisory_lock( - path: Path, parent: _AtomicParentHandle -) -> object: - if _WINDOWS: +def _descriptor_mode(mode: int) -> str: + return format(mode, "o") + + +def _descriptor_stat_identity(value: os.stat_result) -> tuple[int, int]: + return int(value.st_dev), int(value.st_ino) + + +def _descriptor_stat_binding(value: os.stat_result) -> tuple[int, int, int, int, int, int, int]: + return ( + int(value.st_dev), + int(value.st_ino), + int(value.st_mode), + int(value.st_nlink), + int(value.st_size), + int(value.st_mtime_ns), + int(value.st_ctime_ns), + ) + + +def _descriptor_file_binding(value: os.stat_result) -> tuple[int, int, int, int, int]: + """Identity and file shape that survive an atomic same-filesystem rename.""" + return ( + int(value.st_dev), + int(value.st_ino), + int(value.st_mode), + int(value.st_nlink), + int(value.st_size), + ) + + +def _descriptor_unsupported() -> OSError: + return OSError(DESCRIPTOR_RELATIVE_DELETE_UNSUPPORTED) + + +def _delete_descriptor_capabilities_supported(*, mutation: bool) -> bool: + flags = ("O_DIRECTORY", "O_NOFOLLOW", "O_CLOEXEC", "O_NONBLOCK") + if _WINDOWS or not all( + isinstance(getattr(os, flag, None), int) and bool(getattr(os, flag)) + for flag in flags + ): + return False + required = [os.open, os.stat] + if mutation: + required.extend((os.mkdir, os.link, os.unlink, os.rename)) + if not callable(getattr(os, "fchmod", None)): + return False + if os.rename is _ORIGINAL_OS_RENAME and _RENAMEATX_NP is None: + return False + return all(function in os.supports_dir_fd for function in required if function is not None) + + +def _descriptor_protected_relative_paths( + workspace_root: Path, protected_paths: Sequence[str] +) -> frozenset[str]: + """Convert caller-owned protected paths to lexical workspace-relative paths.""" + root_text = os.path.abspath(os.fspath(workspace_root)) + normalized: set[str] = set() + for raw in protected_paths: + if not isinstance(raw, str) or not raw or "\\" in raw: + raise ValueError("protected path must be a path string") + if os.path.isabs(raw): + candidate = os.path.normpath(raw) + try: + relative = os.path.relpath(candidate, root_text) + except ValueError as error: + raise ValueError("protected path is not in the workspace") from error + if relative == os.pardir or relative.startswith(os.pardir + os.sep): + # An actual Git/common directory may live outside a linked + # worktree. It cannot be reached through this descriptor walk, + # but remains valid protection context for the caller. + normalized.add("@" + candidate) + continue + raw = "" if relative == os.curdir else relative.replace(os.sep, "/") + if raw: + path = PurePosixPath(raw) + if ( + path.is_absolute() + or path.as_posix() != raw + or any(part in {"", ".."} for part in path.parts) + ): + raise ValueError(f"protected path is unsafe: {raw!r}") + normalized.add(path.as_posix()) + else: + normalized.add("") + return frozenset(normalized) + + +def _descriptor_path_is_protected(path: str, protected_paths: frozenset[str]) -> bool: + if "" in protected_paths: + return True + return any( + path == protected or path.startswith(protected + "/") + for protected in protected_paths + if protected + ) + + +def descriptor_protection_context( + workspace_root: Path, + *, + program_root: Path | None = None, + inspection: object | None = None, + extra_paths: Sequence[str] = (), +) -> tuple[tuple[str, ...], tuple[tuple[int, int], ...]]: + """Build lexical and identity protections from one fresh repository inspection.""" + workspace = Path(workspace_root).resolve(strict=False) + paths = [path for path in extra_paths if path] + candidates: list[Path] = [] + if program_root is not None: + candidates.append(Path(program_root)) + for field in ("git_directory", "git_common_directory"): + value = getattr(inspection, field, None) + if isinstance(value, str) and value: + candidates.append(Path(value)) + identities: set[tuple[int, int]] = set() + for candidate in candidates: + if os.path.abspath(os.fspath(candidate)) == os.path.abspath(os.fspath(workspace)): + continue + for follow_symlinks in (False, True): + try: + identity_stat = os.stat(candidate, follow_symlinks=follow_symlinks) + identities.add(_descriptor_stat_identity(identity_stat)) + except OSError: + pass + try: + relative = candidate.resolve(strict=False).relative_to(workspace).as_posix() + except ValueError: + continue + if relative and relative != ".": + paths.append(relative) + return tuple(dict.fromkeys(paths)), tuple(sorted(identities)) + + +def _descriptor_chain_matches( + chain: Sequence[tuple[int, str, int, tuple[int, int]]], +) -> bool: + """Re-stat each held directory name through its held parent descriptor.""" + for parent_fd, name, child_fd, identity in chain: + try: + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except OSError: + return False + if _descriptor_stat_identity(current) != identity: + return False + if _descriptor_stat_identity(os.fstat(child_fd)) != identity: + return False + return True + + +def inspect_workspace_path( + workspace_root: Path, + relative_path: str, + *, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> WorkspacePathSnapshot: + """Read one workspace file through held descriptor-relative no-follow handles. + + A missing component is the sole normal absence result. Every other race, + unsupported primitive, unsafe path, or non-regular target is a hard failure. + """ + if ( + _WINDOWS + or not all( + isinstance(getattr(os, flag, None), int) and bool(getattr(os, flag)) + for flag in ("O_DIRECTORY", "O_NOFOLLOW", "O_CLOEXEC") + ) + or not isinstance(getattr(os, "O_NONBLOCK", None), int) + or not os.O_NONBLOCK + or any( + function not in os.supports_dir_fd + for function in (os.open, os.stat) + ) + ): + raise _descriptor_unsupported() + + parts = _descriptor_relative_path(relative_path) + protected = _descriptor_protected_relative_paths( + Path(workspace_root), protected_paths + ) + identities = frozenset( + _descriptor_identity(identity) for identity in protected_identities + ) + if _descriptor_path_is_protected(relative_path, protected): + raise ValueError(f"descriptor-relative path is protected: {relative_path}") + + directory_flags = ( + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + ) + final_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + root = Path(workspace_root) + root_fd: int | None = None + descriptors: list[int] = [] + chain: list[tuple[int, str, int, tuple[int, int]]] = [] + + def absent() -> WorkspacePathSnapshot: + try: + root_current = os.stat(root, follow_symlinks=False) + except OSError as error: + raise ValueError("descriptor-relative workspace root changed") from error + if ( + _descriptor_stat_binding(root_current) + != _descriptor_stat_binding(root_stat) + or not _descriptor_chain_matches(chain) + ): + raise ValueError("descriptor-relative workspace ancestor changed") + return WorkspacePathSnapshot( + path=relative_path, + exists=False, + sha256=None, + mode=None, + device=None, + inode=None, + link_count=None, + ) + + try: + root_fd = os.open(root, directory_flags) + descriptors.append(root_fd) + root_stat = os.fstat(root_fd) + if not stat.S_ISDIR(root_stat.st_mode): + raise ValueError("workspace root must be a directory") + root_identity = _descriptor_stat_identity(root_stat) + if root_identity in identities: + raise ValueError("workspace ancestor has a protected identity") + + parent_fd = root_fd + current_path = "" + for component in parts[:-1]: + current_path = f"{current_path}/{component}".lstrip("/") + if _descriptor_path_is_protected(current_path, protected): + raise ValueError(f"descriptor-relative path is protected: {current_path}") + try: + expected = os.stat(component, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return absent() + if stat.S_ISLNK(expected.st_mode) or not stat.S_ISDIR(expected.st_mode): + raise ValueError(f"descriptor-relative ancestor is unsafe: {current_path}") + try: + child_fd = os.open(component, directory_flags, dir_fd=parent_fd) + except FileNotFoundError: + return absent() + descriptors.append(child_fd) + actual = os.fstat(child_fd) + identity = _descriptor_stat_identity(actual) + if identity != _descriptor_stat_identity(expected): + raise ValueError(f"descriptor-relative ancestor changed: {current_path}") + if identity in identities: + raise ValueError(f"descriptor-relative ancestor has a protected identity: {current_path}") + chain.append((parent_fd, component, child_fd, identity)) + parent_fd = child_fd + + final_name = parts[-1] + final_path = "/".join(parts) + if _descriptor_path_is_protected(final_path, protected): + raise ValueError(f"descriptor-relative path is protected: {final_path}") + try: + expected = os.stat(final_name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return absent() + if stat.S_ISLNK(expected.st_mode): + raise ValueError(f"descriptor-relative final path is a symlink: {final_path}") + try: + target_fd = os.open(final_name, final_flags, dir_fd=parent_fd) + except FileNotFoundError: + return absent() + descriptors.append(target_fd) + try: + before = os.fstat(target_fd) + if _descriptor_stat_binding(before) != _descriptor_stat_binding(expected): + raise ValueError(f"descriptor-relative final path changed: {final_path}") + identity = _descriptor_stat_identity(before) + if identity in identities: + raise ValueError(f"descriptor-relative final path has a protected identity: {final_path}") + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"descriptor-relative final path is not regular: {final_path}") + if before.st_nlink != 1: + raise ValueError(f"descriptor-relative final path has hard links: {final_path}") + digest = _sha256_descriptor(target_fd) + after = os.fstat(target_fd) + if _descriptor_stat_binding(after) != _descriptor_stat_binding(before): + raise ValueError(f"descriptor-relative final path changed while reading: {final_path}") + try: + current = os.stat(final_name, dir_fd=parent_fd, follow_symlinks=False) + except OSError as error: + raise ValueError(f"descriptor-relative final path changed: {final_path}") from error + if _descriptor_stat_binding(current) != _descriptor_stat_binding(after): + raise ValueError(f"descriptor-relative final path changed: {final_path}") + try: + root_current = os.stat(root, follow_symlinks=False) + except OSError as error: + raise ValueError("descriptor-relative workspace root changed") from error + if ( + _descriptor_stat_binding(root_current) + != _descriptor_stat_binding(root_stat) + or not _descriptor_chain_matches(chain) + ): + raise ValueError("descriptor-relative workspace ancestor changed") + return WorkspacePathSnapshot( + path=relative_path, + exists=True, + sha256=digest, + mode=_descriptor_mode(after.st_mode), + device=int(after.st_dev), + inode=int(after.st_ino), + link_count=int(after.st_nlink), + ) + finally: + os.close(target_fd) + descriptors.remove(target_fd) + except (AttributeError, NotImplementedError) as error: + raise _descriptor_unsupported() from error + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + + +def _delete_baseline_snapshot( + target_path: str, binding: WorkspacePathSnapshot | dict[str, object] +) -> WorkspacePathSnapshot: + if isinstance(binding, WorkspacePathSnapshot): + snapshot = binding + elif isinstance(binding, dict): + try: + snapshot = WorkspacePathSnapshot( + path=str(binding["path"]), + exists=bool(binding["exists"]), + sha256=binding.get("sha256", binding.get("baseline_sha256")), + mode=binding.get("mode", binding.get("baseline_mode")), + device=binding.get("device", binding.get("baseline_device")), + inode=binding.get("inode", binding.get("baseline_inode")), + link_count=binding.get("link_count", binding.get("baseline_link_count")), + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("Delete baseline binding is invalid") from error + else: + raise ValueError("Delete baseline binding is invalid") + _descriptor_relative_path(snapshot.path) + _descriptor_relative_path(target_path) + if snapshot.path != target_path or not snapshot.exists: + raise ValueError("Delete baseline must bind an existing target path") + if ( + not isinstance(snapshot.sha256, str) + or len(snapshot.sha256) != 64 + or any(character not in "0123456789abcdef" for character in snapshot.sha256) + or not isinstance(snapshot.mode, str) + or not isinstance(snapshot.device, int) + or isinstance(snapshot.device, bool) + or not isinstance(snapshot.inode, int) + or isinstance(snapshot.inode, bool) + or snapshot.link_count != 1 + ): + raise ValueError("Delete baseline binding is invalid") + return snapshot + + +def _delete_binding_metadata( + program_root: Path, + baseline: WorkspacePathSnapshot | dict[str, object], + *, + require_status_current: bool = True, +) -> tuple[str, int, str, WorkspacePathSnapshot]: + manifest, issues = load_json_object(Path(program_root) / "manifest.json") + if manifest is None: + raise ValueError("; ".join(issues)) + if not isinstance(manifest.get("program_id"), str) or not manifest["program_id"]: + raise ValueError("manifest program_id is required for Delete quarantine") + if not isinstance(manifest.get("program_revision"), int) or isinstance( + manifest["program_revision"], bool + ): + raise ValueError("manifest program_revision is required for Delete quarantine") + if not isinstance(baseline, (WorkspacePathSnapshot, dict)): + raise ValueError("Delete baseline binding is invalid") + raw = baseline if isinstance(baseline, dict) else baseline.__dict__ + program_id = raw.get("program_id", manifest["program_id"]) + revision = raw.get("program_revision", manifest["program_revision"]) + increment_id = raw.get("increment_id", raw.get("current_increment_id")) + if ( + not isinstance(program_id, str) + or program_id != manifest["program_id"] + or not isinstance(revision, int) + or isinstance(revision, bool) + or revision != manifest["program_revision"] + or not isinstance(increment_id, str) + ): + raise ValueError("Delete baseline program binding is invalid") + increment_parts = _descriptor_relative_path(increment_id) + if len(increment_parts) != 1: + raise ValueError("Delete baseline increment_id must be one safe path segment") + logical_roles = manifest.get("logical_roles") + if not isinstance(logical_roles, dict): + raise ValueError("manifest logical_roles is required for Delete quarantine") + status_path, status_issues = resolve_managed_path( + Path(program_root), logical_roles.get("status"), role="logical role status" + ) + if status_path is None: + raise ValueError("; ".join(status_issues)) + status, status_issues = load_json_object(status_path) + if status is None: + raise ValueError("; ".join(status_issues)) + if require_status_current and status.get("current_increment_id") != increment_id: + raise ValueError("Delete baseline increment_id is not the status current increment") + authority = raw.get("current_increment_authority_binding") + if ( + require_status_current + and authority is not None + and authority != status.get("current_increment_authority_binding") + ): + raise ValueError("Delete baseline current increment authority binding mismatch") + path = raw.get("path") + if not isinstance(path, str): + raise ValueError("Delete baseline path is required") + snapshot = _delete_baseline_snapshot(path, baseline) + return str(program_id), int(revision), increment_parts[0], snapshot + + +@dataclass(frozen=True) +class _HeldDeleteDirectory: + root_path: Path + root_fd: int + directory_fd: int + root_stat: os.stat_result + directory_stat: os.stat_result + chain: tuple[tuple[int, str, int, tuple[int, int]], ...] + descriptors: tuple[int, ...] + + +def _program_root_relative_parts( + workspace_root: Path, program_root: Path +) -> tuple[str, ...]: + relative = os.path.relpath( + os.path.normpath(os.fspath(program_root)), + os.path.abspath(os.fspath(workspace_root)), + ) + if relative == os.pardir or relative.startswith(os.pardir + os.sep): + raise ValueError("program root must be inside the selected workspace") + if relative == os.curdir: + return () + return _descriptor_relative_path(relative.replace(os.sep, "/")) + + +def _open_held_delete_directory( + workspace_root: Path, + program_root: Path, + relative_parts: Sequence[str], + *, + create: bool, +) -> _HeldDeleteDirectory: + """Hold the workspace-to-program directory chain without following symlinks.""" + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + root_path = Path(workspace_root) + program_parts = _program_root_relative_parts(root_path, Path(program_root)) + descriptors: list[int] = [] + chain: list[tuple[int, str, int, tuple[int, int]]] = [] + root_fd = os.open(root_path, directory_flags) + descriptors.append(root_fd) + root_stat = os.fstat(root_fd) + if not stat.S_ISDIR(root_stat.st_mode): + os.close(root_fd) + raise ValueError("workspace root must be a directory") + parent_fd = root_fd + try: + for index, component in enumerate((*program_parts, *relative_parts)): + try: + expected = os.stat(component, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + if not create or index < len(program_parts): + raise + os.mkdir(component, 0o700, dir_fd=parent_fd) + expected = os.stat(component, dir_fd=parent_fd, follow_symlinks=False) + if stat.S_ISLNK(expected.st_mode) or not stat.S_ISDIR(expected.st_mode): + if index < len(program_parts): + raise ValueError("Delete program root ancestor is unsafe") + raise ValueError("Delete quarantine storage must be a directory") + child = os.open(component, directory_flags, dir_fd=parent_fd) + descriptors.append(child) + actual = os.fstat(child) + identity = _descriptor_stat_identity(actual) + if identity != _descriptor_stat_identity(expected): + raise ValueError("Delete quarantine storage changed during binding") + chain.append((parent_fd, component, child, identity)) + parent_fd = child + return _HeldDeleteDirectory( + root_path=root_path, + root_fd=root_fd, + directory_fd=parent_fd, + root_stat=root_stat, + directory_stat=os.fstat(parent_fd), + chain=tuple(chain), + descriptors=tuple(descriptors), + ) + except BaseException: + for descriptor in reversed(descriptors): + os.close(descriptor) + raise + + +def _held_delete_directory_matches(directory: _HeldDeleteDirectory) -> bool: + try: + current_root = os.stat(directory.root_path, follow_symlinks=False) + current_directory = os.fstat(directory.directory_fd) + except OSError: + return False + return ( + _descriptor_stat_identity(current_root) + == _descriptor_stat_identity(directory.root_stat) + and _descriptor_mode(current_root.st_mode) + == _descriptor_mode(directory.root_stat.st_mode) + and getattr(current_root, "st_uid", -1) + == getattr(directory.root_stat, "st_uid", -1) + and _descriptor_stat_identity(current_directory) + == _descriptor_stat_identity(directory.directory_stat) + and _descriptor_mode(current_directory.st_mode) + == _descriptor_mode(directory.directory_stat.st_mode) + and getattr(current_directory, "st_uid", -1) + == getattr(directory.directory_stat, "st_uid", -1) + and _descriptor_chain_matches(directory.chain) + ) + + +def _close_held_delete_directory(directory: _HeldDeleteDirectory) -> None: + for descriptor in reversed(directory.descriptors): + os.close(descriptor) + + +def _inspect_held_delete_root( + root_fd: int, + root_stat: os.stat_result, + relative_path: str, +) -> WorkspacePathSnapshot: + """Inspect a file below one already-held canonical quarantine root.""" + parts = _descriptor_relative_path(relative_path) + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + final_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + descriptors: list[int] = [] + chain: list[tuple[int, str, int, tuple[int, int]]] = [] + parent_fd = root_fd + try: + for component in parts[:-1]: + try: + expected = os.stat(component, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return WorkspacePathSnapshot(relative_path, False, None, None, None, None, None) + if stat.S_ISLNK(expected.st_mode) or not stat.S_ISDIR(expected.st_mode): + raise ValueError("Delete quarantine path ancestor is unsafe") + child_fd = os.open(component, directory_flags, dir_fd=parent_fd) + descriptors.append(child_fd) + actual = os.fstat(child_fd) + identity = _descriptor_stat_identity(actual) + if identity != _descriptor_stat_identity(expected): + raise ValueError("Delete quarantine path ancestor changed") + chain.append((parent_fd, component, child_fd, identity)) + parent_fd = child_fd + name = parts[-1] + try: + expected = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return WorkspacePathSnapshot(relative_path, False, None, None, None, None, None) + if stat.S_ISLNK(expected.st_mode): + raise ValueError("Delete quarantine path is a symlink") + descriptor = os.open(name, final_flags, dir_fd=parent_fd) + descriptors.append(descriptor) + before = os.fstat(descriptor) + if _descriptor_stat_binding(before) != _descriptor_stat_binding(expected): + raise ValueError("Delete quarantine path changed") + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise ValueError("Delete quarantine path must be a single regular file") + digest = _sha256_descriptor(descriptor) + after = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if ( + _descriptor_stat_binding(after) != _descriptor_stat_binding(before) + or _descriptor_stat_binding(current) != _descriptor_stat_binding(after) + or not _descriptor_chain_matches(chain) + or _descriptor_stat_binding(os.fstat(root_fd)) + != _descriptor_stat_binding(root_stat) + ): + raise ValueError("Delete quarantine root or path changed") + return WorkspacePathSnapshot( + relative_path, + True, + digest, + _descriptor_mode(after.st_mode), + int(after.st_dev), + int(after.st_ino), + int(after.st_nlink), + ) + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + + +def _delete_storage_parts(manifest: dict[str, object], increment_id: str) -> tuple[str, ...]: + storage = manifest.get("increment_storage") + if not isinstance(storage, dict) or not isinstance(storage.get("root"), str): + raise ValueError("manifest increment_storage root is required for Delete quarantine") + root = storage["root"] + root_parts = _descriptor_relative_path(root) + increment_parts = _descriptor_relative_path(increment_id) + if len(increment_parts) != 1: + raise ValueError("Delete baseline increment_id must be one safe path segment") + return (*root_parts, increment_parts[0], "delete-quarantine") + + +def _delete_allocation_paths( + program_root: Path, + workspace_root: Path, + target_path: str, + baseline: WorkspacePathSnapshot | dict[str, object], + *, + create_root: bool, + require_status_current: bool = True, +) -> tuple[DeleteQuarantineAllocation, tuple[str, int, str, WorkspacePathSnapshot]]: + program_id, revision, increment_id, snapshot = _delete_binding_metadata( + program_root, + baseline, + require_status_current=require_status_current, + ) + manifest, issues = load_json_object(Path(program_root) / "manifest.json") + if manifest is None: + raise ValueError("; ".join(issues)) + storage_parts = _delete_storage_parts(manifest, increment_id) + seed = { + "program_id": program_id, + "program_revision": revision, + "increment_id": increment_id, + "path": target_path, + "baseline_sha256": snapshot.sha256, + "device": snapshot.device, + "inode": snapshot.inode, + "mode": snapshot.mode, + "link_count": snapshot.link_count, + } + binding_digest = _canonical_json_line_sha256(seed) + quarantine_name = f"delete-{binding_digest}.bin" + receipt_name = f"delete-{binding_digest}.receipt.json" + root_relative = "/".join(storage_parts) + allocation = DeleteQuarantineAllocation( + root_path=root_relative, + quarantine_path=f"{root_relative}/{quarantine_name}", + receipt_path=f"{root_relative}/{receipt_name}", + quarantine_name=quarantine_name, + receipt_name=receipt_name, + root_device=None, + root_inode=None, + root_mode=None, + root_owner=None, + ) + if not create_root: + try: + held_root = _open_held_delete_directory( + Path(workspace_root), Path(program_root), storage_parts, create=False + ) + except (FileNotFoundError, ValueError): + return allocation, (program_id, revision, increment_id, snapshot) + try: + root_stat = held_root.directory_stat + return replace( + allocation, + root_device=int(root_stat.st_dev), + root_inode=int(root_stat.st_ino), + root_mode=_descriptor_mode(root_stat.st_mode), + root_owner=int(getattr(root_stat, "st_uid", -1)), + ), (program_id, revision, increment_id, snapshot) + finally: + _close_held_delete_directory(held_root) + held_root = _open_held_delete_directory( + Path(workspace_root), Path(program_root), storage_parts, create=True + ) + try: + root_fd = held_root.directory_fd + root_stat = held_root.directory_stat + if (root_stat.st_mode & 0o777) != 0o700: + raise ValueError("Delete quarantine storage must use private mode 0700") + os.fchmod(root_fd, 0o700) + root_stat = os.fstat(root_fd) + if not _held_delete_directory_matches(held_root): + raise ValueError("Delete quarantine root path changed during allocation") + return replace( + allocation, + root_device=int(root_stat.st_dev), + root_inode=int(root_stat.st_ino), + root_mode=_descriptor_mode(root_stat.st_mode), + root_owner=int(getattr(root_stat, "st_uid", -1)), + ), (program_id, revision, increment_id, snapshot) + finally: + _close_held_delete_directory(held_root) + + +def delete_quarantine_allocation( + program_root: Path, + workspace_root: Path, + target_path: str, + authorized_baseline: WorkspacePathSnapshot | dict[str, object], +) -> DeleteQuarantineAllocation: + """Derive and allocate the manifest-owned deterministic Delete quarantine root.""" + _program_root_relative_parts(Path(workspace_root), Path(program_root)) + if not _delete_descriptor_capabilities_supported(mutation=True): + raise _descriptor_unsupported() + allocation, _ = _delete_allocation_paths( + Path(program_root), + Path(workspace_root), + target_path, + authorized_baseline, + create_root=True, + ) + return allocation + + +def _recorded_delete_allocation( + binding: WorkspacePathSnapshot | dict[str, object], +) -> dict[str, object] | None: + if not isinstance(binding, dict): + return None + fields = { + "quarantine_root_path": binding.get("quarantine_root_path"), + "quarantine_root_device": binding.get("quarantine_root_device"), + "quarantine_root_inode": binding.get("quarantine_root_inode"), + "quarantine_root_mode": binding.get("quarantine_root_mode"), + "quarantine_root_owner": binding.get("quarantine_root_owner"), + } + if not all(value is not None for value in fields.values()): + return None + return fields + + +@dataclass(frozen=True) +class _HeldDeleteTarget: + root_fd: int + parent_fd: int + target_fd: int + target_name: str + root_stat: os.stat_result + target_stat: os.stat_result + target_sha256: str + chain: tuple[tuple[int, str, int, tuple[int, int]], ...] + descriptors: tuple[int, ...] + + +def _open_held_delete_target( + workspace_root: Path, + relative_path: str, + *, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> _HeldDeleteTarget: + parts = _descriptor_relative_path(relative_path) + protected = _descriptor_protected_relative_paths(workspace_root, protected_paths) + identities = frozenset(_descriptor_identity(identity) for identity in protected_identities) + if _descriptor_path_is_protected(relative_path, protected): + raise ValueError(f"descriptor-relative path is protected: {relative_path}") + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + final_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + descriptors: list[int] = [] + chain: list[tuple[int, str, int, tuple[int, int]]] = [] + root_fd = os.open(workspace_root, directory_flags) + descriptors.append(root_fd) + root_stat = os.fstat(root_fd) + if not stat.S_ISDIR(root_stat.st_mode): + os.close(root_fd) + raise ValueError("workspace root must be a directory") + if _descriptor_stat_identity(root_stat) in identities: + os.close(root_fd) + raise ValueError("Delete workspace root has a protected identity") + parent_fd = root_fd + try: + for component in parts[:-1]: + try: + expected = os.stat(component, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + raise ValueError(f"Delete target is absent: {relative_path}") + if stat.S_ISLNK(expected.st_mode) or not stat.S_ISDIR(expected.st_mode): + raise ValueError(f"Delete target ancestor is unsafe: {relative_path}") + child_fd = os.open(component, directory_flags, dir_fd=parent_fd) + descriptors.append(child_fd) + actual = os.fstat(child_fd) + identity = _descriptor_stat_identity(actual) + if identity != _descriptor_stat_identity(expected): + raise ValueError(f"Delete target ancestor changed: {relative_path}") + if identity in identities: + raise ValueError(f"Delete target ancestor has a protected identity: {relative_path}") + chain.append((parent_fd, component, child_fd, identity)) + parent_fd = child_fd + final_name = parts[-1] + try: + expected = os.stat(final_name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + raise ValueError(f"Delete target is absent: {relative_path}") + if stat.S_ISLNK(expected.st_mode): + raise ValueError(f"Delete target is a symlink: {relative_path}") + target_fd = os.open(final_name, final_flags, dir_fd=parent_fd) + descriptors.append(target_fd) + target_stat = os.fstat(target_fd) + if _descriptor_stat_binding(target_stat) != _descriptor_stat_binding(expected): + raise ValueError(f"Delete target changed: {relative_path}") + if _descriptor_stat_identity(target_stat) in identities: + raise ValueError(f"Delete target has a protected identity: {relative_path}") + if not stat.S_ISREG(target_stat.st_mode) or target_stat.st_nlink != 1: + raise ValueError(f"Delete target must be a single regular file: {relative_path}") + target_sha256 = _sha256_descriptor(target_fd) + after = os.fstat(target_fd) + if _descriptor_stat_binding(after) != _descriptor_stat_binding(target_stat): + raise ValueError(f"Delete target changed while reading: {relative_path}") + current = os.stat(final_name, dir_fd=parent_fd, follow_symlinks=False) + if _descriptor_stat_binding(current) != _descriptor_stat_binding(after): + raise ValueError(f"Delete target changed: {relative_path}") + return _HeldDeleteTarget( + root_fd=root_fd, + parent_fd=parent_fd, + target_fd=target_fd, + target_name=final_name, + root_stat=root_stat, + target_stat=after, + target_sha256=target_sha256, + chain=tuple(chain), + descriptors=tuple(descriptors), + ) + except BaseException: + for descriptor in reversed(descriptors): + os.close(descriptor) + raise + + +def _close_held_delete_target(target: _HeldDeleteTarget) -> None: + for descriptor in reversed(target.descriptors): + os.close(descriptor) + + +def _rename_without_replacement( + source_name: str, + quarantine_name: str, + *, + source_fd: int, + quarantine_fd: int, +) -> None: + """Rename atomically without replacing a destination, or fail closed.""" + if os.rename is not _ORIGINAL_OS_RENAME: + os.rename( + source_name, + quarantine_name, + src_dir_fd=source_fd, + dst_dir_fd=quarantine_fd, + ) + return + if _RENAMEATX_NP is None: + raise _descriptor_unsupported() + result = _RENAMEATX_NP( + source_fd, + os.fsencode(source_name), + quarantine_fd, + os.fsencode(quarantine_name), + _RENAME_EXCL, + ) + if result != 0: + error_number = _ctypes.get_errno() + if error_number == errno.EXDEV: + raise _descriptor_unsupported() + raise OSError(error_number, os.strerror(error_number)) + + +def _revalidate_held_delete_target(target: _HeldDeleteTarget) -> None: + """Repeat the held source walk, name lookup, stat, and hash before rename.""" + if not _descriptor_chain_matches(target.chain): + raise ValueError("Delete target ancestor changed") + try: + named = os.stat(target.target_name, dir_fd=target.parent_fd, follow_symlinks=False) + except OSError as error: + raise ValueError("Delete target changed before quarantine move") from error + held = os.fstat(target.target_fd) + if _descriptor_stat_binding(named) != _descriptor_stat_binding(held): + raise ValueError("Delete target changed before quarantine move") + if not stat.S_ISREG(held.st_mode) or held.st_nlink != 1: + raise ValueError("Delete target changed before quarantine move") + os.lseek(target.target_fd, 0, os.SEEK_SET) + digest = _sha256_descriptor(target.target_fd) + after = os.fstat(target.target_fd) + if digest != target.target_sha256 or _descriptor_stat_binding(after) != _descriptor_stat_binding(held): + raise ValueError("Delete target changed before quarantine move") + + +def _delete_receipt_bytes(receipt: DeleteQuarantineReceipt) -> bytes: + return _canonical_json_bytes(asdict(receipt)) + + +def _write_delete_receipt( + quarantine_fd: int, receipt_name: str, receipt: DeleteQuarantineReceipt +) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC + temporary_name = f".{receipt_name}.{secrets.token_hex(8)}.tmp" + descriptor = os.open(temporary_name, flags, 0o600, dir_fd=quarantine_fd) + try: + try: + payload = _delete_receipt_bytes(receipt) + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("Delete receipt write made no progress") + view = view[written:] + os.fsync(descriptor) + except BaseException: + os.unlink(temporary_name, dir_fd=quarantine_fd) + raise + finally: + os.close(descriptor) + try: + os.link( + temporary_name, + receipt_name, + src_dir_fd=quarantine_fd, + dst_dir_fd=quarantine_fd, + ) + finally: + os.unlink(temporary_name, dir_fd=quarantine_fd) + os.fsync(quarantine_fd) + + +def quarantine_bound_regular_file( + program_root: Path, + workspace_root: Path, + target_path: str, + authorized_baseline: WorkspacePathSnapshot | dict[str, object], + *, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> DeleteQuarantineReceipt: + """Atomically move one exact regular file into its manifest-owned quarantine.""" + if not _delete_descriptor_capabilities_supported(mutation=True): + raise _descriptor_unsupported() + allocation, metadata = _delete_allocation_paths( + Path(program_root), + Path(workspace_root), + target_path, + authorized_baseline, + create_root=False, + ) + program_id, revision, increment_id, baseline = metadata + recorded_allocation = _recorded_delete_allocation(authorized_baseline) + if allocation.root_device is None or recorded_allocation is None: + raise ValueError("Delete quarantine allocation is missing") + if ( + recorded_allocation["quarantine_root_path"] != allocation.root_path + or recorded_allocation["quarantine_root_device"] != allocation.root_device + or recorded_allocation["quarantine_root_inode"] != allocation.root_inode + or recorded_allocation["quarantine_root_mode"] != allocation.root_mode + or recorded_allocation["quarantine_root_owner"] != allocation.root_owner + ): + raise ValueError("Delete quarantine allocation binding changed") + program_relative = os.path.relpath( + os.path.normpath(os.fspath(program_root)), + os.path.abspath(os.fspath(workspace_root)), + ).replace(os.sep, "/") + protected_paths = ( + *protected_paths, + program_relative, + f"{program_relative}/{allocation.root_path}", + ) + held = _open_held_delete_target( + Path(workspace_root), + target_path, + protected_paths=protected_paths, + protected_identities=protected_identities, + ) + held_quarantine: _HeldDeleteDirectory | None = None + try: + if ( + held.target_sha256 != baseline.sha256 + or _descriptor_stat_identity(held.target_stat) + != (baseline.device, baseline.inode) + or _descriptor_mode(held.target_stat.st_mode) != baseline.mode + or held.target_stat.st_nlink != 1 + ): + raise ValueError("Delete target no longer matches its authorized baseline") + quarantine_parts = tuple(allocation.root_path.split("/")) + held_quarantine = _open_held_delete_directory( + Path(workspace_root), + Path(program_root), + quarantine_parts, + create=False, + ) + quarantine_fd = held_quarantine.directory_fd + quarantine_stat = held_quarantine.directory_stat + if ( + (quarantine_stat.st_mode & 0o777) != 0o700 + or allocation.root_device != quarantine_stat.st_dev + or allocation.root_inode != quarantine_stat.st_ino + or allocation.root_mode != _descriptor_mode(quarantine_stat.st_mode) + or allocation.root_owner != getattr(quarantine_stat, "st_uid", -1) + ): + raise ValueError("Delete quarantine root identity or mode changed") + if ( + os.fstat(held.parent_fd).st_dev != quarantine_stat.st_dev + or held.target_stat.st_dev != quarantine_stat.st_dev + ): + raise ValueError("Delete target and quarantine are on different filesystems") + for name in (allocation.quarantine_name, allocation.receipt_name): + try: + os.stat(name, dir_fd=quarantine_fd, follow_symlinks=False) + except FileNotFoundError: + continue + raise ValueError("Delete quarantine allocation is already occupied") + _revalidate_held_delete_target(held) + if not _held_delete_directory_matches(held_quarantine): + raise ValueError("Delete quarantine root path changed before rename") + _rename_without_replacement( + held.target_name, + allocation.quarantine_name, + source_fd=held.parent_fd, + quarantine_fd=quarantine_fd, + ) + moved_fd = os.open( + allocation.quarantine_name, + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=quarantine_fd, + ) + try: + moved_stat = os.fstat(moved_fd) + moved_sha256 = _sha256_descriptor(moved_fd) + moved_after = os.fstat(moved_fd) + if ( + _descriptor_file_binding(moved_stat) + != _descriptor_file_binding(held.target_stat) + or moved_sha256 != held.target_sha256 + or moved_stat.st_nlink != 1 + or _descriptor_stat_binding(moved_after) + != _descriptor_stat_binding(moved_stat) + ): + raise ValueError("Delete quarantine bytes do not match the authorized target") + finally: + os.close(moved_fd) + try: + source_after = os.stat( + held.target_name, dir_fd=held.parent_fd, follow_symlinks=False + ) + except FileNotFoundError: + source_after = None + if source_after is not None: + raise ValueError("Delete target replacement appeared after quarantine move") + if not _descriptor_chain_matches(held.chain): + raise ValueError("Delete target ancestor changed after quarantine move") + current_quarantine = os.fstat(quarantine_fd) + if ( + _descriptor_stat_identity(current_quarantine) + != _descriptor_stat_identity(quarantine_stat) + or _descriptor_mode(current_quarantine.st_mode) + != _descriptor_mode(quarantine_stat.st_mode) + or getattr(current_quarantine, "st_uid", -1) + != getattr(quarantine_stat, "st_uid", -1) + ): + raise ValueError("Delete quarantine root changed after quarantine move") + if not _held_delete_directory_matches(held_quarantine): + raise ValueError("Delete quarantine root path changed before receipt") + receipt = DeleteQuarantineReceipt( + schema_version=DELETE_QUARANTINE_RECEIPT_SCHEMA_V1, + program_id=program_id, + program_revision=revision, + increment_id=increment_id, + path=target_path, + baseline_sha256=baseline.sha256 or "", + device=int(held.target_stat.st_dev), + inode=int(held.target_stat.st_ino), + quarantine_path=allocation.quarantine_path, + quarantine_sha256=held.target_sha256, + final_state="absent", + ) + if not _held_delete_directory_matches(held_quarantine): + raise ValueError("Delete quarantine root path changed before receipt") + _write_delete_receipt(quarantine_fd, allocation.receipt_name, receipt) + if not _held_delete_directory_matches(held_quarantine): + raise ValueError("Delete quarantine root path changed after receipt") + return receipt + except OSError as error: + if error.errno == errno.EXDEV: + raise _descriptor_unsupported() from error + raise + except (AttributeError, NotImplementedError, TypeError) as error: + raise _descriptor_unsupported() from error + finally: + if held_quarantine is not None: + _close_held_delete_directory(held_quarantine) + _close_held_delete_target(held) + + +def adopt_delete_quarantine_receipt( + program_root: Path, + workspace_root: Path, + target_path: str, + authorized_baseline: dict[str, object], + *, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), +) -> DeleteQuarantineReceipt: + """Publish the canonical receipt after an already-completed atomic rename.""" + if not _delete_descriptor_capabilities_supported(mutation=True): + raise _descriptor_unsupported() + allocation, metadata = _delete_allocation_paths( + Path(program_root), + Path(workspace_root), + target_path, + authorized_baseline, + create_root=False, + ) + if allocation.root_device is None: + raise ValueError("Delete quarantine allocation is not recorded") + held_root = _open_held_delete_directory( + Path(workspace_root), + Path(program_root), + _descriptor_relative_path(allocation.root_path), + create=False, + ) + root_fd = held_root.directory_fd + root_stat = held_root.directory_stat + source_parts = _descriptor_relative_path(target_path) + try: + held_source_parent = _open_held_delete_directory( + Path(workspace_root), + Path(workspace_root), + source_parts[:-1], + create=False, + ) + except BaseException: + _close_held_delete_directory(held_root) + raise + source_parent_fd = held_source_parent.directory_fd + source_parent_stat = held_source_parent.directory_stat + try: + protected = _descriptor_protected_relative_paths( + Path(workspace_root), protected_paths + ) + if _descriptor_stat_identity(source_parent_stat) in { + _descriptor_identity(identity) for identity in protected_identities + }: + raise ValueError("Delete source parent has a protected identity") + if _descriptor_path_is_protected("/".join(source_parts[:-1]), protected): + raise ValueError("Delete source parent is protected") + source_name = source_parts[-1] + if ( + _descriptor_stat_identity(root_stat) + != (allocation.root_device, allocation.root_inode) + or _descriptor_mode(root_stat.st_mode) != allocation.root_mode + or getattr(root_stat, "st_uid", -1) != allocation.root_owner + ): + raise ValueError("Delete quarantine root identity or mode changed") + if not _held_delete_directory_matches(held_root): + raise ValueError("Delete quarantine root path changed") + if not _held_delete_directory_matches(held_source_parent): + raise ValueError("Delete source parent path changed") + quarantine = _inspect_held_delete_root( + root_fd, root_stat, allocation.quarantine_name + ) + if not quarantine.exists or quarantine.link_count != 1: + raise ValueError("Delete quarantine entry is not an exact regular file") + _program_id, _revision, increment_id, snapshot = metadata + if ( + quarantine.sha256 != snapshot.sha256 + or quarantine.device != snapshot.device + or quarantine.inode != snapshot.inode + or quarantine.mode != snapshot.mode + ): + raise ValueError("Delete quarantine bytes do not match the authorized target") + try: + source_stat = os.stat(source_name, dir_fd=source_parent_fd, follow_symlinks=False) + except FileNotFoundError: + source_stat = None + if source_stat is not None: + raise ValueError("Delete target replacement appeared before receipt adoption") + try: + os.stat(allocation.receipt_name, dir_fd=root_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise ValueError("Delete quarantine receipt already exists") + try: + source_stat = os.stat(source_name, dir_fd=source_parent_fd, follow_symlinks=False) + except FileNotFoundError: + source_stat = None + if source_stat is not None: + raise ValueError("Delete target replacement appeared before receipt adoption") + receipt = DeleteQuarantineReceipt( + schema_version=DELETE_QUARANTINE_RECEIPT_SCHEMA_V1, + program_id=_program_id, + program_revision=_revision, + increment_id=increment_id, + path=target_path, + baseline_sha256=snapshot.sha256 or "", + device=quarantine.device or 0, + inode=quarantine.inode or 0, + quarantine_path=allocation.quarantine_path, + quarantine_sha256=quarantine.sha256 or "", + final_state="absent", + ) + if not _held_delete_directory_matches(held_root): + raise ValueError("Delete quarantine root path changed before receipt adoption") + if not _held_delete_directory_matches(held_source_parent): + raise ValueError("Delete source parent path changed before receipt adoption") + _write_delete_receipt(root_fd, allocation.receipt_name, receipt) + current_root = os.fstat(root_fd) + if ( + _descriptor_stat_identity(current_root) + != _descriptor_stat_identity(root_stat) + or _descriptor_mode(current_root.st_mode) + != _descriptor_mode(root_stat.st_mode) + or getattr(current_root, "st_uid", -1) + != getattr(root_stat, "st_uid", -1) + ): + raise ValueError("Delete quarantine root changed after receipt adoption") + if not _held_delete_directory_matches(held_root): + raise ValueError("Delete quarantine root path changed after receipt adoption") + if not _held_delete_directory_matches(held_source_parent): + raise ValueError("Delete source parent path changed after receipt adoption") + try: + source_stat = os.stat(source_name, dir_fd=source_parent_fd, follow_symlinks=False) + except FileNotFoundError: + source_stat = None + if source_stat is not None: + raise ValueError("Delete target replacement appeared after receipt adoption") + return receipt + finally: + _close_held_delete_directory(held_source_parent) + _close_held_delete_directory(held_root) + + +def _read_delete_receipt( + program_root: Path, + receipt_path: str, + *, + root_fd: int | None = None, + root_stat: os.stat_result | None = None, + receipt_name: str | None = None, +) -> DeleteQuarantineReceipt: + held = ( + _open_held_delete_target(Path(program_root), receipt_path) + if root_fd is None + else None + ) + target_fd = ( + held.target_fd + if held is not None + else os.open( + receipt_name or _descriptor_relative_path(receipt_path)[-1], + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=root_fd, + ) + ) + try: + os.lseek(target_fd, 0, os.SEEK_SET) + payload = bytearray() + for chunk in iter(lambda: os.read(target_fd, 1024 * 1024), b""): + payload.extend(chunk) + def object_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + value: dict[str, object] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate receipt key") + value[key] = item + return value + + value = json.loads( + bytes(payload).decode("utf-8"), + object_pairs_hook=object_pairs, + parse_constant=lambda constant: (_ for _ in ()).throw( + ValueError(f"invalid receipt constant: {constant}") + ), + ) + if not isinstance(value, dict): + raise ValueError("Delete quarantine receipt must be an object") + required = { + "schema_version", + "program_id", + "program_revision", + "increment_id", + "path", + "baseline_sha256", + "device", + "inode", + "quarantine_path", + "quarantine_sha256", + "final_state", + } + if set(value) != required: + raise ValueError("Delete quarantine receipt fields are invalid") + if ( + value["schema_version"] != DELETE_QUARANTINE_RECEIPT_SCHEMA_V1 + or not all(isinstance(value[field], str) and value[field] for field in ( + "program_id", "increment_id", "path", "quarantine_path" + )) + or not isinstance(value["program_revision"], int) + or isinstance(value["program_revision"], bool) + or value["program_revision"] < 1 + or not isinstance(value["device"], int) + or isinstance(value["device"], bool) + or not isinstance(value["inode"], int) + or isinstance(value["inode"], bool) + or not isinstance(value["baseline_sha256"], str) + or len(value["baseline_sha256"]) != 64 + or any(character not in "0123456789abcdef" for character in value["baseline_sha256"]) + or not isinstance(value["quarantine_sha256"], str) + or len(value["quarantine_sha256"]) != 64 + or any(character not in "0123456789abcdef" for character in value["quarantine_sha256"]) + or value["final_state"] != "absent" + ): + raise ValueError("Delete quarantine receipt fields are invalid") + _descriptor_relative_path(value["path"]) + _descriptor_relative_path(value["quarantine_path"]) + receipt = DeleteQuarantineReceipt( + schema_version=value["schema_version"], + program_id=value["program_id"], + program_revision=value["program_revision"], + increment_id=value["increment_id"], + path=value["path"], + baseline_sha256=value["baseline_sha256"], + device=value["device"], + inode=value["inode"], + quarantine_path=value["quarantine_path"], + quarantine_sha256=value["quarantine_sha256"], + final_state=value["final_state"], + ) + if _delete_receipt_bytes(receipt) != bytes(payload): + raise ValueError("Delete quarantine receipt is not canonical") + except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error: + raise ValueError("Delete quarantine receipt is invalid") from error + finally: + if held is not None: + _close_held_delete_target(held) + else: + os.close(target_fd) + return receipt + + +def _delete_snapshot_error(path: str, error: BaseException) -> WorkspacePathSnapshot: + return WorkspacePathSnapshot( + path=path, + exists=False, + sha256=None, + mode=None, + device=None, + inode=None, + link_count=None, + ) + + +def classify_delete_quarantine_recovery( + program_root: Path, + workspace_root: Path, + target_path: str, + authorized_baseline: WorkspacePathSnapshot | dict[str, object], + *, + protected_paths: Sequence[str] = (), + protected_identities: Sequence[tuple[int, int]] = (), + require_status_current: bool = True, +) -> DeleteQuarantineRecovery: + """Classify an existing Delete intent without deleting, restoring, or overwriting bytes.""" + allocation, metadata = _delete_allocation_paths( + Path(program_root), + Path(workspace_root), + target_path, + authorized_baseline, + create_root=False, + require_status_current=require_status_current, + ) + _, _, _, baseline = metadata + program_relative = os.path.relpath( + os.path.normpath(os.fspath(program_root)), + os.path.abspath(os.fspath(workspace_root)), + ).replace(os.sep, "/") + issues: list[str] = [] + recorded_allocation = _recorded_delete_allocation(authorized_baseline) + if allocation.root_device is None: + issues.append("quarantine root is missing") + elif recorded_allocation is None: + issues.append("quarantine root allocation is not recorded") + elif ( + recorded_allocation["quarantine_root_path"] != allocation.root_path + or recorded_allocation["quarantine_root_device"] != allocation.root_device + or recorded_allocation["quarantine_root_inode"] != allocation.root_inode + or recorded_allocation["quarantine_root_mode"] != allocation.root_mode + or recorded_allocation["quarantine_root_owner"] != allocation.root_owner + ): + issues.append("quarantine root allocation binding changed") + held_quarantine_root: _HeldDeleteDirectory | None = None + quarantine_root_fd: int | None = None + quarantine_root_stat: os.stat_result | None = None + if allocation.root_device is not None and not issues: + try: + held_quarantine_root = _open_held_delete_directory( + Path(workspace_root), + Path(program_root), + _descriptor_relative_path(allocation.root_path), + create=False, + ) + quarantine_root_fd = held_quarantine_root.directory_fd + quarantine_root_stat = held_quarantine_root.directory_stat + if ( + _descriptor_stat_identity(quarantine_root_stat) + != (allocation.root_device, allocation.root_inode) + or _descriptor_mode(quarantine_root_stat.st_mode) != allocation.root_mode + or getattr(quarantine_root_stat, "st_uid", -1) != allocation.root_owner + ): + issues.append("quarantine root identity or mode changed") + elif not _held_delete_directory_matches(held_quarantine_root): + issues.append("quarantine root path changed") + except (OSError, ValueError) as error: + issues.append(f"quarantine root inspection failed: {error}") + try: + source = inspect_workspace_path( + Path(workspace_root), + target_path, + protected_paths=tuple( + path for path in (*protected_paths, program_relative) if path + ), + protected_identities=protected_identities, + ) + except (OSError, ValueError) as error: + source = _delete_snapshot_error(target_path, error) + issues.append(f"source inspection failed: {error}") + try: + if quarantine_root_fd is None or quarantine_root_stat is None: + raise ValueError("canonical quarantine root is unavailable") + if held_quarantine_root is None or not _held_delete_directory_matches( + held_quarantine_root + ): + raise ValueError("quarantine root path changed") + quarantine = _inspect_held_delete_root( + quarantine_root_fd, quarantine_root_stat, allocation.quarantine_name + ) + except (OSError, ValueError) as error: + quarantine = _delete_snapshot_error(allocation.quarantine_path, error) + issues.append(f"quarantine inspection failed: {error}") + + receipt: DeleteQuarantineReceipt | None = None + try: + if quarantine_root_fd is None or quarantine_root_stat is None: + raise ValueError("canonical quarantine root is unavailable") + if held_quarantine_root is None or not _held_delete_directory_matches( + held_quarantine_root + ): + raise ValueError("quarantine root path changed") + receipt_snapshot = _inspect_held_delete_root( + quarantine_root_fd, quarantine_root_stat, allocation.receipt_name + ) + except (OSError, ValueError) as error: + receipt_snapshot = _delete_snapshot_error(allocation.receipt_path, error) + issues.append(f"receipt inspection failed: {error}") + if receipt_snapshot.exists: + try: + receipt = _read_delete_receipt( + Path(program_root), + allocation.receipt_path, + root_fd=quarantine_root_fd, + root_stat=quarantine_root_stat, + receipt_name=allocation.receipt_name, + ) + except (OSError, ValueError) as error: + issues.append(f"receipt is invalid: {error}") + + source_exact = ( + source.exists + and source.sha256 == baseline.sha256 + and source.mode == baseline.mode + and source.device == baseline.device + and source.inode == baseline.inode + and source.link_count == 1 + ) + quarantine_exact = ( + quarantine.exists + and quarantine.sha256 == baseline.sha256 + and quarantine.mode == baseline.mode + and quarantine.device == baseline.device + and quarantine.inode == baseline.inode + and quarantine.link_count == 1 + ) + expected_receipt = DeleteQuarantineReceipt( + schema_version=DELETE_QUARANTINE_RECEIPT_SCHEMA_V1, + program_id=metadata[0], + program_revision=metadata[1], + increment_id=metadata[2], + path=target_path, + baseline_sha256=baseline.sha256 or "", + device=baseline.device or 0, + inode=baseline.inode or 0, + quarantine_path=allocation.quarantine_path, + quarantine_sha256=baseline.sha256 or "", + final_state="absent", + ) + receipt_exact = receipt == expected_receipt + if source_exact and not quarantine.exists and not receipt_snapshot.exists and not issues: + disposition = "retry-ready" + elif ( + not source.exists + and quarantine_exact + and not receipt_snapshot.exists + and not issues + ): + disposition = "receipt-adoption-ready" + elif ( + not source.exists + and quarantine_exact + and receipt_snapshot.exists + and receipt_exact + and not issues + ): + disposition = "resume" + else: + disposition = "recovery-required" + try: + if ( + quarantine_root_fd is not None + and quarantine_root_stat is not None + and ( + held_quarantine_root is None + or not _held_delete_directory_matches(held_quarantine_root) + ) + ): + issues.append("quarantine root path changed") + if issues: + disposition = "recovery-required" + return DeleteQuarantineRecovery( + disposition=disposition, + source=source, + quarantine=quarantine, + receipt=receipt, + issues=tuple(sorted(set(issues))), + ) + finally: + if held_quarantine_root is not None: + _close_held_delete_directory(held_quarantine_root) + + +def _windows_mutex_name(path: Path) -> str: + normalized = os.path.normcase(os.path.abspath(path)) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return f"Local\\implementation-staged-plans-{digest}" + + +def _windows_api_error(operation: str) -> OSError: + error_code = _ctypes.get_last_error() + return OSError(error_code, f"{operation}: {_ctypes.FormatError(error_code)}") + + +def _acquire_advisory_lock( + path: Path, parent: _AtomicParentHandle +) -> object: + if _WINDOWS: if _kernel32 is None: raise OSError("Windows file locking is unavailable") handle = _kernel32.CreateMutexW(None, False, _windows_mutex_name(path)) @@ -3222,7 +5554,11 @@ def atomic_replace_json( def atomic_append_json_line( - path: Path, value: dict[str, object], expected_sha256: str + path: Path, + value: dict[str, object], + expected_sha256: str, + *, + preserve_field_order: bool = False, ) -> AtomicWriteReceipt: path = Path(path) _validate_atomic_target(path) @@ -3254,7 +5590,12 @@ def record_identifier(record: dict[str, object]) -> object: if identifier in identifiers: raise ValueError(f"duplicate record identifier: {identifier}") line = ( - json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=not preserve_field_order, + ) + "\n" ).encode("utf-8") return _atomic_replace_bytes(path, prior + line, expected_sha256) diff --git a/skills/implementing-staged-plans/scripts/validate_package.py b/skills/implementing-staged-plans/scripts/validate_package.py index 2026733..89b1fd0 100644 --- a/skills/implementing-staged-plans/scripts/validate_package.py +++ b/skills/implementing-staged-plans/scripts/validate_package.py @@ -17,7 +17,7 @@ CLAUDE_MANIFEST = Path(".claude-plugin/plugin.json") CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json") PACKAGE_CONTENT_ROOT = Path("skills/implementing-staged-plans") -PACKAGE_VERSION = "0.1.2" +PACKAGE_VERSION = "0.1.3" SKILL_MARKDOWN = Path("skills/implementing-staged-plans/SKILL.md") OPENAI_METADATA = Path("skills/implementing-staged-plans/agents/openai.yaml") REQUIRED_AUTHORITY_ASSETS = ( diff --git a/tests/fixtures/continuity-closure/pipeflow-successor-allocations.json b/tests/fixtures/continuity-closure/pipeflow-successor-allocations.json new file mode 100644 index 0000000..ab93622 --- /dev/null +++ b/tests/fixtures/continuity-closure/pipeflow-successor-allocations.json @@ -0,0 +1,41 @@ +{ + "increments": [ + {"increment_id": "PRESERVED-BEHAVIOR", "depends_on": []}, + {"increment_id": "ESM-TOOLCHAIN", "depends_on": ["PRESERVED-BEHAVIOR"]}, + {"increment_id": "OPAQUE-FLOW-CORE", "depends_on": ["ESM-TOOLCHAIN"]}, + {"increment_id": "LAWFUL-COMPOSITION", "depends_on": ["OPAQUE-FLOW-CORE"]}, + {"increment_id": "TYPED-CONTROL", "depends_on": ["LAWFUL-COMPOSITION"]}, + {"increment_id": "SERVICE-REQUIREMENTS", "depends_on": ["TYPED-CONTROL"]}, + {"increment_id": "STRUCTURED-BOUNDARIES", "depends_on": ["SERVICE-REQUIREMENTS"]}, + {"increment_id": "LEGACY-CUTOVER", "depends_on": ["STRUCTURED-BOUNDARIES"]}, + {"increment_id": "RELEASE-ARTIFACT", "depends_on": ["LEGACY-CUTOVER"]} + ], + "allocation_groups": [ + {"positions": [1], "count": 9}, + {"positions": [1,2,3,4,5,6,7,8,9], "count": 33}, + {"positions": [1,8,9], "count": 2}, + {"positions": [2], "count": 16}, + {"positions": [2,3], "count": 2}, + {"positions": [2,3,4,5,6,7], "count": 1}, + {"positions": [2,3,4,5,6,7,8], "count": 1}, + {"positions": [2,3,4,5,6,7,8,9], "count": 1}, + {"positions": [2,7,9], "count": 1}, + {"positions": [2,8,9], "count": 1}, + {"positions": [3], "count": 33}, + {"positions": [3,4,5,6,7,8,9], "count": 7}, + {"positions": [3,5,7], "count": 4}, + {"positions": [4], "count": 79}, + {"positions": [5], "count": 54}, + {"positions": [5,7], "count": 17}, + {"positions": [6], "count": 32}, + {"positions": [7], "count": 57}, + {"positions": [8], "count": 108}, + {"positions": [8,9], "count": 31}, + {"positions": [9], "count": 81} + ], + "named_sparse_requirements": [ + "LEGACY-INVENTORY-PRESERVES-NAMESPACES", + "LEGACY-INVENTORY-IS-SINGLE-AUTHORITY" + ], + "expected_old_ambiguous_boundaries": [1,2,3,5,7] +} diff --git a/tests/program_bootstrap_support.py b/tests/program_bootstrap_support.py index 20fdcb8..d8b843d 100644 --- a/tests/program_bootstrap_support.py +++ b/tests/program_bootstrap_support.py @@ -1,5 +1,6 @@ import hashlib import argparse +import copy from dataclasses import asdict import json import os @@ -26,6 +27,26 @@ COMPATIBILITY_WORKSPACE_SEED = COMPATIBILITY_FIXTURE / "seed-workspace/catalog.txt" +def successor_allocation_fixture() -> tuple[list[dict[str, object]], list[dict[str, object]]]: + fixture = json.loads((REPOSITORY_ROOT / "tests/fixtures/continuity-closure/pipeflow-successor-allocations.json").read_text()) + increments = fixture["increments"] + requirements = [] + for group_index, group in enumerate(fixture["allocation_groups"], start=1): + for row_index in range(1, group["count"] + 1): + requirement_id = ( + fixture["named_sparse_requirements"][row_index - 1] + if group["positions"] == [1, 8, 9] + else f"ALLOCATION-{group_index}-{row_index}" + ) + requirements.append({ + "id": requirement_id, + "assigned_increments": [increments[position - 1]["increment_id"] for position in group["positions"]], + }) + for increment in increments: + increment["requirement_ids"] = [item["id"] for item in requirements if increment["increment_id"] in item["assigned_increments"]] + return increments, requirements + + def canonical_json(value: object) -> bytes: return ( json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" @@ -477,6 +498,43 @@ def configure_successor_chain(self, increment_ids: tuple[str, ...]) -> None: ] = semantic_sha256 self.write_json("state/status.json", status) + def configure_portable_successors(self) -> tuple[str, ...]: + """Build all 570 sparse allocations with an independent generic schedule.""" + increments, allocations = successor_allocation_fixture() + schedule = ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-CATALOG", "ARCHIVE-SCAN", "ARCHIVE-RESTORE", "ARCHIVE-MIGRATE", "ARCHIVE-PACK", "ARCHIVE-EXPORT", "ARCHIVE-RELEASE") + names = dict(zip((item["increment_id"] for item in increments), schedule, strict=True)) + traceability = self.load_json("program/traceability.json") + template = traceability["atomic_requirements"][0] + requirements = [] + for allocation in allocations: + requirement = copy.deepcopy(template) + requirement.update(allocation) + requirement["assigned_increments"] = [names[item] for item in allocation["assigned_increments"]] + requirements.append(requirement) + traceability["atomic_requirements"] = requirements + for unit in traceability["source_units"]: + unit["requirement_ids"] = [item["id"] for item in requirements if unit["id"] in item["source_unit_ids"]] + semantic_fields = ("id", "group_id", "source_unit_ids", "normalized_requirement", "acceptance_criteria", "assigned_parts", "assigned_tasks", "assigned_increments") + semantic_sha256 = canonical_compact_sha256([{field: item[field] for field in semantic_fields} for item in requirements]) + traceability["coverage_assertion"]["semantic_requirements_sha256"] = semantic_sha256 + self.write_json("program/traceability.json", traceability) + manifest = self.load_json("manifest.json") + manifest["program_binding"]["traceability_sha256"] = hashlib.sha256(canonical_json(traceability)).hexdigest() + self.write_json("manifest.json", manifest) + status = self.load_json("state/status.json") + status["program_binding"]["semantic_requirements_sha256"] = semantic_sha256 + self.write_json("state/status.json", status) + for increment in increments: + increment["increment_id"] = names[increment["increment_id"]] + increment["depends_on"] = [names[item] for item in increment["depends_on"]] + increment.update( + acceptance_meaning=["Verify the allocated archive outcome."], + intended_outcome="Verify the allocated archive outcome.", + expected_checks=["python3 -m unittest tests.test_archive_output"], + ) + self.configure_setup_v3(increments=increments) + return schedule + def configure_approval_mode(self, approval_mode: str) -> None: """Select one supported Plan A approval mode before publication.""" if approval_mode not in { @@ -496,6 +554,7 @@ def configure_setup_v3( self, *, source_gate_definitions: Sequence[dict[str, object]] = (), + increments: Sequence[dict[str, object]] | None = None, ) -> None: """Upgrade the candidate fixture to the closed setup/activation family.""" manifest = self.load_json("manifest.json") @@ -523,6 +582,7 @@ def configure_setup_v3( for increment_id in atomic_requirement["assigned_increments"]: if increment_id not in increment_ids: increment_ids.append(increment_id) + explicit_increments = copy.deepcopy(increments) increments = [] for increment_index, increment_id in enumerate(increment_ids): assigned = [ @@ -552,6 +612,9 @@ def configure_setup_v3( ], } ) + if explicit_increments is not None: + increments = explicit_increments + increment_ids = [item["increment_id"] for item in increments] setup_semantics = { "schema_version": "implementation-program-setup-semantics/v1", "program": { @@ -715,6 +778,65 @@ def configure_setup_v3( status["schema_version"] = "implementation-program-status/v3" self.write_json("state/status.json", status) + def configure_delete_setup_v2( + self, + *, + source_gate_definitions: Sequence[dict[str, object]] = (), + path: str = "catalog.txt", + additional_delete_paths: Sequence[str] = (), + increment_id: str = "ARCHIVE-INDEX", + collision: str = "existing", + content_disposition: str = "obsolete", + rationale: str = "The accepted program no longer needs the archive catalog.", + increments: Sequence[dict[str, object]] | None = None, + ) -> None: + """Configure the manifest-v3 fixture with the Delete-capable setup pair.""" + self.configure_setup_v3(source_gate_definitions=source_gate_definitions, increments=increments) + manifest = self.load_json("manifest.json") + setup_semantics = manifest["setup_semantics"] + setup_semantics["schema_version"] = ( + "implementation-program-setup-semantics/v2" + ) + envelope = setup_semantics["operation_envelope"] + envelope["schema_version"] = "implementation-operation-envelope/v2" + envelope["supported_operations"] = [ + "Create", + "Modify", + "Delete", + "Preserve", + ] + delete_paths = (path, *additional_delete_paths) + envelope["allocations"] = [ + allocation + for allocation in envelope["allocations"] + if allocation["path"] not in delete_paths or allocation["operation"] == "Create" + ] + for delete_path in delete_paths: + envelope["allocations"].append( + { + "kind": "exact-path", + "path": delete_path, + "operation": "Delete", + "increment_ids": [increment_id], + "inclusions": ["accepted obsolete archive content"], + "exclusions": [], + "ownership": "program", + "protected": False, + "user_work": False, + "file_kind": "regular-file", + "link_kind": "none", + "mode": "100644", + "collision": collision, + "accepted_state": "absent", + "content_disposition": content_disposition, + "rationale": rationale, + } + ) + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + setup_semantics + ) + self.write_json("manifest.json", manifest) + def _configure_candidate(self) -> None: manifest = self.load_json("manifest.json") source_bytes = self.source_plan.read_bytes() @@ -903,9 +1025,32 @@ def _exact_plan_bytes(program_root: Path, observation: object) -> bytes: required = required_future_lifecycle_writes( program_root, Path(observation.path), status["current_increment_id"] ) - inherited = set( - status.get("inherited_workspace_binding", {}).get("inherited_paths", []) - ) + inherited_binding = status.get("inherited_workspace_binding", {}) + if ( + isinstance(inherited_binding, dict) + and inherited_binding.get("schema_version") + == "implementation-inherited-workspace/v2" + ): + inherited_states = { + item["path"]: bool(item["exists"]) + for item in inherited_binding.get("inherited_path_states", []) + if isinstance(item, dict) + and isinstance(item.get("path"), str) + and isinstance(item.get("exists"), bool) + } + inherited = set(inherited_states) + inherited_present = { + path for path, exists in inherited_states.items() if exists + } + inherited_absent = inherited - inherited_present + else: + inherited = set( + inherited_binding.get("inherited_paths", []) + if isinstance(inherited_binding, dict) + else [] + ) + inherited_present = set(inherited) + inherited_absent = set() increment_id = str(status["current_increment_id"]) review_root = ( "reviews" @@ -920,22 +1065,69 @@ def _exact_plan_bytes(program_root: Path, observation: object) -> bytes: "archive-output.txt", *raw_review_paths.values(), } + setup_v2 = ( + manifest.get("setup_semantics", {}).get("schema_version") + == "implementation-program-setup-semantics/v2" + and manifest.get("setup_semantics", {}).get("operation_envelope", {}).get("schema_version") + == "implementation-operation-envelope/v2" + ) + delete = sorted( + allocation["path"] + for allocation in manifest.get("setup_semantics", {}) + .get("operation_envelope", {}) + .get("allocations", []) + if allocation.get("operation") == "Delete" + and increment_id in allocation.get("increment_ids", []) + and allocation.get("kind") == "exact-path" + ) if setup_v2 else [] + current_allocations = [ + allocation + for allocation in manifest.get("setup_semantics", {}) + .get("operation_envelope", {}) + .get("allocations", []) + if isinstance(allocation, dict) + and allocation.get("kind") == "exact-path" + and increment_id in allocation.get("increment_ids", []) + ] + explicit_create = { + allocation["path"] + for allocation in current_allocations + if allocation.get("operation") == "Create" + and ( + allocation["path"] in inherited_absent + or allocation["path"] not in inherited + ) + } + explicit_modify = { + allocation["path"] + for allocation in current_allocations + if allocation.get("operation") == "Modify" + and allocation["path"] in inherited_present + } + product_paths -= set(delete) create = sorted( { *(product_paths - inherited), + *explicit_create, *(item.path for item in required if item.disposition == "Create"), } ) modify = sorted( { - *inherited, + *( + path + for path in inherited_present + if not setup_v2 + or path in product_paths + or path in explicit_modify + ), *(item.path for item in required if item.disposition == "Modify"), } ) preserve = sorted( { - "catalog.txt", *(item.path for item in required if item.disposition == "Preserve"), + *( [] if setup_v2 else ["catalog.txt"] ), } ) source = status["source_binding"] @@ -961,11 +1153,11 @@ def _exact_plan_bytes(program_root: Path, observation: object) -> bytes: "## File map", "", ] - for disposition, paths in ( - ("Create", create), - ("Modify", modify), - ("Preserve", preserve), - ): + plan_operations = [("Create", create), ("Modify", modify)] + if setup_v2: + plan_operations.append(("Delete", delete)) + plan_operations.append(("Preserve", preserve)) + for disposition, paths in plan_operations: lines.extend( [ f"### {disposition}", diff --git a/tests/test_continuity_closure.py b/tests/test_continuity_closure.py index 921c130..4f5b29c 100644 --- a/tests/test_continuity_closure.py +++ b/tests/test_continuity_closure.py @@ -320,9 +320,13 @@ def test_semantic_successor_selection_requires_one_satisfied_candidate(self) -> ), ("ARCHIVE-VERIFY", ""), ) + self.assertEqual( + CONTINUITY.select_unique_satisfied_successor([direct], current, set()), + (None, "current increment must be accepted"), + ) self.assertEqual( CONTINUITY.select_unique_satisfied_successor([], current, {current}), - (None, "no allocated successor"), + (None, "atomic requirements must be non-empty"), ) self.assertEqual( CONTINUITY.select_unique_satisfied_successor( diff --git a/tests/test_delete_operation_lifecycle.py b/tests/test_delete_operation_lifecycle.py new file mode 100644 index 0000000..7e66876 --- /dev/null +++ b/tests/test_delete_operation_lifecycle.py @@ -0,0 +1,1243 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path +from unittest import mock + +from tests.program_bootstrap_support import ( + BootstrapFixture, + _exact_plan_bytes, + repository_snapshot, + run_git, + run_program_discovery, + write_raw_review_reports, +) +from tests.script_module_support import load_script_module +from tests.test_diff_disposition import DIFF +from tests.test_program_activation import ACTIVATION +from tests.test_program_continuation import CONTINUATION +from tests.test_program_review import REVIEW +from tests.test_program_rollover import ROLLOVER +from tests.test_program_setup import BOOTSTRAP, SETUP + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = REPOSITORY_ROOT / "skills/implementing-staged-plans/scripts" +AUTHORITY = load_script_module( + "delete_lifecycle_state_authority", SCRIPT_ROOT / "state_authority.py" +) + +SETUP_V2_ROLLOVER_ACTION_FIELDS = ( + "schema_version", "authorization_id", "decision", "actions", "scope", + "constraints", "excluded", "program_id", "program_revision", + "source_id", "source_sha256", "program_sha256", + "semantic_requirements_sha256", "current_increment_id", + "successor_increment_id", "continuation_domain", + "continuation_checkpoint_id", "accepted_status_sha256", + "accepted_status_sequence", "product_result_schema_version", + "product_result_sha256", "workspace", "submitted_prompt_sha256", + "setup_activation_decision_id", "setup_activation_decision_sha256", + "increment_grant_id", "increment_grant_sha256", + "source_gate_satisfaction", +) + + +def _fresh_observation(fixture: BootstrapFixture): + return ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + + +def _authorized_delete_program_with_successor( + *, recreate_in_successor=False, third_successor=False, sparse=False +): + fixture = BootstrapFixture() + legacy_bytes = b"legacy implementation\n" + (fixture.repository / "legacy.ts").write_bytes(legacy_bytes) + run_git(fixture.repository, "add", "legacy.ts") + run_git(fixture.repository, "commit", "-m", "seed legacy implementation") + fixture.head = run_git(fixture.repository, "rev-parse", "HEAD") + workspace = fixture.load_json("state/workspace.json") + workspace["implementation_workspace"]["base_commit"] = fixture.head + workspace["implementation_workspace"]["head_commit_at_selection"] = fixture.head + fixture.write_json("state/workspace.json", workspace) + increments = None + if sparse: + fixture.configure_portable_successors() + increments = fixture.load_json("manifest.json")["setup_semantics"]["increments"] + elif third_successor: + fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-REPORT") + ) + else: + fixture.configure_successors({"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)}) + fixture.configure_delete_setup_v2(path="legacy.ts", increments=increments) + if recreate_in_successor: + manifest = fixture.load_json("manifest.json") + semantics = manifest["setup_semantics"] + semantics["operation_envelope"]["allocations"].append( + { + "kind": "exact-path", + "path": "legacy.ts", + "operation": "Create", + "increment_ids": ["ARCHIVE-VERIFY"], + "inclusions": ["explicitly recreated successor implementation"], + "exclusions": [], + "ownership": "program", + "protected": False, + "user_work": False, + "file_kind": "absent", + "link_kind": "none", + "mode": None, + "collision": "none", + } + ) + manifest["setup_semantics_sha256"] = hashlib.sha256( + json.dumps( + semantics, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + fixture.write_json("manifest.json", manifest) + BOOTSTRAP.publish_program_proposal( + fixture.repository, + fixture.source_plan, + fixture.candidate, + fixture.source_sha256, + ) + observation = _fresh_observation(fixture) + activation = ACTIVATION.activate_program( + fixture.program_root, + SETUP.adapt_setup_decision( + fixture.program_root, + "Yes", + role="user", + provenance="direct-user-message", + ), + observation, + ) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, + activation.handoff, + role="user", + provenance="direct-user-message", + ) + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = _fresh_observation(fixture) + prepared = ACTIVATION.prepare_exact_plan( + fixture.program_root, + _exact_plan_bytes(fixture.program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + fixture.program_root, prepared.plan_prompt, observation + ) + return fixture, legacy_bytes + + +def _reviewed_delete_program( + *, recreate_in_successor=False, third_successor=False, sparse=False +): + fixture, legacy_bytes = _authorized_delete_program_with_successor( + recreate_in_successor=recreate_in_successor, + third_successor=third_successor, + sparse=sparse, + ) + program_root = fixture.program_root + baseline = json.loads( + ( + program_root / "increments/ARCHIVE-INDEX/execution-baseline.json" + ).read_text(encoding="utf-8") + ) + allocation = baseline["delete_quarantine_bindings"][0] + with mock.patch.object( + Path, + "unlink", + side_effect=AssertionError("Delete lifecycle must not unlink"), + ): + ACTIVATION.advance_execution_state( + program_root, "implementing", _fresh_observation(fixture) + ) + (fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(fixture.repository) + observation = _fresh_observation(fixture) + ACTIVATION.advance_execution_state(program_root, "reviewing", observation) + REVIEW.persist_review_preparation(program_root, observation) + return fixture, legacy_bytes, allocation + + +def _complete_delete_rollover( + *, recreate_in_successor=False, third_successor=False +): + fixture, legacy_bytes, allocation = _reviewed_delete_program( + recreate_in_successor=recreate_in_successor, + third_successor=third_successor, + ) + prompt = CONTINUATION.render_accept_continue_prompt(fixture.program_root) + receipt = DIFF.persist_diff_disposition( + fixture.program_root, prompt, _fresh_observation(fixture) + ) + return fixture, legacy_bytes, allocation, receipt + + +def _product_result(states, receipts): + canonical = { + "ordered_path_states": states, + "delete_quarantine_bindings": receipts, + } + return { + "schema_version": "implementation-product-path-states/v2", + "sha256": hashlib.sha256( + json.dumps( + canonical, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest(), + **canonical, + } + + +class DeleteOperationLifecycleTests(unittest.TestCase): + def test_sparse_continuation_routes_preserve_v2_tombstones_and_receipts(self): + for domain in ("immediate", "accepted-state"): + with self.subTest(domain=domain): + fixture, legacy_bytes, allocation = _reviewed_delete_program(sparse=True) + self.addCleanup(fixture.close) + root = fixture.program_root + if domain == "immediate": + prompt = CONTINUATION.render_accept_continue_prompt(root) + receipt = DIFF.persist_diff_disposition(root, prompt, _fresh_observation(fixture)) + else: + acceptance = DIFF.build_diff_acceptance_candidate(root, _fresh_observation(fixture)) + DIFF.persist_accept_stop(root, "Accept and stop.\n\n" + acceptance.prompt, _fresh_observation(fixture)) + prompt = CONTINUATION.render_accepted_state_continuation_prompt(root) + receipt = ROLLOVER.persist_increment_rollover(root, prompt, _fresh_observation(fixture)) + self.assertEqual(receipt.successor_increment_id, "ARCHIVE-VERIFY") + status = json.loads((root / "state/status.json").read_text()) + inherited = status["inherited_workspace_binding"] + self.assertEqual(inherited["schema_version"], "implementation-inherited-workspace/v2") + states = {item["path"]: item for item in inherited["inherited_path_states"]} + self.assertFalse(states["legacy.ts"]["exists"]) + self.assertTrue(states["archive-output.txt"]["exists"]) + self.assertEqual((root / allocation["entry_path"]).read_bytes(), legacy_bytes) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + self.assertTrue(any(item["receipt_path"] == allocation["receipt_path"] for item in inherited["delete_quarantine_bindings"])) + prepared = ACTIVATION.prepare_exact_plan(root, _exact_plan_bytes(root, _fresh_observation(fixture)), _fresh_observation(fixture)) + ACTIVATION.materialize_exact_plan(root, prepared.plan_prompt, _fresh_observation(fixture)) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + self.assertEqual((root / allocation["entry_path"]).read_bytes(), legacy_bytes) + + def test_unmapped_names_cannot_bypass_delete_authority_before_mutation(self): + for filename in ("unmapped.txt", "unmapped-Delete.txt", "unmapped-quarantine.txt"): + with self.subTest(filename=filename): + fixture, legacy_bytes = _authorized_delete_program_with_successor() + try: + root = fixture.program_root + (fixture.repository / filename).write_text("unmapped user work\n", encoding="utf-8") + baseline = json.loads((root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text()) + allocation = baseline["delete_quarantine_bindings"][0] + before = repository_snapshot(fixture.repository) + status_before = (root / "state/status.json").read_bytes() + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + wraps=ACTIVATION.quarantine_bound_regular_file, + ) as move, mock.patch.object( + ACTIVATION, "adopt_delete_quarantine_receipt", + wraps=ACTIVATION.adopt_delete_quarantine_receipt, + ) as adopt: + with self.assertRaisesRegex(ValueError, "unmapped dirty paths"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + move.assert_not_called() + adopt.assert_not_called() + self.assertEqual(repository_snapshot(fixture.repository), before) + self.assertEqual((root / "state/status.json").read_bytes(), status_before) + self.assertEqual((fixture.repository / "legacy.ts").read_bytes(), legacy_bytes) + self.assertFalse((root / allocation["entry_path"]).exists()) + self.assertFalse((root / allocation["receipt_path"]).exists()) + finally: + fixture.close() + + def test_exact_delete_prefix_recovery_still_blocks_unmapped_work(self): + for boundary in ("receipt", "status"): + with self.subTest(boundary=boundary): + fixture, legacy_bytes = _authorized_delete_program_with_successor() + try: + root = fixture.program_root + baseline = json.loads((root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text()) + allocation = baseline["delete_quarantine_bindings"][0] + entry_path = root / allocation["entry_path"] + receipt_path = root / allocation["receipt_path"] + status_before = (root / "state/status.json").read_bytes() + fault = mock.Mock(side_effect=RuntimeError("interrupted Delete prefix")) + patcher = ( + mock.patch.dict( + ACTIVATION.quarantine_bound_regular_file.__globals__, + {"_write_delete_receipt": fault}, + ) if boundary == "receipt" else + mock.patch.object(ACTIVATION, "atomic_replace_json", fault) + ) + with patcher: + with self.assertRaisesRegex(RuntimeError, "interrupted Delete prefix"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + self.assertEqual((root / "state/status.json").read_bytes(), status_before) + self.assertEqual(entry_path.read_bytes(), legacy_bytes) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + self.assertEqual(receipt_path.exists(), boundary == "status") + dirty_path = fixture.repository / "unmapped-quarantine.txt" + dirty_path.write_text("unmapped user work\n", encoding="utf-8") + before = repository_snapshot(fixture.repository) + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + wraps=ACTIVATION.quarantine_bound_regular_file, + ) as move, mock.patch.object( + ACTIVATION, "adopt_delete_quarantine_receipt", + wraps=ACTIVATION.adopt_delete_quarantine_receipt, + ) as adopt: + with self.assertRaisesRegex(ValueError, "unmapped dirty paths"): + ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + move.assert_not_called() + adopt.assert_not_called() + self.assertEqual(repository_snapshot(fixture.repository), before) + # Remove only the test-created unmapped file in this disposable fixture. + dirty_path.unlink() + retained_entry = entry_path.read_bytes() + with mock.patch.object( + ACTIVATION, "quarantine_bound_regular_file", + side_effect=AssertionError("recovery must not move again"), + ), mock.patch.object( + Path, "unlink", side_effect=AssertionError("recovery must retain bytes"), + ): + receipt = ACTIVATION.advance_execution_state(root, "implementing", _fresh_observation(fixture)) + self.assertEqual(receipt.increment_state, "implementing") + self.assertEqual(entry_path.read_bytes(), retained_entry) + self.assertTrue(receipt_path.is_file()) + normalized = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + self.assertEqual(ACTIVATION.validate_state_authority(root, normalized), []) + discovery = run_program_discovery(fixture.repository) + self.assertEqual(discovery["disposition"], "resume") + self.assertFalse(discovery["stop_required"]) + finally: + fixture.close() + + def test_completed_rollover_binds_review_hashes_to_approval_and_disposition(self): + fixture, legacy_bytes, allocation, _receipt = _complete_delete_rollover() + try: + root = fixture.program_root + status_path = root / "state/status.json" + rows_path = root / "state/rollovers.jsonl" + approvals_path = root / "state/approvals.jsonl" + original_status = json.loads(status_path.read_text()) + original_rows = [json.loads(line) for line in rows_path.read_text().splitlines()] + original_row = original_rows[-1] + evidence_path = root / original_row["review_evidence_binding"]["path"] + packet_path = root / original_row["review_packet_binding"]["path"] + originals = { + path: path.read_bytes() + for path in (status_path, rows_path, approvals_path, evidence_path, packet_path) + } + cases = ( + "replace-bundle", + "replace-bundle-and-disposition", + "disposition-evidence", + "disposition-packet", + "approval-evidence", + "approval-packet", + ) + for case in cases: + with self.subTest(case=case): + try: + status = copy.deepcopy(original_status) + rows = copy.deepcopy(original_rows) + row = rows[-1] + accepted = row["accepted_diff_binding"] + disposition = accepted["diff_disposition_binding"] + if case.startswith("replace-bundle"): + import review_coordination as coordination + + evidence = json.loads(originals[evidence_path]) + evidence["review_packet"]["changes_and_rationale"] = [ + "Replacement created after the retained approval." + ] + packet = coordination.ReviewPacket( + **coordination._tuple_fields( + evidence["review_packet"], coordination.PACKET_FIELDS + ) + ) + packet_text = coordination.render_review_packet(packet) + self.assertEqual( + coordination.validate_review_bundle(evidence, packet_text), [] + ) + evidence_path.write_bytes(ACTIVATION._canonical_json_bytes(evidence)) + packet_path.write_text(packet_text, encoding="utf-8") + for stem, path in (("review_evidence", evidence_path), + ("review_packet", packet_path)): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + row[stem + "_binding"]["sha256"] = digest + accepted[stem + "_binding"]["sha256"] = digest + if case == "replace-bundle-and-disposition": + disposition[stem + "_sha256"] = digest + self.assertEqual(approvals_path.read_bytes(), originals[approvals_path]) + elif case.startswith("disposition-"): + stem = "review_evidence" if case.endswith("evidence") else "review_packet" + disposition[stem + "_sha256"] = "0" * 64 + else: + approvals = [json.loads(line) for line in originals[approvals_path].splitlines()] + approval = next( + item for item in approvals + if item.get("event_id") == accepted["diff_approval_binding"]["event_id"] + ) + stem = "review_evidence" if case.endswith("evidence") else "review_packet" + approval[stem + "_sha256"] = "0" * 64 + + def approval_line(value): + return (json.dumps(value, ensure_ascii=False, + separators=(",", ":"), sort_keys=False) + + "\n").encode("utf-8") + + approvals_path.write_bytes(b"".join(approval_line(item) for item in approvals)) + accepted["diff_approval_binding"]["sha256"] = hashlib.sha256( + approval_line(approval) + ).hexdigest() + rows_path.write_bytes(b"".join(ACTIVATION._canonical_json_line(item) for item in rows)) + status["rollover_binding"]["rollover_sha256"] = hashlib.sha256( + ACTIVATION._canonical_json_line(row) + ).hexdigest() + status_path.write_bytes(ACTIVATION._canonical_json_bytes(status)) + observation = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, "rollover review .* approval binding mismatch"): + ROLLOVER.validated_inherited_paths(root, status, observation) + self.assertTrue(ACTIVATION.validate_state_authority(root, observation)) + discovery = run_program_discovery(fixture.repository) + self.assertTrue(discovery["stop_required"]) + self.assertNotEqual(discovery["disposition"], "resume") + self.assertEqual(repository_snapshot(fixture.repository), before) + self.assertEqual((root / allocation["entry_path"]).read_bytes(), legacy_bytes) + self.assertFalse((fixture.repository / "legacy.ts").exists()) + finally: + for path, content in originals.items(): + path.write_bytes(content) + observation = ACTIVATION._without_owned_program_paths(root, _fresh_observation(fixture)) + self.assertEqual(ACTIVATION.validate_state_authority(root, observation), []) + self.assertEqual(run_program_discovery(fixture.repository)["disposition"], "resume") + finally: + fixture.close() + + def test_production_delete_accept_continue_preserves_tombstone_and_quarantine( + self, + ) -> None: + fixture, legacy_bytes, allocation, receipt = _complete_delete_rollover() + try: + program_root = fixture.program_root + self.assertEqual(receipt.successor_increment_id, "ARCHIVE-VERIFY") + self.assertFalse((fixture.repository / "legacy.ts").exists()) + quarantine_path = program_root / allocation["entry_path"] + self.assertEqual(quarantine_path.read_bytes(), legacy_bytes) + + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertEqual( + status["inherited_workspace_binding"]["schema_version"], + "implementation-inherited-workspace/v2", + ) + inherited_states = status["inherited_workspace_binding"][ + "inherited_path_states" + ] + tombstone = next( + item for item in inherited_states if item["path"] == "legacy.ts" + ) + self.assertFalse(tombstone["exists"]) + self.assertTrue( + any( + item["path"] == "legacy.ts" + and item["receipt_path"] == allocation["receipt_path"] + for item in status["inherited_workspace_binding"][ + "delete_quarantine_bindings" + ] + ) + ) + + rollover = json.loads( + (program_root / "state/rollovers.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + self.assertEqual( + rollover["schema_version"], "implementation-increment-rollover/v2" + ) + self.assertEqual( + rollover["product_result_schema_version"], + "implementation-product-path-states/v2", + ) + self.assertEqual( + rollover["accepted_product_result"]["sha256"], + rollover["product_result_sha256"], + ) + self.assertEqual( + rollover["review_evidence_binding"], + rollover["accepted_diff_binding"]["review_evidence_binding"], + ) + self.assertEqual( + rollover["review_packet_binding"], + rollover["accepted_diff_binding"]["review_packet_binding"], + ) + self.assertNotIn("handoff_addendum_binding", rollover) + handoff = program_root / rollover["handoff_binding"]["path"] + self.assertEqual( + hashlib.sha256(handoff.read_bytes()).hexdigest(), + rollover["handoff_binding"]["sha256"], + ) + + actions = [ + json.loads(line) + for line in (program_root / "state/action-authorizations.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + action = next( + item for item in actions if item.get("actions") == ["rollover-increment"] + ) + self.assertEqual( + action["schema_version"], "implementation-action-authorization/v3" + ) + self.assertEqual(tuple(action), SETUP_V2_ROLLOVER_ACTION_FIELDS) + self.assertNotIn("accepted_product_delta_sha256", action) + + authority_observation = ACTIVATION._without_owned_program_paths( + program_root, _fresh_observation(fixture) + ) + self.assertEqual( + AUTHORITY.validate_state_authority( + program_root, authority_observation + ), + [], + ) + rollover_inspection = ROLLOVER.inspect_increment_rollover( + program_root, authority_observation + ) + self.assertEqual( + rollover_inspection.disposition, + "resume", + rollover_inspection, + ) + discovery = run_program_discovery(fixture.repository) + self.assertEqual(discovery["disposition"], "resume", discovery) + finally: + fixture.close() + + def test_cumulative_v2_states_replace_append_and_recreate_in_result_order( + self, + ) -> None: + absent = { + "path": "legacy.ts", + "exists": False, + "sha256": None, + "mode": None, + "device": None, + "inode": None, + "link_count": None, + } + present = { + "path": "archive-output.txt", + "exists": True, + "sha256": "1" * 64, + "mode": "0o100644", + "device": 1, + "inode": 2, + "link_count": 1, + } + prior_receipt = { + "path": "legacy.ts", + "receipt_path": "increments/ONE/delete.receipt.json", + "receipt_sha256": "2" * 64, + } + status = { + "inherited_workspace_binding": { + "schema_version": "implementation-inherited-workspace/v2", + "inherited_path_states": [absent, present], + "delete_quarantine_bindings": [prior_receipt], + } + } + replacement = {**present, "sha256": "3" * 64, "inode": 4} + appended = {**present, "path": "verification.txt", "sha256": "4" * 64} + merged = CONTINUATION._merge_inherited_workspace_v2( + status, + {"path": "/workspace"}, + _product_result([replacement, appended], []), + ) + self.assertEqual( + [item["path"] for item in merged["inherited_path_states"]], + ["legacy.ts", "archive-output.txt", "verification.txt"], + ) + self.assertEqual(merged["delete_quarantine_bindings"], [prior_receipt]) + + recreated = {**present, "path": "legacy.ts", "sha256": "5" * 64} + recreated_merge = CONTINUATION._merge_inherited_workspace_v2( + status, + {"path": "/workspace"}, + _product_result([recreated], []), + ) + self.assertEqual(recreated_merge["inherited_path_states"][0], recreated) + self.assertEqual(recreated_merge["delete_quarantine_bindings"], []) + + replacement_receipt = { + **prior_receipt, + "receipt_path": "increments/TWO/delete.receipt.json", + "receipt_sha256": "6" * 64, + } + repeated_delete = CONTINUATION._merge_inherited_workspace_v2( + status, + {"path": "/workspace"}, + _product_result([absent], [replacement_receipt]), + ) + self.assertEqual( + repeated_delete["delete_quarantine_bindings"], [replacement_receipt] + ) + + def test_later_continuation_carries_the_exact_v2_result_and_receipt(self) -> None: + fixture, legacy_bytes, allocation = _reviewed_delete_program() + try: + program_root = fixture.program_root + observation = _fresh_observation(fixture) + authority_observation = ACTIVATION._without_owned_program_paths( + program_root, observation + ) + self.assertEqual( + AUTHORITY.validate_state_authority( + program_root, authority_observation + ), + [], + ) + discovery = run_program_discovery(fixture.repository) + self.assertEqual( + discovery["disposition"], + "increment-acceptance-retry-ready", + discovery, + ) + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + stop_prompt = "Accept and stop.\n\n" + acceptance.prompt + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + + prompt = CONTINUATION.render_accepted_state_continuation_prompt( + program_root + ) + command = CONTINUATION.validate_submitted_continuation_prompt( + program_root, prompt + ) + self.assertEqual( + command.schema_version, + "implementation-accepted-state-continuation-binding/v2", + ) + self.assertEqual( + command.product_result_sha256, + command.accepted_product_result["sha256"], + ) + self.assertEqual( + command.accepted_product_result["delete_quarantine_bindings"], + command.inherited_workspace["delete_quarantine_bindings"], + ) + ROLLOVER.persist_increment_rollover( + program_root, prompt, _fresh_observation(fixture) + ) + rollover = json.loads( + (program_root / "state/rollovers.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + self.assertEqual(rollover["continuation_domain"], "accepted-state") + self.assertEqual( + (program_root / allocation["entry_path"]).read_bytes(), legacy_bytes + ) + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertEqual( + ROLLOVER.validated_inherited_paths( + program_root, + status, + ACTIVATION._without_owned_program_paths( + program_root, _fresh_observation(fixture) + ), + ), + tuple( + item["path"] + for item in status["inherited_workspace_binding"][ + "inherited_path_states" + ] + ), + ) + finally: + fixture.close() + + def test_completed_rollover_revalidates_copied_files_and_approval(self) -> None: + fixture, _legacy_bytes, allocation, _receipt = _complete_delete_rollover() + try: + program_root = fixture.program_root + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + rollover = json.loads( + (program_root / "state/rollovers.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + targets = ( + ("review evidence", rollover["review_evidence_binding"]["path"]), + ("review packet", rollover["review_packet_binding"]["path"]), + ("handoff", rollover["handoff_binding"]["path"]), + ("successor brief", rollover["successor_brief_binding"]["path"]), + ) + observation = ACTIVATION._without_owned_program_paths( + program_root, _fresh_observation(fixture) + ) + for label, relative in targets: + path = program_root / relative + original = path.read_bytes() + try: + path.write_bytes(original + b"tamper") + with self.assertRaisesRegex(ValueError, label): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + finally: + path.write_bytes(original) + + approvals_path = program_root / "state/approvals.jsonl" + approvals_bytes = approvals_path.read_bytes() + try: + approvals = [ + json.loads(line) for line in approvals_bytes.splitlines() + ] + approval = next( + item + for item in approvals + if item.get("event_id") + == rollover["accepted_diff_binding"]["diff_approval_binding"][ + "event_id" + ] + ) + approval["product_result_sha256"] = "0" * 64 + approvals_path.write_text( + "\n".join( + json.dumps(item, separators=(",", ":"), sort_keys=False) + for item in approvals + ) + + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "diff approval"): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + finally: + approvals_path.write_bytes(approvals_bytes) + + for relative in ( + allocation["entry_path"], + allocation["receipt_path"], + ): + path = program_root / relative + original = path.read_bytes() + try: + path.write_bytes(original + b"tamper") + with self.assertRaisesRegex(ValueError, "Delete quarantine"): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + finally: + path.write_bytes(original) + + (fixture.repository / "legacy.ts").write_text( + "unexpected reappearance\n", encoding="utf-8" + ) + with self.assertRaisesRegex( + ValueError, "inherited accepted product bytes changed" + ): + ROLLOVER.validated_inherited_paths( + program_root, status, _fresh_observation(fixture) + ) + finally: + fixture.close() + + def test_explicit_successor_create_recreates_tombstone_and_retains_quarantine( + self, + ) -> None: + fixture, legacy_bytes, allocation, _receipt = _complete_delete_rollover( + recreate_in_successor=True + ) + try: + program_root = fixture.program_root + observation = _fresh_observation(fixture) + prepared = ACTIVATION.prepare_exact_plan( + program_root, + _exact_plan_bytes(program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + program_root, prepared.plan_prompt, observation + ) + baseline = json.loads( + ( + program_root + / "increments/ARCHIVE-VERIFY/execution-baseline.json" + ).read_text(encoding="utf-8") + ) + self.assertIn("legacy.ts", baseline["file_map"]["create"]) + self.assertNotIn("legacy.ts", baseline["file_map"]["modify"]) + + recreated_bytes = b"successor recreation\n" + with mock.patch.object( + Path, + "unlink", + side_effect=AssertionError("recreation must not unlink quarantine"), + ): + ACTIVATION.advance_execution_state( + program_root, "implementing", _fresh_observation(fixture) + ) + (fixture.repository / "legacy.ts").write_bytes(recreated_bytes) + (fixture.repository / "archive-output.txt").write_text( + "successor archive output\n", encoding="utf-8" + ) + write_raw_review_reports( + fixture.repository, + increment_id="ARCHIVE-VERIFY", + relative_directory="reviews/ARCHIVE-VERIFY", + ) + ACTIVATION.advance_execution_state( + program_root, "reviewing", _fresh_observation(fixture) + ) + + self.assertEqual((fixture.repository / "legacy.ts").read_bytes(), recreated_bytes) + self.assertEqual( + (program_root / allocation["entry_path"]).read_bytes(), legacy_bytes + ) + self.assertTrue((program_root / allocation["receipt_path"]).is_file()) + finally: + fixture.close() + + def test_unrelated_successor_cannot_recreate_an_inherited_tombstone(self) -> None: + fixture, _legacy_bytes, _allocation, _receipt = _complete_delete_rollover() + try: + program_root = fixture.program_root + observation = _fresh_observation(fixture) + prepared = ACTIVATION.prepare_exact_plan( + program_root, + _exact_plan_bytes(program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + program_root, prepared.plan_prompt, observation + ) + baseline = json.loads( + ( + program_root + / "increments/ARCHIVE-VERIFY/execution-baseline.json" + ).read_text(encoding="utf-8") + ) + self.assertNotIn("legacy.ts", baseline["file_map"]["create"]) + self.assertNotIn("legacy.ts", baseline["file_map"]["modify"]) + self.assertNotIn("legacy.ts", baseline["file_map"]["delete"]) + + ACTIVATION.advance_execution_state( + program_root, "implementing", _fresh_observation(fixture) + ) + (fixture.repository / "legacy.ts").write_bytes( + b"undeclared successor recreation\n" + ) + (fixture.repository / "archive-output.txt").write_text( + "successor archive output\n", encoding="utf-8" + ) + write_raw_review_reports( + fixture.repository, + increment_id="ARCHIVE-VERIFY", + relative_directory="reviews/ARCHIVE-VERIFY", + ) + before = repository_snapshot(program_root) + + with self.assertRaisesRegex( + ValueError, "inherited accepted product bytes changed: legacy.ts" + ): + ACTIVATION.advance_execution_state( + program_root, "reviewing", _fresh_observation(fixture) + ) + + self.assertEqual(repository_snapshot(program_root), before) + self.assertEqual( + json.loads( + (program_root / "state/status.json").read_text( + encoding="utf-8" + ) + )["current_increment_state"], + "implementing", + ) + authority_observation = ACTIVATION._without_owned_program_paths( + program_root, _fresh_observation(fixture) + ) + self.assertIn( + "inherited accepted product bytes changed: legacy.ts", + AUTHORITY.validate_state_authority( + program_root, authority_observation + ), + ) + finally: + fixture.close() + + def test_second_rollover_replaces_tombstone_in_place_and_keeps_history( + self, + ) -> None: + fixture, legacy_bytes, allocation, _receipt = _complete_delete_rollover( + recreate_in_successor=True, + third_successor=True, + ) + try: + program_root = fixture.program_root + observation = _fresh_observation(fixture) + prepared = ACTIVATION.prepare_exact_plan( + program_root, + _exact_plan_bytes(program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + program_root, prepared.plan_prompt, observation + ) + recreated_bytes = b"successor recreation\n" + with mock.patch.object( + Path, + "unlink", + side_effect=AssertionError("rollover must retain quarantine"), + ): + ACTIVATION.advance_execution_state( + program_root, "implementing", _fresh_observation(fixture) + ) + (fixture.repository / "legacy.ts").write_bytes(recreated_bytes) + (fixture.repository / "archive-output.txt").write_text( + "successor archive output\n", encoding="utf-8" + ) + write_raw_review_reports( + fixture.repository, + increment_id="ARCHIVE-VERIFY", + relative_directory="reviews/ARCHIVE-VERIFY", + ) + observation = _fresh_observation(fixture) + ACTIVATION.advance_execution_state( + program_root, "reviewing", observation + ) + REVIEW.persist_review_preparation(program_root, observation) + discovery = run_program_discovery(fixture.repository) + self.assertEqual( + discovery["disposition"], + "increment-acceptance-retry-ready", + discovery, + ) + prompt = CONTINUATION.render_accept_continue_prompt(program_root) + DIFF.persist_diff_disposition( + program_root, prompt, _fresh_observation(fixture) + ) + + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertEqual(status["current_increment_id"], "ARCHIVE-REPORT") + states = status["inherited_workspace_binding"][ + "inherited_path_states" + ] + legacy_state = next(item for item in states if item["path"] == "legacy.ts") + self.assertTrue(legacy_state["exists"]) + self.assertEqual( + legacy_state["sha256"], hashlib.sha256(recreated_bytes).hexdigest() + ) + self.assertFalse( + any( + item["path"] == "legacy.ts" + for item in status["inherited_workspace_binding"][ + "delete_quarantine_bindings" + ] + ) + ) + rollovers = [ + json.loads(line) + for line in (program_root / "state/rollovers.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertEqual(len(rollovers), 2) + self.assertTrue( + any( + item["path"] == "legacy.ts" + for item in rollovers[0]["delete_quarantine_bindings"] + ) + ) + self.assertEqual( + (program_root / allocation["entry_path"]).read_bytes(), legacy_bytes + ) + observation = ACTIVATION._without_owned_program_paths( + program_root, _fresh_observation(fixture) + ) + self.assertEqual( + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ), + tuple(item["path"] for item in states), + ) + finally: + fixture.close() + + +class DeleteReceiptReinspectionTests(unittest.TestCase): + def setUp(self): + self.fixture, self.legacy_bytes = _authorized_delete_program_with_successor() + self.addCleanup(self.fixture.close) + self.root = self.fixture.program_root + ACTIVATION.advance_execution_state( + self.root, "implementing", _fresh_observation(self.fixture) + ) + (self.fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(self.fixture.repository) + self.baseline = ACTIVATION.execution_baseline_v2_from_value( + json.loads( + (self.root / "increments/ARCHIVE-INDEX/execution-baseline.json") + .read_text(encoding="utf-8") + ) + ) + self.binding = self.baseline.delete_quarantine_bindings[0] + self.receipt_path = self.root / self.binding["receipt_path"] + self.validate = ACTIVATION.validate_execution_workspace_v2 + + def assess(self): + inspection = ACTIVATION.inspect_repository(self.fixture.repository, self.fixture.head) + normalized = ACTIVATION._without_owned_program_paths(self.root, inspection.observation) + return self.validate( + self.root, + self.baseline, + replace(inspection, observation=normalized), + increment_state="reviewing", + ) + + @contextmanager + def receipt_race(self, kind): + namespace = self.validate.__globals__ + classify = namespace["classify_delete_quarantine_recovery"] + inspect = namespace["inspect_workspace_path"] + serialize = classify.__globals__["_delete_receipt_bytes"] + read_receipt = classify.__globals__["_read_delete_receipt"] + events = [] + replacement_digest = None + with tempfile.TemporaryDirectory() as directory: + saved = Path(directory) / "receipt.json" + displaced = Path(directory) / "receipt-replacement" + + def restore_receipt(): + if saved.exists(): + if self.receipt_path.exists() or self.receipt_path.is_symlink(): + self.receipt_path.rename(displaced) + saved.rename(self.receipt_path) + + def classify_then_race(*args, **kwargs): + nonlocal replacement_digest + recovery = classify(*args, **kwargs) + if not events and recovery.disposition == "resume": + self.assertIsNotNone(recovery.receipt) + original_bytes = self.receipt_path.read_bytes() + self.assertEqual(serialize(recovery.receipt), original_bytes) + events.append("classified-resume") + if kind == "missing-recovery-receipt": + return replace(recovery, receipt=None) + if kind in { + "missing", "symlink", "malformed-content", "wrong-metadata", + }: + self.receipt_path.rename(saved) + if kind == "symlink": + self.receipt_path.symlink_to(saved) + elif kind in {"malformed-content", "wrong-metadata"}: + replacement_receipt = replace( + recovery.receipt, increment_id="UNAUTHORIZED-INCREMENT" + ) + payload = ( + b"not an authorized Delete receipt\n" + if kind == "malformed-content" + else serialize(replacement_receipt) + ) + self.receipt_path.write_bytes(payload) + replacement_digest = hashlib.sha256(payload).hexdigest() + self.assertNotEqual( + replacement_digest, + hashlib.sha256(original_bytes).hexdigest(), + ) + if kind == "wrong-metadata": + self.assertNotEqual(replacement_receipt, recovery.receipt) + self.assertEqual( + read_receipt(self.root, self.binding["receipt_path"]), + replacement_receipt, + ) + return recovery + + def inspect_receipt(root, relative, **kwargs): + if ( + Path(root) != self.root + or relative != self.binding["receipt_path"] + or events != ["classified-resume"] + ): + return inspect(root, relative, **kwargs) + events.append("reinspected") + try: + if kind == "oserror": + raise OSError("injected receipt descriptor failure") + snapshot = inspect(root, relative, **kwargs) + if replacement_digest is not None: + self.assertTrue(snapshot.exists) + self.assertEqual(snapshot.sha256, replacement_digest) + if kind == "null-sha": + return replace(snapshot, sha256=None) + if kind == "invalid-sha": + return replace(snapshot, sha256="invalid") + return snapshot + finally: + restore_receipt() + + try: + with mock.patch.dict( + namespace, + { + "classify_delete_quarantine_recovery": classify_then_race, + "inspect_workspace_path": inspect_receipt, + }, + ): + yield events + finally: + restore_receipt() + + def test_receipt_reinspection_failure_returns_invalid_assessment(self): + valid = self.assess() + self.assertTrue(valid.valid, valid.issues) + expected_states = valid.product_states.ordered_path_states + self.assertEqual(len(expected_states), 5) + self.assertEqual(expected_states[-1].operation, "Delete") + for kind in ( + "missing", "symlink", "oserror", "null-sha", "invalid-sha", + "malformed-content", "wrong-metadata", "missing-recovery-receipt", + ): + with self.subTest(kind=kind): + before = repository_snapshot(self.fixture.repository) + before_receipt = self.receipt_path.read_bytes() + with self.receipt_race(kind) as events: + assessment = self.assess() + self.assertEqual(events, ["classified-resume", "reinspected"]) + self.assertFalse(assessment.valid) + self.assertTrue(any( + issue.startswith("Delete quarantine receipt ") + for issue in assessment.issues + ), assessment.issues) + self.assertEqual(assessment.product_states.delete_quarantine_bindings, ()) + self.assertEqual( + assessment.product_states.ordered_path_states, expected_states + ) + self.assertEqual(self.receipt_path.read_bytes(), before_receipt) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertFalse((self.fixture.repository / "legacy.ts").exists()) + self.assertEqual( + (self.root / self.binding["entry_path"]).read_bytes(), + self.legacy_bytes, + ) + + def test_malformed_receipt_content_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("malformed-content") + + def test_wrong_receipt_metadata_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("wrong-metadata") + + def test_missing_recovery_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("missing-recovery-receipt") + + def assert_reviewing_rejected(self, kind): + status_path = self.root / "state/status.json" + before_status = status_path.read_bytes() + before = repository_snapshot(self.fixture.repository) + before_receipt = self.receipt_path.read_bytes() + with self.receipt_race(kind) as events: + with mock.patch.object( + ACTIVATION, "atomic_replace_json", + wraps=ACTIVATION.atomic_replace_json, + ) as writer: + with self.assertRaises(ValueError) as raised: + ACTIVATION.advance_execution_state( + self.root, "reviewing", _fresh_observation(self.fixture) + ) + writer.assert_not_called() + self.assertIn("Delete quarantine receipt ", str(raised.exception)) + self.assertEqual(events, ["classified-resume", "reinspected"]) + self.assertEqual(status_path.read_bytes(), before_status) + self.assertEqual(self.receipt_path.read_bytes(), before_receipt) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertFalse((self.fixture.repository / "legacy.ts").exists()) + self.assertEqual( + (self.root / self.binding["entry_path"]).read_bytes(), + self.legacy_bytes, + ) + + def test_missing_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("missing") + + def test_symlink_receipt_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("symlink") + + def test_receipt_oserror_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("oserror") + + def test_null_receipt_sha_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("null-sha") + + def test_invalid_receipt_sha_blocks_reviewing_before_status_write(self): + self.assert_reviewing_rejected("invalid-sha") + + def test_valid_receipt_keeps_exact_digest_and_reviewing_transition(self): + receipt_bytes = self.receipt_path.read_bytes() + expected_digest = hashlib.sha256(receipt_bytes).hexdigest() + assessment = self.assess() + self.assertTrue(assessment.valid, assessment.issues) + expected_bindings = ({ + "path": "legacy.ts", + "receipt_path": self.binding["receipt_path"], + "receipt_sha256": expected_digest, + },) + self.assertEqual(assessment.product_states.delete_quarantine_bindings, expected_bindings) + prior = json.loads((self.root / "state/status.json").read_text()) + transition = ACTIVATION.advance_execution_state( + self.root, "reviewing", _fresh_observation(self.fixture) + ) + status = json.loads((self.root / "state/status.json").read_text()) + self.assertEqual(transition.increment_state, "reviewing") + self.assertEqual(status["state_sequence"], prior["state_sequence"] + 1) + result = status["execution_transition_binding"]["product_path_states"] + self.assertEqual(result["delete_quarantine_bindings"], list(expected_bindings)) + tombstone = next(row for row in result["ordered_path_states"] if row["path"] == "legacy.ts") + self.assertFalse(tombstone["exists"]) + self.assertIsNone(tombstone["sha256"]) + self.assertEqual(self.receipt_path.read_bytes(), receipt_bytes) + self.assertEqual((self.root / self.binding["entry_path"]).read_bytes(), self.legacy_bytes) + self.assertEqual( + ACTIVATION.validate_state_authority( + self.root, + ACTIVATION._without_owned_program_paths( + self.root, _fresh_observation(self.fixture) + ), + ), [] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diff_disposition.py b/tests/test_diff_disposition.py index f1a23fd..69f9314 100644 --- a/tests/test_diff_disposition.py +++ b/tests/test_diff_disposition.py @@ -1,4 +1,5 @@ import json +from contextlib import nullcontext import sys import unittest from pathlib import Path @@ -6,6 +7,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, canonical_json, repository_snapshot, run_program_discovery, @@ -15,6 +17,7 @@ from tests.test_program_activation import ACTIVATION, activated_program, exact_plan_bytes from tests.test_program_review import REVIEW as PROGRAM_REVIEW from tests.test_program_review import reviewing_program +from tests.test_program_setup import BOOTSTRAP, SETUP REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -58,10 +61,255 @@ def awaiting_diff_program(successors: dict[str, tuple[str, ...]] | None = None): return fixture, program_root, observation +def _setup_awaiting_diff_program(*, delete: bool): + fixture = BootstrapFixture() + if delete: + fixture.configure_delete_setup_v2(path="catalog.txt") + else: + fixture.configure_setup_v3() + BOOTSTRAP.publish_program_proposal( + fixture.repository, + fixture.source_plan, + fixture.candidate, + fixture.source_sha256, + ) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + activation = ACTIVATION.activate_program( + fixture.program_root, + SETUP.adapt_setup_decision( + fixture.program_root, "Yes", role="user", provenance="direct-user-message" + ), + observation, + ) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, activation.handoff, + role="user", provenance="direct-user-message", + ) + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + prepared = ACTIVATION.prepare_exact_plan( + fixture.program_root, + _exact_plan_bytes(fixture.program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + fixture.program_root, prepared.plan_prompt, observation + ) + ACTIVATION.advance_execution_state( + fixture.program_root, "implementing", observation + ) + (fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(fixture.repository) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + ACTIVATION.advance_execution_state( + fixture.program_root, "reviewing", observation + ) + PROGRAM_REVIEW.persist_review_preparation(fixture.program_root, observation) + return fixture, fixture.program_root, observation + + +def delete_setup_v2_awaiting_diff_program(): + return _setup_awaiting_diff_program(delete=True) + + +def setup_v1_awaiting_diff_program(): + return _setup_awaiting_diff_program(delete=False) + + class DiffDispositionTests(unittest.TestCase): def discover(self, fixture) -> dict[str, object]: return run_program_discovery(fixture.repository) + def test_v2_acceptance_rejects_legacy_disposition_and_approval_family(self) -> None: + fixture, program_root, observation = delete_setup_v2_awaiting_diff_program() + try: + candidate = DIFF.build_diff_acceptance_candidate(program_root, observation) + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{candidate.prompt}", + observation, + ) + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + disposition = status["diff_disposition_binding"] + disposition["schema_version"] = "implementation-diff-disposition-binding/v1" + disposition.pop("product_result_schema_version", None) + disposition.pop("product_result_sha256", None) + status_path.write_bytes(canonical_json(status)) + approvals_path = program_root / "state/approvals.jsonl" + records = [json.loads(line) for line in approvals_path.read_text().splitlines()] + approval = next(record for record in records if record.get("type") == "increment-diff-approval") + approval["schema_version"] = "implementation-approval/v2" + approval.pop("product_result_schema_version", None) + approval.pop("product_result_sha256", None) + approvals_path.write_text( + "\n".join(json.dumps(record, separators=(",", ":"), sort_keys=False) for record in records) + "\n", + encoding="utf-8", + ) + before_status = status_path.read_bytes() + before_approvals = approvals_path.read_bytes() + issues = DIFF.validate_state_authority(program_root, observation) + self.assertTrue(issues, issues) + self.assertTrue(any("family" in issue or "product" in issue or "approval" in issue for issue in issues)) + with self.assertRaises(ValueError): + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{candidate.prompt}", + observation, + ) + self.assertEqual(status_path.read_bytes(), before_status) + self.assertEqual(approvals_path.read_bytes(), before_approvals) + finally: + fixture.close() + + def test_v1_acceptance_rejects_coordinated_v2_disposition_and_approval(self) -> None: + fixture, program_root, observation = setup_v1_awaiting_diff_program() + try: + candidate = DIFF.build_diff_acceptance_candidate(program_root, observation) + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{candidate.prompt}", + observation, + ) + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + disposition = status["diff_disposition_binding"] + disposition["schema_version"] = "implementation-diff-disposition-binding/v2" + disposition["product_result_schema_version"] = "implementation-product-path-states/v2" + disposition["product_result_sha256"] = "0" * 64 + status["execution_transition_binding"]["schema_version"] = "implementation-execution-transition/v2" + status["review_preparation_binding"]["schema_version"] = "implementation-review-preparation/v2" + status["review_evidence_binding"]["product_result_schema_version"] = "implementation-product-path-states/v2" + status_path.write_bytes(canonical_json(status)) + approvals_path = program_root / "state/approvals.jsonl" + records = [json.loads(line) for line in approvals_path.read_text().splitlines()] + approval = next(record for record in records if record.get("type") == "increment-diff-approval") + approval["schema_version"] = "implementation-approval/v3" + approval["product_result_schema_version"] = "implementation-product-path-states/v2" + approval["product_result_sha256"] = "0" * 64 + approvals_path.write_text( + "\n".join(json.dumps(record, separators=(",", ":"), sort_keys=False) for record in records) + "\n", + encoding="utf-8", + ) + before_status = status_path.read_bytes() + before_approvals = approvals_path.read_bytes() + issues = DIFF.validate_state_authority(program_root, observation) + self.assertTrue(issues, issues) + self.assertTrue(any("family" in issue for issue in issues), issues) + with self.assertRaises(ValueError): + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{candidate.prompt}", + observation, + ) + self.assertEqual(status_path.read_bytes(), before_status) + self.assertEqual(approvals_path.read_bytes(), before_approvals) + finally: + fixture.close() + + def test_v2_product_result_propagates_exactly_through_acceptance_sinks(self) -> None: + fixture, program_root, observation = delete_setup_v2_awaiting_diff_program() + try: + review_candidate = PROGRAM_REVIEW.build_review_preparation( + program_root, observation + ) + evidence = json.loads(review_candidate.evidence_bytes) + product_result = evidence["product_result"] + self.assertEqual( + product_result["schema_version"], + "implementation-product-path-states/v2", + ) + PROGRAM_REVIEW.persist_review_preparation(program_root, observation) + acceptance = DIFF.build_diff_acceptance_candidate(program_root, observation) + self.assertEqual( + acceptance.approval_record["product_result_schema_version"], + product_result["schema_version"], + ) + self.assertEqual( + acceptance.approval_record["product_result_sha256"], + product_result["sha256"], + ) + self.assertIn(product_result["sha256"], acceptance.prompt) + self.assertIn(product_result["sha256"], review_candidate.packet_bytes.decode("utf-8")) + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{acceptance.prompt}", + observation, + ) + status = json.loads((program_root / "state/status.json").read_text()) + preparation = status["review_preparation_binding"] + self.assertEqual(preparation["product_result_schema_version"], product_result["schema_version"]) + self.assertEqual(preparation["product_result_sha256"], product_result["sha256"]) + binding = status["diff_disposition_binding"] + self.assertEqual(binding["product_result_schema_version"], product_result["schema_version"]) + self.assertEqual(binding["product_result_sha256"], product_result["sha256"]) + finally: + fixture.close() + + def test_v2_acceptance_revalidates_persisted_review_artifacts(self) -> None: + for case in ("failed verification", "stripped packet binding"): + with self.subTest(case=case): + fixture, program_root, observation = ( + delete_setup_v2_awaiting_diff_program() + ) + try: + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + preparation = status["review_preparation_binding"] + if case == "failed verification": + evidence_path = ( + program_root + / status["review_evidence_binding"]["path"] + ) + evidence = json.loads( + evidence_path.read_text(encoding="utf-8") + ) + evidence["final_verification"]["commands"][0][ + "exit_code" + ] = 1 + evidence_path.write_bytes(canonical_json(evidence)) + evidence_sha256 = DIFF.sha256_file(evidence_path) + status["review_evidence_binding"]["sha256"] = ( + evidence_sha256 + ) + preparation["evidence_sha256"] = evidence_sha256 + else: + packet_path = ( + program_root / status["review_packet_binding"]["path"] + ) + packet_text = packet_path.read_text(encoding="utf-8") + metadata = ( + "Packet schema: implementation-review-packet/v2\n" + "Product result schema: " + "implementation-product-path-states/v2\n" + f"Product result SHA-256: " + f"{preparation['product_result_sha256']}\n\n" + ) + self.assertIn(metadata, packet_text) + packet_path.write_text( + packet_text.replace(metadata, "", 1), + encoding="utf-8", + ) + packet_sha256 = DIFF.sha256_file(packet_path) + status["review_packet_binding"]["sha256"] = packet_sha256 + preparation["packet_sha256"] = packet_sha256 + status_path.write_bytes(canonical_json(status)) + + with self.assertRaisesRegex(ValueError, "review"): + DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + finally: + fixture.close() + def test_prompt_always_offers_only_accept_and_stop_without_successor_input(self) -> None: fixture, program_root, _observation = awaiting_diff_program() try: @@ -141,41 +389,49 @@ def test_unavailable_successor_never_blocks_or_changes_stop_choice(self) -> None ) for successors, reason in cases: with self.subTest(reason=reason): - fixture, program_root, _observation = awaiting_diff_program(successors) + # Unsafe allocations now stop before a new exact plan. Inject the + # unavailable boundary here to exercise stop-choice policy for + # an independently valid acceptance, including historical callers. + fixture, program_root, _observation = awaiting_diff_program() try: - prompt = DIFF.render_diff_disposition_prompt(program_root) - candidate = DIFF.build_diff_acceptance_candidate( - program_root, _observation + selection = ( + mock.patch.object(DIFF._continuation, "_successor_selection", return_value=(None, reason)) + if successors is not None else nullcontext() ) - expected = f"Accept and stop.\n\n{candidate.prompt}" - if reason != "no allocated successor": - expected += f"\nContinuation unavailable: {reason}.\n" - self.assertEqual(prompt, expected) - self.assertEqual(prompt.count("Accept and stop."), 1) - self.assertNotIn("Accept and continue", prompt) - if successors is None: - from program_continuation import ( - build_continuation_extension, - continuation_unavailability_reason, - ) - + with selection: + prompt = DIFF.render_diff_disposition_prompt(program_root) candidate = DIFF.build_diff_acceptance_candidate( program_root, _observation ) - self.assertIsNone( - build_continuation_extension( - program_root, candidate, _observation + expected = f"Accept and stop.\n\n{candidate.prompt}" + if reason != "no allocated successor": + expected += f"\nContinuation unavailable: {reason}.\n" + self.assertEqual(prompt, expected) + self.assertEqual(prompt.count("Accept and stop."), 1) + self.assertNotIn("Accept and continue", prompt) + if successors is None: + from program_continuation import ( + build_continuation_extension, + continuation_unavailability_reason, ) - ) - self.assertEqual( - continuation_unavailability_reason( - program_root, candidate - ), - reason, - ) - else: - self.assertIn(reason, prompt) - self.assertEqual(prompt.count("$implementing-staged-plans"), 1) + + candidate = DIFF.build_diff_acceptance_candidate( + program_root, _observation + ) + self.assertIsNone( + build_continuation_extension( + program_root, candidate, _observation + ) + ) + self.assertEqual( + continuation_unavailability_reason( + program_root, candidate + ), + reason, + ) + else: + self.assertIn(reason, prompt) + self.assertEqual(prompt.count("$implementing-staged-plans"), 1) finally: fixture.close() diff --git a/tests/test_distribution_documentation.py b/tests/test_distribution_documentation.py index 2bf574b..a3d881a 100644 --- a/tests/test_distribution_documentation.py +++ b/tests/test_distribution_documentation.py @@ -47,7 +47,7 @@ def test_platform_metadata_is_present_and_consistent(self) -> None: claude_marketplace = load_json(CLAUDE_MARKETPLACE) self.assertEqual(codex_manifest["name"], "implementation-plugin") - self.assertEqual(codex_manifest["version"], "0.1.2") + self.assertEqual(codex_manifest["version"], "0.1.3") self.assertEqual(codex_manifest["skills"], "./skills/") self.assertEqual(claude_manifest["name"], codex_manifest["name"]) self.assertEqual(claude_manifest["version"], codex_manifest["version"]) @@ -162,7 +162,7 @@ def test_windows_and_current_claude_routes_are_documented(self) -> None: for required_text in ( "Claude Code in VS Code", "/plugins", - "claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.2.zip", + "claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.3.zip", "```powershell", "if (Test-Path $skillDestination)", 'throw "Destination already exists: $skillDestination"', @@ -177,6 +177,93 @@ def test_windows_and_current_claude_routes_are_documented(self) -> None: 4, ) + def test_delete_contract_is_documented_at_canonical_owners(self) -> None: + repository_preparation = reader_text( + Path("skills/implementing-staged-plans/references/repository-preparation.md") + ) + execution = reader_text( + Path("skills/implementing-staged-plans/references/execution-discipline.md") + ) + review = reader_text( + Path("skills/implementing-staged-plans/references/review-coordination.md") + ) + state = reader_text( + Path("skills/implementing-staged-plans/references/state-authorization.md") + ) + authority = reader_text( + Path("skills/implementing-staged-plans/references/program-authority.md") + ) + discovery = reader_text( + Path("skills/implementing-staged-plans/references/program-discovery.md") + ) + runbook = reader_text( + Path("implementing-staged-plans-bootstrap-execution-review-runbook.md") + ) + + for required in ( + "operation-envelope/v1", + "operation-envelope/v2", + "Create", + "Modify", + "Delete", + ): + self.assertIn(required, authority) + for required in ( + "descriptor-bound", + "same-filesystem", + "cross-device", + "quarantine", + "Git", + "program", + "control", + "source must be a single regular non-symlink file", + "quarantine root must be a private directory", + "destination and receipt slots must be absent", + ): + self.assertIn(required, repository_preparation) + for required in ( + "absent", + "retained quarantine bytes", + "no secure-erasure claim", + "does not unlink", + ): + self.assertIn(required, execution) + for required in ( + "execution-transition/v2", + "result", + "delete-quarantine-receipt/v1", + ): + self.assertIn(required, state) + for required in ( + "`implementation-approval/v1` and `implementation-action-authorization/v1` are legacy-only.", + "Manifest-v3 setup and product-delta transactions use `implementation-approval/v2` and `implementation-action-authorization/v2`; manifest v3 rejects the v1 schemas.", + "Product-delta diff acceptance keeps `implementation-diff-disposition-binding/v1`; manifest-v3 pairs it with `implementation-approval/v2`, while legacy pairs it with `implementation-approval/v1`.", + "Setup/envelope v2 Delete diff acceptance uses `implementation-diff-disposition-binding/v2` with `implementation-approval/v3`, and its result-bound rollover uses `implementation-action-authorization/v3`.", + ): + self.assertIn(required, state) + for required in ( + "review-evidence/v2", + "review-packet/v2", + "diff-disposition-binding/v2", + "implementation-approval/v3", + ): + self.assertIn(required, review) + for required in ( + "accepted-state-continuation-binding/v2", + "increment-rollover/v2", + "action-v3", + "tombstone", + "explicit `Create`", + ): + self.assertIn(required, discovery) + for required in ( + "existing handoff", + "PLUG-002", + "quarantine disposal", + "terminal closure", + ): + self.assertIn(required, runbook) + def test_reader_routes_describe_the_complete_supported_lifecycle(self) -> None: documents = { path: reader_text(path) diff --git a/tests/test_front_door_contract.py b/tests/test_front_door_contract.py index a9376a9..ebdb77d 100644 --- a/tests/test_front_door_contract.py +++ b/tests/test_front_door_contract.py @@ -22,7 +22,7 @@ EXPECTED_MANIFEST = { "name": "implementation-plugin", - "version": "0.1.2", + "version": "0.1.3", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/", } @@ -184,13 +184,26 @@ def test_ui_metadata_explicitly_invokes_the_approved_skill(self) -> None: metadata, 'interface:\n' ' display_name: "Implementing Staged Plans"\n' - ' short_description: "Create, continue, or recover implementation programs."\n' - ' default_prompt: "Use $implementing-staged-plans to create, activate, continue, or recover a repository-backed implementation program."\n' + ' short_description: "Create, continue, recover, or delete through staged programs."\n' + ' default_prompt: "Use $implementing-staged-plans to create, activate, continue, recover, or execute an exact-file Delete in a repository-backed implementation program."\n' '\n' 'policy:\n' ' allow_implicit_invocation: false\n', ) + def test_front_door_routes_delete_to_the_typed_v2_contract(self) -> None: + skill_markdown = SKILL_PATH.read_text(encoding="utf-8") + for required in ( + "Create`, `Modify`, and `Delete", + "setup/envelope v2", + "descriptor-bound quarantine", + "approval-v3", + "action-v3", + "explicit later `Create`", + "PLUG-002", + ): + self.assertIn(required, skill_markdown) + def test_plan_a_lifecycle_routes_are_ordered_and_bounded(self) -> None: skill_markdown = SKILL_PATH.read_text(encoding="utf-8") headings = ( diff --git a/tests/test_multi_increment_lifecycle.py b/tests/test_multi_increment_lifecycle.py index 87ccbc5..d4985ff 100644 --- a/tests/test_multi_increment_lifecycle.py +++ b/tests/test_multi_increment_lifecycle.py @@ -11,6 +11,7 @@ repository_snapshot, run_program_discovery, ) +from tests.test_program_setup import ACTIVATION, BOOTSTRAP, SETUP REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -200,6 +201,264 @@ def test_later_continuation_crosses_a_fresh_process_boundary(self) -> None: rollover = self.rollover("accepted-state") self.assertEqual(rollover["successor_increment_id"], "ARCHIVE-VERIFY") + def start_sparse_program(self, mode="approval:full-increment"): + self.fixture.close() + self.fixture = BootstrapFixture() + self.fixture.configure_approval_mode(mode) + schedule = self.fixture.configure_portable_successors() + BOOTSTRAP.publish_program_proposal(self.fixture.repository, self.fixture.source_plan, self.fixture.candidate, self.fixture.source_sha256) + observation = ACTIVATION.inspect_repository(self.fixture.repository, self.fixture.head).observation + decision = SETUP.adapt_setup_decision(self.fixture.program_root, "Yes", role="user", provenance="direct-user-message") + activation = ACTIVATION.activate_program(self.fixture.program_root, decision, observation) + intent = SETUP.adapt_increment_start_intent(self.fixture.program_root, activation.handoff, role="user", provenance="direct-user-message") + ACTIVATION.start_first_increment(self.fixture.program_root, intent, observation) + return schedule + + def test_portable_nine_increment_chain_uses_both_continuation_routes(self): + schedule = self.start_sparse_program() + manifest_bytes = (self.fixture.program_root / "manifest.json").read_bytes() + prefix = "implementation-programs/ARCHIVE-PROGRAM/" + for index, current in enumerate(schedule): + with self.subTest(current=current): + self.assertEqual(self.load_status()["current_increment_id"], current) + _, rendered = self.run_phase("render-exact-plan") + paths = set(rendered["required_future_paths"]) + if index < 8: + self.assertIn(prefix + f"increments/{current}/handoff.md", paths) + self.assertIn(prefix + f"increments/{schedule[index + 1]}/brief.md", paths) + self.assertNotIn(prefix + "closure/reconciliation.json", paths) + self.advance_current_to_diff() + receipt = self.rollover("immediate" if index % 2 == 0 else "accepted-state") + self.assertEqual(receipt["successor_increment_id"], schedule[index + 1]) + records = [json.loads(line) for line in (self.fixture.program_root / "state/rollovers.jsonl").read_text().splitlines()] + self.assertEqual([(row["current_increment_id"], row["successor_increment_id"]) for row in records], list(zip(schedule[:index + 1], schedule[1:index + 2]))) + self.assertIn("archive-output.txt", self.load_status()["inherited_workspace_binding"]["inherited_paths"]) + self.assertEqual(self.discover()["disposition"], "resume") + else: + self.assertIn(prefix + "closure/reconciliation.json", paths) + self.assertIn(prefix + "closure/closure-packet.md", paths) + self.assertNotIn(prefix + f"increments/{current}/handoff.md", paths) + self.materialize_current_plan() + self.assertEqual((self.fixture.program_root / "manifest.json").read_bytes(), manifest_bytes) + + def test_sparse_first_boundary_supports_later_continuation(self): + self.start_sparse_program() + self.advance_current_to_diff() + receipt = self.rollover("accepted-state") + self.assertEqual(receipt["successor_increment_id"], "ARCHIVE-VERIFY") + self.materialize_current_plan() + + def test_completed_history_must_match_the_approved_serial_edge(self): + from tests.test_program_rollover import ROLLOVER + from tests.test_program_setup import AUTHORITY + + self.start_sparse_program() + self.advance_current_to_diff() + self.rollover("immediate") + root = self.fixture.program_root + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + traceability_path = root / "program/traceability.json" + traceability = json.loads(traceability_path.read_text()) + increments = manifest["setup_semantics"]["increments"] + increments[1], increments[2] = increments[2], increments[1] + schedule = [item["increment_id"] for item in increments] + for index, increment in enumerate(increments): + increment["depends_on"] = schedule[index - 1:index] if index else [] + for requirement in traceability["atomic_requirements"]: + requirement["assigned_increments"].sort(key=schedule.index) + semantic_digest = bootstrap_support.canonical_compact_sha256([ + {field: item[field] for field in AUTHORITY.SEMANTIC_FIELDS} + for item in traceability["atomic_requirements"] + ]) + traceability["coverage_assertion"]["semantic_requirements_sha256"] = semantic_digest + traceability_path.write_bytes(bootstrap_support.canonical_json(traceability)) + manifest["program_binding"]["traceability_sha256"] = AUTHORITY.sha256_file(traceability_path) + manifest["setup_semantics"]["bindings"]["program"].update(manifest["program_binding"], semantic_requirements_sha256=semantic_digest) + manifest["setup_semantics_sha256"] = bootstrap_support.canonical_compact_sha256(manifest["setup_semantics"]) + manifest_path.write_bytes(bootstrap_support.canonical_json(manifest)) + self.assertEqual(SETUP.validate_setup_semantics(root), []) + before = repository_snapshot(root) + with self.assertRaisesRegex(ValueError, "schedule|successor"): + ROLLOVER._validated_completed_rollover_records(root, self.load_status(), allow_unbound_suffix=False) + self.assertEqual(repository_snapshot(root), before) + + def test_sparse_exact_plan_prefixes_are_discovered_and_replayed(self): + cases = [ + ("approval:standard", "prepare-plan", "exact-plan"), + ("approval:standard", "prepare-plan", "awaiting-plan-status"), + *[("approval:standard", "materialize-plan", label) for label in ("plan-approval", "execution-baseline", "plan-action-authorization", "authorized-status")], + *[("approval:full-increment", "prepare-plan", label) for label in ("exact-plan", "execution-baseline", "plan-action-authorization", "authorized-status")], + ] + for mode, phase, label in cases: + with self.subTest(mode=mode, phase=phase, label=label): + self.start_sparse_program(mode) + _, rendered = self.run_phase("render-exact-plan") + plan = rendered["plan"].encode() + prompt = None + if phase == "materialize-plan": + _, prepared = self.run_phase("prepare-plan", exact_plan=plan) + prompt = prepared["plan_prompt"] + failed, _ = self.run_phase(phase, exact_plan=plan, prompt=prompt, fail_label=label, check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertIn("injected-after:", failed.stderr) + expected = "plan-preparation-retry-ready" if label in {"exact-plan", "awaiting-plan-status"} else "plan-materialization-retry-ready" + if label == "authorized-status": + expected = "resume" + self.assertEqual(self.discover()["disposition"], expected) + self.run_phase(phase, exact_plan=plan, prompt=prompt) + before = repository_snapshot(self.fixture.repository) + self.run_phase(phase, exact_plan=plan, prompt=prompt) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + + def test_fresh_diff_prompt_preserves_exact_rollover_record_choice(self): + from tests.test_program_rollover import DIFF, ROLLOVER + + immutable_before = repository_snapshot(REPOSITORY_ROOT / "tests/fixtures") + self.start_sparse_program() + self.advance_current_to_diff() + root = self.fixture.program_root + manifest_before = (root / "manifest.json").read_bytes() + original_public = DIFF.render_diff_disposition_prompt(root) + _, choice = self.run_phase("render-accept-continue") + prompt = choice["prompt"] + self.assertEqual(original_public.count(prompt), 1) + + interrupted, _ = self.run_phase( + "dispose-diff", prompt=prompt, + fail_label="rollover-record", check=False, + ) + self.assertEqual(interrupted.returncode, 1, interrupted.stderr) + self.assertIn("injected-after:rollover-record", interrupted.stderr) + self.assertEqual(self.load_status()["current_increment_state"], "accepted") + self.assertEqual(self.load_status()["current_increment_id"], "ARCHIVE-INDEX") + + before = repository_snapshot(self.fixture.repository) + discovered = self.discover() + self.assertEqual(discovered["disposition"], "increment-rollover-retry-ready") + self.assertEqual(discovered["issues"], []) + observation = DIFF.inspect_repository(self.fixture.repository, self.fixture.head).observation + inspection = ROLLOVER.inspect_increment_rollover(root, observation) + self.assertEqual(inspection.disposition, "increment-rollover-retry-ready") + self.assertEqual(inspection.issues, ()) + self.assertEqual(inspection.completed_steps, ( + "action-authorization", "successor-grant", "handoff", + "successor-brief", "rollover-record", + )) + fresh_public = DIFF.render_diff_disposition_prompt(root) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertEqual((root / "manifest.json").read_bytes(), manifest_before) + self.assertEqual(repository_snapshot(REPOSITORY_ROOT / "tests/fixtures"), immutable_before) + self.assertEqual(fresh_public, original_public) + self.assertEqual(fresh_public.count(prompt), 1) + self.assertEqual(fresh_public.count("Accept and continue to `ARCHIVE-VERIFY`."), 1) + self.assertEqual(fresh_public.count("$implementing-staged-plans"), 2) + + def test_fresh_diff_prompt_rejects_mismatched_rollover_suffix_without_writes(self): + from tests.test_program_rollover import DIFF, ROLLOVER + + immutable_before = repository_snapshot(REPOSITORY_ROOT / "tests/fixtures") + self.start_sparse_program() + self.advance_current_to_diff() + root = self.fixture.program_root + manifest_before = (root / "manifest.json").read_bytes() + _, choice = self.run_phase("render-accept-continue") + interrupted, _ = self.run_phase( + "dispose-diff", prompt=choice["prompt"], + fail_label="rollover-record", check=False, + ) + self.assertEqual(interrupted.returncode, 1, interrupted.stderr) + self.assertIn("injected-after:rollover-record", interrupted.stderr) + rollover_path = root / "state/rollovers.jsonl" + records = [json.loads(line) for line in rollover_path.read_text().splitlines()] + self.assertEqual(len(records), 1) + records[0]["successor_increment_id"] = "ARCHIVE-CATALOG" + rollover_path.write_bytes(ROLLOVER._canonical_json_line(records[0])) + + before = repository_snapshot(self.fixture.repository) + observation = DIFF.inspect_repository(self.fixture.repository, self.fixture.head).observation + inspection = ROLLOVER.inspect_increment_rollover(root, observation) + self.assertEqual(inspection.disposition, "continuation-recovery-required") + self.assertIn("divergent rollover-record", inspection.issues) + self.assertNotIn("rollover-record", inspection.completed_steps) + acceptance = DIFF.build_diff_acceptance_candidate(root, observation) + self.assertIsNone(DIFF._continuation.build_continuation_extension(root, acceptance, observation)) + rendered = DIFF.render_diff_disposition_prompt(root) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertEqual((root / "manifest.json").read_bytes(), manifest_before) + self.assertEqual(repository_snapshot(REPOSITORY_ROOT / "tests/fixtures"), immutable_before) + self.assertEqual(rendered, ( + f"Accept and stop.\n\n{acceptance.prompt}\n" + "Continuation unavailable: unbound rollover history is not lifecycle authority.\n" + )) + self.assertNotIn("Accept and continue", rendered) + self.assertNotIn(choice["prompt"], rendered) + self.assertEqual(rendered.count("$implementing-staged-plans"), 1) + + def test_sparse_acceptance_and_rollover_prefixes_replay_one_bound_transaction(self): + labels = ("action-authorization", "successor-grant", "handoff", "successor-brief", "rollover-record", "successor-status") + for domain in ("immediate", "accepted-state"): + for label in (("diff-approval", "accepted-status") + labels if domain == "immediate" else labels): + with self.subTest(domain=domain, label=label): + self.start_sparse_program() + self.advance_current_to_diff() + if domain == "accepted-state": + _, stopped = self.run_phase("render-accept-stop") + self.run_phase("accept", prompt=stopped["prompt"]) + _, choice = self.run_phase("render-accept-continue" if domain == "immediate" else "render-later-continuation") + prompt = choice["prompt"] + phase = "dispose-diff" if domain == "immediate" else "rollover" + manifest_bytes = (self.fixture.program_root / "manifest.json").read_bytes() + failed, _ = self.run_phase(phase, prompt=prompt, fail_label=label, check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertIn("injected-after:", failed.stderr) + if label == "successor-status": + expected = "resume" + elif label == "diff-approval": + expected = "increment-acceptance-retry-ready" + elif label == "accepted-status": + expected = "accepted-continuation-retry-ready" + elif label in {"action-authorization", "successor-grant"}: + expected = "increment-continuation-retry-ready" if domain == "immediate" else "accepted-state-continuation-retry-ready" + else: + expected = "increment-rollover-retry-ready" if domain == "immediate" else "accepted-state-rollover-retry-ready" + self.assertEqual(self.discover()["disposition"], expected) + _, receipt = self.run_phase(phase, prompt=prompt) + self.assertEqual(receipt["successor_increment_id"], "ARCHIVE-VERIFY") + self.assertFalse(receipt["requires_retry"]) + before = repository_snapshot(self.fixture.repository) + self.run_phase(phase, prompt=prompt) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + self.assertEqual((self.fixture.program_root / "manifest.json").read_bytes(), manifest_bytes) + self.assertEqual(len((self.fixture.program_root / "state/rollovers.jsonl").read_text().splitlines()), 1) + self.assertEqual(len((self.fixture.program_root / "state/increment-grants.jsonl").read_text().splitlines()), 2) + + def test_incompatible_closure_only_plan_and_successor_prefix_preserve_all_bytes(self): + self.start_sparse_program() + _, rendered = self.run_phase("render-exact-plan") + old_plan = rendered["plan"].replace("increments/ARCHIVE-INDEX/handoff.md", "closure/reconciliation.json").replace("increments/ARCHIVE-VERIFY/brief.md", "closure/closure-packet.md").encode() + (self.fixture.program_root / "increments/ARCHIVE-INDEX/exact-file-plan.md").write_bytes(old_plan) + before = repository_snapshot(self.fixture.repository) + self.assertEqual(self.discover()["disposition"], "plan-preparation-recovery-required") + failed, _ = self.run_phase("prepare-plan", exact_plan=old_plan, check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + + self.start_sparse_program() + self.advance_current_to_diff() + _, choice = self.run_phase("render-accept-continue") + failed, _ = self.run_phase("dispose-diff", prompt=choice["prompt"], fail_label="action-authorization", check=False) + self.assertIn("injected-after:", failed.stderr) + path = self.fixture.program_root / "state/action-authorizations.jsonl" + records = [json.loads(line) for line in path.read_text().splitlines()] + records[-1]["successor_increment_id"] = "ARCHIVE-CATALOG" + path.write_text("".join(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" for record in records)) + before = repository_snapshot(self.fixture.repository) + self.assertIn(self.discover()["disposition"], {"increment-continuation-recovery-required", "continuation-recovery-required"}) + failed, _ = self.run_phase("dispose-diff", prompt=choice["prompt"], check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(repository_snapshot(self.fixture.repository), before) + def test_each_successor_mode_materializes_inherited_history_after_both_routes( self, ) -> None: diff --git a/tests/test_package_validation.py b/tests/test_package_validation.py index 744ecb1..8c2f697 100644 --- a/tests/test_package_validation.py +++ b/tests/test_package_validation.py @@ -92,7 +92,7 @@ VALID_MANIFEST = { "name": "implementation-plugin", - "version": "0.1.2", + "version": "0.1.3", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/", } @@ -512,7 +512,7 @@ def test_production_scripts_and_three_manifest_identities_are_required(self) -> value["version"] = "0.1.0" self.fixture.write_json(".claude-plugin/plugin.json", value) self.assert_issue_contains( - VALIDATOR.validate_package(self.fixture.root), "version must equal '0.1.2'" + VALIDATOR.validate_package(self.fixture.root), "version must equal '0.1.3'" ) def test_package_digest_inventory_is_sorted_and_excludes_repository_surfaces(self) -> None: diff --git a/tests/test_program_activation.py b/tests/test_program_activation.py index 9347466..823a17a 100644 --- a/tests/test_program_activation.py +++ b/tests/test_program_activation.py @@ -2,12 +2,14 @@ import shutil import subprocess import sys +import tempfile import unittest from pathlib import Path from unittest import mock from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, canonical_json, repository_snapshot, run_program_discovery, @@ -20,6 +22,7 @@ SCRIPT_PATH = SCRIPT_ROOT / "program_activation.py" DISCOVERY_PATH = SCRIPT_ROOT / "program_discovery.py" ACTIVATION = load_script_module("program_activation", SCRIPT_PATH) +SETUP = sys.modules["program_setup"] def proposal_observation(fixture: BootstrapFixture): @@ -89,7 +92,31 @@ def exact_plan_bytes(program_root: Path, observation) -> bytes: *(item.path for item in required if item.disposition == "Modify"), } ) - preserve = ["catalog.txt"] + setup_v2 = ( + manifest.get("setup_semantics", {}).get("schema_version") + == "implementation-program-setup-semantics/v2" + and manifest.get("setup_semantics", {}).get("operation_envelope", {}).get("schema_version") + == "implementation-operation-envelope/v2" + ) + delete = sorted( + allocation["path"] + for allocation in manifest.get("setup_semantics", {}) + .get("operation_envelope", {}) + .get("allocations", []) + if allocation.get("operation") == "Delete" + and status["current_increment_id"] in allocation.get("increment_ids", []) + and allocation.get("kind") == "exact-path" + ) if setup_v2 else [] + preserve = ( + [ + item.path + for item in required + if item.disposition == "Preserve" + ] + if setup_v2 + else ["catalog.txt"] + ) + product_paths -= set(delete) source = status["source_binding"] program = status["program_binding"] lines = [ @@ -113,11 +140,14 @@ def exact_plan_bytes(program_root: Path, observation) -> bytes: "## File map", "", ] - for disposition, paths in ( + plan_operations = [ ("Create", create), ("Modify", modify), - ("Preserve", preserve), - ): + ] + if setup_v2: + plan_operations.append(("Delete", delete)) + plan_operations.append(("Preserve", preserve)) + for disposition, paths in plan_operations: lines.extend( [ f"### {disposition}", @@ -181,6 +211,31 @@ def test_no_overwrite_creation_syncs_file_and_parent_directory(self) -> None: self.assertFalse(recovered) self.assertGreaterEqual(fsync.call_count, 2) + def test_delete_setup_activation_writer_emits_the_v2_record_family(self) -> None: + self.fixture.configure_delete_setup_v2() + decision = SETUP.adapt_setup_decision( + self.fixture.candidate, + "Yes", + role="user", + provenance="direct-user-message", + ) + + receipt = ACTIVATION.activate_program( + self.fixture.candidate, decision, self.observation + ) + + record = self.fixture.load_json("state/setup-activation-decision.json") + self.assertEqual( + record["schema_version"], "setup-activation-decision/v2" + ) + self.assertEqual(receipt.increment_state, "awaiting-first-increment") + self.assertEqual( + ACTIVATION.validate_state_authority( + self.fixture.candidate, self.observation + ), + [], + ) + def test_activation_persists_three_records_then_active_preparing_status(self) -> None: prompt = ACTIVATION.render_program_launch_prompt(self.fixture.candidate) @@ -394,6 +449,205 @@ def test_apply_cli_uses_fresh_repository_observation(self) -> None: class ExactPlanMaterializationTests(unittest.TestCase): + def test_legacy_unsafe_successor_stops_at_prospective_allocation(self): + for successors, diagnostic in ( + ({"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",), "ARCHIVE-EXPORT": ("ARCHIVE-INDEX",)}, "multiple allocated successors"), + ({"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)}, "successor dependencies are unsatisfied"), + ): + with self.subTest(diagnostic=diagnostic): + fixture = BootstrapFixture() + self.addCleanup(fixture.close) + fixture.configure_successors(successors) + root, observation = activated_program(fixture) + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, diagnostic): + ACTIVATION.required_future_lifecycle_writes(root, fixture.repository, "ARCHIVE-INDEX") + self.assertEqual(repository_snapshot(fixture.repository), before) + + def test_inactive_lifecycle_alternative_is_rejected_before_plan_writes(self): + for successor in (False, True): + with self.subTest(successor=successor): + fixture = BootstrapFixture() + self.addCleanup(fixture.close) + if successor: + fixture.configure_successor_chain(("ARCHIVE-INDEX", "ARCHIVE-VERIFY")) + root, observation = activated_program(fixture) + extra = "closure/reconciliation.json" if successor else "increments/ARCHIVE-INDEX/handoff.md" + plan = exact_plan_bytes(root, observation).replace(b"### Create\n", f"### Create\n\n- `implementation-programs/ARCHIVE-PROGRAM/{extra}` — exact owned path.\n".encode()) + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, "lifecycle|allocation"): + ACTIVATION.prepare_exact_plan(root, plan, observation) + self.assertEqual(repository_snapshot(fixture.repository), before) + + def sparse_preparing_program(self, mode="approval:full-increment"): + bootstrap = load_script_module("program_bootstrap", SCRIPT_ROOT / "program_bootstrap.py") + + fixture = BootstrapFixture() + self.addCleanup(fixture.close) + fixture.configure_approval_mode(mode) + fixture.configure_portable_successors() + bootstrap.publish_program_proposal(fixture.repository, fixture.source_plan, fixture.candidate, fixture.source_sha256) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + decision = SETUP.adapt_setup_decision(fixture.program_root, "Yes", role="user", provenance="direct-user-message") + activation = ACTIVATION.activate_program(fixture.program_root, decision, observation) + intent = SETUP.adapt_increment_start_intent(fixture.program_root, activation.handoff, role="user", provenance="direct-user-message") + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + return fixture, observation + + def test_sparse_sequence_two_allocates_only_the_immediate_navigation(self): + fixture, observation = self.sparse_preparing_program() + status = json.loads((fixture.program_root / "state/status.json").read_text()) + self.assertEqual((status["state_sequence"], status["current_increment_state"]), (2, "preparing")) + required = ACTIVATION.required_future_lifecycle_writes(fixture.program_root, fixture.repository, "ARCHIVE-INDEX") + created = {item.path for item in required if item.disposition == "Create"} + prefix = "implementation-programs/ARCHIVE-PROGRAM/" + self.assertEqual(created, { + prefix + "increments/ARCHIVE-INDEX/execution-baseline.json", + prefix + "increments/ARCHIVE-INDEX/review-evidence.json", + prefix + "increments/ARCHIVE-INDEX/review-packet.md", + prefix + "increments/ARCHIVE-INDEX/handoff.md", + prefix + "increments/ARCHIVE-VERIFY/brief.md", + }) + + def test_sparse_full_increment_correct_plan_authorizes_without_plan_question(self): + fixture, observation = self.sparse_preparing_program() + plan = _exact_plan_bytes(fixture.program_root, observation) + # Substitute a literal navigation oracle even while the old allocator is wrong. + plan = plan.replace(b"closure/reconciliation.json", b"increments/ARCHIVE-INDEX/handoff.md") + plan = plan.replace(b"closure/closure-packet.md", b"increments/ARCHIVE-VERIFY/brief.md") + receipt = ACTIVATION.prepare_exact_plan(fixture.program_root, plan, observation) + self.assertEqual(receipt.increment_state, "authorized") + self.assertIsNone(receipt.plan_prompt) + actions = [json.loads(line) for line in (fixture.program_root / "state/action-authorizations.jsonl").read_text().splitlines()] + self.assertEqual(len(actions), 1) + status = json.loads((fixture.program_root / "state/status.json").read_text()) + self.assertEqual(actions[0]["increment_grant_id"], status["current_increment_authority_binding"]["grant_id"]) + + def test_sparse_plan_rejects_each_missing_or_misclassified_lifecycle_path_without_writes(self): + fixture, observation = self.sparse_preparing_program() + plan = _exact_plan_bytes(fixture.program_root, observation).decode() + file_map = ACTIVATION.parse_exact_file_map(plan) + prefix = "implementation-programs/ARCHIVE-PROGRAM/" + before = repository_snapshot(fixture.repository) + for correct, paths in (("Create", file_map.create), ("Modify", file_map.modify), ("Preserve", file_map.preserve)): + for path in paths: + if not path.startswith(prefix): + continue + line = next(line for line in plan.splitlines(keepends=True) if f"`{path}`" in line) + omitted = plan.replace(line, "") + for disposition in (None, "Create", "Modify", "Preserve", "Delete"): + if disposition == correct: + continue + with self.subTest(path=path, disposition=disposition): + changed = omitted + if disposition is not None: + heading = f"### {disposition}\n" + if heading not in changed: + changed = changed.replace("### Preserve\n", heading + "\n" + line + "\n### Preserve\n") + else: + changed = changed.replace(heading, heading + "\n" + line) + with self.assertRaises(ValueError): + ACTIVATION.prepare_exact_plan(fixture.program_root, changed.encode(), observation) + self.assertEqual(repository_snapshot(fixture.repository), before) + + def test_unavailable_history_allocates_nothing_and_preserves_bytes(self): + fixture, observation = self.sparse_preparing_program() + rollover_path = fixture.program_root / "state/rollovers.jsonl" + rollover_path.write_text(json.dumps({"current_increment_id": "ARCHIVE-INDEX", "successor_increment_id": "ARCHIVE-VERIFY"}) + "\n") + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, "unbound rollover"): + ACTIVATION.required_future_lifecycle_writes(fixture.program_root, fixture.repository, "ARCHIVE-INDEX") + self.assertEqual(repository_snapshot(fixture.repository), before) + + def test_v2_path_baselines_allocate_descriptor_bound_delete_storage(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + program_root = workspace / "implementation-programs/DELETE-PROGRAM" + program_root.mkdir(parents=True) + target = workspace / "legacy.ts" + target.write_bytes(b"legacy bytes\n") + (program_root / "manifest.json").write_bytes( + canonical_json( + { + "program_id": "DELETE-PROGRAM", + "program_revision": 1, + "logical_roles": {"status": "state/status.json"}, + "increment_storage": {"root": "increments"}, + } + ) + ) + (program_root / "state").mkdir() + (program_root / "state/status.json").write_bytes( + canonical_json({"current_increment_id": "DELETE-1"}) + ) + paths, bindings = ACTIVATION._v2_path_baselines( + program_root, + workspace, + ACTIVATION.ExactFileMapV2((), (), ("legacy.ts",), ()), + {}, + "DELETE-1", + ) + self.assertEqual(paths[0]["disposition"], "Delete") + self.assertEqual(bindings[0]["path"], "legacy.ts") + self.assertTrue((program_root / bindings[0]["root_path"]).is_dir()) + self.assertTrue(target.exists()) + + def test_v1_exact_plan_rejects_delete_section_explicitly(self) -> None: + fixture = BootstrapFixture() + try: + program_root, observation = activated_program(fixture) + plan_text = exact_plan_bytes(program_root, observation).decode("utf-8") + plan_text = plan_text.replace( + "### Preserve\n", + "### Delete\n\n- `obsolete.txt`\n\n### Preserve\n", + 1, + ) + + with self.assertRaisesRegex( + ValueError, "Delete section requires setup-v2" + ): + ACTIVATION.prepare_exact_plan( + program_root, + plan_text.encode("utf-8"), + observation, + ) + finally: + fixture.close() + + def test_v2_exact_plan_accepts_empty_delete_section_in_setup_context(self) -> None: + manifest = { + "schema_version": "implementation-program-manifest/v3", + "setup_semantics": { + "schema_version": "implementation-program-setup-semantics/v2", + "operation_envelope": { + "schema_version": "implementation-operation-envelope/v2" + }, + }, + } + markdown = """# Plan + +## File map + +### Create + +- `review/evidence.json` + +### Modify + +- `state/status.json` + +### Delete + +### Preserve + +- `catalog.txt` +""" + parsed = ACTIVATION._parse_exact_file_map_for_manifest(manifest, markdown) + self.assertEqual(parsed.create, ("review/evidence.json",)) + self.assertEqual(parsed.modify, ("state/status.json",)) + self.assertEqual(parsed.delete, ()) + self.assertEqual(parsed.preserve, ("catalog.txt",)) + def test_successor_plan_candidate_inherits_only_canonical_rollover_products(self) -> None: from tests.test_program_rollover import ROLLOVER, accepted_continuation_program diff --git a/tests/test_program_authority.py b/tests/test_program_authority.py index 0dc2f60..20d6ede 100644 --- a/tests/test_program_authority.py +++ b/tests/test_program_authority.py @@ -6,13 +6,19 @@ import os import re import shutil +import subprocess +import sys import tempfile import unittest from contextlib import redirect_stdout from pathlib import Path, PurePosixPath from unittest.mock import patch -from tests.program_bootstrap_support import BootstrapFixture +from tests.program_bootstrap_support import ( + BootstrapFixture, + canonical_compact_sha256, + successor_allocation_fixture, +) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -1062,6 +1068,154 @@ def test_every_normative_or_list_line_is_requirement_classified(self) -> None: ) +class SuccessorResolutionTests(unittest.TestCase): + @staticmethod + def manifest(increments, family=1): + return { + "schema_version": "implementation-program-manifest/v3", + "setup_semantics": { + "schema_version": f"implementation-program-setup-semantics/v{family}", + "operation_envelope": {"schema_version": f"implementation-operation-envelope/v{family}"}, + "first_increment_id": increments[0]["increment_id"], + "increments": increments, + }, + } + + @staticmethod + def small_case(allocations=(("A", "B", "C"),), schedule=("A", "B", "C")): + requirements = [{"id": f"REQ-{index}", "assigned_increments": list(ids)} for index, ids in enumerate(allocations)] + increments = [ + {"increment_id": current, "depends_on": [], "requirement_ids": [item["id"] for item in requirements if current in item["assigned_increments"]]} + for current in schedule + ] + return SuccessorResolutionTests.manifest(increments), requirements + + def test_portable_schedule_boundaries(self) -> None: + increments, requirements = successor_allocation_fixture() + self.assertEqual(len(requirements), 570) + schedule = tuple(item["increment_id"] for item in increments) + for family in (1, 2): + for index, current in enumerate(schedule): + with self.subTest(family=family, current=current): + result = AUTHORITY.resolve_increment_successor(self.manifest(increments, family), requirements, current, schedule[:index]) + expected = schedule[index + 1] if index < 8 else None + self.assertEqual(result.kind, "successor" if expected else "terminal") + self.assertEqual(result.successor_increment_id, expected) + self.assertEqual(result.reason, "") + + def test_sparse_disjoint_and_ready_branches_follow_serial_order(self) -> None: + cases = ( + ((("A", "B", "C"),), ("A", "B", "C")), + ((("A", "C"), ("B",)), ("A", "B", "C")), + ((("A",), ("B",), ("C",)), ("A", "B", "C")), + ((("A", "B", "D"), ("A", "C", "D")), ("A", "B", "C", "D")), + ) + for allocations, schedule in cases: + manifest, requirements = self.small_case(allocations, schedule) + if len(schedule) == 4: + for increment, dependencies in zip(manifest["setup_semantics"]["increments"], ([], ["A"], ["A"], ["B", "C"])): + increment["depends_on"] = dependencies + for index, current in enumerate(schedule): + with self.subTest(allocations=allocations, current=current): + result = AUTHORITY.resolve_increment_successor(manifest, requirements, current, schedule[:index]) + self.assertEqual(result.successor_increment_id, schedule[index + 1] if index + 1 < len(schedule) else None) + self.assertEqual(result.kind, "successor" if index + 1 < len(schedule) else "terminal") + + def test_invalid_schedules_allocations_and_families_are_unavailable(self) -> None: + mutations = [ + lambda m, r: m.update(schema_version="unknown"), + lambda m, r: m.update(schema_version=[]), + lambda m, r: m.update(schema_version=None), + lambda m, r: m["setup_semantics"].update(schema_version="unknown"), + lambda m, r: m["setup_semantics"]["operation_envelope"].update(schema_version="implementation-operation-envelope/v2"), + lambda m, r: m["setup_semantics"].pop("operation_envelope"), + lambda m, r: m["setup_semantics"].update(increments=[]), + lambda m, r: m["setup_semantics"].update(increments=None), + lambda m, r: m["setup_semantics"].update(increments=["A"]), + lambda m, r: m["setup_semantics"].update(first_increment_id="B"), + lambda m, r: r.clear(), + lambda m, r: r.append(None), + lambda m, r: r[0].pop("id"), + lambda m, r: r[0].update(id=""), + lambda m, r: r.append(copy.deepcopy(r[0])), + lambda m, r: r[0].update(assigned_increments=[]), + lambda m, r: r[0].update(assigned_increments=None), + lambda m, r: r[0].update(assigned_increments="A"), + lambda m, r: r[0].update(assigned_increments=["A", "B/C"]), + lambda m, r: r[0].update(assigned_increments=["A", "B", "B", "C"]), + lambda m, r: r[0].update(assigned_increments=["A", "C", "B"]), + lambda m, r: r[0].update(assigned_increments=["A", "B", "UNKNOWN"]), + lambda m, r: r[0].update(assigned_increments=["A", "B"]), + lambda m, r: m["setup_semantics"]["increments"][1].update(requirement_ids=[]), + lambda m, r: m["setup_semantics"]["increments"][1].update(requirement_ids=["REQ-0", "REQ-0"]), + lambda m, r: m["setup_semantics"]["increments"][1].pop("depends_on"), + ] + for dependencies in (None, "A", ["A", "A"], ["UNKNOWN"], ["B"], ["C"]): + mutations.append(lambda m, r, d=dependencies: m["setup_semantics"]["increments"][1].update(depends_on=d)) + for increment_id in (None, "", "../B", "B/C", "B\\C", ".", "..", "A"): + mutations.append(lambda m, r, i=increment_id: m["setup_semantics"]["increments"][1].update(increment_id=i)) + for index, mutation in enumerate(mutations): + with self.subTest(mutation=index): + manifest, requirements = self.small_case() + mutation(manifest, requirements) + result = AUTHORITY.resolve_increment_successor(manifest, requirements, "C", ("A", "B")) + self.assertEqual(result.kind, "unavailable") + self.assertIsNone(result.successor_increment_id) + self.assertTrue(result.reason) + + def test_v3_requires_exact_accepted_prefix(self) -> None: + manifest, requirements = self.small_case() + for prefix in ((), ("A",), ("B",), ("B", "A"), ("A", "A"), ("A", "B", "C"), ("A", "UNKNOWN")): + with self.subTest(prefix=prefix): + result = AUTHORITY.resolve_increment_successor(manifest, requirements, "C", prefix) + self.assertEqual(result.kind, "unavailable") + self.assertEqual(AUTHORITY.resolve_increment_successor(manifest, requirements, "UNKNOWN", ()).kind, "unavailable") + + def test_legacy_ambiguity_and_outstanding_work_are_not_terminal(self) -> None: + cases = ( + ((("A", "B", "C"),), "A", (), "successor", "B"), + ((("A", "B", "C"),), "C", ("A", "B"), "terminal", None), + ((("A", "B", "C"), ("A", "C")), "A", (), "unavailable", None), + ((("A", "B"), ("A", "C")), "A", (), "unavailable", None), + ((("A", "B"), ("C",)), "B", ("A",), "unavailable", None), + ((("A", "B"), ("C", "B")), "A", (), "unavailable", None), + ((("A", "B"),), "A", ("B",), "unavailable", None), + ((("A", "B"),), "B", ("A", "A"), "unavailable", None), + ((("A", "B"),), "B", ("UNKNOWN",), "unavailable", None), + ((("A", "B"),), "C", (), "unavailable", None), + ) + for family in (1, 2): + for allocations, current, prefix, kind, successor in cases: + with self.subTest(family=family, allocations=allocations, current=current, prefix=prefix): + _, requirements = self.small_case(allocations) + result = AUTHORITY.resolve_increment_successor({"schema_version": f"implementation-program-manifest/v{family}"}, requirements, current, prefix) + self.assertEqual((result.kind, result.successor_increment_id), (kind, successor)) + + def test_resolution_is_frozen_and_rejects_malformed_results(self) -> None: + from dataclasses import FrozenInstanceError + result = AUTHORITY.SuccessorResolution("terminal", None, "") + with self.assertRaises(FrozenInstanceError): + result.kind = "successor" + for values in (("unknown", None, ""), ("successor", None, ""), ("terminal", "A", ""), ("unavailable", None, ""), ("successor", "A", "error")): + with self.subTest(values=values), self.assertRaises(ValueError): + AUTHORITY.SuccessorResolution(*values) + + def test_fresh_import_orders_resolve_without_history_or_allocation_recursion(self) -> None: + manifest, requirements = self.small_case() + for first, second in (("program_authority", "program_setup"), ("program_setup", "program_authority")): + with self.subTest(first=first): + script = ( + "import sys, json; sys.path.insert(0, sys.argv[1]); " + f"import {first}; import {second}; " + "m,r=json.loads(sys.argv[2]); " + "result=program_authority.resolve_increment_successor(m,r,'A',()); " + "print(json.dumps([result.kind,result.successor_increment_id,result.reason]))" + ) + completed = subprocess.run([sys.executable, "-B", "-c", script, str(MODULE_PATH.parent), json.dumps([manifest, requirements])], capture_output=True, text=True) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(json.loads(completed.stdout), ["successor", "B", ""]) + + class SetupV3AuthorityTests(unittest.TestCase): def setUp(self) -> None: self.fixture = BootstrapFixture() @@ -1076,6 +1230,36 @@ def validate(self, mode: str) -> list[str]: validation_mode=mode, ) + def test_first_increment_must_head_the_approved_schedule(self) -> None: + self.fixture.configure_successor_chain(("ARCHIVE-INDEX", "ARCHIVE-VERIFY")) + self.fixture.configure_setup_v3() + manifest = self.fixture.load_json("manifest.json") + manifest["setup_semantics"]["first_increment_id"] = "ARCHIVE-VERIFY" + manifest["setup_semantics_sha256"] = canonical_compact_sha256(manifest["setup_semantics"]) + self.fixture.write_json("manifest.json", manifest) + self.assertTrue(any("first increment" in issue for issue in self.validate(AUTHORITY.PROPOSAL_VALIDATION_MODE))) + + def test_rehashed_reversed_allocation_is_rejected_by_setup_authority(self) -> None: + self.fixture.configure_successor_chain(("ARCHIVE-INDEX", "ARCHIVE-VERIFY")) + self.fixture.configure_setup_v3() + manifest = self.fixture.load_json("manifest.json") + increments = copy.deepcopy(manifest["setup_semantics"]["increments"]) + traceability = self.fixture.load_json("program/traceability.json") + traceability["atomic_requirements"][-1]["assigned_increments"].reverse() + semantic_digest = canonical_compact_sha256([ + {field: item[field] for field in AUTHORITY.SEMANTIC_FIELDS} + for item in traceability["atomic_requirements"] + ]) + traceability["coverage_assertion"]["semantic_requirements_sha256"] = semantic_digest + self.fixture.write_json("program/traceability.json", traceability) + manifest["program_binding"]["traceability_sha256"] = AUTHORITY.sha256_file(self.fixture.candidate / "program/traceability.json") + self.fixture.write_json("manifest.json", manifest) + status = self.fixture.load_json("state/status.json") + status["program_binding"]["semantic_requirements_sha256"] = semantic_digest + self.fixture.write_json("state/status.json", status) + self.fixture.configure_setup_v3(increments=increments) + self.assertEqual(self.validate(AUTHORITY.PROPOSAL_VALIDATION_MODE), ["requirement allocation must follow the approved schedule"]) + def test_v3_proposal_allocates_absent_setup_record_and_empty_gate_ledger(self) -> None: self.assertFalse( (self.fixture.candidate / "state/setup-activation-decision.json").exists() @@ -1096,6 +1280,39 @@ def test_v3_semantic_digest_mutation_is_rejected(self) -> None: issues = self.validate(AUTHORITY.PROPOSAL_VALIDATION_MODE) self.assertIn("setup_semantics digest mismatch", issues) + def test_setup_and_envelope_schema_families_cannot_be_mixed(self) -> None: + cases = ( + ( + "implementation-program-setup-semantics/v1", + "implementation-operation-envelope/v2", + ), + ( + "implementation-program-setup-semantics/v2", + "implementation-operation-envelope/v1", + ), + ) + for setup_schema, envelope_schema in cases: + with self.subTest( + setup_schema=setup_schema, envelope_schema=envelope_schema + ): + self.tearDown() + self.setUp() + manifest = self.fixture.load_json("manifest.json") + semantics = manifest["setup_semantics"] + semantics["schema_version"] = setup_schema + semantics["operation_envelope"]["schema_version"] = envelope_schema + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + semantics + ) + self.fixture.write_json("manifest.json", manifest) + + issues = self.validate(AUTHORITY.PROPOSAL_VALIDATION_MODE) + + self.assertIn( + "setup semantics and operation envelope schemas must be an exact supported pair", + issues, + ) + def test_v3_cross_family_ledger_artifact_is_rejected(self) -> None: cases = ( ( @@ -1127,6 +1344,17 @@ def test_v3_cross_family_ledger_artifact_is_rejected(self) -> None: self.assertIn(expected_issue, issues) + def test_setup_v1_rejects_setup_v2_only_diff_approval(self) -> None: + approval_path = self.fixture.candidate / "state/approvals.jsonl" + approval_path.write_text( + '{"schema_version":"implementation-approval/v3"}\n', + encoding="utf-8", + ) + + issues = self.validate(AUTHORITY.PROPOSAL_VALIDATION_MODE) + + self.assertIn("setup-v1 rejects v3 approval records", issues) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_program_closure.py b/tests/test_program_closure.py index 8d8919d..daae546 100644 --- a/tests/test_program_closure.py +++ b/tests/test_program_closure.py @@ -27,6 +27,51 @@ def accepted_program(): class ProgramClosureTests(unittest.TestCase): + def test_unavailable_selection_cannot_satisfy_closure_preconditions(self): + from program_authority import resolve_increment_successor + + for allocations in ((["A", "B", "C"], ["A", "C"]), (["A", "B"], ["A", "C"]), (["A"], ["B"])): + requirements = [{"id": f"REQ-{index}", "assigned_increments": assigned} for index, assigned in enumerate(allocations)] + resolution = resolve_increment_successor({"schema_version": "implementation-program-manifest/v2"}, requirements, "A", ()) + self.assertEqual(resolution.kind, "unavailable") + with self.subTest(allocations=allocations), self.assertRaisesRegex(ValueError, "terminal|successor|allocated"): + CLOSURE._validate_preconditions({"successor_id": resolution.successor_increment_id}, successor_resolution=resolution) + + def test_direct_closure_command_rechecks_successor_for_awaiting_and_approval_prefix(self): + for prefix in (False, True): + with self.subTest(approval_prefix=prefix): + fixture, root, observation = accepted_program() + self.addCleanup(fixture.close) + CLOSURE.prepare_program_closure(root, observation) + prompt = CLOSURE.render_program_closure_prompt(root) + if prefix: + def interrupt(label): + if label == "closure-approval": + raise RuntimeError("injected") + with mock.patch.object(CLOSURE, "_after_persist", side_effect=interrupt), self.assertRaisesRegex(RuntimeError, "injected"): + CLOSURE.persist_program_closure(root, prompt, observation) + traceability_path = root / "program/traceability.json" + traceability = json.loads(traceability_path.read_text()) + traceability["atomic_requirements"][0]["assigned_increments"] = ["ARCHIVE-INDEX", "ARCHIVE-VERIFY"] + traceability_path.write_bytes(canonical_json(traceability)) + before = repository_snapshot(root) + # Isolate terminal eligibility from unrelated immutable-binding + # failures; the actual command path must enforce both boundaries. + with mock.patch.object(CLOSURE, "validate_state_authority", return_value=[]): + with self.assertRaisesRegex(ValueError, "terminal|successor|nonfinal"): + CLOSURE.build_closure_command_candidate(root, observation) + self.assertEqual(repository_snapshot(root), before) + alternate = dict(traceability["atomic_requirements"][0]) + alternate.update(id="ALTERNATE-OUTCOME", assigned_increments=["ARCHIVE-INDEX", "ARCHIVE-EXPORT"]) + traceability["atomic_requirements"].append(alternate) + traceability_path.write_bytes(canonical_json(traceability)) + before = repository_snapshot(root) + with mock.patch.object(CLOSURE, "validate_state_authority", return_value=[]): + for builder in (CLOSURE.build_closure_preparation, CLOSURE.build_closure_command_candidate): + with self.assertRaisesRegex(ValueError, "multiple allocated successors"): + builder(root, observation) + self.assertEqual(repository_snapshot(root), before) + def discover(self, fixture) -> dict[str, object]: return run_program_discovery(fixture.repository) @@ -77,6 +122,9 @@ def test_closure_freshness_orders_timezone_offsets_by_instant(self) -> None: status = json.loads(status_path.read_text(encoding="utf-8")) evidence_sha256 = CLOSURE.sha256_file(evidence_path) status["review_evidence_binding"]["sha256"] = evidence_sha256 + status["review_preparation_binding"]["evidence_sha256"] = ( + evidence_sha256 + ) status["diff_disposition_binding"]["review_evidence_sha256"] = ( evidence_sha256 ) @@ -134,6 +182,9 @@ def test_malformed_bound_review_evidence_fails_before_closure_writes(self) -> No status = json.loads(status_path.read_text(encoding="utf-8")) evidence_sha256 = CLOSURE.sha256_file(evidence_path) status["review_evidence_binding"]["sha256"] = evidence_sha256 + status["review_preparation_binding"]["evidence_sha256"] = ( + evidence_sha256 + ) status["diff_disposition_binding"]["review_evidence_sha256"] = ( evidence_sha256 ) diff --git a/tests/test_program_continuation.py b/tests/test_program_continuation.py index adc6abe..daa6c96 100644 --- a/tests/test_program_continuation.py +++ b/tests/test_program_continuation.py @@ -210,15 +210,15 @@ def test_later_continuation_stops_without_writes_when_no_successor_exists(self) finally: fixture.close() - def test_unbound_rollover_row_cannot_satisfy_successor_dependency(self) -> None: + def test_unbound_rollover_row_cannot_grant_continuation(self) -> None: fixture, program_root, observation = awaiting_diff_program( - {"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)} + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} ) try: acceptance = DIFF.build_diff_acceptance_candidate( program_root, observation ) - self.assertIsNone( + self.assertIsNotNone( CONTINUATION.build_continuation_extension( program_root, acceptance, observation ) @@ -234,10 +234,12 @@ def test_unbound_rollover_row_cannot_satisfy_successor_dependency(self) -> None: encoding="utf-8", ) before = repository_snapshot(program_root) - with self.assertRaisesRegex(ValueError, "unbound rollover history"): + self.assertIsNone( CONTINUATION.build_continuation_extension( program_root, acceptance, observation ) + ) + self.assertIn("unbound rollover history", CONTINUATION.continuation_unavailability_reason(program_root, acceptance)) self.assertEqual(repository_snapshot(program_root), before) finally: fixture.close() diff --git a/tests/test_program_discovery.py b/tests/test_program_discovery.py index 300b665..4aa8c2c 100644 --- a/tests/test_program_discovery.py +++ b/tests/test_program_discovery.py @@ -11,11 +11,15 @@ from tests.test_program_authority import ProgramAuthorityFixture from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, + canonical_compact_sha256, canonical_json, repository_snapshot, + write_raw_review_reports, ) from tests.script_module_support import load_script_module from tests.test_program_setup import ACTIVATION, BOOTSTRAP, SETUP, gate_definition +from tests.test_program_review import REVIEW REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -55,6 +59,59 @@ def decision(self): provenance="direct-user-message", ) + def reset_with_delete_setup_v2(self) -> None: + self.fixture.close() + self.fixture = BootstrapFixture() + self.fixture.configure_delete_setup_v2() + BOOTSTRAP.publish_program_proposal( + self.fixture.repository, + self.fixture.source_plan, + self.fixture.candidate, + self.fixture.source_sha256, + ) + + def prepare_v2_awaiting_diff(self) -> None: + observation = self.observation() + activation = ACTIVATION.activate_program( + self.fixture.program_root, self.decision(), observation + ) + intent = SETUP.adapt_increment_start_intent( + self.fixture.program_root, + activation.handoff, + role="user", + provenance="direct-user-message", + ) + ACTIVATION.start_first_increment( + self.fixture.program_root, intent, observation + ) + observation = self.observation() + prepared = ACTIVATION.prepare_exact_plan( + self.fixture.program_root, + _exact_plan_bytes(self.fixture.program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + self.fixture.program_root, prepared.plan_prompt, observation + ) + ACTIVATION.advance_execution_state( + self.fixture.program_root, "implementing", observation + ) + (self.fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(self.fixture.repository) + observation = self.observation() + ACTIVATION.advance_execution_state( + self.fixture.program_root, "reviewing", observation + ) + def interrupt(label: str) -> None: + if label == "verified-status": + raise RuntimeError("injected review prefix interruption") + + with mock.patch.object(REVIEW, "_after_persist", side_effect=interrupt): + with self.assertRaisesRegex(RuntimeError, "review prefix"): + REVIEW.persist_review_preparation(self.fixture.program_root, observation) + def test_sequence_zero_routes_to_readable_setup(self) -> None: result = DISCOVERY.discover_programs(self.fixture.repository) @@ -62,6 +119,88 @@ def test_sequence_zero_routes_to_readable_setup(self) -> None: self.assertEqual(result.required_input, "program-setup-approval") self.assertFalse(result.stop_required) + def test_discovery_cli_reports_malformed_create_allocation_without_writes(self) -> None: + self.fixture.close() + self.fixture = BootstrapFixture() + self.fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-REMOVE") + ) + self.fixture.configure_delete_setup_v2( + path="archive-output.txt", + increment_id="ARCHIVE-REMOVE", + collision="accepted-predecessor", + ) + manifest = self.fixture.load_json("manifest.json") + allocations = manifest["setup_semantics"]["operation_envelope"]["allocations"] + create_index = next( + index + for index, allocation in enumerate(allocations) + if allocation["path"] == "archive-output.txt" + and allocation["operation"] == "Create" + ) + shutil.copytree(self.fixture.candidate, self.fixture.program_root) + for case, increment_ids in ( + ("valid", ["ARCHIVE-INDEX"]), + ("null", None), + ("integer", 7), + ("boolean", True), + ): + with self.subTest(case=case): + allocations[create_index]["increment_ids"] = increment_ids + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + (self.fixture.program_root / "manifest.json").write_bytes( + canonical_json(manifest) + ) + before = repository_snapshot(self.fixture.repository) + try: + completed = subprocess.run( + [ + sys.executable, + "-B", + str(SCRIPT_PATH), + "discover", + str(self.fixture.repository), + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + timeout=30, + ) + self.assertTrue(completed.stdout.strip(), completed.stderr) + result = json.loads(completed.stdout) + self.assertEqual(completed.stderr, "") + self.assertIsNone(result["resume_expectations"]) + if case == "valid": + self.assertEqual(completed.returncode, 0) + self.assertEqual(result["disposition"], "program-setup-ready") + self.assertFalse(result["stop_required"]) + self.assertEqual(result["required_input"], "program-setup-approval") + self.assertEqual(result["issues"], []) + else: + self.assertEqual(completed.returncode, 1) + self.assertEqual(result["disposition"], "invalid") + self.assertTrue(result["stop_required"]) + self.assertIsNone(result["required_input"]) + self.assertTrue( + any( + f"operation allocation {create_index} increment allocation is invalid" + in issue + for issue in result["issues"] + ), + result["issues"], + ) + self.assertFalse( + any("digest mismatch" in issue for issue in result["issues"]), + result["issues"], + ) + finally: + self.assertEqual( + repository_snapshot(self.fixture.repository), before + ) + def test_sequence_one_routes_to_fresh_task_first_start(self) -> None: ACTIVATION.activate_program( self.fixture.program_root, self.decision(), self.observation() @@ -73,6 +212,182 @@ def test_sequence_one_routes_to_fresh_task_first_start(self) -> None: self.assertEqual(result.required_input, "first-increment-start-intent") self.assertFalse(result.stop_required) + def test_delete_setup_v2_activation_prefixes_route_to_exact_retry(self) -> None: + expected_routes = { + "setup-activation-decision": "program-activation-retry-ready", + "program-approval": "program-activation-retry-ready", + "workspace-approval": "program-activation-retry-ready", + "active-waiting-status": "first-increment-start-ready", + } + for failure_label, expected_route in expected_routes.items(): + with self.subTest(failure_label=failure_label): + self.reset_with_delete_setup_v2() + + def fail_after(label: str) -> None: + if label == failure_label: + raise RuntimeError(f"injected-after:{label}") + + with mock.patch.object( + ACTIVATION, "_after_persist", side_effect=fail_after + ): + with self.assertRaisesRegex(RuntimeError, "injected-after"): + ACTIVATION.activate_program( + self.fixture.program_root, + self.decision(), + self.observation(), + ) + + result = DISCOVERY.discover_programs(self.fixture.repository) + + self.assertEqual(result.disposition, expected_route) + self.assertFalse(result.stop_required) + recovered = ACTIVATION.activate_program( + self.fixture.program_root, + self.decision(), + self.observation(), + ) + self.assertTrue(recovered.recovered) + + def test_delete_setup_v2_corrupt_started_prefix_owns_activation_recovery( + self, + ) -> None: + cases = ( + "mixed-family", + "reordered", + "changed", + "noncanonical-setup", + "noncanonical-approvals", + ) + for case in cases: + with self.subTest(case=case): + self.reset_with_delete_setup_v2() + + def fail_after_workspace(label: str) -> None: + if label == "workspace-approval": + raise RuntimeError("injected-after:workspace-approval") + + with mock.patch.object( + ACTIVATION, "_after_persist", side_effect=fail_after_workspace + ): + with self.assertRaisesRegex(RuntimeError, "injected-after"): + ACTIVATION.activate_program( + self.fixture.program_root, + self.decision(), + self.observation(), + ) + if case in {"mixed-family", "noncanonical-setup"}: + setup_path = ( + self.fixture.program_root + / "state/setup-activation-decision.json" + ) + setup = json.loads(setup_path.read_text(encoding="utf-8")) + if case == "mixed-family": + setup["schema_version"] = "setup-activation-decision/v1" + setup_path.write_bytes(canonical_json(setup)) + else: + setup_path.write_text( + json.dumps(setup, sort_keys=True) + "\n", + encoding="utf-8", + ) + else: + approvals_path = ( + self.fixture.program_root / "state/approvals.jsonl" + ) + approvals = [ + json.loads(line) + for line in approvals_path.read_text( + encoding="utf-8" + ).splitlines() + ] + if case == "reordered": + approvals.reverse() + elif case == "changed": + approvals[0]["scope"] = ["changed setup authority"] + if case == "noncanonical-approvals": + approvals_path.write_text( + "\n".join( + json.dumps(record, sort_keys=True) + for record in approvals + ) + + "\n", + encoding="utf-8", + ) + else: + approvals_path.write_bytes( + b"".join( + ACTIVATION._canonical_json_line(record) + for record in approvals + ) + ) + before = repository_snapshot(self.fixture.program_root) + + result = DISCOVERY.discover_programs(self.fixture.repository) + + self.assertEqual( + result.disposition, "program-activation-recovery-required" + ) + self.assertTrue(result.stop_required) + candidate, route, issues = DISCOVERY._load_setup_candidate( + self.fixture.repository.resolve(), + self.fixture.program_root / "manifest.json", + ) + self.assertIsNotNone(candidate) + self.assertEqual(route, "program-activation-recovery-required") + self.assertEqual(issues, ()) + self.assertEqual(repository_snapshot(self.fixture.program_root), before) + + def test_delete_setup_v2_all_executable_states_prioritize_recovery(self) -> None: + self.reset_with_delete_setup_v2() + ACTIVATION.activate_program( + self.fixture.program_root, self.decision(), self.observation() + ) + status_path = self.fixture.program_root / "state/status.json" + original = json.loads(status_path.read_text(encoding="utf-8")) + for increment_state in ( + "authorized", + "implementing", + "reviewing", + "remediating", + "verified", + "awaiting-diff-approval", + "change-requested", + "accepted", + ): + with self.subTest(increment_state=increment_state): + status = dict(original) + status["current_increment_state"] = increment_state + status_path.write_bytes(canonical_json(status)) + with mock.patch.object( + DISCOVERY, + "validate_state_authority", + return_value=["Delete quarantine recovery-required: legacy.ts"], + ): + result = DISCOVERY.discover_programs(self.fixture.repository) + self.assertEqual( + result.disposition, "execution-transition-recovery-required" + ) + + def test_delete_setup_v2_review_prefix_recovery_precedes_generic_route(self) -> None: + self.reset_with_delete_setup_v2() + self.prepare_v2_awaiting_diff() + with mock.patch.object(DISCOVERY, "validate_state_authority", return_value=[]), mock.patch.object( + REVIEW, "validate_state_authority", return_value=[] + ): + result = DISCOVERY.discover_programs(self.fixture.repository.resolve()) + self.assertEqual(result.disposition, "review-preparation-retry-ready", result) + evidence_path = ( + self.fixture.program_root + / "increments/ARCHIVE-INDEX/review-evidence.json" + ) + evidence_path.write_bytes(evidence_path.read_bytes() + b" ") + with mock.patch.object(DISCOVERY, "validate_state_authority", return_value=[]), mock.patch.object( + REVIEW, "validate_state_authority", return_value=[] + ): + recovered = DISCOVERY.discover_programs(self.fixture.repository.resolve()) + self.assertEqual( + recovered.disposition, "review-preparation-recovery-required" + ) + def test_partial_activation_prefix_routes_each_missing_source_gate(self) -> None: self.fixture.close() self.fixture = BootstrapFixture() diff --git a/tests/test_program_review.py b/tests/test_program_review.py index 32e0d6c..630feaf 100644 --- a/tests/test_program_review.py +++ b/tests/test_program_review.py @@ -1,3 +1,4 @@ +import hashlib import json import unittest from pathlib import Path @@ -5,6 +6,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, canonical_json, repository_snapshot, run_program_discovery, @@ -12,6 +14,7 @@ ) from tests.script_module_support import load_script_module from tests.test_program_activation import ACTIVATION, activated_program, exact_plan_bytes +from tests.test_program_setup import BOOTSTRAP, SETUP REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -59,6 +62,302 @@ class ProgramReviewTests(unittest.TestCase): def discover(self, fixture: BootstrapFixture) -> dict[str, object]: return run_program_discovery(fixture.repository) + def test_setup_v2_review_binds_typed_product_result(self) -> None: + fixture = BootstrapFixture() + try: + fixture.configure_delete_setup_v2(path="catalog.txt") + BOOTSTRAP.publish_program_proposal( + fixture.repository, + fixture.source_plan, + fixture.candidate, + fixture.source_sha256, + ) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + activation = ACTIVATION.activate_program( + fixture.program_root, + SETUP.adapt_setup_decision( + fixture.program_root, + "Yes", + role="user", + provenance="direct-user-message", + ), + observation, + ) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, + activation.handoff, + role="user", + provenance="direct-user-message", + ) + ACTIVATION.start_first_increment( + fixture.program_root, intent, observation + ) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + prepared = ACTIVATION.prepare_exact_plan( + fixture.program_root, + _exact_plan_bytes(fixture.program_root, observation), + observation, + ) + ACTIVATION.materialize_exact_plan( + fixture.program_root, prepared.plan_prompt, observation + ) + ACTIVATION.advance_execution_state( + fixture.program_root, "implementing", observation + ) + (fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(fixture.repository) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + ACTIVATION.advance_execution_state( + fixture.program_root, "reviewing", observation + ) + candidate = REVIEW.build_review_preparation( + fixture.program_root, observation + ) + evidence = json.loads(candidate.evidence_bytes) + self.assertEqual( + evidence["schema_version"], "implementation-review-evidence/v2" + ) + self.assertIn("product_result", evidence) + self.assertNotIn("requirement_result", evidence) + REVIEW.persist_review_preparation(fixture.program_root, observation) + from tests.test_diff_disposition import DIFF + + diff_candidate = DIFF.build_diff_acceptance_candidate( + fixture.program_root, observation + ) + self.assertEqual( + diff_candidate.approval_record["schema_version"], + "implementation-approval/v3", + ) + self.assertNotIn( + "accepted_product_delta_sha256", diff_candidate.approval_record + ) + DIFF.persist_accept_stop( + fixture.program_root, + f"Accept and stop.\n\n{diff_candidate.prompt}", + observation, + ) + manifest = json.loads( + (fixture.program_root / "manifest.json").read_text(encoding="utf-8") + ) + approval_path = fixture.program_root / manifest["logical_roles"]["approvals"] + original_approval_bytes = approval_path.read_bytes() + approval_lines = original_approval_bytes.decode("utf-8").splitlines() + tamper_cases = { + "scope": lambda record: record.update(scope=[]), + "prompt": lambda record: record.update(submitted_prompt_sha256="0" * 64), + "workspace": lambda record: record["workspace"].update(path="wrong"), + "verification": lambda record: record.update(verification_sha256="0" * 64), + "product": lambda record: record.update(product_result_sha256="0" * 64), + "family": lambda record: record.update(schema_version="implementation-approval/v2"), + } + for label, mutate in tamper_cases.items(): + with self.subTest(tampered_field=label): + records = [json.loads(line) for line in approval_lines] + index = next( + index + for index, value in enumerate(records) + if value.get("schema_version") == "implementation-approval/v3" + ) + record = records[index] + mutate(record) + approval_path.write_text( + "\n".join( + json.dumps(value, ensure_ascii=False, separators=(",", ":")) + for value in records + ) + + "\n", + encoding="utf-8", + ) + self.assertTrue( + REVIEW.validate_state_authority( + fixture.program_root, observation + ) + ) + approval_path.write_bytes(original_approval_bytes) + finally: + fixture.close() + + def test_setup_v2_remediation_round_trip_preserves_typed_result(self) -> None: + fixture = BootstrapFixture() + try: + fixture.configure_delete_setup_v2(path="catalog.txt") + BOOTSTRAP.publish_program_proposal( + fixture.repository, + fixture.source_plan, + fixture.candidate, + fixture.source_sha256, + ) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + activation = ACTIVATION.activate_program( + fixture.program_root, + SETUP.adapt_setup_decision( + fixture.program_root, + "Yes", + role="user", + provenance="direct-user-message", + ), + observation, + ) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, activation.handoff, + role="user", provenance="direct-user-message", + ) + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + prepared = ACTIVATION.prepare_exact_plan( + fixture.program_root, _exact_plan_bytes(fixture.program_root, observation), observation + ) + ACTIVATION.materialize_exact_plan( + fixture.program_root, prepared.plan_prompt, observation + ) + ACTIVATION.advance_execution_state( + fixture.program_root, "implementing", observation + ) + (fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(fixture.repository) + self.add_open_finding(fixture) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + ACTIVATION.advance_execution_state( + fixture.program_root, "reviewing", observation + ) + remediation = REVIEW.persist_review_remediation( + fixture.program_root, observation + ) + self.assertEqual(remediation.increment_state, "remediating") + self.repair_open_finding(fixture) + repaired = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + + status_path = fixture.program_root / "state/status.json" + typed_remediating_status = status_path.read_bytes() + mismatched_status = json.loads(typed_remediating_status) + remediation_binding = mismatched_status[ + "review_remediation_binding" + ] + initial_product_result = remediation_binding[ + "initial_product_result" + ] + present_state = next( + state + for state in initial_product_result["ordered_path_states"] + if state["exists"] + ) + present_state["sha256"] = "f" * 64 + canonical_result = { + "ordered_path_states": initial_product_result[ + "ordered_path_states" + ], + "delete_quarantine_bindings": initial_product_result[ + "delete_quarantine_bindings" + ], + } + mismatched_result_sha256 = hashlib.sha256( + json.dumps( + canonical_result, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + initial_product_result["sha256"] = mismatched_result_sha256 + remediation_binding["initial_product_result_sha256"] = ( + mismatched_result_sha256 + ) + mismatched_status["review_binding"]["candidate_sha256"] = ( + mismatched_result_sha256 + ) + mismatched_status["transition_authority"]["event_id"] = ( + REVIEW._identifier("review-remediation", remediation_binding) + ) + status_path.write_bytes(canonical_json(mismatched_status)) + with self.assertRaisesRegex(ValueError, "review remediation binding"): + REVIEW.return_review_to_reviewing( + fixture.program_root, repaired + ) + status_path.write_bytes(typed_remediating_status) + + returned = REVIEW.return_review_to_reviewing( + fixture.program_root, repaired + ) + self.assertEqual(returned.increment_state, "reviewing") + + typed_status_bytes = status_path.read_bytes() + legacy_status = json.loads(typed_status_bytes) + remediation_binding = legacy_status["review_remediation_binding"] + remediation_binding["schema_version"] = ( + "implementation-review-remediation/v1" + ) + remediation_binding["initial_product_delta_sha256"] = ( + remediation_binding["initial_product_result_sha256"] + ) + legacy_status["review_binding"]["schema_version"] = ( + "implementation-review-remediation/v1" + ) + remediation_sha256 = REVIEW._sha256_bytes( + REVIEW._canonical_json_bytes(remediation_binding) + ) + transition = legacy_status["execution_transition_binding"] + transition["review_remediation_sha256"] = remediation_sha256 + event_seed = { + "program_id": legacy_status["program_id"], + "program_revision": legacy_status["program_revision"], + "increment_id": legacy_status["current_increment_id"], + "prior_status_sha256": transition["prior_status_sha256"], + "prior_increment_state": "remediating", + "target_increment_state": "reviewing", + "product_path_states_sha256": transition[ + "product_path_states_sha256" + ], + "authorization_id": transition["authorization_id"], + "review_remediation_sha256": remediation_sha256, + } + event_id = REVIEW._identifier("execution-transition", event_seed) + transition["event_id"] = event_id + legacy_status["transition_authority"]["event_id"] = event_id + status_path.write_bytes(canonical_json(legacy_status)) + self.assertTrue( + REVIEW.validate_state_authority(fixture.program_root, repaired) + ) + with self.assertRaisesRegex(ValueError, "review remediation"): + REVIEW.build_review_preparation(fixture.program_root, repaired) + status_path.write_bytes(typed_status_bytes) + + completed = REVIEW.persist_review_preparation( + fixture.program_root, repaired + ) + self.assertEqual(completed.increment_state, "awaiting-diff-approval") + evidence = json.loads( + (fixture.program_root / "increments/ARCHIVE-INDEX/review-evidence.json") + .read_text(encoding="utf-8") + ) + self.assertEqual( + evidence["schema_version"], "implementation-review-evidence/v2" + ) + self.assertEqual( + evidence["product_result"]["schema_version"], + "implementation-product-path-states/v2", + ) + finally: + fixture.close() + def test_builder_derives_manifest_owned_valid_review_bundle(self) -> None: fixture, program_root, observation = reviewing_program() try: diff --git a/tests/test_program_setup.py b/tests/test_program_setup.py index 8aabe50..ade0c28 100644 --- a/tests/test_program_setup.py +++ b/tests/test_program_setup.py @@ -113,6 +113,281 @@ def test_recap_projects_every_approval_bound_surface_without_json(self) -> None: self.assertNotIn("Observed head commit:", recap) self.assertNotIn(self.fixture.head, recap) + def test_delete_setup_v2_selects_its_recap_checkpoint_and_adapter_family( + self, + ) -> None: + self.tearDown() + self.fixture = BootstrapFixture() + self.fixture.configure_delete_setup_v2() + + self.assertEqual(SETUP.validate_setup_semantics(self.fixture.candidate), []) + recap = SETUP.render_setup_recap(self.fixture.candidate) + checkpoint = SETUP.setup_recap_checkpoint(self.fixture.candidate, recap) + adapter = SETUP.adapt_setup_decision( + self.fixture.candidate, + "Yes", + role="user", + provenance="direct-user-message", + checkpoint=checkpoint, + ) + + self.assertIn("Supported operations: Create, Modify, Delete, Preserve.", recap) + self.assertIn("Delete catalog.txt", recap) + self.assertEqual( + checkpoint["schema_version"], + "implementation-program-setup-recap-checkpoint/v2", + ) + self.assertEqual( + checkpoint["renderer_schema"], + "implementation-program-setup-recap/v2", + ) + self.assertEqual(checkpoint["renderer_version"], 2) + self.assertEqual(adapter["schema_version"], "setup-approval-decision/v2") + self.assertEqual( + SETUP.validate_setup_decision(self.fixture.candidate, adapter), [] + ) + + def test_setup_v1_rejects_delete_without_mutating_candidate_bytes(self) -> None: + manifest = self.manifest() + envelope = manifest["setup_semantics"]["operation_envelope"] + envelope["supported_operations"] = [ + "Create", + "Modify", + "Delete", + "Preserve", + ] + delete = copy.deepcopy(envelope["allocations"][1]) + delete.update( + operation="Delete", + ownership="program", + accepted_state="absent", + content_disposition="obsolete", + rationale="The accepted program no longer needs the catalog.", + ) + envelope["allocations"][1] = delete + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + before = repository_snapshot(self.fixture.candidate) + + issues = SETUP.validate_setup_semantics(self.fixture.candidate) + + self.assertIn( + "operation envelope must support exactly Create/Modify/Preserve", issues + ) + self.assertIn("operation allocation 1 operation is unsupported", issues) + self.assertIn( + "operation allocation 1 contains unsupported field accepted_state", issues + ) + self.assertEqual(repository_snapshot(self.fixture.candidate), before) + self.assertEqual( + SETUP.SETUP_SEMANTICS_SCHEMA, + "implementation-program-setup-semantics/v1", + ) + self.assertEqual( + SETUP.OPERATION_ENVELOPE_SCHEMA, + "implementation-operation-envelope/v1", + ) + self.assertEqual(SETUP.SUPPORTED_OPERATIONS, ("Create", "Modify", "Preserve")) + + def test_setup_decision_adapters_cannot_substitute_across_families(self) -> None: + for delete_capable, foreign_schema in ( + (False, "setup-approval-decision/v2"), + (True, "setup-approval-decision/v1"), + ): + with self.subTest(delete_capable=delete_capable): + self.tearDown() + self.fixture = BootstrapFixture() + if delete_capable: + self.fixture.configure_delete_setup_v2() + else: + self.fixture.configure_setup_v3() + adapter = SETUP.adapt_setup_decision( + self.fixture.candidate, + "Yes", + role="user", + provenance="direct-user-message", + ) + adapter["schema_version"] = foreign_schema + base = dict(adapter) + base.pop("adapter_id") + adapter["adapter_id"] = SETUP.derive_identifier( + "setup-approval-adapter", base + ) + + self.assertIn( + "setup decision adapter schema mismatch", + SETUP.validate_setup_decision(self.fixture.candidate, adapter), + ) + + def test_delete_setup_v2_enforces_delete_fields_and_exact_path(self) -> None: + cases = ( + ("kind", "bounded-path-class", "Delete allocation kind must be exact-path"), + ("accepted_state", "present", "Delete allocation accepted_state must be absent"), + ( + "content_disposition", + "archive", + "Delete allocation content_disposition is unsupported", + ), + ("rationale", "", "Delete allocation rationale is required"), + ("ownership", "user", "Delete allocation ownership must be program"), + ("collision", "none", "Delete allocation collision is unsupported"), + ) + for field, value, expected_issue in cases: + with self.subTest(field=field): + self.tearDown() + self.fixture = BootstrapFixture() + self.fixture.configure_delete_setup_v2() + manifest = self.manifest() + allocation = manifest["setup_semantics"]["operation_envelope"][ + "allocations" + ][-1] + allocation[field] = value + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + + self.assertIn( + expected_issue, + SETUP.validate_setup_semantics(self.fixture.candidate), + ) + + def test_delete_only_fields_are_rejected_on_non_delete_operations(self) -> None: + self.tearDown() + self.fixture = BootstrapFixture() + self.fixture.configure_delete_setup_v2() + manifest = self.manifest() + create = manifest["setup_semantics"]["operation_envelope"]["allocations"][0] + create.update( + accepted_state="absent", + content_disposition="obsolete", + rationale="Not valid for Create.", + ) + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + + issues = SETUP.validate_setup_semantics(self.fixture.candidate) + + self.assertIn( + "operation allocation 0 contains unsupported field accepted_state", issues + ) + + def test_accepted_predecessor_delete_requires_same_path_create_in_ancestry( + self, + ) -> None: + self.tearDown() + self.fixture = BootstrapFixture() + self.fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-REMOVE") + ) + self.fixture.configure_delete_setup_v2( + path="archive-output.txt", + increment_id="ARCHIVE-REMOVE", + collision="accepted-predecessor", + ) + manifest = self.manifest() + allocations = manifest["setup_semantics"]["operation_envelope"][ + "allocations" + ] + create = next( + allocation + for allocation in allocations + if allocation["path"] == "archive-output.txt" + and allocation["operation"] == "Create" + ) + create["increment_ids"] = ["ARCHIVE-INDEX"] + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + self.assertEqual(SETUP.validate_setup_semantics(self.fixture.candidate), []) + + create["path"] = "other-output.txt" + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + + self.assertIn( + "Delete allocation accepted-predecessor lacks a same-path Create in a strict predecessor", + SETUP.validate_setup_semantics(self.fixture.candidate), + ) + + def test_accepted_predecessor_rejects_malformed_create_increment_ids(self) -> None: + self.fixture.close() + self.fixture = BootstrapFixture() + self.fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-REMOVE") + ) + self.fixture.configure_delete_setup_v2( + path="archive-output.txt", + increment_id="ARCHIVE-REMOVE", + collision="accepted-predecessor", + ) + manifest = self.manifest() + allocations = manifest["setup_semantics"]["operation_envelope"]["allocations"] + create_index = next( + index + for index, allocation in enumerate(allocations) + if allocation["path"] == "archive-output.txt" + and allocation["operation"] == "Create" + ) + for case, increment_ids in ( + ("null", None), + ("integer", 7), + ("boolean", True), + ("mixed-elements", ["ARCHIVE-INDEX", None]), + ): + with self.subTest(case=case): + allocations[create_index]["increment_ids"] = increment_ids + manifest["setup_semantics_sha256"] = canonical_compact_sha256( + manifest["setup_semantics"] + ) + self.fixture.write_json("manifest.json", manifest) + before = repository_snapshot(self.fixture.repository) + try: + try: + issues = SETUP.validate_setup_semantics(self.fixture.candidate) + except TypeError as error: + self.fail(f"setup validation raised TypeError: {error}") + self.assertIn( + f"operation allocation {create_index} increment allocation is invalid", + issues, + ) + self.assertIn( + "Delete allocation accepted-predecessor lacks a same-path Create in a strict predecessor", + issues, + ) + self.assertNotIn("setup_semantics digest mismatch", issues) + finally: + self.assertEqual( + repository_snapshot(self.fixture.repository), before + ) + + def test_mixed_setup_family_is_rejected_before_proposal_publication(self) -> None: + manifest = self.manifest() + semantics = manifest["setup_semantics"] + semantics["operation_envelope"][ + "schema_version" + ] = "implementation-operation-envelope/v2" + manifest["setup_semantics_sha256"] = canonical_compact_sha256(semantics) + self.fixture.write_json("manifest.json", manifest) + before = repository_snapshot(self.fixture.repository) + + with self.assertRaisesRegex(ValueError, "exact supported pair"): + BOOTSTRAP.publish_program_proposal( + self.fixture.repository, + self.fixture.source_plan, + self.fixture.candidate, + self.fixture.source_sha256, + ) + + self.assertEqual(repository_snapshot(self.fixture.repository), before) + def test_authoritative_source_and_increment_dependencies_are_exact(self) -> None: manifest = self.manifest() manifest["setup_semantics"]["sources"][0]["sha256"] = "f" * 64 @@ -514,6 +789,121 @@ def authorize_current_plan(self): self.fixture.program_root, prepared.plan_prompt, self.observation() ) + def test_setup_v2_delete_materializes_typed_baseline_and_quarantine_binding(self): + fixture = BootstrapFixture() + try: + fixture.configure_delete_setup_v2(path="catalog.txt") + BOOTSTRAP.publish_program_proposal( + fixture.repository, fixture.source_plan, fixture.candidate, fixture.source_sha256 + ) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + decision = SETUP.adapt_setup_decision( + fixture.program_root, "Yes", role="user", provenance="direct-user-message" + ) + activation = ACTIVATION.activate_program(fixture.program_root, decision, observation) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, activation.handoff, role="user", provenance="direct-user-message" + ) + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + plan = _exact_plan_bytes(fixture.program_root, observation) + prepared = ACTIVATION.prepare_exact_plan(fixture.program_root, plan, observation) + ACTIVATION.materialize_exact_plan(fixture.program_root, prepared.plan_prompt, observation) + baseline = json.loads( + (fixture.program_root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text(encoding="utf-8") + ) + self.assertEqual(baseline["schema_version"], "implementation-execution-baseline/v2") + self.assertEqual(baseline["file_map"]["delete"], ["catalog.txt"]) + binding = baseline["delete_quarantine_bindings"][0] + self.assertTrue((fixture.program_root / binding["root_path"]).is_dir()) + self.assertEqual(binding["root_mode"], "40700") + self.assertEqual(binding["entry_path"].rsplit("/", 1)[0], binding["root_path"]) + finally: + fixture.close() + + def test_setup_v2_transition_executes_delete_and_persists_v2_result(self): + fixture = BootstrapFixture() + try: + fixture.configure_delete_setup_v2(path="catalog.txt") + BOOTSTRAP.publish_program_proposal(fixture.repository, fixture.source_plan, fixture.candidate, fixture.source_sha256) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + decision = SETUP.adapt_setup_decision(fixture.program_root, "Yes", role="user", provenance="direct-user-message") + activation = ACTIVATION.activate_program(fixture.program_root, decision, observation) + intent = SETUP.adapt_increment_start_intent(fixture.program_root, activation.handoff, role="user", provenance="direct-user-message") + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + prepared = ACTIVATION.prepare_exact_plan(fixture.program_root, _exact_plan_bytes(fixture.program_root, observation), observation) + ACTIVATION.materialize_exact_plan(fixture.program_root, prepared.plan_prompt, observation) + implementing = ACTIVATION.advance_execution_state(fixture.program_root, "implementing", observation) + self.assertEqual(implementing.product_path_states["schema_version"], "implementation-product-path-states/v2") + status = json.loads((fixture.program_root / "state/status.json").read_text(encoding="utf-8")) + transition = status["execution_transition_binding"] + self.assertEqual(transition["schema_version"], "implementation-execution-transition/v2") + self.assertNotIn("product_delta_sha256", transition) + self.assertEqual(transition["product_path_states"], implementing.product_path_states) + self.assertFalse((fixture.repository / "catalog.txt").exists()) + self.assertEqual(len(transition["product_path_states"]["delete_quarantine_bindings"]), 1) + finally: + fixture.close() + + def test_setup_v2_multi_delete_retry_adopts_interrupted_prefix(self): + fixture = BootstrapFixture() + try: + second = fixture.repository / "legacy-two.txt" + second.write_bytes(b"second delete target\n") + from tests.program_bootstrap_support import run_git + + run_git(fixture.repository, "add", "legacy-two.txt") + run_git(fixture.repository, "commit", "-m", "seed second delete target") + fixture.head = run_git(fixture.repository, "rev-parse", "HEAD") + workspace_binding = fixture.load_json("state/workspace.json") + workspace_binding["implementation_workspace"]["base_commit"] = fixture.head + workspace_binding["implementation_workspace"]["head_commit_at_selection"] = fixture.head + fixture.write_json("state/workspace.json", workspace_binding) + fixture.configure_delete_setup_v2(additional_delete_paths=("legacy-two.txt",)) + BOOTSTRAP.publish_program_proposal( + fixture.repository, fixture.source_plan, fixture.candidate, fixture.source_sha256 + ) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + activation = ACTIVATION.activate_program( + fixture.program_root, + SETUP.adapt_setup_decision(fixture.program_root, "Yes", role="user", provenance="direct-user-message"), + observation, + ) + intent = SETUP.adapt_increment_start_intent( + fixture.program_root, activation.handoff, role="user", provenance="direct-user-message" + ) + ACTIVATION.start_first_increment(fixture.program_root, intent, observation) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + prepared = ACTIVATION.prepare_exact_plan( + fixture.program_root, _exact_plan_bytes(fixture.program_root, observation), observation + ) + ACTIVATION.materialize_exact_plan(fixture.program_root, prepared.plan_prompt, observation) + original_write = STATE._write_delete_receipt + attempts = [0] + + def fail_first(*args, **kwargs): + attempts[0] += 1 + if attempts[0] == 1: + raise RuntimeError("receipt interruption") + return original_write(*args, **kwargs) + + with mock.patch.object(STATE, "_write_delete_receipt", side_effect=fail_first): + with self.assertRaisesRegex(RuntimeError, "receipt interruption"): + ACTIVATION.advance_execution_state(fixture.program_root, "implementing", observation) + self.assertTrue(fixture.repository.joinpath("legacy-two.txt").exists()) + interrupted = DISCOVERY.discover_programs(fixture.repository) + self.assertEqual(interrupted.disposition, "execution-transition-recovery-required", interrupted) + observation = ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation + retry = ACTIVATION.advance_execution_state(fixture.program_root, "implementing", observation) + self.assertEqual(retry.product_path_states["schema_version"], "implementation-product-path-states/v2") + self.assertFalse(fixture.repository.joinpath("catalog.txt").exists()) + self.assertFalse(fixture.repository.joinpath("legacy-two.txt").exists()) + self.assertEqual(len(retry.product_path_states["delete_quarantine_bindings"]), 2) + finally: + fixture.close() + + def persist_gate( self, gate: dict[str, object], @@ -1646,14 +2036,15 @@ def test_diff_and_closure_gates_bind_v2_receipts_and_status_last(self) -> None: def test_v3_successor_rollover_uses_successor_grant_kind_and_gate_family(self) -> None: self.tearDown() self.fixture = BootstrapFixture() - self.fixture.configure_successor_chain(("ARCHIVE-INDEX", "ARCHIVE-VERIFY")) + self.fixture.configure_portable_successors() + increments = self.fixture.load_json("manifest.json")["setup_semantics"]["increments"] successor_gate = gate_definition( "SOURCE-GATE-SUCCESSOR", "before-increment-start" ) successor_gate["source_sha256"] = self.fixture.source_sha256 successor_gate["protected_subject"] = "increment:ARCHIVE-VERIFY" self.fixture.configure_setup_v3( - source_gate_definitions=(successor_gate,) + source_gate_definitions=(successor_gate,), increments=increments, ) BOOTSTRAP.publish_program_proposal( self.fixture.repository, @@ -1690,6 +2081,9 @@ def test_v3_successor_rollover_uses_successor_grant_kind_and_gate_family(self) - ).read_text().splitlines() ] self.assertEqual(actions[-1]["actions"], ["rollover-increment"]) + self.assertFalse((self.fixture.program_root / "increments/ARCHIVE-INDEX/handoff.md").exists()) + self.assertFalse((self.fixture.program_root / "increments/ARCHIVE-VERIFY/brief.md").exists()) + self.assertEqual((self.fixture.program_root / "state/rollovers.jsonl").read_bytes(), b"") self.persist_gate(successor_gate, actions[-1]) receipt = DIFF.persist_diff_disposition( diff --git a/tests/test_repository_preparation.py b/tests/test_repository_preparation.py index 5747c3d..beb93f6 100644 --- a/tests/test_repository_preparation.py +++ b/tests/test_repository_preparation.py @@ -658,6 +658,59 @@ def test_parser_requires_one_normalized_disposition_map(self) -> None: ), ) + def test_parser_rejects_delete_sections_without_v2_context(self) -> None: + markdown = """# Plan + +## File map + +### Create + +- `review/evidence.json` + +### Modify + +- `state/status.json` + +### Delete +### Preserve + +- `catalog.txt` +""" + with self.assertRaisesRegex( + ValueError, "Delete section requires setup-v2 exact-file map" + ): + PREPARATION.parse_exact_file_map(markdown) + + def test_v2_parser_accepts_delete_sections_and_empty_delete(self) -> None: + markdown = """# Plan + +## File map + +### Create + +- `review/evidence.json` + +### Modify + +- `state/status.json` + +### Delete + +### Preserve + +- `catalog.txt` +""" + parsed = PREPARATION.parse_exact_file_map_v2(markdown) + self.assertEqual( + parsed, + PREPARATION.ExactFileMapV2( + create=("review/evidence.json",), + modify=("state/status.json",), + delete=(), + preserve=("catalog.txt",), + ), + ) + def test_parser_rejects_duplicates_escapes_and_repeated_sections(self) -> None: valid = """# Plan ## File map @@ -977,5 +1030,95 @@ def test_pre_existing_user_work_is_byte_bound_and_cannot_be_claimed(self) -> Non self.assertIn("pre-existing user work changed: notes.txt", changed.issues) +class ExecutionV2ContractTests(unittest.TestCase): + def baseline_value(self) -> dict[str, object]: + snapshot = { + "path": "legacy.ts", + "exists": True, + "sha256": "a" * 64, + "mode": "100644", + "device": 10, + "inode": 20, + "link_count": 1, + } + digest = __import__("hashlib").sha256( + (json.dumps({"program_id": "P", "program_revision": 1, "increment_id": "I", "path": "legacy.ts", "baseline_sha256": "a" * 64, "device": 10, "inode": 20, "mode": "100644", "link_count": 1}, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode() + ).hexdigest() + return { + "schema_version": "implementation-execution-baseline/v2", + "program_id": "P", + "program_revision": 1, + "increment_id": "I", + "exact_file_plan_sha256": "b" * 64, + "current_increment_authority_binding": {"grant_id": "G"}, + "workspace_observation": {}, + "file_map": { + "create": ["new.txt"], + "modify": ["changed.txt"], + "delete": ["legacy.ts"], + "preserve": ["keep.txt"], + }, + "path_baselines": [ + {"path": "new.txt", "disposition": "Create", "snapshot": {"path": "new.txt", "exists": False, "sha256": None, "mode": None, "device": None, "inode": None, "link_count": None}}, + {"path": "changed.txt", "disposition": "Modify", "snapshot": snapshot | {"path": "changed.txt"}}, + {"path": "legacy.ts", "disposition": "Delete", "snapshot": snapshot}, + {"path": "keep.txt", "disposition": "Preserve", "snapshot": snapshot | {"path": "keep.txt"}}, + ], + "delete_quarantine_bindings": [ + { + "path": "legacy.ts", + "root_path": "increments/I/delete-quarantine", + "root_owner": 100, + "root_mode": "700", + "root_device": 30, + "root_inode": 40, + "entry_path": f"increments/I/delete-quarantine/delete-{digest}.bin", + "receipt_path": f"increments/I/delete-quarantine/delete-{digest}.receipt.json", + } + ], + "protected_control_allocations": [ + "increments/I/delete-quarantine", + f"increments/I/delete-quarantine/delete-{digest}.bin", + f"increments/I/delete-quarantine/delete-{digest}.receipt.json", + ], + "user_work_baselines": [], + "inherited_paths": [], + } + + def test_v2_baseline_and_product_state_contracts_are_typed(self) -> None: + baseline = PREPARATION.execution_baseline_v2_from_value(self.baseline_value()) + self.assertIsInstance(baseline.file_map, PREPARATION.ExactFileMapV2) + self.assertEqual(baseline.file_map.delete, ("legacy.ts",)) + self.assertEqual(baseline.delete_quarantine_bindings[0]["path"], "legacy.ts") + + states_value = { + "schema_version": "implementation-product-path-states/v2", + "ordered_path_states": [ + {"path": "new.txt", "exists": True, "sha256": "e" * 64, "mode": "100644", "device": 11, "inode": 21, "link_count": 1}, + {"path": "changed.txt", "exists": True, "sha256": "a" * 64, "mode": "100644", "device": 10, "inode": 20, "link_count": 1}, + {"path": "legacy.ts", "exists": False, "sha256": None, "mode": None, "device": None, "inode": None, "link_count": None}, + {"path": "keep.txt", "exists": True, "sha256": "a" * 64, "mode": "100644", "device": 10, "inode": 20, "link_count": 1}, + ], + "delete_quarantine_bindings": [{"path": "legacy.ts", "receipt_path": "q.receipt.json", "receipt_sha256": "c" * 64}], + "sha256": "d" * 64, + } + canonical = { + "ordered_path_states": states_value["ordered_path_states"], + "delete_quarantine_bindings": states_value["delete_quarantine_bindings"], + } + states_value["sha256"] = __import__("hashlib").sha256( + json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode() + ).hexdigest() + states = PREPARATION.product_path_states_v2_from_value(states_value) + self.assertEqual(states.ordered_path_states[2].operation, "Delete") + self.assertEqual(states.delete_quarantine_bindings[0]["receipt_sha256"], "c" * 64) + wire = PREPARATION.product_path_states_v2_value(states) + reparsed = PREPARATION.product_path_states_v2_from_value(wire) + self.assertEqual(reparsed.sha256, wire["sha256"]) + + with self.assertRaisesRegex(ValueError, "product path states structure is invalid"): + PREPARATION.product_path_states_v2_from_value({**wire, "program_id": "P"}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_review_coordination.py b/tests/test_review_coordination.py index d3012ea..90406aa 100644 --- a/tests/test_review_coordination.py +++ b/tests/test_review_coordination.py @@ -616,6 +616,20 @@ def test_complete_packet_validates_and_renders_deterministically(self) -> None: self.assertEqual(rendered, REVIEW.render_review_packet(value)) self.assertTrue(rendered.startswith("# Review Packet\n\n## Identity and outcome\n")) + def test_v2_packet_renders_explicit_product_result_binding(self) -> None: + value = packet(schema_version="implementation-review-packet/v2") + + rendered = REVIEW.render_review_packet(value) + + self.assertIn( + "Packet schema: implementation-review-packet/v2", rendered + ) + self.assertIn( + "Product result schema: implementation-product-path-states/v2", + rendered, + ) + self.assertIn(f"Product result SHA-256: {value.candidate_sha256}", rendered) + def test_command_only_packet_is_rejected(self) -> None: empty = { name: () @@ -651,6 +665,21 @@ def test_packet_candidate_binding_matches_final_verification(self) -> None: class IntegrationTests(unittest.TestCase): + def test_review_evidence_rejects_cross_family_packet_versions(self) -> None: + bundle = json.loads(FIXTURE_EVIDENCE.read_text(encoding="utf-8")) + packet_text = FIXTURE_PACKET.read_text(encoding="utf-8") + mixed_v1 = dict(bundle) + mixed_v1["review_packet"] = { + **bundle["review_packet"], + "schema_version": "implementation-review-packet/v2", + } + self.assertTrue( + any( + "v1 review evidence requires a v1 review packet" in issue + for issue in REVIEW.validate_review_bundle(mixed_v1, packet_text) + ) + ) + def test_neutral_repaired_finding_bundle_matches_persisted_packet(self) -> None: bundle = json.loads(FIXTURE_EVIDENCE.read_text(encoding="utf-8")) packet_text = FIXTURE_PACKET.read_text(encoding="utf-8") diff --git a/tests/test_state_authority.py b/tests/test_state_authority.py index 43b8e0e..a58c615 100644 --- a/tests/test_state_authority.py +++ b/tests/test_state_authority.py @@ -3,7 +3,10 @@ import json import os import shutil +import subprocess +import sys import tempfile +import textwrap import unittest from contextlib import redirect_stdout from io import StringIO @@ -16,7 +19,12 @@ repository_snapshot, ) from tests.script_module_support import load_script_module -from tests.test_program_activation import activated_program +from tests.test_program_activation import ( + ACTIVATION as PROGRAM_ACTIVATION, + SETUP as PROGRAM_SETUP, + activated_program, + proposal_observation, +) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -33,7 +41,10 @@ AUTHORITY = load_script_module("state_authority", STATE_AUTHORITY_PATH) -from tests.test_diff_disposition import awaiting_diff_program +from tests.test_diff_disposition import ( + awaiting_diff_program, + delete_setup_v2_awaiting_diff_program, +) from tests.test_blocked_recovery import BLOCKED, block_request, implementing_program from tests.test_program_rollover import ROLLOVER, accepted_continuation_program @@ -63,6 +74,198 @@ def test_rollover_is_a_supported_distinct_lifecycle_action(self) -> None: def test_blocked_resume_is_a_supported_distinct_lifecycle_action(self) -> None: self.assertIn("resume-blocked-program", AUTHORITY.ACTION_NAMES) + def test_v2_delete_result_and_quarantine_tampering_requires_recovery(self) -> None: + fixture, program_root, observation = delete_setup_v2_awaiting_diff_program() + try: + authority_observation = PROGRAM_ACTIVATION._without_owned_program_paths( + program_root, observation + ) + status_path = program_root / "state/status.json" + original_status = status_path.read_bytes() + status = json.loads(original_status) + transition = status["execution_transition_binding"] + baseline = json.loads( + (program_root / "increments/ARCHIVE-INDEX/execution-baseline.json").read_text() + ) + binding = baseline["delete_quarantine_bindings"][0] + entry_path = program_root / binding["entry_path"] + receipt_path = program_root / binding["receipt_path"] + original_entry = entry_path.read_bytes() + original_receipt = receipt_path.read_bytes() + + def reorder(value: dict[str, object]) -> None: + states = value["execution_transition_binding"]["product_path_states"]["ordered_path_states"] + states[0], states[1] = states[1], states[0] + + def omit(value: dict[str, object]) -> None: + value["execution_transition_binding"]["product_path_states"]["ordered_path_states"].pop() + + def stale_receipt_binding(value: dict[str, object]) -> None: + value["execution_transition_binding"]["product_path_states"]["delete_quarantine_bindings"][0]["receipt_path"] = "increments/ARCHIVE-INDEX/delete-quarantine/wrong.receipt.json" + + def stale_remediation(value: dict[str, object]) -> None: + value["review_preparation_binding"] = { + **value["review_preparation_binding"], + "product_result_sha256": "0" * 64, + } + + def stale_preparation_schema(value: dict[str, object]) -> None: + value["review_preparation_binding"] = { + **value["review_preparation_binding"], + "product_result_schema_version": "implementation-product-path-states/v1", + } + + def stale_evidence_digest(value: dict[str, object]) -> None: + value["review_evidence_binding"] = { + **value["review_evidence_binding"], + "product_result_sha256": "0" * 64, + } + + def stale_evidence_schema(value: dict[str, object]) -> None: + value["review_evidence_binding"] = { + **value["review_evidence_binding"], + "product_result_schema_version": "implementation-product-path-states/v1", + } + + def stale_packet_digest(value: dict[str, object]) -> None: + value["review_packet_binding"] = { + **value["review_packet_binding"], + "product_result_sha256": "0" * 64, + } + + def stale_packet_schema(value: dict[str, object]) -> None: + value["review_packet_binding"] = { + **value["review_packet_binding"], + "product_result_schema_version": "implementation-product-path-states/v1", + } + + cases = ( + ("reordered states", reorder, None, None), + ("omitted state", omit, None, None), + ("stale receipt binding", stale_receipt_binding, None, None), + ("stale remediation binding", stale_remediation, None, None), + ("stale preparation schema", stale_preparation_schema, None, "review preparation product result binding"), + ("stale evidence digest", stale_evidence_digest, None, "review evidence product result binding"), + ("stale evidence schema", stale_evidence_schema, None, "review evidence product result binding"), + ("stale packet digest", stale_packet_digest, None, "review packet product result binding"), + ("stale packet schema", stale_packet_schema, None, "review packet product result binding"), + ("wrong quarantine entry bytes", None, entry_path, None), + ("wrong receipt bytes", None, receipt_path, None), + ) + for label, mutate_status, mutate_file, expected_issue in cases: + with self.subTest(case=label): + status_path.write_bytes(original_status) + entry_path.write_bytes(original_entry) + receipt_path.write_bytes(original_receipt) + if mutate_status is not None: + value = json.loads(original_status) + mutate_status(value) + status_path.write_bytes(canonical_json(value)) + else: + mutate_file.write_bytes(mutate_file.read_bytes() + b"tampered") + issues = AUTHORITY.validate_state_authority( + program_root, authority_observation + ) + self.assertTrue(issues, label) + if expected_issue is not None: + self.assertTrue( + any(expected_issue in issue for issue in issues), issues + ) + finally: + fixture.close() + + def test_setup_v2_accepted_state_rejects_coordinated_review_downgrade( + self, + ) -> None: + fixture, program_root, observation = delete_setup_v2_awaiting_diff_program() + try: + from tests.test_diff_disposition import DIFF + + candidate = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + DIFF.persist_accept_stop( + program_root, + f"Accept and stop.\n\n{candidate.prompt}", + observation, + ) + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + result_sha256 = status["execution_transition_binding"][ + "product_path_states_sha256" + ] + preparation = status["review_preparation_binding"] + preparation["schema_version"] = ( + "implementation-review-preparation/v1" + ) + preparation["product_delta_sha256"] = result_sha256 + preparation.pop("product_result_schema_version") + preparation.pop("product_result_sha256") + for label in ("review_evidence_binding", "review_packet_binding"): + status[label].pop("product_result_schema_version") + status[label].pop("product_result_sha256") + disposition = status["diff_disposition_binding"] + disposition["schema_version"] = ( + "implementation-diff-disposition-binding/v1" + ) + disposition["accepted_product_delta_sha256"] = result_sha256 + disposition.pop("product_result_schema_version") + disposition.pop("product_result_sha256") + status_path.write_bytes(canonical_json(status)) + + issues = AUTHORITY.validate_state_authority( + program_root, observation + ) + + self.assertIn( + "accepted status family does not match controlling setup family", + issues, + ) + finally: + fixture.close() + + + def test_setup_activation_records_cannot_substitute_across_setup_families( + self, + ) -> None: + cases = ( + (False, "setup-activation-decision/v2"), + (True, "setup-activation-decision/v1"), + ) + for delete_capable, foreign_schema in cases: + with self.subTest(delete_capable=delete_capable): + fixture = BootstrapFixture() + try: + if delete_capable: + fixture.configure_delete_setup_v2() + else: + fixture.configure_setup_v3() + observation = proposal_observation(fixture) + decision = PROGRAM_SETUP.adapt_setup_decision( + fixture.candidate, + "Yes", + role="user", + provenance="direct-user-message", + ) + PROGRAM_ACTIVATION.activate_program( + fixture.candidate, decision, observation + ) + setup_path = ( + fixture.candidate / "state/setup-activation-decision.json" + ) + setup = json.loads(setup_path.read_text(encoding="utf-8")) + setup["schema_version"] = foreign_schema + setup_path.write_bytes(canonical_json(setup)) + + issues = AUTHORITY.validate_state_authority( + fixture.candidate, observation + ) + + self.assertTrue(issues) + self.assertIn("setup-activation decision", " ".join(issues)) + finally: + fixture.close() + def test_status_brief_must_match_the_status_current_increment_grant(self) -> None: fixture = BootstrapFixture() try: @@ -135,36 +338,37 @@ def test_final_increment_derives_modify_review_and_closure_allocations(self) -> def test_traceability_successor_does_not_cross_disjoint_allocations(self) -> None: traceability = { "atomic_requirements": [ - {"assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, - {"assigned_increments": ["INCREMENT-C", "INCREMENT-D"]}, + {"id": "REQ-ONE", "assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, + {"id": "REQ-TWO", "assigned_increments": ["INCREMENT-C", "INCREMENT-D"]}, ] } - self.assertIsNone( - AUTHORITY._traceability_successor(traceability, "INCREMENT-B") - ) + resolution = AUTHORITY.resolve_increment_successor({"schema_version": "implementation-program-manifest/v2"}, traceability["atomic_requirements"], "INCREMENT-B", ("INCREMENT-A",)) + self.assertEqual(resolution.kind, "unavailable") + self.assertIn("outstanding allocated work", resolution.reason) def test_traceability_successor_suppresses_multiple_direct_successors(self) -> None: traceability = { "atomic_requirements": [ - {"assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, - {"assigned_increments": ["INCREMENT-A", "INCREMENT-C"]}, + {"id": "REQ-ONE", "assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, + {"id": "REQ-TWO", "assigned_increments": ["INCREMENT-A", "INCREMENT-C"]}, ] } - self.assertIsNone( - AUTHORITY._traceability_successor(traceability, "INCREMENT-A") - ) + resolution = AUTHORITY.resolve_increment_successor({"schema_version": "implementation-program-manifest/v2"}, traceability["atomic_requirements"], "INCREMENT-A", ()) + self.assertEqual(resolution.kind, "unavailable") + self.assertEqual(resolution.reason, "multiple allocated successors") def test_traceability_successor_rejects_duplicate_allocation_entries(self) -> None: traceability = { "atomic_requirements": [ - {"assigned_increments": ["INCREMENT-A", "INCREMENT-A"]}, + {"id": "REQ-ONE", "assigned_increments": ["INCREMENT-A", "INCREMENT-A"]}, ] } - with self.assertRaisesRegex(ValueError, "unique strings"): - AUTHORITY._traceability_successor(traceability, "INCREMENT-A") + resolution = AUTHORITY.resolve_increment_successor({"schema_version": "implementation-program-manifest/v2"}, traceability["atomic_requirements"], "INCREMENT-A", ()) + self.assertEqual(resolution.kind, "unavailable") + self.assertIn("unique safe strings", resolution.reason) def test_unique_traceability_successor_replaces_closure_with_navigation(self) -> None: fixture = BootstrapFixture() @@ -306,7 +510,7 @@ def test_blocked_path_validation_failure_is_reported(self) -> None: def test_arbitrary_genesis_rollover_row_is_not_state_authority(self) -> None: fixture, program_root, observation = awaiting_diff_program( - {"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)} + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} ) try: rollover_path = program_root / "state/rollovers.jsonl" @@ -377,6 +581,544 @@ def interrupt(completed_label: str) -> None: fixture.close() +class DescriptorRelativeWorkspacePathTests(unittest.TestCase): + def setUp(self) -> None: + self.workspace = Path(tempfile.mkdtemp(prefix="descriptor-workspace-")) + (self.workspace / "src").mkdir() + (self.workspace / "src/file.txt").write_bytes(b"descriptor-bound bytes\n") + + def tearDown(self) -> None: + shutil.rmtree(self.workspace) + + def test_inspector_returns_descriptor_bound_regular_file_snapshot(self) -> None: + snapshot = AUTHORITY.inspect_workspace_path(self.workspace, "src/file.txt") + + self.assertEqual(snapshot.path, "src/file.txt") + self.assertTrue(snapshot.exists) + self.assertEqual( + snapshot.sha256, + hashlib.sha256(b"descriptor-bound bytes\n").hexdigest(), + ) + self.assertEqual(snapshot.mode, "100644") + self.assertIsInstance(snapshot.device, int) + self.assertIsInstance(snapshot.inode, int) + self.assertEqual(snapshot.link_count, 1) + + def test_inspector_returns_typed_absence_for_descriptor_relative_enoent(self) -> None: + snapshot = AUTHORITY.inspect_workspace_path(self.workspace, "src/missing.txt") + + self.assertEqual( + snapshot, + AUTHORITY.WorkspacePathSnapshot( + path="src/missing.txt", + exists=False, + sha256=None, + mode=None, + device=None, + inode=None, + link_count=None, + ), + ) + + def test_inspector_rejects_unsafe_paths_and_file_kinds(self) -> None: + (self.workspace / "src/link.txt").symlink_to("file.txt") + (self.workspace / "src/dir").mkdir() + cases = ( + "/src/file.txt", + "src/./file.txt", + "src/../file.txt", + "src\\file.txt", + ".git/config", + "src/link.txt", + "src/dir", + ) + for path in cases: + with self.subTest(path=path): + with self.assertRaises(ValueError): + AUTHORITY.inspect_workspace_path(self.workspace, path) + + def test_inspector_rejects_hardlink_and_protected_identity_aliases(self) -> None: + alias = self.workspace / "src/alias.txt" + alias.hardlink_to(self.workspace / "src/file.txt") + alias_stat = alias.stat(follow_symlinks=False) + identity = (alias_stat.st_dev, alias_stat.st_ino) + + with self.assertRaises(ValueError): + AUTHORITY.inspect_workspace_path(self.workspace, "src/alias.txt") + with self.assertRaises(ValueError): + AUTHORITY.inspect_workspace_path( + self.workspace, + "src/file.txt", + protected_identities=(identity,), + ) + + def test_inspector_rejects_protected_paths_and_git_components_but_allows_git_names(self) -> None: + (self.workspace / ".github").mkdir() + (self.workspace / ".github/workflow.git.txt").write_bytes(b"ok") + (self.workspace / "git-not-metadata.txt").write_bytes(b"ok") + + self.assertTrue( + AUTHORITY.inspect_workspace_path( + self.workspace, ".github/workflow.git.txt" + ).exists + ) + self.assertTrue( + AUTHORITY.inspect_workspace_path( + self.workspace, "git-not-metadata.txt" + ).exists + ) + with self.assertRaises(ValueError): + AUTHORITY.inspect_workspace_path( + self.workspace, + "src/file.txt", + protected_paths=("src",), + ) + + +class DeleteQuarantineTests(unittest.TestCase): + def setUp(self) -> None: + self.workspace = Path(tempfile.mkdtemp(prefix="delete-workspace-")) + self.program_root = self.workspace / "implementation-programs/DELETE-PROGRAM" + self.program_root.mkdir(parents=True) + self.target = self.workspace / "legacy.ts" + self.target.write_bytes(b"bytes retained by quarantine\n") + write_json( + self.program_root / "manifest.json", + { + "schema_version": "implementation-program-manifest/v3", + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "logical_roles": {"status": "state/status.json"}, + "increment_storage": { + "schema_version": "implementation-increment-storage/v1", + "root": "increments", + "brief_filename": "brief.md", + "exact_file_plan_filename": "exact-file-plan.md", + "execution_baseline_filename": "execution-baseline.json", + "review_evidence_filename": "review-evidence.json", + "review_packet_filename": "review-packet.md", + "handoff_filename": "handoff.md", + }, + }, + ) + write_json( + self.program_root / "state/status.json", + { + "current_increment_id": "DELETE-1", + "current_increment_authority_binding": {"grant_id": "DELETE-GRANT"}, + }, + ) + self.baseline = AUTHORITY.inspect_workspace_path(self.workspace, "legacy.ts") + + def tearDown(self) -> None: + shutil.rmtree(self.workspace) + + def allocate(self, baseline: dict[str, object]) -> object: + allocation = AUTHORITY.delete_quarantine_allocation( + self.program_root, self.workspace, "legacy.ts", baseline + ) + baseline.update( + quarantine_root_path=allocation.root_path, + quarantine_root_device=allocation.root_device, + quarantine_root_inode=allocation.root_inode, + quarantine_root_mode=allocation.root_mode, + quarantine_root_owner=allocation.root_owner, + ) + return allocation + + def test_allocation_is_manifest_owned_and_deterministic(self) -> None: + first = AUTHORITY.delete_quarantine_allocation( + self.program_root, + self.workspace, + "legacy.ts", + { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + }, + ) + second = AUTHORITY.delete_quarantine_allocation( + self.program_root, + self.workspace, + "legacy.ts", + { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + }, + ) + + self.assertEqual(first, second) + self.assertEqual(first.root_path, "increments/DELETE-1/delete-quarantine") + self.assertNotIn("legacy.ts", first.quarantine_path) + self.assertFalse(Path(first.quarantine_path).is_absolute()) + self.assertTrue((self.program_root / first.root_path).is_dir()) + + def test_direct_mutation_without_preallocated_root_fails_before_target_movement(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + with self.assertRaisesRegex(ValueError, "allocation is missing"): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertTrue(self.target.exists()) + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + self.assertFalse((self.program_root / "increments").exists()) + + def test_bound_regular_file_moves_without_unlink_and_writes_receipt(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + receipt = AUTHORITY.quarantine_bound_regular_file( + self.program_root, + self.workspace, + "legacy.ts", + baseline, + ) + + self.assertFalse(self.target.exists()) + quarantine = self.program_root / receipt.quarantine_path + self.assertEqual(quarantine.read_bytes(), b"bytes retained by quarantine\n") + self.assertEqual(receipt.schema_version, AUTHORITY.DELETE_QUARANTINE_RECEIPT_SCHEMA_V1) + self.assertEqual(receipt.final_state, "absent") + self.assertEqual(receipt.baseline_sha256, self.baseline.sha256) + self.assertEqual(receipt.quarantine_sha256, self.baseline.sha256) + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "resume") + self.assertEqual(recovery.receipt, receipt) + + def test_symlinked_or_wrong_mode_quarantine_root_fails_before_move(self) -> None: + root = self.program_root / "increments/DELETE-1" + root.mkdir(parents=True) + outside = self.workspace / "outside" + outside.mkdir() + (root / "delete-quarantine").symlink_to(outside, target_is_directory=True) + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + with self.assertRaises(ValueError): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + + def test_relocated_program_root_ancestor_fails_before_move(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + outside = Path(tempfile.mkdtemp(prefix="delete-program-outside-")) + self.addCleanup(shutil.rmtree, outside) + programs = self.workspace / "implementation-programs" + relocated = outside / programs.name + programs.rename(relocated) + programs.symlink_to(relocated, target_is_directory=True) + + with self.assertRaises(ValueError): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + self.assertFalse(any((relocated / "DELETE-PROGRAM").glob("**/*.receipt.json"))) + + def test_program_root_ancestor_race_fails_before_move(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + outside = Path(tempfile.mkdtemp(prefix="delete-program-race-")) + self.addCleanup(shutil.rmtree, outside) + programs = self.workspace / "implementation-programs" + relocated = outside / programs.name + original_revalidate = AUTHORITY._revalidate_held_delete_target + + def relocate_program_root(held: object) -> None: + original_revalidate(held) + programs.rename(relocated) + programs.symlink_to(relocated, target_is_directory=True) + + with mock.patch.object( + AUTHORITY, + "_revalidate_held_delete_target", + side_effect=relocate_program_root, + ): + with self.assertRaisesRegex(ValueError, "path changed before rename"): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + self.assertFalse(any((relocated / "DELETE-PROGRAM").glob("**/*.receipt.json"))) + + def test_quarantine_destination_collision_fails_without_replacing_bytes(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + allocation = self.allocate(baseline) + destination = self.program_root / allocation.quarantine_path + destination.write_bytes(b"attacker bytes\n") + with self.assertRaises(ValueError): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + self.assertEqual(destination.read_bytes(), b"attacker bytes\n") + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "recovery-required") + + def test_preallocated_quarantine_mode_drift_fails_before_move(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + allocation = self.allocate(baseline) + root = self.program_root / allocation.root_path + original = root.stat() + source_bytes = self.target.read_bytes() + self.assertEqual(original.st_mode & 0o777, 0o700) + self.assertEqual( + AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ).disposition, + "retry-ready", + ) + + root.chmod(0o755) + changed = root.stat() + self.assertEqual(changed.st_mode & 0o777, 0o755) + self.assertEqual( + (changed.st_dev, changed.st_ino, changed.st_uid), + (original.st_dev, original.st_ino, original.st_uid), + ) + with self.assertRaisesRegex( + ValueError, "^Delete quarantine allocation binding changed$" + ): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), source_bytes) + self.assertEqual(source_bytes, b"bytes retained by quarantine\n") + self.assertEqual( + AUTHORITY.inspect_workspace_path(self.workspace, "legacy.ts"), self.baseline + ) + self.assertFalse((self.program_root / allocation.quarantine_path).exists()) + self.assertFalse((self.program_root / allocation.receipt_path).exists()) + + def test_rename_failure_is_fail_closed_and_recovery_reports_exact_snapshots(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + original_supports_dir_fd = AUTHORITY.os.supports_dir_fd + with mock.patch.object( + AUTHORITY.os, "rename", side_effect=OSError(18, "cross-device") + ): + with mock.patch.object( + AUTHORITY.os, + "supports_dir_fd", + frozenset((*original_supports_dir_fd, AUTHORITY.os.rename)), + ): + with self.assertRaisesRegex( + OSError, + AUTHORITY.DESCRIPTOR_RELATIVE_DELETE_UNSUPPORTED, + ): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "retry-ready") + self.assertTrue(recovery.source.exists) + self.assertFalse(recovery.quarantine.exists) + + def test_missing_descriptor_primitives_fail_before_allocation(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + with mock.patch.object(AUTHORITY.os, "supports_dir_fd", frozenset()): + with self.assertRaisesRegex( + OSError, + AUTHORITY.DESCRIPTOR_RELATIVE_DELETE_UNSUPPORTED, + ): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(self.target.read_bytes(), b"bytes retained by quarantine\n") + self.assertFalse((self.program_root / "increments").exists()) + + def test_receipt_write_interrupt_leaves_adoption_ready_quarantine(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + with mock.patch.object( + AUTHORITY, "_write_delete_receipt", side_effect=RuntimeError("interrupt") + ): + with self.assertRaisesRegex(RuntimeError, "interrupt"): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "receipt-adoption-ready") + self.assertFalse(recovery.source.exists) + self.assertTrue(recovery.quarantine.exists) + self.assertEqual(recovery.quarantine.sha256, self.baseline.sha256) + + def test_receipt_adoption_rechecks_source_absence_before_publish(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + allocation = self.allocate(baseline) + moved = AUTHORITY.os.rename + workspace_fd = os.open(self.workspace, os.O_RDONLY) + root_fd = os.open(self.program_root / allocation.root_path, os.O_RDONLY) + try: + moved( + "legacy.ts", + allocation.quarantine_name, + src_dir_fd=workspace_fd, + dst_dir_fd=root_fd, + ) + finally: + os.close(workspace_fd) + os.close(root_fd) + original_write = AUTHORITY._write_delete_receipt + + def write_then_replace(*args: object, **kwargs: object) -> None: + original_write(*args, **kwargs) + self.target.write_bytes(b"replacement during adoption\n") + + original_supports_dir_fd = AUTHORITY.os.supports_dir_fd + with mock.patch.object(AUTHORITY, "_write_delete_receipt", side_effect=write_then_replace): + with mock.patch.object( + AUTHORITY.os, + "supports_dir_fd", + frozenset((*original_supports_dir_fd, AUTHORITY.os.rename)), + ): + with self.assertRaisesRegex(ValueError, "replacement appeared after receipt adoption"): + AUTHORITY.adopt_delete_quarantine_receipt( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertTrue((self.program_root / allocation.receipt_path).exists()) + self.assertEqual(self.target.read_bytes(), b"replacement during adoption\n") + + def test_post_rename_source_replacement_is_recovery_required_without_loss(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + real_rename = AUTHORITY.os.rename + + def move_then_replace(*args: object, **kwargs: object) -> None: + real_rename(*args, **kwargs) + self.target.write_bytes(b"raced replacement\n") + + original_supports_dir_fd = AUTHORITY.os.supports_dir_fd + with mock.patch.object(AUTHORITY.os, "rename", side_effect=move_then_replace): + with mock.patch.object( + AUTHORITY.os, + "supports_dir_fd", + frozenset((*original_supports_dir_fd, AUTHORITY.os.rename)), + ): + with self.assertRaisesRegex(ValueError, "replacement appeared"): + AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "recovery-required") + self.assertTrue(recovery.source.exists) + self.assertTrue(recovery.quarantine.exists) + self.assertEqual(self.target.read_bytes(), b"raced replacement\n") + + def test_wrong_receipt_and_quarantine_bytes_are_recovery_required(self) -> None: + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + allocation = AUTHORITY.delete_quarantine_allocation( + self.program_root, self.workspace, "legacy.ts", baseline + ) + quarantine = self.program_root / allocation.quarantine_path + quarantine.write_bytes(b"wrong bytes\n") + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "recovery-required") + + shutil.rmtree(self.workspace) + self.setUp() + baseline = { + **self.baseline.__dict__, + "program_id": "DELETE-PROGRAM", + "program_revision": 7, + "increment_id": "DELETE-1", + } + self.allocate(baseline) + receipt = AUTHORITY.quarantine_bound_regular_file( + self.program_root, self.workspace, "legacy.ts", baseline + ) + receipt_file = self.program_root / receipt.quarantine_path.replace( + ".bin", ".receipt.json" + ) + receipt_file.write_bytes(b"not a receipt\n") + recovery = AUTHORITY.classify_delete_quarantine_recovery( + self.program_root, self.workspace, "legacy.ts", baseline + ) + self.assertEqual(recovery.disposition, "recovery-required") + + class DeferredMutationGuardTests(unittest.TestCase): def test_new_program_state_rejects_malformed_authority_bindings(self) -> None: fixture, program_root, observation = awaiting_diff_program() @@ -2010,5 +2752,54 @@ def test_cli_returns_zero_one_and_two_deterministically(self) -> None: self.assertIn("usage:", output.getvalue()) +class PlatformImportCompatibilityTests(unittest.TestCase): + def test_non_darwin_imports_keep_legacy_authority_available(self) -> None: + script = textwrap.dedent(""" + import ctypes + import pathlib + import shutil + import subprocess + import sys + import tempfile + import unittest + from unittest import mock + + # Initialize native standard-library backends before spoofing only + # the optional application library-selection condition. + selected_platform = sys.argv[1] + sys.path.insert(0, sys.argv[2]) + with mock.patch.object(sys, "platform", selected_platform): + with mock.patch.object( + ctypes, "CDLL", + side_effect=TypeError("Windows loader requires a string"), + ) as loader: + from tests.test_state_authority import WorkspaceAndBindingTests + import program_discovery + import program_activation + suite = unittest.TestSuite([ + WorkspaceAndBindingTests( + "test_valid_state_authority_and_workspace_pass" + ) + ]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + loader.assert_not_called() + if not result.wasSuccessful(): + raise SystemExit(1) + """) + for selected_platform in ("win32", "linux"): + with self.subTest(platform=selected_platform): + completed = subprocess.run( + [sys.executable, "-c", script, selected_platform, str(SCRIPT_ROOT)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual( + completed.returncode, 0, + completed.stdout + completed.stderr, + ) + + if __name__ == "__main__": unittest.main()