From eb3440fb712f2bbe22e25117465a33ae1984817e Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sat, 5 Sep 2026 22:20:18 -0400 Subject: [PATCH 01/19] docs: plan typed delete operation support --- .../2026-09-05-delete-operation-support.md | 1003 +++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-delete-operation-support.md 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..ea8cfea --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -0,0 +1,1003 @@ +# Delete Operation Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add truthful, fail-closed support for exact regular-file `Delete` operations so a program can preserve an accepted absent path through review, diff acceptance, rollover, recovery, and closure without weakening existing `Create`, `Modify`, or `Preserve` contracts. + +**Architecture:** Keep manifest/status v1, v2, and existing v3 programs on their exact current routes. Extend manifest-v3 only through explicitly versioned nested v2 setup, file-map, baseline, product-result, rollover, blocked-context, and closure contracts; route by exact schema, never by optional-field presence. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. + +**Tech Stack:** Python 3 standard library, frozen dataclasses, canonical JSON and SHA-256, `unittest`, temporary Git repositories, existing atomic/no-overwrite/status-last writers. + +**Spec:** The real consumer is `/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md` at SHA-256 `a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b`; the compatible broader design context is `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` and `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md`. + +## Global Constraints + +- Start only from a clean branch `repair/delete-operation-support` whose kickoff HEAD is the plan-only commit directly above `b5eb689e780f48b218b807a4691f0994474e4178` (candidate parent `c6a32575ee07b79cc26fcecfec037f2a206f442a`) and whose only kickoff delta from the candidate is this plan file. +- Use `rtk` for every repository command. +- Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. +- Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. +- Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; mixed v1/v2 nested contracts fail before every write. +- A `Delete` target must be one normalized repository-relative path to an existing program-owned regular non-symlink, non-hard-linked file beneath the selected workspace. Directories, symlinks, symlinked ancestors, hard links, special files, missing parents, external paths, protected paths, and pre-existing user work remain unsupported. +- `Delete` means the approved final state is absent. Never encode absence as `Modify`, `Preserve`, an omitted path, an empty digest, or a fabricated digest. +- `authorized` requires every Delete target to remain byte-identical to its baseline; `implementing` permits either the exact baseline file or its absence; `reviewing` and later require absence. A changed-but-present Delete target is always invalid. +- A typed local Delete remains within the exact plan-bound `modify-workspace` action. It does not grant the separately named `destructive-operation`, cleanup, migration, Git, publication, deployment, provider, or external-state actions. +- Keep public `prepare_exact_plan(program_root, exact_plan_bytes, observation)`, `materialize_exact_plan(program_root, submitted_plan_prompt, observation)`, and `required_future_lifecycle_writes(program_root, workspace_root, increment_id)` signatures unchanged. +- Keep deterministic candidate construction, exact-prefix adoption, atomic compare-and-swap, no-overwrite publication, immutable ledgers, and status-last ordering at every existing transaction boundary. +- Add no dependency, generic operation framework, automatic restore, staging engine, Move/Rename, Replace, directory deletion, progress cursor, or v4/v5 manifest/status implementation. +- Release the coherent implementation as package version `0.1.3`; synchronize only the existing version owners. +- Run the full deterministic suite once after the coherent implementation batch. Focused RED/GREEN commands may run per task. +- Do not push, open a pull request, install the plugin, synchronize a cached copy, mutate the pipeFlow worktree, or perform any external action under this plan. + +--- + +## Confirmed Root Cause and Scope Decision + +The defect is confirmed at the locked baseline: + +1. `program_setup.py::SUPPORTED_OPERATIONS` is exactly `("Create", "Modify", "Preserve")`; `validate_setup_semantics(...)` rejects both a v1 envelope listing `Delete` and every allocation whose operation is `Delete`. A real probe returns `operation allocation 0 operation is unsupported` and `operation envelope must support exactly Create/Modify/Preserve`. +2. `repository_preparation.py::parse_exact_file_map(...)` recognizes only `Create`, `Modify`, and `Preserve`. Worse, an unversioned `### Delete` heading is currently ignored and its bullet is absorbed into the preceding `Modify` section. The repair must make this legacy input fail explicitly before adding the versioned v2 route. +3. `program_activation.py::_path_baselines(...)` and `repository_preparation.py::validate_execution_workspace(...)` require every `Modify` path to remain a file. The focused baseline test confirms deletion is rejected as `execution workspace deleted Modify path: `. +4. The accepted product-delta and rollover contracts require a string `sha256` for every result, so they cannot represent a legitimate absent path. `program_rollover.py::_validated_inherited_paths(...)` also requires every inherited path to remain a regular file with the accepted digest. +5. The pipeFlow Task 8 file map contains 27 explicit regular-file Delete paths. Omitting them would make the exact plan incomplete and make their Git deletions unmapped product changes; relabeling them `Modify` would preserve the existing, correct missing-Modify failure. + +The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. + +Unsafe alternatives are rejected: + +- **Encode Delete as Modify:** destroys the invariant that every Modify result is present and causes the confirmed missing-Modify failure. +- **Omit deleted paths:** removes them from setup authority, exact-plan ownership, review surfaces, accepted results, rollover inheritance, and closure evidence; it also turns the Git deletion into an unmapped dirty path. +- **Use Preserve:** contradicts both the requested outcome and Preserve's byte-identical present-state contract. +- **Store `""`, zeroes, or `str(None)` as a digest:** fabricates an identity for an absent file and lets existing string-only consumers confuse absence with content. +- **Loosen v1 validators:** reinterprets accepted manifests and fixtures in place and can turn accidental file loss into a valid legacy result. +- **Implement the entire pending expanded-operations engine:** adds unrelated Move/Rename, Replace, staging, leases, cleanup, and migration-group machinery without solving a current requirement that needs only exact regular-file removal and durable tombstones. + +## File Map + +### Create + +- `tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json` — frozen 27-path real-scenario inventory and authoritative source digest. +- `tests/test_delete_operation_lifecycle.py` — one causal proposal-to-closure application-path replay plus the optional live source-identity check. + +### Modify + +- `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` — record the narrow v3 nested-v2 Delete repair between setup v3 and the still-pending expanded v4 design. +- `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md` — state that basic exact regular-file Delete is owned by `0.1.3`, while advanced migration/staging semantics remain pending v4 work. +- `skills/implementing-staged-plans/scripts/program_setup.py` — own setup-semantics/envelope v2 validation, pairing, and recap rendering. +- `skills/implementing-staged-plans/scripts/program_authority.py` — recognize only the exact new setup authority schemas on manifest-v3 and reject cross-family substitution. +- `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, state bindings, and v1 compatibility rejection. +- `skills/implementing-staged-plans/scripts/repository_preparation.py` — parse exact-file-map v2, parse baseline v2, and assess present/absent path states. +- `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. +- `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. +- `skills/implementing-staged-plans/scripts/review_coordination.py` — carry and validate the v2 accepted path-state result in review evidence and packets. +- `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings. +- `skills/implementing-staged-plans/scripts/diff_disposition.py` — bind the exact reviewed v2 product result during acceptance. +- `skills/implementing-staged-plans/scripts/blocked_recovery.py` — freeze and revalidate Delete path states across blocked/resume. +- `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results without coercing absence to a string digest. +- `skills/implementing-staged-plans/scripts/program_rollover.py` — persist v2 rollover records and cumulative inherited present/absent path states. +- `skills/implementing-staged-plans/scripts/continuity_closure.py` — validate/render versioned closure reconciliation over accepted result bindings and cumulative path states. +- `skills/implementing-staged-plans/scripts/program_closure.py` — build closure from the complete accepted increment chain and final cumulative state. +- `skills/implementing-staged-plans/scripts/validate_package.py` — set and enforce package version `0.1.3`. +- `skills/implementing-staged-plans/SKILL.md` — route and explain the Delete-capable nested v2 family. +- `skills/implementing-staged-plans/agents/openai.yaml` — describe exact local Delete support without implying generic destructive authority. +- `skills/implementing-staged-plans/references/program-authority.md` — document v1/v2 setup pairing and authority limits. +- `skills/implementing-staged-plans/references/repository-preparation.md` — own the v2 file-map grammar and baseline path-state rules. +- `skills/implementing-staged-plans/references/execution-discipline.md` — own lifecycle-state behavior for Delete. +- `skills/implementing-staged-plans/references/review-coordination.md` — own review/remediation result binding. +- `skills/implementing-staged-plans/references/state-authorization.md` — own acceptance and rollover version routing. +- `skills/implementing-staged-plans/references/continuity-closure.md` — own cumulative tombstone and closure rules. +- `docs/reference.md`, `docs/workflows.md`, `docs/troubleshooting.md`, `docs/maintainers.md`, `docs/installation.md` — synchronize the user-visible `0.1.3` contract, failure messages, and installation examples. +- `.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` — synchronize only the package version. +- `tests/program_bootstrap_support.py` — construct exact v1 and Delete-capable v2 setup fixtures. +- `tests/test_program_setup.py`, `tests/test_program_authority.py`, `tests/test_program_bootstrap.py` — setup, authority, recap, publication, and v1 compatibility coverage. +- `tests/test_repository_preparation.py`, `tests/test_program_activation.py`, `tests/test_approval_checkpoint.py` — parser, baseline, exact-plan, and execution assessment coverage. +- `tests/test_execution_discipline.py`, `tests/test_review_coordination.py`, `tests/test_program_review.py`, `tests/test_diff_disposition.py` — review/diff path-state coverage. +- `tests/test_blocked_recovery.py`, `tests/test_program_discovery.py`, `tests/test_state_authority.py` — recovery and schema-routing coverage. +- `tests/test_program_continuation.py`, `tests/test_program_rollover.py`, `tests/test_multi_increment_lifecycle.py` — cumulative present/absent inheritance coverage. +- `tests/test_continuity_closure.py`, `tests/test_program_closure.py` — complete-chain closure coverage. +- `tests/test_front_door_contract.py`, `tests/test_distribution_documentation.py`, `tests/test_package_validation.py` — contract, documentation, and version synchronization. + +### Preserve + +- `docs/superpowers/plans/2026-09-05-delete-operation-support.md` — use as the locked implementation plan; do not rewrite it while executing the tasks. +- `implementation-programs/ISP-001/**` — historical accepted program/control-plane evidence is not part of this repair. +- `tests/fixtures/program-bootstrap/v0.1.1/**` — frozen compatibility fixtures remain byte-for-byte unchanged. +- `skills/implementing-staged-plans/scripts/program_bootstrap.py`, `program_launch.py`, `approval_checkpoint.py`, `program_discovery.py`, and `task_prompt.py` — exercise their existing generic routes in tests; change them only if a focused RED test proves an exact-schema integration defect. +- `/Users/CoveMB/Code/CoveMB/implementation-plugin/**` and `/private/tmp/pipeflow-effect-flow.4Ox4Wl/**` — read-only/out of scope throughout implementation. + +--- + +### Task 1: Version the Setup-Level Delete Contract + +**Files:** +- Modify: `skills/implementing-staged-plans/scripts/program_setup.py` +- Modify: `skills/implementing-staged-plans/scripts/program_authority.py` +- Modify: `tests/program_bootstrap_support.py` +- Test: `tests/test_program_setup.py` +- Test: `tests/test_program_authority.py` +- Test: `tests/test_program_bootstrap.py` + +**Interfaces:** +- Consumes: manifest-v3 `setup_semantics` and the existing immutable setup decision flow. +- Produces: `SETUP_SEMANTICS_SCHEMA_V2`, `OPERATION_ENVELOPE_SCHEMA_V2`, `SETUP_RECAP_SCHEMA_V2`, `SETUP_RECAP_CHECKPOINT_SCHEMA_V2`, `SETUP_DECISION_ADAPTER_SCHEMA_V2`, and `SETUP_ACTIVATION_SCHEMA_V2`. +- Produces: `_operation_contract(semantics: Mapping[str, object]) -> tuple[tuple[str, ...], bool]`, returning the exact supported-operation tuple and whether Delete fields are required. +- Produces test helpers: `BootstrapFixture.configure_delete_setup_v2(allocation: Mapping[str, object]) -> dict[str, object]`, `configure_v1_envelope_with_delete() -> list[str]`, and `configure_mixed_setup_versions() -> list[str]`; each recomputes the semantic digest after its exact mutation. +- Preserves: every v1 setup/envelope/recap/decision/activation byte and error route. + +- [ ] **Step 1: Write failing setup and authority tests** + +Add these test cases with a helper that rewrites the candidate before recomputing `setup_semantics_sha256`: + +```python +def delete_allocation(path: str, increment_id: str) -> dict[str, object]: + return { + "kind": "exact-path", + "path": path, + "operation": "Delete", + "increment_ids": [increment_id], + "inclusions": ["legacy implementation removal"], + "exclusions": ["directories", "user-owned work"], + "ownership": "program", + "protected": False, + "user_work": False, + "file_kind": "regular-file", + "link_kind": "none", + "mode": "100644", + "collision": "existing", + "accepted_state": "absent", + "content_disposition": "obsolete", + "rationale": "The approved replacement implementation makes this file obsolete.", + } + +def test_setup_v2_accepts_and_renders_delete(self) -> None: + manifest = self.fixture.configure_delete_setup_v2( + delete_allocation("legacy.ts", "ARCHIVE-INDEX") + ) + self.assertEqual(SETUP.validate_setup_semantics(self.fixture.candidate), []) + recap = SETUP.render_setup_recap(self.fixture.candidate) + self.assertIn("Supported operations: Create, Modify, Delete, Preserve.", recap) + self.assertIn("Delete legacy.ts", recap) + self.assertIn("final state: absent", recap) + self.assertIn("content: obsolete", recap) + self.assertIn("makes this file obsolete", recap) + self.assertEqual( + manifest["setup_semantics"]["schema_version"], + "implementation-program-setup-semantics/v2", + ) + +def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: + issues = self.fixture.configure_v1_envelope_with_delete() + self.assertIn("operation allocation 0 operation is unsupported", issues) + self.assertIn("operation envelope must support exactly Create/Modify/Preserve", issues) + self.assertIn( + "setup semantics and operation envelope schema families do not match", + self.fixture.configure_mixed_setup_versions(), + ) +``` + +Also assert proposal validation, publication, recap checkpoint, setup decision, and setup activation accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap -v +``` + +Expected: new tests fail because only setup/envelope v1 exists and `Delete` is unsupported; all pre-existing tests remain green. + +- [ ] **Step 3: Implement exact nested-schema dispatch** + +Add literal paired contracts; do not mutate the v1 tuple: + +```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"}) + +def _operation_contract( + semantics: Mapping[str, object], +) -> tuple[tuple[str, ...], bool]: + schema = semantics.get("schema_version") + envelope = semantics.get("operation_envelope") + envelope_schema = envelope.get("schema_version") if isinstance(envelope, dict) else None + if (schema, envelope_schema) == (SETUP_SEMANTICS_SCHEMA, OPERATION_ENVELOPE_SCHEMA): + return SUPPORTED_OPERATIONS_V1, False + if (schema, envelope_schema) == (SETUP_SEMANTICS_SCHEMA_V2, OPERATION_ENVELOPE_SCHEMA_V2): + return SUPPORTED_OPERATIONS_V2, True + raise ValueError("setup semantics and operation envelope schema families do not match") +``` + +For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Preserve existing ownership/facts checks and require Delete to be program-owned, non-protected, and non-user-work before setup approval can be valid. + +Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS` and its manifest-v3 foreign-schema checks with the v2 setup records without relaxing v1 matching. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: all setup, authority, and generic proposal-publication tests pass; the recap exposes each Delete fact and legacy bytes stay exact. + +- [ ] **Step 5: Commit the setup contract** + +```bash +rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py +rtk git commit -m "feat: add typed delete setup contracts" +``` + +--- + +### Task 2: Add Exact-Plan, Baseline, and Product Path-State Semantics + +**Files:** +- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` +- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` +- Modify: `skills/implementing-staged-plans/scripts/program_activation.py` +- Test: `tests/test_repository_preparation.py` +- Test: `tests/test_program_activation.py` +- Test: `tests/test_approval_checkpoint.py` +- Test: `tests/test_state_authority.py` + +**Interfaces:** +- Produces: `ExactFileMapV2`, `ExecutionBaselineV2`, and `InheritedPathStateV2` while retaining `ExactFileMap` and `ExecutionBaseline` as v1 types. +- Produces: `file_map_entries(file_map) -> tuple[tuple[str, tuple[str, ...]], ...]` and `file_map_paths(file_map, *, mutable_only: bool) -> tuple[str, ...]` so consumers do not reconstruct operation inventories inconsistently. +- Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. +- Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. +- Preserves: public plan preparation/materialization and three-argument future-write signatures. + +- [ ] **Step 1: Write failing parser and assessment tests** + +Add exact parser and lifecycle assertions: + +```python +DELETE_MAP = """# Delete plan +## File map +Schema: `implementation-exact-file-map/v2` + +### Create +- `review/evidence.json` +### Modify +- `state/status.json` +### Delete +- `legacy.ts` +### Preserve +- `catalog.txt` +""" + +def test_unversioned_delete_heading_is_rejected_instead_of_absorbed_as_modify(self) -> None: + unversioned = DELETE_MAP.replace( + "Schema: `implementation-exact-file-map/v2`\n\n", "" + ) + with self.assertRaisesRegex( + ValueError, "unversioned exact-file map contains unsupported heading: Delete" + ): + PREPARATION.parse_exact_file_map(unversioned) + +def test_v2_delete_path_must_transition_from_exact_file_to_absence(self) -> None: + baseline = self.delete_baseline("legacy.ts") + self.assertTrue(self.assess_v2(baseline, "authorized").valid) + self.workspace.joinpath("legacy.ts").write_text("changed\n", encoding="utf-8") + self.assertIn( + "execution workspace changed Delete path before removal: legacy.ts", + self.assess_v2(baseline, "implementing").issues, + ) + self.workspace.joinpath("legacy.ts").unlink() + reviewing = self.assess_v2(baseline, "reviewing") + self.assertTrue(reviewing.valid, reviewing.issues) + self.assertEqual( + reviewing.product_delta, + ({ + "path": "legacy.ts", + "disposition": "Delete", + "final_state": "absent", + "sha256": None, + },), + ) +``` + +Add negative cases for a missing Delete target at baseline, unchanged Delete at reviewing, changed-but-present Delete, symlink/hard-link/directory/special-file targets, overlap with recorded user work, duplicate cross-disposition paths, `sha256` on an absent result, and `None` on a present result. Retain the existing assertion that deleting a v1 Modify path fails. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_repository_preparation tests.test_program_activation tests.test_approval_checkpoint tests.test_state_authority -v +``` + +Expected: the unversioned parser test exposes the current Delete-to-Modify absorption; v2 imports and absent-result assertions fail; existing v1 tests pass. + +- [ ] **Step 3: Add versioned file-map and baseline types** + +In `state_authority.py`, retain `ExactFileMap` unchanged and add: + +```python +EXACT_FILE_MAP_SCHEMA_V2 = "implementation-exact-file-map/v2" + +@dataclass(frozen=True) +class ExactFileMapV2: + schema_version: str + create: tuple[str, ...] + modify: tuple[str, ...] + delete: tuple[str, ...] + preserve: tuple[str, ...] + +def file_map_entries( + file_map: ExactFileMap | ExactFileMapV2, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if isinstance(file_map, ExactFileMapV2): + return ( + ("Create", file_map.create), + ("Modify", file_map.modify), + ("Delete", file_map.delete), + ("Preserve", file_map.preserve), + ) + return ( + ("Create", file_map.create), + ("Modify", file_map.modify), + ("Preserve", file_map.preserve), + ) +``` + +`parse_exact_file_map(...)` must first reject every unrecognized `###` heading within the v1 file-map body. Select v2 only from the exact schema marker, then require one ordered Create/Modify/Delete/Preserve heading; allow the Delete section to contain no path only for a successor that needs v2 inherited-state validation. Duplicate and unsafe path rejection remains global across all sections. + +Add `implementation-execution-baseline/v2` with an exact v2 file-map object, current path baselines, user-work baselines, and ordered `inherited_path_states`. Dispatch `execution_baseline_from_value(...)` on the exact baseline schema. Do not add fields to the v1 serialization. + +- [ ] **Step 4: Implement Delete-aware candidate and workspace validation** + +In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 when the current increment has a setup-envelope Delete allocation or status carries v2 inherited path states. Match every Delete path to exactly one current-increment exact or bounded-class setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. + +Use the shared operation iterator in `_path_baselines(...)`, `_user_work_baselines(...)`, `validate_required_managed_file_map(...)`, and `validate_execution_workspace(...)`. Enforce: + +```python +if disposition == "Delete": + if increment_state == "authorized" and (actual is None or actual != entry.sha256): + issues.append(f"authorized workspace changed Delete path: {relative}") + elif increment_state == "implementing" and actual not in {None, entry.sha256}: + issues.append( + f"execution workspace changed Delete path before removal: {relative}" + ) + elif increment_state in later_states and actual is not None: + issues.append(f"reviewing workspace still contains Delete path: {relative}") + elif actual is None: + product_delta.append({ + "path": relative, + "disposition": "Delete", + "final_state": "absent", + "sha256": None, + }) +``` + +For v2 Create/Modify results emit `final_state: "present"` with the real digest. Keep the v1 result object and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: the exact parser, baseline, authorization, partial implementation, complete absence, and legacy-negative tests pass. + +- [ ] **Step 6: Commit exact-plan and baseline support** + +```bash +rtk git add skills/implementing-staged-plans/scripts/state_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py tests/test_repository_preparation.py tests/test_program_activation.py tests/test_approval_checkpoint.py tests/test_state_authority.py +rtk git commit -m "feat: validate delete path states" +``` + +--- + +### Task 3: Carry Absent Results Through Review and Diff Acceptance + +**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/state_authority.py` +- Modify: `tests/program_bootstrap_support.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_state_authority.py` + +**Interfaces:** +- Produces: `implementation-review-evidence/v2`, `implementation-review-packet/v2`, `implementation-review-preparation/v2`, `implementation-review-remediation/v2`, `implementation-diff-disposition-binding/v2`, and `implementation-diff-disposition-command/v2` only for product path-state v2. +- Produces: review evidence field `product_result = {schema_version, sha256, ordered_path_states}`. +- Produces test helpers in `tests/program_bootstrap_support.py`: `BootstrapFixture.observation() -> RepositoryObservation` and `reviewing_delete_program() -> tuple[BootstrapFixture, Path, RepositoryObservation]`, returning a real temporary manifest-v3/setup-v2 program at `reviewing` with `legacy.ts` absent and raw review reports ready. +- Preserves: v1 review evidence, packet rendering, remediation, prompt bytes, diff bindings, and approval records. + +- [ ] **Step 1: Write failing review/remediation/diff tests** + +Add a reviewing fixture with one absent Delete path and assert: + +```python +def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: + fixture, program_root, observation = reviewing_delete_program() + try: + candidate = REVIEW.build_review_preparation(program_root, observation) + evidence = json.loads(candidate.evidence_bytes) + self.assertEqual(evidence["schema_version"], "implementation-review-evidence/v2") + self.assertEqual( + evidence["product_result"]["ordered_path_states"], + [{ + "path": "legacy.ts", + "disposition": "Delete", + "final_state": "absent", + "sha256": None, + }], + ) + REVIEW.persist_review_preparation(program_root, observation) + accepted = DIFF.build_diff_acceptance_candidate(program_root, observation) + self.assertEqual( + accepted.accepted_status["diff_disposition_binding"]["schema_version"], + "implementation-diff-disposition-binding/v2", + ) + self.assertEqual( + accepted.accepted_status["diff_disposition_binding"][ + "product_result_schema_version" + ], + "implementation-product-path-states/v2", + ) + finally: + fixture.close() +``` + +Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```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_state_authority -v +``` + +Expected: new v2 review/result schemas are absent and Delete surfaces cannot be represented. + +- [ ] **Step 3: Implement typed review result persistence** + +Extend execution ownership with a literal `delete` disposition: it requires a non-empty pre-write fingerprint, exact `post_write_fingerprint == "absent"`, program ownership, and no accepted user-work overlap. Add `deleted` to execution surface changes and require one semantic naming/compatibility record for each deleted path; keep physical `renamed` rejection unchanged. + +When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. + +`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. Keep v1 base-seed construction and prompt bytes unchanged. + +- [ ] **Step 4: Extend state validation by exact review/diff schema** + +In `state_authority.py`, pair baseline v1 with review/diff v1 and baseline v2 with review/diff v2. Reject mixed families, missing result schemas, changed state order, or a digest that does not reproduce from `ordered_path_states`. Preserve the existing source-gate and status-last checks. + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: Delete absence is visible and immutable from review preparation through accepted status; every v1 golden remains exact. + +- [ ] **Step 6: Commit review and diff support** + +```bash +rtk git add 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/state_authority.py tests/program_bootstrap_support.py tests/test_execution_discipline.py tests/test_review_coordination.py tests/test_program_review.py tests/test_diff_disposition.py tests/test_state_authority.py +rtk git commit -m "feat: bind deleted results through review" +``` + +--- + +### Task 4: Freeze Delete State Across Blocked Recovery + +**Files:** +- Modify: `skills/implementing-staged-plans/scripts/blocked_recovery.py` +- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` +- Test: `tests/test_blocked_recovery.py` +- Test: `tests/test_program_discovery.py` +- Test: `tests/test_state_authority.py` + +**Interfaces:** +- Produces: `implementation-blocked-context/v2` with `product_result_schema_version`, `product_result_sha256`, and exact `ordered_path_states` captured at the block boundary. +- Produces: `blocked_workspace_paths(...)` including current Delete paths for v2 baselines. +- Consumes test helper: `reviewing_delete_program()` from `tests/program_bootstrap_support.py`; expose its fixture observation as `BootstrapFixture.observation() -> RepositoryObservation` there. +- Preserves: blocked-context/resolution/command v1 and the existing prohibition on entering blocked from `remediating`. + +- [ ] **Step 1: Write failing block/resume tests** + +```python +def test_reviewing_delete_can_block_and_resume_only_with_the_same_absence(self) -> None: + fixture, program_root, observation = reviewing_delete_program() + try: + receipt = BLOCKED.block_current_program( + program_root, + BLOCKED.BlockedTransitionRequest( + reason_code="review-evidence-unavailable", + recovery_criteria=("Review evidence is available.",), + evidence_bindings=(), + ), + observation, + ) + self.assertEqual(receipt.increment_state, "blocked") + status = fixture.load_json("state/status.json") + self.assertEqual( + status["blocked_context"]["schema_version"], + "implementation-blocked-context/v2", + ) + self.assertEqual( + status["blocked_context"]["ordered_path_states"][0]["final_state"], + "absent", + ) + fixture.repository.joinpath("legacy.ts").write_text("restored\n", encoding="utf-8") + self.assertIn( + "blocked product path states changed", + BLOCKED.validate_blocked_context(program_root, status, fixture.observation()), + ) + finally: + fixture.close() +``` + +Also cover an implementing-state partial deletion, a post-block extra deletion, changed state order, evidence that claims a missing Delete file digest, exact prompt-bound resume, every failure-injection prefix, and v1 context byte compatibility. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_blocked_recovery tests.test_program_discovery tests.test_state_authority -v +``` + +Expected: blocked context v1 has no path-state binding and Delete is not included in plan-owned workspace paths. + +- [ ] **Step 3: Implement v2 blocked-context binding** + +Build a fresh execution assessment before writing blocked status. For a v2 baseline, bind its exact schema, result digest, and ordered current path states into the block identifier. `validate_blocked_context(...)` must reproduce all three from fresh observation before resolution. Include Delete in `blocked_workspace_paths(...)` but exclude absent Delete targets from regular-file evidence bindings. + +Keep the existing status-last transaction and exact record adoption. Never recreate, restore, remove, or clean a product path during block or resume. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: exact absence/partial-state prefixes resume; any post-block path-state change is preserved and fails closed. + +- [ ] **Step 5: Commit blocked recovery support** + +```bash +rtk git add skills/implementing-staged-plans/scripts/blocked_recovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_blocked_recovery.py tests/test_program_discovery.py tests/test_state_authority.py +rtk git commit -m "feat: preserve delete state in recovery" +``` + +--- + +### Task 5: Carry Tombstones Through Successor Rollover + +**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_activation.py` +- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` +- Modify: `skills/implementing-staged-plans/scripts/state_authority.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_state_authority.py` + +**Interfaces:** +- Produces: `ProductPathStateV2(path, disposition, final_state, sha256)` without changing `ProductDeltaPath` v1. +- Produces: `implementation-successor-authority-projection/v2`, `implementation-increment-rollover/v2`, `implementation-increment-rollover-binding/v2`, and `implementation-inherited-workspace/v2`. +- Produces: `validated_inherited_path_states(program_root, status, observation) -> tuple[InheritedPathStateV2, ...]` while preserving `validated_inherited_paths(...)` for v1. +- Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation. +- Produces test fixture: `ThreeIncrementDeleteFixture` with `accept_delete(path)`, `rollover(accepted_status, successor_id)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_third_plan()` methods that call production writers rather than editing lifecycle artifacts directly. + +- [ ] **Step 1: Write failing three-increment inheritance tests** + +```python +def test_delete_tombstone_survives_unrelated_successor_and_closes_over_third_increment(self) -> None: + fixture = ThreeIncrementDeleteFixture() + try: + first = fixture.accept_delete("legacy.ts") + second = fixture.rollover(first, "SECOND") + self.assertEqual( + second["inherited_workspace_binding"]["inherited_path_states"], + [{ + "path": "legacy.ts", + "final_state": "absent", + "disposition": "Delete", + "sha256": None, + }], + ) + fixture.accept_unrelated_create("SECOND", "new.ts") + third = fixture.rollover_current("THIRD") + self.assertEqual( + [item["path"] for item in third["inherited_workspace_binding"]["inherited_path_states"]], + ["legacy.ts", "new.ts"], + ) + fixture.repository.joinpath("legacy.ts").write_text("reappeared\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "inherited absent path reappeared: legacy.ts"): + fixture.prepare_third_plan() + finally: + fixture.close() +``` + +Add a positive recreation case where the later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. + +- [ ] **Step 2: Run focused continuation/rollover tests and verify RED** + +```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_state_authority -v +``` + +Expected: current continuation coerces `None` to a string and current rollover requires each accepted path to remain a regular file with a digest. + +- [ ] **Step 3: Implement versioned accepted-result consumption and cumulative merge** + +For v2, load the exact product result from current review evidence, freshly reassess it, and compare it with the diff binding before constructing continuation authority. Use a separate dataclass: + +```python +@dataclass(frozen=True) +class ProductPathStateV2: + path: str + disposition: str + final_state: str + sha256: str | None +``` + +Never pass v2 entries through `ProductDeltaPath(sha256: str)`. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Merge by path in accepted increment order; replace an earlier state only when the current exact operation inventory owns that same path and its baseline agrees with the inherited state. + +`validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review result, diff decision, and cumulative digest. It requires present files to match exact digests and absent files to remain absent. Mixed result families stop before persistence. + +- [ ] **Step 4: Consume inherited states in successor baselines** + +`program_activation.py::_build_plan_candidate(...)` stores validated v2 inherited states in baseline v2 and strips their expected Git dirt from user-work observation. `repository_preparation.py::validate_execution_workspace(...)` validates untouched inherited states throughout the successor. It allows an explicit Create only from inherited absence and Modify/Delete/Preserve only from inherited presence; no current operation means the inherited state must remain exact. + +`state_authority.py` validates exact v1 or v2 rollover/binding pairs and delegates to the matching inherited validator. Do not modify v1 cumulative-path or digest behavior. + +- [ ] **Step 5: Run focused continuation/rollover tests and verify GREEN** + +Run the Step 2 command. + +Expected: present identities and absent tombstones survive unrelated increments; explicit recreation is valid; implicit or mixed-family state changes fail before writes. + +- [ ] **Step 6: Commit rollover inheritance** + +```bash +rtk git add skills/implementing-staged-plans/scripts/program_continuation.py skills/implementing-staged-plans/scripts/program_rollover.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_program_continuation.py tests/test_program_rollover.py tests/test_multi_increment_lifecycle.py tests/test_program_activation.py tests/test_state_authority.py +rtk git commit -m "feat: inherit accepted delete tombstones" +``` + +--- + +### Task 6: Reconcile the Complete Accepted Path-State Chain at Closure + +**Files:** +- Modify: `skills/implementing-staged-plans/scripts/continuity_closure.py` +- Modify: `skills/implementing-staged-plans/scripts/program_closure.py` +- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` +- Test: `tests/test_continuity_closure.py` +- Test: `tests/test_program_closure.py` +- Test: `tests/test_multi_increment_lifecycle.py` +- Test: `tests/test_state_authority.py` + +**Interfaces:** +- Produces: `implementation-closure-reconciliation/v2`, `implementation-closure-packet/v2`, `implementation-closure-preparation/v2`, `implementation-program-closure-command/v2`, and `implementation-program-closure-command-binding/v2` for a v2 accepted chain. +- Produces: reconciliation fields `accepted_result_bindings`, `final_inherited_path_states`, and `final_inherited_path_states_sha256`. +- Consumes test helper: `accepted_three_increment_delete_program() -> ThreeIncrementDeleteFixture`, which extends the Task 5 fixture through accepted `THIRD` state with current review/diff evidence intact. +- Preserves: all v1 closure dataclasses, renderers, commands, approvals, and singleton first-increment closure bytes. + +- [ ] **Step 1: Write failing closure-chain tests** + +```python +def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> None: + fixture = accepted_three_increment_delete_program() + try: + candidate = CLOSURE.build_closure_preparation( + fixture.program_root, fixture.observation() + ) + reconciliation = json.loads(candidate.reconciliation_bytes) + self.assertEqual( + reconciliation["accepted_increment_ids"], + ["FIRST", "SECOND", "THIRD"], + ) + self.assertEqual(len(reconciliation["accepted_result_bindings"]), 3) + self.assertEqual( + next( + item + for item in reconciliation["final_inherited_path_states"] + if item["path"] == "legacy.ts" + )["final_state"], + "absent", + ) + self.assertNotIn( + "legacy.ts", + reconciliation["requirement_dispositions"][0]["evidence_paths"], + ) + finally: + fixture.close() +``` + +Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. + +- [ ] **Step 2: Run closure tests and verify RED** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_continuity_closure tests.test_program_closure tests.test_multi_increment_lifecycle tests.test_state_authority -v +``` + +Expected: current production closure emits only the final increment and has no cumulative path-state binding. + +- [ ] **Step 3: Add versioned closure values and validators** + +Keep `ClosureReconciliation` and `ClosurePacket` unchanged. Add v2 dataclasses with the three new result fields and exact schema-specific constructors/validators/renderers. Canonical validation requires: + +```python +expected_state_digest = hashlib.sha256( + json.dumps( + list(candidate.final_inherited_path_states), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") +).hexdigest() +if candidate.final_inherited_path_states_sha256 != expected_state_digest: + issues.append("final inherited path-state digest mismatch") +``` + +Do not put absent paths in `evidence_paths`; bind their typed result and final-state digest instead. + +- [ ] **Step 4: Build closure from the canonical rollover chain** + +In `program_closure.py::build_closure_preparation(...)`, dispatch on the accepted product-result schema. For v2, enumerate `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted increment in order. Bind each increment's exact reviewed result, review packet, diff decision, and required handoff addendum; merge the final current result into validated cumulative inherited states; perform later-invalidation checks across every accepted increment; then construct v2 reconciliation and packet. + +Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Existing v1 closure remains on its current singleton or legacy route. + +- [ ] **Step 5: Run closure tests and verify GREEN** + +Run the Step 2 command. + +Expected: closure succeeds only when all accepted increments and the final cumulative present/absent state are exact; every tamper fails before closure persistence. + +- [ ] **Step 6: Commit closure reconciliation** + +```bash +rtk git add skills/implementing-staged-plans/scripts/continuity_closure.py skills/implementing-staged-plans/scripts/program_closure.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_continuity_closure.py tests/test_program_closure.py tests/test_multi_increment_lifecycle.py tests/test_state_authority.py +rtk git commit -m "feat: reconcile deleted paths at closure" +``` + +--- + +### Task 7: Replay the PipeFlow Scenario and Synchronize Release Contracts + +**Files:** +- Create: `tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json` +- Create: `tests/test_delete_operation_lifecycle.py` +- Modify: `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` +- Modify: `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md` +- Modify: `skills/implementing-staged-plans/SKILL.md` +- Modify: `skills/implementing-staged-plans/agents/openai.yaml` +- Modify: `skills/implementing-staged-plans/references/program-authority.md` +- 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/continuity-closure.md` +- Modify: `docs/reference.md` +- Modify: `docs/workflows.md` +- Modify: `docs/troubleshooting.md` +- Modify: `docs/maintainers.md` +- Modify: `docs/installation.md` +- Modify: `.codex-plugin/plugin.json` +- Modify: `.claude-plugin/plugin.json` +- Modify: `.claude-plugin/marketplace.json` +- Modify: `skills/implementing-staged-plans/scripts/validate_package.py` +- Test: `tests/test_front_door_contract.py` +- Test: `tests/test_distribution_documentation.py` +- Test: `tests/test_package_validation.py` + +**Interfaces:** +- Produces: `load_pipeflow_delete_inventory() -> tuple[str, tuple[str, ...]]` returning the source SHA-256 and exactly 27 normalized paths. +- Produces: a deterministic temporary-repository replay from Delete-capable proposal validation through final closure. +- Produces: `DeleteLifecycleFixture(delete_paths: Sequence[str], source_sha256: str)` with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_through_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. +- Produces: package version `0.1.3` on all existing version owners. +- Preserves: the external pipeFlow source and workspace as read-only inputs. + +- [ ] **Step 1: Create the frozen real-scenario inventory** + +Create this exact JSON fixture: + +```json +{ + "schema_version": "pipeflow-delete-scenario/v1", + "source_plan_sha256": "a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b", + "source_task": "Task 8: Migrate Use Cases and Delete the Legacy Architecture", + "delete_paths": [ + "src/pipeFlow.ts", + "src/helpers/helpers-error.ts", + "src/helpers/index.ts", + "src/helpers/utils.ts", + "src/types/context.ts", + "src/types/error.ts", + "src/types/flow.ts", + "src/types/helpers.ts", + "src/types/index.ts", + "src/types/internals.ts", + "src/types/middleware.ts", + "src/utils/const.ts", + "src/utils/context.ts", + "src/utils/fp.ts", + "src/utils/guards-messages.ts", + "src/utils/guards-reasons.ts", + "__tests__/compatibility.test.ts", + "__tests__/error.test.ts", + "__tests__/flow.test.ts", + "__tests__/gard.test.ts", + "__tests__/pipeFlow.integration.test.ts", + "__tests__/public-api.types.ts", + "__tests__/subFlow.ts", + "__tests__/utils.test.ts", + "__tests__/fixtures/data.ts", + "__tests__/fixtures/helpers.ts", + "test/legacy/characterization.test.ts" + ] +} +``` + +The loader rejects a non-27 count, duplicate, unsafe path, wrong order, missing source digest, or directory-like entry. + +- [ ] **Step 2: Write the failing proposal-to-closure replay** + +In `tests/test_delete_operation_lifecycle.py`, build a temporary Git repository with all 27 regular files, a Delete-capable setup/envelope v2 proposal, and a later unrelated increment. Exercise real production writers and validators: + +```python +def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: + source_sha256, delete_paths = load_pipeflow_delete_inventory() + self.assertEqual(len(delete_paths), 27) + fixture = DeleteLifecycleFixture(delete_paths, source_sha256) + try: + fixture.validate_and_publish_proposal() + recap = fixture.render_setup_recap() + self.assertTrue(all(path in recap for path in delete_paths)) + fixture.approve_activate_and_start() + fixture.prepare_and_authorize_delete_plan() + fixture.delete_every_target() + fixture.review_and_accept_delete_result() + fixture.rollover_through_unrelated_increment() + fixture.assert_every_target_is_inherited_absent() + closure = fixture.prepare_final_closure() + self.assertEqual( + [item["path"] for item in closure["final_inherited_path_states"] if item["final_state"] == "absent"], + list(delete_paths), + ) + finally: + fixture.close() +``` + +Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. + +- [ ] **Step 3: Run the scenario tests and verify RED, then GREEN** + +Initial RED: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle -v +``` + +Expected before the Tasks 1–6 implementation: proposal validation rejects Delete. Expected after Tasks 1–6: the frozen scenario passes and the optional external-source test is skipped. + +Run the live identity replay against the locked read-only source: + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 PIPEFLOW_PLAN_PATH=/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md python3 -m unittest tests.test_delete_operation_lifecycle -v +``` + +Expected: no skip; source digest and ordered Task 8 inventory match; all 27 tombstones survive the unrelated rollover and final closure. + +- [ ] **Step 4: Update canonical documentation without overstating authority** + +Document both grammars exactly: + +```markdown +Legacy setup/envelope v1 and unversioned exact-file maps support Create, Modify, +and Preserve only. Delete-capable programs use setup/envelope v2 and an exact +file map marked `implementation-exact-file-map/v2` with ordered Create, Modify, +Delete, and Preserve sections. Delete authorizes only the named regular-file +absence inside the bound local `modify-workspace` action; it is not generic +destructive-operation, cleanup, migration, Git, publication, deployment, or +external-state authority. +``` + +Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. + +- [ ] **Step 5: Synchronize package version `0.1.3`** + +Set: + +```python +PACKAGE_VERSION = "0.1.3" +``` + +Update the three plugin/marketplace manifests, installation archive examples, maintainers' current-version text, front-door snapshot, distribution tests, and package-validator expectations to the same literal version. Change no plugin name, skill path, invocation policy, marketplace owner, repository identity, or manifest field set. + +- [ ] **Step 6: Run focused package and documentation checks** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest 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 . +``` + +Expected: the real-scenario fixture, front-door contract, documentation, synchronized version, package inventory, and package validator pass. + +- [ ] **Step 7: Commit scenario and release contracts** + +```bash +rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py +rtk git commit -m "feat: release typed delete operation support" +``` + +--- + +## Final Verification, Review, and Claim Gate + +- [ ] **Step 1: Reconfirm exact scope before final checks** + +```bash +rtk git status --short --branch +rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD +rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD +``` + +Expected: only this locked plan plus the File Map Create/Modify paths are changed; the tree is clean; `diff --check` reports no errors; frozen v0.1.1 and historical ISP-001 paths are absent from the diff. + +- [ ] **Step 2: Run the complete deterministic suite once on the unchanged candidate** + +```bash +rtk env PYTHONDONTWRITEBYTECODE=1 python3 skills/implementing-staged-plans/scripts/validate_package.py . +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v +``` + +Expected: package validation passes and the complete test process exits `0`. Record the exact executed test count and skipped-platform limitations; do not convert an interrupted or partial run into a pass. + +- [ ] **Step 3: Run the locked live scenario once without changing either repository** + +```bash +rtk shasum -a 256 /private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md +rtk env PYTHONDONTWRITEBYTECODE=1 PIPEFLOW_PLAN_PATH=/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md python3 -m unittest tests.test_delete_operation_lifecycle -v +``` + +Expected: SHA-256 is `a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b`; all scenario tests pass; the pipeFlow worktree has no writes. + +- [ ] **Step 4: Perform one bounded independent material review** + +Review only `b5eb689e780f48b218b807a4691f0994474e4178...HEAD` for correctness, fail-closed path-state behavior, compatibility, recovery, and test protection. Verify every finding against current code. Fix only confirmed material defects, rerun only affected focused checks, then rerun the full suite only if a relevant input changed after Step 2. If no material issue exists, record exactly: `No material improvements recommended.` + +- [ ] **Step 5: Stop before publication or consumer mutation** + +Report commits, exact changed paths, focused/full check evidence, scenario replay evidence, compatibility limitations, and residual risks. Do not push, open a pull request, install, update caches, edit pipeFlow, or claim Move/Rename, Replace, directory deletion, automatic rollback, external migration, production safety, or platform behavior not exercised by the completed checks. + +## Rollback and Failure Semantics + +- Before any v2 program artifact is persisted, the implementation commits can be reverted normally; v1 programs remain readable throughout. +- After a Delete-capable v2 setup, baseline, review, rollover, blocked context, or closure artifact exists, do not downgrade that program to `0.1.2` or rewrite it as v1. Retain a `0.1.3` reader or ship a forward repair that preserves the v2 bytes. +- A failure before product mutation preserves the baseline file and exact partial control-plane prefix; retry may adopt only byte-identical owner-bound artifacts. +- A failure after a planned Delete while status is `implementing` preserves the absence as a valid partial product result. Recovery may block and resume from the exact bound absence; it does not restore automatically. +- A failure after review or diff acceptance must reproduce the same ordered path states and digest. Reappearance, changed content, missing result records, reordered states, or mixed schema families is divergent and stops without cleanup. +- Rollover merges a Delete tombstone only after exact diff acceptance. An unrelated successor cannot erase it; only a later exact Create operation whose baseline agrees with inherited absence can replace it. +- Closure binds the entire accepted chain and final cumulative state. It cannot close when a tombstone disappeared, a deleted file reappeared, an earlier accepted result is missing, or evidence treats an absent path as a file. +- Recovery bytes are not created by this repair. Source recovery remains a separately authorized manual or Git operation; the plugin never resets, restores, stashes, cleans, or deletes automatically. + +## Final Validation Matrix + +| Requirement | Primary owner | Required evidence | Failure signal | +| --- | --- | --- | --- | +| Locked implementation baseline | Git preflight | branch `repair/delete-operation-support`, HEAD `b5eb689e...`, clean | stop before edits | +| Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | +| Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | +| Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | +| Exact plan does not misclassify Delete | `repository_preparation.py` | unversioned heading fails; v2 parses ordered Delete section | Delete absorbed as Modify | +| Baseline proves a real removable file | `program_activation.py` | existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | +| Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest | accidental loss or fabricated digest | +| Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve | Delete accepted for a control path | +| Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | +| Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | +| Blocked recovery freezes path state | `blocked_recovery.py` | v2 context reproduces exact partial/complete states | post-block change or evidence fabrication | +| Rollover preserves tombstones | `program_rollover.py` | three-increment test retains absent state through unrelated work | reappearance, omission, mixed chain | +| Recreation is explicit | activation/preparation | later Create owns inherited absent path and baseline agrees | implicit recreation or wrong operation | +| Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff decisions plus final cumulative digest | singleton-only or lost tombstone | +| Front door does not over-authorize | skill/references/docs | Delete remains local plan-bound `modify-workspace` only | generic destructive/external claim | +| Package is synchronized | manifests/validator/docs | every owner says `0.1.3`; package validation exits `0` | version or inventory mismatch | +| Full regression | complete suite | one completed exit `0`, exact count recorded | failure, interruption, or partial output | +| External boundary | final status/diff | no push, PR, install, cache sync, pipeFlow edit, or provider action | any unauthorized external mutation | From cc230e8737480d06dc497e20e46a467de7147ca0 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sat, 5 Sep 2026 22:49:57 -0400 Subject: [PATCH 02/19] docs: repair delete operation support plan --- .../2026-09-05-delete-operation-support.md | 148 +++++++++++++----- 1 file changed, 105 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index ea8cfea..4462b41 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -4,7 +4,7 @@ **Goal:** Add truthful, fail-closed support for exact regular-file `Delete` operations so a program can preserve an accepted absent path through review, diff acceptance, rollover, recovery, and closure without weakening existing `Create`, `Modify`, or `Preserve` contracts. -**Architecture:** Keep manifest/status v1, v2, and existing v3 programs on their exact current routes. Extend manifest-v3 only through explicitly versioned nested v2 setup, file-map, baseline, product-result, rollover, blocked-context, and closure contracts; route by exact schema, never by optional-field presence. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. +**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. **Tech Stack:** Python 3 standard library, frozen dataclasses, canonical JSON and SHA-256, `unittest`, temporary Git repositories, existing atomic/no-overwrite/status-last writers. @@ -12,17 +12,19 @@ ## Global Constraints -- Start only from a clean branch `repair/delete-operation-support` whose kickoff HEAD is the plan-only commit directly above `b5eb689e780f48b218b807a4691f0994474e4178` (candidate parent `c6a32575ee07b79cc26fcecfec037f2a206f442a`) and whose only kickoff delta from the candidate is this plan file. +- The reviewed plan baseline is commit `44ef42acdef540af72577e224054d763da80fc5f`; its parent and implementation candidate are exactly `b5eb689e780f48b218b807a4691f0994474e4178`. This plan-repair commit must be the single plan-only child of `44ef42acdef540af72577e224054d763da80fc5f`. Start implementation only from that clean repaired-plan HEAD on branch `repair/delete-operation-support`, with no path other than this plan changed from the candidate. - Use `rtk` for every repository command. - Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. - Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. -- Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; mixed v1/v2 nested contracts fail before every write. +- Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; that setup choice fixes the complete program to the nested v2 lifecycle family from its first increment, and mixed v1/v2 nested contracts fail before every write. - A `Delete` target must be one normalized repository-relative path to an existing program-owned regular non-symlink, non-hard-linked file beneath the selected workspace. Directories, symlinks, symlinked ancestors, hard links, special files, missing parents, external paths, protected paths, and pre-existing user work remain unsupported. +- Baseline capture and every later reassessment must repeat one shared component-by-component `lstat` walk and workspace-containment proof for the final path and every ancestor; no earlier safe observation authorizes a later swapped ancestor. - `Delete` means the approved final state is absent. Never encode absence as `Modify`, `Preserve`, an omitted path, an empty digest, or a fabricated digest. - `authorized` requires every Delete target to remain byte-identical to its baseline; `implementing` permits either the exact baseline file or its absence; `reviewing` and later require absence. A changed-but-present Delete target is always invalid. - A typed local Delete remains within the exact plan-bound `modify-workspace` action. It does not grant the separately named `destructive-operation`, cleanup, migration, Git, publication, deployment, provider, or external-state actions. - Keep public `prepare_exact_plan(program_root, exact_plan_bytes, observation)`, `materialize_exact_plan(program_root, submitted_plan_prompt, observation)`, and `required_future_lifecycle_writes(program_root, workspace_root, increment_id)` signatures unchanged. - Keep deterministic candidate construction, exact-prefix adoption, atomic compare-and-swap, no-overwrite publication, immutable ledgers, and status-last ordering at every existing transaction boundary. +- V2 current results use operation-section order followed by exact file-map order; cumulative v2 states replace an already-owned path in place and append newly owned paths in current-result order. Preserve every v1 lexical ordering rule and byte sequence. - Add no dependency, generic operation framework, automatic restore, staging engine, Move/Rename, Replace, directory deletion, progress cursor, or v4/v5 manifest/status implementation. - Release the coherent implementation as package version `0.1.3`; synchronize only the existing version owners. - Run the full deterministic suite once after the coherent implementation batch. Focused RED/GREEN commands may run per task. @@ -39,6 +41,11 @@ The defect is confirmed at the locked baseline: 3. `program_activation.py::_path_baselines(...)` and `repository_preparation.py::validate_execution_workspace(...)` require every `Modify` path to remain a file. The focused baseline test confirms deletion is rejected as `execution workspace deleted Modify path: `. 4. The accepted product-delta and rollover contracts require a string `sha256` for every result, so they cannot represent a legitimate absent path. `program_rollover.py::_validated_inherited_paths(...)` also requires every inherited path to remain a regular file with the accepted digest. 5. The pipeFlow Task 8 file map contains 27 explicit regular-file Delete paths. Omitting them would make the exact plan incomplete and make their Git deletions unmapped product changes; relabeling them `Modify` would preserve the existing, correct missing-Modify failure. +6. `program_activation.py::_build_v3_setup_record(...)` imports and writes only `SETUP_ACTIVATION_SCHEMA` (`setup-activation-decision/v1`), so a setup-v2 activation cannot be a truthful Task 1 GREEN until that writer and both authority validators dispatch together. +7. The consumer rescan found two additional hard-coded v1 edges: `program_discovery.py::_exact_closure_prefix_disposition(...)` requires diff-disposition and closure-preparation v1, and `program_continuation.py::build_accept_continue_candidate(...)` rewrites its acceptance binding with `DIFF_DISPOSITION_BINDING_SCHEMA` v1. Both must dispatch on exact families for v2 retry, recovery, and continuation to work. +8. Current product deltas and inherited paths are lexically sorted, while a typed v2 result needs one specified order. V2 therefore requires operation-section/exact-map result order and stable cumulative replace-in-place/append semantics while leaving v1 sorting unchanged. +9. Current activation and workspace assessment check the final `Path` with `is_symlink()`/`is_file()` but do not share a component walk. A safe final file beneath a later-swapped symlink ancestor can therefore evade the intended workspace-bound path contract until each baseline and reassessment performs the same `lstat`/containment proof. +10. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. @@ -67,6 +74,7 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, state bindings, and v1 compatibility rejection. - `skills/implementing-staged-plans/scripts/repository_preparation.py` — parse exact-file-map v2, parse baseline v2, and assess present/absent path states. - `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. +- `skills/implementing-staged-plans/scripts/program_discovery.py` — classify v2 accepted-stop, closure preparation/approval retry, and divergent-prefix recovery by exact schema family. - `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. - `skills/implementing-staged-plans/scripts/review_coordination.py` — carry and validate the v2 accepted path-state result in review evidence and packets. - `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings. @@ -86,6 +94,7 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/references/state-authorization.md` — own acceptance and rollover version routing. - `skills/implementing-staged-plans/references/continuity-closure.md` — own cumulative tombstone and closure rules. - `docs/reference.md`, `docs/workflows.md`, `docs/troubleshooting.md`, `docs/maintainers.md`, `docs/installation.md` — synchronize the user-visible `0.1.3` contract, failure messages, and installation examples. +- `implementing-staged-plans-bootstrap-execution-review-runbook.md` — extend the live bootstrap/execution/review runbook through the `0.1.3` path-state, discovery, rollover, and complete-chain closure contract. - `.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` — synchronize only the package version. - `tests/program_bootstrap_support.py` — construct exact v1 and Delete-capable v2 setup fixtures. - `tests/test_program_setup.py`, `tests/test_program_authority.py`, `tests/test_program_bootstrap.py` — setup, authority, recap, publication, and v1 compatibility coverage. @@ -101,26 +110,46 @@ Unsafe alternatives are rejected: - `docs/superpowers/plans/2026-09-05-delete-operation-support.md` — use as the locked implementation plan; do not rewrite it while executing the tasks. - `implementation-programs/ISP-001/**` — historical accepted program/control-plane evidence is not part of this repair. - `tests/fixtures/program-bootstrap/v0.1.1/**` — frozen compatibility fixtures remain byte-for-byte unchanged. -- `skills/implementing-staged-plans/scripts/program_bootstrap.py`, `program_launch.py`, `approval_checkpoint.py`, `program_discovery.py`, and `task_prompt.py` — exercise their existing generic routes in tests; change them only if a focused RED test proves an exact-schema integration defect. +- `skills/implementing-staged-plans/scripts/program_bootstrap.py`, `program_launch.py`, `approval_checkpoint.py`, and `task_prompt.py` — exercise their existing generic routes in tests; change them only if a focused RED test proves an exact-schema integration defect. - `/Users/CoveMB/Code/CoveMB/implementation-plugin/**` and `/private/tmp/pipeflow-effect-flow.4Ox4Wl/**` — read-only/out of scope throughout implementation. --- +## Implementation Kickoff Preflight + +Before Task 1, record and require all of the following without changing the tree: + +```bash +rtk git status --short --branch +rtk git rev-parse HEAD^ HEAD^^ +rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD +rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD +``` + +Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is `44ef42acdef540af72577e224054d763da80fc5f`; `HEAD^^` is `b5eb689e780f48b218b807a4691f0994474e4178`; the only candidate-to-kickoff path is `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; and `diff --check` is empty. Stop before implementation on any mismatch. + +--- + ### Task 1: Version the Setup-Level Delete Contract **Files:** - Modify: `skills/implementing-staged-plans/scripts/program_setup.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/state_authority.py` - Modify: `tests/program_bootstrap_support.py` - Test: `tests/test_program_setup.py` - Test: `tests/test_program_authority.py` - Test: `tests/test_program_bootstrap.py` +- Test: `tests/test_program_activation.py` +- Test: `tests/test_state_authority.py` **Interfaces:** - Consumes: manifest-v3 `setup_semantics` and the existing immutable setup decision flow. - Produces: `SETUP_SEMANTICS_SCHEMA_V2`, `OPERATION_ENVELOPE_SCHEMA_V2`, `SETUP_RECAP_SCHEMA_V2`, `SETUP_RECAP_CHECKPOINT_SCHEMA_V2`, `SETUP_DECISION_ADAPTER_SCHEMA_V2`, and `SETUP_ACTIVATION_SCHEMA_V2`. - Produces: `_operation_contract(semantics: Mapping[str, object]) -> tuple[tuple[str, ...], bool]`, returning the exact supported-operation tuple and whether Delete fields are required. - Produces test helpers: `BootstrapFixture.configure_delete_setup_v2(allocation: Mapping[str, object]) -> dict[str, object]`, `configure_v1_envelope_with_delete() -> list[str]`, and `configure_mixed_setup_versions() -> list[str]`; each recomputes the semantic digest after its exact mutation. +- Produces: recap, checkpoint, decision, activation-record, program-authority, and state-authority dispatch selected from the exact setup/envelope family before any activation record is written. - Preserves: every v1 setup/envelope/recap/decision/activation byte and error route. - [ ] **Step 1: Write failing setup and authority tests** @@ -174,14 +203,14 @@ def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: ) ``` -Also assert proposal validation, publication, recap checkpoint, setup decision, and setup activation accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. +Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. - [ ] **Step 2: Run the focused tests and verify RED** Run: ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap tests.test_program_activation tests.test_state_authority -v ``` Expected: new tests fail because only setup/envelope v1 exists and `Delete` is unsupported; all pre-existing tests remain green. @@ -216,7 +245,7 @@ def _operation_contract( For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Preserve existing ownership/facts checks and require Delete to be program-owned, non-protected, and non-user-work before setup approval can be valid. -Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS` and its manifest-v3 foreign-schema checks with the v2 setup records without relaxing v1 matching. +Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. In this same task, change `program_activation.py::_build_v3_setup_record(...)` to select and write the matching activation schema instead of importing and unconditionally emitting `SETUP_ACTIVATION_SCHEMA`; update its activation-prefix adoption tests before calling this task GREEN. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS`, `program_setup.py`'s activation-record loaders/validators, and `state_authority.py::SETUP_ONLY_STATUS_SCHEMAS` plus its manifest-v3 family validation without admitting the v2 records to setup-v1 or legacy manifests. - [ ] **Step 4: Run the focused tests and verify GREEN** @@ -227,7 +256,7 @@ Expected: all setup, authority, and generic proposal-publication tests pass; the - [ ] **Step 5: Commit the setup contract** ```bash -rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py +rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_state_authority.py rtk git commit -m "feat: add typed delete setup contracts" ``` @@ -247,7 +276,9 @@ rtk git commit -m "feat: add typed delete setup contracts" **Interfaces:** - Produces: `ExactFileMapV2`, `ExecutionBaselineV2`, and `InheritedPathStateV2` while retaining `ExactFileMap` and `ExecutionBaseline` as v1 types. - Produces: `file_map_entries(file_map) -> tuple[tuple[str, tuple[str, ...]], ...]` and `file_map_paths(file_map, *, mutable_only: bool) -> tuple[str, ...]` so consumers do not reconstruct operation inventories inconsistently. +- Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. - Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. +- Produces: v2 product states in operation-section order and exact file-map order; v1 product deltas retain their current lexical ordering and bytes. - Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. - Preserves: public plan preparation/materialization and three-argument future-write signatures. @@ -303,6 +334,10 @@ def test_v2_delete_path_must_transition_from_exact_file_to_absence(self) -> None Add negative cases for a missing Delete target at baseline, unchanged Delete at reviewing, changed-but-present Delete, symlink/hard-link/directory/special-file targets, overlap with recorded user work, duplicate cross-disposition paths, `sha256` on an absent result, and `None` on a present result. Retain the existing assertion that deleting a v1 Modify path fails. +Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve and whose later increment owns Delete. Assert that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches accepted state before the Delete increment starts. Assert the inverse family substitution fails for setup v1. + +For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Cover the same symlinked-ancestor rejection during baseline construction, and assert the external sentinel is unchanged in both cases. + - [ ] **Step 2: Run the focused tests and verify RED** ```bash @@ -343,13 +378,15 @@ def file_map_entries( ) ``` -`parse_exact_file_map(...)` must first reject every unrecognized `###` heading within the v1 file-map body. Select v2 only from the exact schema marker, then require one ordered Create/Modify/Delete/Preserve heading; allow the Delete section to contain no path only for a successor that needs v2 inherited-state validation. Duplicate and unsafe path rejection remains global across all sections. +`parse_exact_file_map(...)` must first reject every unrecognized `###` heading within the v1 file-map body. Select v2 only from the exact schema marker, then require one ordered Create/Modify/Delete/Preserve heading. The Delete section may be empty for any increment in a setup-v2 program; the other required sections retain their current non-empty contract. Duplicate and unsafe path rejection remains global across all sections. + +Add `implementation-execution-baseline/v2` in `repository_preparation.py` with an exact v2 file-map object, current path baselines, user-work baselines, and ordered `inherited_path_states`. Dispatch `execution_baseline_from_value(...)` on the exact baseline schema. In `program_activation.py::_build_plan_candidate(...)`, select file-map and baseline v2 for every increment solely when the manifest's exact setup/envelope pair is v2, even when Delete is empty and no inherited state exists; reject v1/v2 substitutions in both directions before persistence. Do not add fields to the v1 serialization. -Add `implementation-execution-baseline/v2` with an exact v2 file-map object, current path baselines, user-work baselines, and ordered `inherited_path_states`. Dispatch `execution_baseline_from_value(...)` on the exact baseline schema. Do not add fields to the v1 serialization. +Implement `inspect_workspace_path(...)` with `os.lstat`, never `Path.is_file()` or `resolve()` as the symlink test: normalize the relative POSIX path; `lstat` and reject a symlinked/non-directory supplied workspace root before resolving it strictly; prove the lexically joined candidate is relative to that root; `lstat` the root, every ancestor, and the final component; require every existing ancestor to be a non-symlink directory; reject missing ancestors; allow only an absent or regular non-symlink final component; and require the strict resolved location of every existing component, including the final file, to remain inside the strict workspace root. Return the final digest, mode, and link count from that checked path state. Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. A later lifecycle reassessment must repeat the complete walk; an authorization-time result is never reused as current path safety evidence. - [ ] **Step 4: Implement Delete-aware candidate and workspace validation** -In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 when the current increment has a setup-envelope Delete allocation or status carries v2 inherited path states. Match every Delete path to exactly one current-increment exact or bounded-class setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. +In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 for the complete setup-v2 program family from its first increment. Match every non-managed current path, including each Delete path, to exactly one current-increment exact or bounded-class setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. Use the shared operation iterator in `_path_baselines(...)`, `_user_work_baselines(...)`, `validate_required_managed_file_map(...)`, and `validate_execution_workspace(...)`. Enforce: @@ -372,7 +409,7 @@ if disposition == "Delete": }) ``` -For v2 Create/Modify results emit `final_state: "present"` with the real digest. Keep the v1 result object and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. +For v2 Create/Modify results emit `final_state: "present"` with the real digest. Construct v2 results by iterating `file_map_entries(...)` in Create, Modify, Delete, Preserve section order and retaining each section's exact path order; do not sort v2 states after construction. Keep the v1 result object, lexical sort, and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. - [ ] **Step 5: Run the focused tests and verify GREEN** @@ -396,17 +433,20 @@ rtk git commit -m "feat: validate delete path states" - 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_discovery.py` - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` - Modify: `tests/program_bootstrap_support.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_discovery.py` - Test: `tests/test_state_authority.py` **Interfaces:** - Produces: `implementation-review-evidence/v2`, `implementation-review-packet/v2`, `implementation-review-preparation/v2`, `implementation-review-remediation/v2`, `implementation-diff-disposition-binding/v2`, and `implementation-diff-disposition-command/v2` only for product path-state v2. - Produces: review evidence field `product_result = {schema_version, sha256, ordered_path_states}`. +- Produces: exact-family discovery of v2 acceptance prefixes and an `accepted-stop` route for an exact accepted v2 diff binding. - Produces test helpers in `tests/program_bootstrap_support.py`: `BootstrapFixture.observation() -> RepositoryObservation` and `reviewing_delete_program() -> tuple[BootstrapFixture, Path, RepositoryObservation]`, returning a real temporary manifest-v3/setup-v2 program at `reviewing` with `legacy.ts` absent and raw review reports ready. - Preserves: v1 review evidence, packet rendering, remediation, prompt bytes, diff bindings, and approval records. @@ -446,12 +486,12 @@ def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: fixture.close() ``` -Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. +Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. In `tests/test_program_discovery.py`, persist an exact v2 diff-acceptance prefix and assert the pre-status prefix is `increment-acceptance-retry-ready`, the byte-exact accepted status is `accepted-stop`, and a substituted v1 binding, reordered state, or changed digest is `increment-acceptance-recovery-required` rather than resume or terminal. - [ ] **Step 2: Run the focused tests and verify RED** ```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_state_authority -v +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_discovery tests.test_state_authority -v ``` Expected: new v2 review/result schemas are absent and Delete surfaces cannot be represented. @@ -462,7 +502,7 @@ Extend execution ownership with a literal `delete` disposition: it requires a no When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. -`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. Keep v1 base-seed construction and prompt bytes unchanged. +`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. `program_discovery.py::_exact_acceptance_prefix_disposition(...)` and both accepted-state loaders must recognize only the exact v1 or v2 diff binding/command family, rebuild the matching production candidate, and classify byte-exact v2 accepted-stop and retry prefixes without a hard-coded v1 gate. Keep v1 base-seed construction, prompt bytes, and discovery dispositions unchanged. - [ ] **Step 4: Extend state validation by exact review/diff schema** @@ -477,7 +517,7 @@ Expected: Delete absence is visible and immutable from review preparation throug - [ ] **Step 6: Commit review and diff support** ```bash -rtk git add 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/state_authority.py tests/program_bootstrap_support.py tests/test_execution_discipline.py tests/test_review_coordination.py tests/test_program_review.py tests/test_diff_disposition.py tests/test_state_authority.py +rtk git add 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_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_execution_discipline.py tests/test_review_coordination.py tests/test_program_review.py tests/test_diff_disposition.py tests/test_program_discovery.py tests/test_state_authority.py rtk git commit -m "feat: bind deleted results through review" ``` @@ -580,41 +620,48 @@ rtk git commit -m "feat: preserve delete state in recovery" **Interfaces:** - Produces: `ProductPathStateV2(path, disposition, final_state, sha256)` without changing `ProductDeltaPath` v1. - Produces: `implementation-successor-authority-projection/v2`, `implementation-increment-rollover/v2`, `implementation-increment-rollover-binding/v2`, and `implementation-inherited-workspace/v2`. +- Produces: accept-and-continue status bindings that retain the exact v1 or v2 diff-disposition family of the accepted stop candidate instead of rewriting v2 acceptance as v1. - Produces: `validated_inherited_path_states(program_root, status, observation) -> tuple[InheritedPathStateV2, ...]` while preserving `validated_inherited_paths(...)` for v1. -- Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation. -- Produces test fixture: `ThreeIncrementDeleteFixture` with `accept_delete(path)`, `rollover(accepted_status, successor_id)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_third_plan()` methods that call production writers rather than editing lifecycle artifacts directly. +- Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation, using stable replace-in-place/append ordering rather than lexical resorting. +- Produces test fixture: `ThreeIncrementDeleteFixture` configured as setup/envelope v2 from sequence zero, with `accept_predecessor_create(path)`, `rollover(accepted_status, successor_id)`, `accept_delete(increment_id, path)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_current_plan()` methods that call production writers rather than editing lifecycle artifacts directly. - [ ] **Step 1: Write failing three-increment inheritance tests** ```python -def test_delete_tombstone_survives_unrelated_successor_and_closes_over_third_increment(self) -> None: +def test_late_delete_tombstone_survives_an_unrelated_successor(self) -> None: fixture = ThreeIncrementDeleteFixture() try: - first = fixture.accept_delete("legacy.ts") + first = fixture.accept_predecessor_create("first.ts") second = fixture.rollover(first, "SECOND") self.assertEqual( - second["inherited_workspace_binding"]["inherited_path_states"], + [item["path"] for item in second["inherited_workspace_binding"]["inherited_path_states"]], + ["first.ts"], + ) + fixture.accept_delete("SECOND", "legacy.ts") + third = fixture.rollover_current("THIRD") + self.assertEqual( + third["inherited_workspace_binding"]["inherited_path_states"], [{ + "path": "first.ts", + "final_state": "present", + "disposition": "Create", + "sha256": fixture.sha256("first.ts"), + }, { "path": "legacy.ts", "final_state": "absent", "disposition": "Delete", "sha256": None, }], ) - fixture.accept_unrelated_create("SECOND", "new.ts") - third = fixture.rollover_current("THIRD") - self.assertEqual( - [item["path"] for item in third["inherited_workspace_binding"]["inherited_path_states"]], - ["legacy.ts", "new.ts"], - ) + fixture.prepare_current_plan() fixture.repository.joinpath("legacy.ts").write_text("reappeared\n", encoding="utf-8") with self.assertRaisesRegex(ValueError, "inherited absent path reappeared: legacy.ts"): - fixture.prepare_third_plan() + fixture.prepare_current_plan() finally: fixture.close() ``` -Add a positive recreation case where the later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. +The first increment's exact file map must be v2 with an empty Delete section, and it must be fully accepted before the SECOND Delete increment is prepared. Assert immediate accept-and-continue and later accepted-state continuation both retain `implementation-diff-disposition-binding/v2`; a hard-coded v1 rewrite must fail before rollover. Add a positive recreation case where a later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. Add an ordering case whose first result has two paths in non-lexical exact-map order and whose second result replaces the first path and adds a new path: the replacement must keep its existing cumulative slot, the untouched state must keep its slot, and the new state must append in current result order. - [ ] **Step 2: Run focused continuation/rollover tests and verify RED** @@ -637,7 +684,7 @@ class ProductPathStateV2: sha256: str | None ``` -Never pass v2 entries through `ProductDeltaPath(sha256: str)`. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Merge by path in accepted increment order; replace an earlier state only when the current exact operation inventory owns that same path and its baseline agrees with the inherited state. +Never pass v2 entries through `ProductDeltaPath(sha256: str)`. In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. `validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review result, diff decision, and cumulative digest. It requires present files to match exact digests and absent files to remain absent. Mixed result families stop before persistence. @@ -667,15 +714,18 @@ rtk git commit -m "feat: inherit accepted delete tombstones" **Files:** - Modify: `skills/implementing-staged-plans/scripts/continuity_closure.py` - Modify: `skills/implementing-staged-plans/scripts/program_closure.py` +- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` - Test: `tests/test_continuity_closure.py` - Test: `tests/test_program_closure.py` +- Test: `tests/test_program_discovery.py` - Test: `tests/test_multi_increment_lifecycle.py` - Test: `tests/test_state_authority.py` **Interfaces:** - Produces: `implementation-closure-reconciliation/v2`, `implementation-closure-packet/v2`, `implementation-closure-preparation/v2`, `implementation-program-closure-command/v2`, and `implementation-program-closure-command-binding/v2` for a v2 accepted chain. - Produces: reconciliation fields `accepted_result_bindings`, `final_inherited_path_states`, and `final_inherited_path_states_sha256`. +- Produces: exact v2 discovery classification for closure-preparation and closure-approval retry/recovery prefixes. - Consumes test helper: `accepted_three_increment_delete_program() -> ThreeIncrementDeleteFixture`, which extends the Task 5 fixture through accepted `THIRD` state with current review/diff evidence intact. - Preserves: all v1 closure dataclasses, renderers, commands, approvals, and singleton first-increment closure bytes. @@ -710,12 +760,14 @@ def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> Non fixture.close() ``` -Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. +`accepted_three_increment_delete_program()` must accept FIRST as a non-Delete predecessor under setup/file-map/baseline/result v2 with an empty Delete section, accept the Delete in SECOND, and accept an unrelated THIRD increment before closure. Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. + +In `tests/test_program_discovery.py`, interrupt v2 closure preparation after each persisted reconciliation/packet prefix. Require a byte-exact prefix to return `closure-preparation-retry-ready`, packet-without-reconciliation or any changed/reordered v2 path state/digest to return `closure-preparation-recovery-required`, an exact persisted closure approval before status-last completion to return `closure-approval-retry-ready`, and any substituted v1 closure-preparation/command binding or divergent closed status to return `closure-approval-recovery-required`. Assert the same disposition names and bytes remain unchanged for v1. - [ ] **Step 2: Run closure tests and verify RED** ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_continuity_closure tests.test_program_closure tests.test_multi_increment_lifecycle tests.test_state_authority -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_continuity_closure tests.test_program_closure tests.test_program_discovery tests.test_multi_increment_lifecycle tests.test_state_authority -v ``` Expected: current production closure emits only the final increment and has no cumulative path-state binding. @@ -741,9 +793,9 @@ Do not put absent paths in `evidence_paths`; bind their typed result and final-s - [ ] **Step 4: Build closure from the canonical rollover chain** -In `program_closure.py::build_closure_preparation(...)`, dispatch on the accepted product-result schema. For v2, enumerate `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted increment in order. Bind each increment's exact reviewed result, review packet, diff decision, and required handoff addendum; merge the final current result into validated cumulative inherited states; perform later-invalidation checks across every accepted increment; then construct v2 reconciliation and packet. +In `program_closure.py::build_closure_preparation(...)`, dispatch on the exact setup family paired with the accepted product-result schema. For v2, enumerate `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted increment in order. Bind each increment's exact reviewed result, review packet, diff decision, and required handoff addendum; merge the final current result into validated cumulative inherited states; perform later-invalidation checks across every accepted increment; then construct v2 reconciliation and packet. -Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Existing v1 closure remains on its current singleton or legacy route. +Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. - [ ] **Step 5: Run closure tests and verify GREEN** @@ -754,7 +806,7 @@ Expected: closure succeeds only when all accepted increments and the final cumul - [ ] **Step 6: Commit closure reconciliation** ```bash -rtk git add skills/implementing-staged-plans/scripts/continuity_closure.py skills/implementing-staged-plans/scripts/program_closure.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_continuity_closure.py tests/test_program_closure.py tests/test_multi_increment_lifecycle.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/continuity_closure.py skills/implementing-staged-plans/scripts/program_closure.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_continuity_closure.py tests/test_program_closure.py tests/test_program_discovery.py tests/test_multi_increment_lifecycle.py tests/test_state_authority.py rtk git commit -m "feat: reconcile deleted paths at closure" ``` @@ -778,6 +830,7 @@ rtk git commit -m "feat: reconcile deleted paths at closure" - Modify: `docs/reference.md` - Modify: `docs/workflows.md` - Modify: `docs/troubleshooting.md` +- Modify: `implementing-staged-plans-bootstrap-execution-review-runbook.md` - Modify: `docs/maintainers.md` - Modify: `docs/installation.md` - Modify: `.codex-plugin/plugin.json` @@ -791,7 +844,7 @@ rtk git commit -m "feat: reconcile deleted paths at closure" **Interfaces:** - Produces: `load_pipeflow_delete_inventory() -> tuple[str, tuple[str, ...]]` returning the source SHA-256 and exactly 27 normalized paths. - Produces: a deterministic temporary-repository replay from Delete-capable proposal validation through final closure. -- Produces: `DeleteLifecycleFixture(delete_paths: Sequence[str], source_sha256: str)` with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_through_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. +- Produces: `DeleteLifecycleFixture(delete_paths: Sequence[str], source_sha256: str)` configured as setup/envelope v2 from sequence zero, with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_and_accept_predecessor()`, `rollover_to_delete_increment()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_to_unrelated_increment()`, `prepare_and_accept_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. - Produces: package version `0.1.3` on all existing version owners. - Preserves: the external pipeFlow source and workspace as read-only inputs. @@ -852,10 +905,13 @@ def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: recap = fixture.render_setup_recap() self.assertTrue(all(path in recap for path in delete_paths)) fixture.approve_activate_and_start() + fixture.prepare_and_accept_predecessor() + fixture.rollover_to_delete_increment() fixture.prepare_and_authorize_delete_plan() fixture.delete_every_target() fixture.review_and_accept_delete_result() - fixture.rollover_through_unrelated_increment() + fixture.rollover_to_unrelated_increment() + fixture.prepare_and_accept_unrelated_increment() fixture.assert_every_target_is_inherited_absent() closure = fixture.prepare_final_closure() self.assertEqual( @@ -866,7 +922,7 @@ def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: fixture.close() ``` -Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. +The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section and reach accepted status before the Delete increment is prepared; assert discovery returns `accepted-stop` at that boundary. The unrelated successor must also use v2 with an empty Delete section. Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. - [ ] **Step 3: Run the scenario tests and verify RED, then GREEN** @@ -902,6 +958,8 @@ external-state authority. Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. +Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain and final cumulative digest. Do not rewrite older dated design plans; they remain historical version-bound records. + - [ ] **Step 5: Synchronize package version `0.1.3`** Set: @@ -924,7 +982,7 @@ Expected: the real-scenario fixture, front-door contract, documentation, synchro - [ ] **Step 7: Commit scenario and release contracts** ```bash -rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py +rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md implementing-staged-plans-bootstrap-execution-review-runbook.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py rtk git commit -m "feat: release typed delete operation support" ``` @@ -983,21 +1041,25 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Requirement | Primary owner | Required evidence | Failure signal | | --- | --- | --- | --- | -| Locked implementation baseline | Git preflight | branch `repair/delete-operation-support`, HEAD `b5eb689e...`, clean | stop before edits | +| Locked implementation baseline | Git preflight | review commit `44ef42ac...` has parent candidate `b5eb689e...`; repaired-plan kickoff is its single clean plan-only child on `repair/delete-operation-support` | stop before edits | | Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | | Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | | Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | | Exact plan does not misclassify Delete | `repository_preparation.py` | unversioned heading fails; v2 parses ordered Delete section | Delete absorbed as Modify | -| Baseline proves a real removable file | `program_activation.py` | existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | -| Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest | accidental loss or fabricated digest | +| Late Delete uses one program family | setup/activation/preparation/rollover | a setup-v2 predecessor accepts with empty Delete under file-map/baseline/result v2 before the later Delete increment | mixed v1/v2 rollover or closure | +| Baseline proves a real removable file | `program_activation.py`, `inspect_workspace_path(...)` | component `lstat`, workspace containment, existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | +| Ancestor safety is reassessed | `inspect_workspace_path(...)`, `validate_execution_workspace(...)` | baseline symlinked ancestor and post-authorization ancestor swap both fail before external reads | path escapes through ancestor | +| Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest and exact-map ordering | accidental loss, fabricated digest, or reordered state | | Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve | Delete accepted for a control path | | Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | | Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | +| Discovery resumes v2 safely | `program_discovery.py` | v2 accepted-stop plus exact closure preparation/approval retries; divergent prefixes recover | v1-only gate, wrong resume, or terminal route | | Blocked recovery freezes path state | `blocked_recovery.py` | v2 context reproduces exact partial/complete states | post-block change or evidence fabrication | -| Rollover preserves tombstones | `program_rollover.py` | three-increment test retains absent state through unrelated work | reappearance, omission, mixed chain | +| Rollover preserves ordered tombstones | `program_rollover.py` | accepted predecessor before Delete; replace-in-place/append merge retains absent state through unrelated work | reappearance, omission, reorder, or mixed chain | | Recreation is explicit | activation/preparation | later Create owns inherited absent path and baseline agrees | implicit recreation or wrong operation | | Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff decisions plus final cumulative digest | singleton-only or lost tombstone | | Front door does not over-authorize | skill/references/docs | Delete remains local plan-bound `modify-workspace` only | generic destructive/external claim | +| Operational runbook is current | bootstrap/execution/review runbook | `0.1.3` path states, discovery, and complete-chain closure match canonical owners | live runbook remains at `0.1.2` | | Package is synchronized | manifests/validator/docs | every owner says `0.1.3`; package validation exits `0` | version or inventory mismatch | | Full regression | complete suite | one completed exit `0`, exact count recorded | failure, interruption, or partial output | | External boundary | final status/diff | no push, PR, install, cache sync, pipeFlow edit, or provider action | any unauthorized external mutation | From e927c3a135f66503341c0e22b0db458ac4e4b422 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sat, 5 Sep 2026 23:27:42 -0400 Subject: [PATCH 03/19] docs: complete delete operation repair plan --- .../2026-09-05-delete-operation-support.md | 223 +++++++++++++----- 1 file changed, 162 insertions(+), 61 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index 4462b41..11528a7 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -12,13 +12,13 @@ ## Global Constraints -- The reviewed plan baseline is commit `44ef42acdef540af72577e224054d763da80fc5f`; its parent and implementation candidate are exactly `b5eb689e780f48b218b807a4691f0994474e4178`. This plan-repair commit must be the single plan-only child of `44ef42acdef540af72577e224054d763da80fc5f`. Start implementation only from that clean repaired-plan HEAD on branch `repair/delete-operation-support`, with no path other than this plan changed from the candidate. +- The first reviewed plan-repair baseline is commit `31a04be196c5235cd1f75ec931502c0d79f2a46d`; its parent is the first plan commit `44ef42acdef540af72577e224054d763da80fc5f`, whose parent and implementation candidate is exactly `b5eb689e780f48b218b807a4691f0994474e4178`. This second-corrections commit must be the single plan-only child of `31a04be196c5235cd1f75ec931502c0d79f2a46d`. Start implementation only from that clean second-corrections HEAD on branch `repair/delete-operation-support`, with no path other than this plan changed from the candidate. - Use `rtk` for every repository command. - Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. - Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. - Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; that setup choice fixes the complete program to the nested v2 lifecycle family from its first increment, and mixed v1/v2 nested contracts fail before every write. -- A `Delete` target must be one normalized repository-relative path to an existing program-owned regular non-symlink, non-hard-linked file beneath the selected workspace. Directories, symlinks, symlinked ancestors, hard links, special files, missing parents, external paths, protected paths, and pre-existing user work remain unsupported. -- Baseline capture and every later reassessment must repeat one shared component-by-component `lstat` walk and workspace-containment proof for the final path and every ancestor; no earlier safe observation authorizes a later swapped ancestor. +- A `Delete` target must be one normalized repository-relative path that is a program-owned regular non-symlink, non-hard-linked file beneath the selected workspace when its deletion-increment baseline is captured. Setup may bind either an initially `existing` target or an `accepted-predecessor` target created and accepted by a strict predecessor increment; the latter requires a same-path predecessor `Create` allocation. Directories, symlinks, symlinked ancestors, hard links, special files, absent Delete baselines, external paths, protected paths, and pre-existing user work remain unsupported. +- Baseline capture and every later reassessment must repeat one shared component-by-component `lstat` walk and workspace-containment proof. Reject any unsafe existing component or containment escape, but return an absent snapshot after the first missing component so a caller may permit an absent suffix for Create, accepted Delete tombstones, inherited absence, or already-absent user work. Delete/Modify/Preserve baseline callers and every present-state caller separately require the ancestors and final regular file their operation needs; no earlier safe observation authorizes a later swapped ancestor. - `Delete` means the approved final state is absent. Never encode absence as `Modify`, `Preserve`, an omitted path, an empty digest, or a fabricated digest. - `authorized` requires every Delete target to remain byte-identical to its baseline; `implementing` permits either the exact baseline file or its absence; `reviewing` and later require absence. A changed-but-present Delete target is always invalid. - A typed local Delete remains within the exact plan-bound `modify-workspace` action. It does not grant the separately named `destructive-operation`, cleanup, migration, Git, publication, deployment, provider, or external-state actions. @@ -42,10 +42,11 @@ The defect is confirmed at the locked baseline: 4. The accepted product-delta and rollover contracts require a string `sha256` for every result, so they cannot represent a legitimate absent path. `program_rollover.py::_validated_inherited_paths(...)` also requires every inherited path to remain a regular file with the accepted digest. 5. The pipeFlow Task 8 file map contains 27 explicit regular-file Delete paths. Omitting them would make the exact plan incomplete and make their Git deletions unmapped product changes; relabeling them `Modify` would preserve the existing, correct missing-Modify failure. 6. `program_activation.py::_build_v3_setup_record(...)` imports and writes only `SETUP_ACTIVATION_SCHEMA` (`setup-activation-decision/v1`), so a setup-v2 activation cannot be a truthful Task 1 GREEN until that writer and both authority validators dispatch together. -7. The consumer rescan found two additional hard-coded v1 edges: `program_discovery.py::_exact_closure_prefix_disposition(...)` requires diff-disposition and closure-preparation v1, and `program_continuation.py::build_accept_continue_candidate(...)` rewrites its acceptance binding with `DIFF_DISPOSITION_BINDING_SCHEMA` v1. Both must dispatch on exact families for v2 retry, recovery, and continuation to work. +7. The consumer rescan found additional hard-coded v1 edges. `program_discovery.py::_exact_closure_prefix_disposition(...)` requires diff-disposition and closure-preparation v1; its manifest-v3 loader validates full state authority before inspecting plan, review, acceptance, closure, or rollover prefixes and otherwise falls through to generic routes. `program_continuation.py::build_accept_continue_candidate(...)` rewrites its acceptance binding with `DIFF_DISPOSITION_BINDING_SCHEMA` v1, while its accepted-state command, parser, and embedded product values are fixed to `implementation-accepted-state-continuation-binding/v1` and `ProductDeltaPath(sha256: str)`. All must dispatch on exact families for v2 retry, recovery, and continuation to work. 8. Current product deltas and inherited paths are lexically sorted, while a typed v2 result needs one specified order. V2 therefore requires operation-section/exact-map result order and stable cumulative replace-in-place/append semantics while leaving v1 sorting unchanged. -9. Current activation and workspace assessment check the final `Path` with `is_symlink()`/`is_file()` but do not share a component walk. A safe final file beneath a later-swapped symlink ancestor can therefore evade the intended workspace-bound path contract until each baseline and reassessment performs the same `lstat`/containment proof. -10. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. +9. Current activation and workspace assessment check the final `Path` with `is_symlink()`/`is_file()` but do not share a component walk. A safe final file beneath a later-swapped symlink ancestor can therefore evade the intended workspace-bound path contract. The shared walk must still preserve the current valid absence semantics for a Create target whose parent is not created yet and for already-absent user work; operation callers, not the primitive walk, own required-presence rules. +10. The real pipeFlow lifecycle does not begin with all 27 Task 8 Delete targets. `test/legacy/characterization.test.ts` is absent at setup, created and accepted in Task 1, inherited as present, and deleted with the other 26 legacy files in Task 8. A fixture that pre-creates all 27 paths does not exercise future Delete allocation, predecessor collision facts, or a real late tombstone. +11. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. @@ -74,13 +75,13 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, state bindings, and v1 compatibility rejection. - `skills/implementing-staged-plans/scripts/repository_preparation.py` — parse exact-file-map v2, parse baseline v2, and assess present/absent path states. - `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. -- `skills/implementing-staged-plans/scripts/program_discovery.py` — classify v2 accepted-stop, closure preparation/approval retry, and divergent-prefix recovery by exact schema family. +- `skills/implementing-staged-plans/scripts/program_discovery.py` — route manifest-v3/setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, closure, and divergent prefixes by exact schema family before generic state validation. - `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. - `skills/implementing-staged-plans/scripts/review_coordination.py` — carry and validate the v2 accepted path-state result in review evidence and packets. - `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings. - `skills/implementing-staged-plans/scripts/diff_disposition.py` — bind the exact reviewed v2 product result during acceptance. - `skills/implementing-staged-plans/scripts/blocked_recovery.py` — freeze and revalidate Delete path states across blocked/resume. -- `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results without coercing absence to a string digest. +- `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results and render/parse exact accepted-state-continuation v1/v2 commands without coercing absence to a string digest. - `skills/implementing-staged-plans/scripts/program_rollover.py` — persist v2 rollover records and cumulative inherited present/absent path states. - `skills/implementing-staged-plans/scripts/continuity_closure.py` — validate/render versioned closure reconciliation over accepted result bindings and cumulative path states. - `skills/implementing-staged-plans/scripts/program_closure.py` — build closure from the complete accepted increment chain and final cumulative state. @@ -88,6 +89,7 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/SKILL.md` — route and explain the Delete-capable nested v2 family. - `skills/implementing-staged-plans/agents/openai.yaml` — describe exact local Delete support without implying generic destructive authority. - `skills/implementing-staged-plans/references/program-authority.md` — document v1/v2 setup pairing and authority limits. +- `skills/implementing-staged-plans/references/program-discovery.md` — document prefix-first exact v1/v2 discovery, retry, and recovery classification. - `skills/implementing-staged-plans/references/repository-preparation.md` — own the v2 file-map grammar and baseline path-state rules. - `skills/implementing-staged-plans/references/execution-discipline.md` — own lifecycle-state behavior for Delete. - `skills/implementing-staged-plans/references/review-coordination.md` — own review/remediation result binding. @@ -121,12 +123,12 @@ Before Task 1, record and require all of the following without changing the tree ```bash rtk git status --short --branch -rtk git rev-parse HEAD^ HEAD^^ +rtk git rev-parse HEAD^ HEAD^^ HEAD^^^ rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD ``` -Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is `44ef42acdef540af72577e224054d763da80fc5f`; `HEAD^^` is `b5eb689e780f48b218b807a4691f0994474e4178`; the only candidate-to-kickoff path is `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; and `diff --check` is empty. Stop before implementation on any mismatch. +Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is `31a04be196c5235cd1f75ec931502c0d79f2a46d`; `HEAD^^` is `44ef42acdef540af72577e224054d763da80fc5f`; `HEAD^^^` is `b5eb689e780f48b218b807a4691f0994474e4178`; the only candidate-to-kickoff path is `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; and `diff --check` is empty. Stop before implementation on any mismatch. --- @@ -142,6 +144,7 @@ Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is - Test: `tests/test_program_authority.py` - Test: `tests/test_program_bootstrap.py` - Test: `tests/test_program_activation.py` +- Test: `tests/test_program_discovery.py` - Test: `tests/test_state_authority.py` **Interfaces:** @@ -157,7 +160,12 @@ Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is Add these test cases with a helper that rewrites the candidate before recomputing `setup_semantics_sha256`: ```python -def delete_allocation(path: str, increment_id: str) -> dict[str, object]: +def delete_allocation( + path: str, + increment_id: str, + *, + collision: str = "existing", +) -> dict[str, object]: return { "kind": "exact-path", "path": path, @@ -171,7 +179,7 @@ def delete_allocation(path: str, increment_id: str) -> dict[str, object]: "file_kind": "regular-file", "link_kind": "none", "mode": "100644", - "collision": "existing", + "collision": collision, "accepted_state": "absent", "content_disposition": "obsolete", "rationale": "The approved replacement implementation makes this file obsolete.", @@ -203,14 +211,16 @@ def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: ) ``` -Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. +Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Add a two-increment allocation for one initially absent exact path: `Create` in the first increment with facts `absent`/`none`/`None`/`none`, then `Delete` in its strict successor with facts `regular-file`/`none`/`100644`/`accepted-predecessor`. Require one same-path Create allocation in a transitive predecessor for `accepted-predecessor`, and reject an unrelated, same-increment, later, or absent predecessor allocation; the distinct operations are not a duplicate allocation. Keep an initially present Delete allocation on collision `existing` and reject any other collision/fact combination. + +Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. After each byte-exact v2 activation prefix, run discovery and require the existing `program-activation-retry-ready` route; a mixed or divergent prefix must return `program-activation-recovery-required` before generic sequence-zero rejection. - [ ] **Step 2: Run the focused tests and verify RED** Run: ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap tests.test_program_activation tests.test_state_authority -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap tests.test_program_activation tests.test_program_discovery tests.test_state_authority -v ``` Expected: new tests fail because only setup/envelope v1 exists and `Delete` is unsupported; all pre-existing tests remain green. @@ -243,7 +253,7 @@ def _operation_contract( raise ValueError("setup semantics and operation envelope schema families do not match") ``` -For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Preserve existing ownership/facts checks and require Delete to be program-owned, non-protected, and non-user-work before setup approval can be valid. +For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Require an exact-path Delete allocation to be program-owned, non-protected, non-user-work, and to declare collision `existing` or `accepted-predecessor`. The latter is valid only when the setup dependency graph contains one same-path `Create` allocation in a strict transitive predecessor; it does not weaken the activation-time exact fact comparison. Preserve all existing ownership, file-kind, link-kind, mode, collision, overlap, and duplicate-allocation checks. Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. In this same task, change `program_activation.py::_build_v3_setup_record(...)` to select and write the matching activation schema instead of importing and unconditionally emitting `SETUP_ACTIVATION_SCHEMA`; update its activation-prefix adoption tests before calling this task GREEN. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS`, `program_setup.py`'s activation-record loaders/validators, and `state_authority.py::SETUP_ONLY_STATUS_SCHEMAS` plus its manifest-v3 family validation without admitting the v2 records to setup-v1 or legacy manifests. @@ -256,7 +266,7 @@ Expected: all setup, authority, and generic proposal-publication tests pass; the - [ ] **Step 5: Commit the setup contract** ```bash -rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py rtk git commit -m "feat: add typed delete setup contracts" ``` @@ -268,9 +278,11 @@ rtk git commit -m "feat: add typed delete setup contracts" - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` - Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` - Modify: `skills/implementing-staged-plans/scripts/program_activation.py` +- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` - Test: `tests/test_repository_preparation.py` - Test: `tests/test_program_activation.py` - Test: `tests/test_approval_checkpoint.py` +- Test: `tests/test_program_discovery.py` - Test: `tests/test_state_authority.py` **Interfaces:** @@ -279,6 +291,7 @@ rtk git commit -m "feat: add typed delete setup contracts" - Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. - Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. - Produces: v2 product states in operation-section order and exact file-map order; v1 product deltas retain their current lexical ordering and bytes. +- Produces: manifest-v3/setup-v2 `plan-preparation-*` and `plan-materialization-*` retry/recovery classification from exact transaction prefixes before full state-authority validation or generic lifecycle routing. - Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. - Preserves: public plan preparation/materialization and three-argument future-write signatures. @@ -334,14 +347,14 @@ def test_v2_delete_path_must_transition_from_exact_file_to_absence(self) -> None Add negative cases for a missing Delete target at baseline, unchanged Delete at reviewing, changed-but-present Delete, symlink/hard-link/directory/special-file targets, overlap with recorded user work, duplicate cross-disposition paths, `sha256` on an absent result, and `None` on a present result. Retain the existing assertion that deleting a v1 Modify path fails. -Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve and whose later increment owns Delete. Assert that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches accepted state before the Delete increment starts. Assert the inverse family substitution fails for setup v1. +Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve, including Create for a currently absent exact path, and whose strict successor owns Delete for that same path with collision `accepted-predecessor`. In this task, assert only that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches `authorized` with an exact v2 baseline. Do not fabricate or require accepted predecessor state here: Task 3 owns v2 review/diff acceptance, and Task 5 owns the production rollover into the Delete increment. Assert the inverse family substitution fails for setup v1. -For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Cover the same symlinked-ancestor rejection during baseline construction, and assert the external sentinel is unchanged in both cases. +For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Cover the same symlinked-ancestor rejection during baseline construction, and assert the external sentinel is unchanged in both cases. Also preserve the current positive cases for an absent v1 Create target below a not-yet-created parent and an already-absent tracked user-work path whose suffix is missing. A missing suffix returns `exists=False`; Delete/Modify/Preserve baseline callers must then reject it as missing, while Create, accepted/inherited absence, and already-absent user-work callers may accept it. - [ ] **Step 2: Run the focused tests and verify RED** ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_repository_preparation tests.test_program_activation tests.test_approval_checkpoint tests.test_state_authority -v +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: the unversioned parser test exposes the current Delete-to-Modify absorption; v2 imports and absent-result assertions fail; existing v1 tests pass. @@ -382,13 +395,15 @@ def file_map_entries( Add `implementation-execution-baseline/v2` in `repository_preparation.py` with an exact v2 file-map object, current path baselines, user-work baselines, and ordered `inherited_path_states`. Dispatch `execution_baseline_from_value(...)` on the exact baseline schema. In `program_activation.py::_build_plan_candidate(...)`, select file-map and baseline v2 for every increment solely when the manifest's exact setup/envelope pair is v2, even when Delete is empty and no inherited state exists; reject v1/v2 substitutions in both directions before persistence. Do not add fields to the v1 serialization. -Implement `inspect_workspace_path(...)` with `os.lstat`, never `Path.is_file()` or `resolve()` as the symlink test: normalize the relative POSIX path; `lstat` and reject a symlinked/non-directory supplied workspace root before resolving it strictly; prove the lexically joined candidate is relative to that root; `lstat` the root, every ancestor, and the final component; require every existing ancestor to be a non-symlink directory; reject missing ancestors; allow only an absent or regular non-symlink final component; and require the strict resolved location of every existing component, including the final file, to remain inside the strict workspace root. Return the final digest, mode, and link count from that checked path state. Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. A later lifecycle reassessment must repeat the complete walk; an authorization-time result is never reused as current path safety evidence. +Implement `inspect_workspace_path(...)` with `os.lstat`, never `Path.is_file()` or `resolve()` as the symlink test: normalize the relative POSIX path; `lstat` and reject a symlinked/non-directory supplied workspace root before resolving it strictly; prove the lexical candidate is beneath that root; then `lstat` components from the root downward. Every existing ancestor must be a non-symlink directory whose strict resolution remains inside the strict workspace root. If a component is missing, stop walking and return one absent snapshot for the whole remaining suffix without resolving, reading, or creating it. If the final component exists, require a regular non-symlink file, prove its strict resolution remains inside the workspace, and only then return its digest, mode, and link count. Reject every other existing component kind or containment escape. + +Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. Callers then enforce their own presence contract: baseline Delete/Modify/Preserve and every state that expects presence require an existing regular file; Create before creation, Delete after removal, inherited tombstones, and recorded already-absent user work permit an absent suffix. A later lifecycle reassessment must repeat the complete walk; an authorization-time result is never reused as current path-safety evidence. - [ ] **Step 4: Implement Delete-aware candidate and workspace validation** -In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 for the complete setup-v2 program family from its first increment. Match every non-managed current path, including each Delete path, to exactly one current-increment exact or bounded-class setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. +In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 for the complete setup-v2 program family from its first increment. Match every non-managed current path, including each exact Delete path, to exactly one current-increment setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. For the later-created Delete path, the first increment's Create facts must be absent/none/`None`/none; after accepted rollover, the Delete allocation must reproduce regular-file/none/mode/`accepted-predecessor`. Initially present Delete targets reproduce collision `existing`. A mismatched collision, ownership, file kind, link kind, mode, or inherited-state fact fails before plan or baseline persistence. -Use the shared operation iterator in `_path_baselines(...)`, `_user_work_baselines(...)`, `validate_required_managed_file_map(...)`, and `validate_execution_workspace(...)`. Enforce: +Use the shared operation iterator in `_path_baselines(...)`, `_user_work_baselines(...)`, `validate_required_managed_file_map(...)`, and `validate_execution_workspace(...)`. Include Delete in both the program-owned-operation check and the claimed-path/user-work-overlap set currently applied to Create/Modify. Enforce: ```python if disposition == "Delete": @@ -411,16 +426,20 @@ if disposition == "Delete": For v2 Create/Modify results emit `final_state: "present"` with the real digest. Construct v2 results by iterating `file_map_entries(...)` in Create, Modify, Delete, Preserve section order and retaining each section's exact path order; do not sort v2 states after construction. Keep the v1 result object, lexical sort, and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. -- [ ] **Step 5: Run the focused tests and verify GREEN** +- [ ] **Step 5: Route exact setup-v2 plan prefixes before generic rejection** + +In `program_discovery.py::_load_setup_candidate(...)`, keep sequence-zero activation routing unchanged. For sequence one and later, load the allocated transaction files and relevant ledgers, derive the fresh observation, and call `_exact_plan_prefix_disposition(...)` before `validate_state_authority(...)` or any generic `resume`/invalid route. Do not accept a prefix by state name alone. For a manifest-v3/setup-v2 program, interrupt standard-mode preparation after the exact plan and awaiting-plan status, and materialization after the plan approval, v2 baseline, and action authorization. Each byte-exact incomplete prefix must return the matching `plan-preparation-retry-ready` or `plan-materialization-retry-ready`; missing/out-of-order records, changed plan bytes, a v1 baseline, or changed v2 path-state order must return the matching recovery-required disposition. After the exact authorized status is written last, discovery returns `resume` only after full state validation. `approval:pre-approve` and `approval:full-increment` must exercise their shorter exact materialization prefixes and completed-status route. The same cases for setup-v1 keep their existing bytes and disposition names. + +- [ ] **Step 6: Run the focused tests and verify GREEN** Run the Step 2 command. Expected: the exact parser, baseline, authorization, partial implementation, complete absence, and legacy-negative tests pass. -- [ ] **Step 6: Commit exact-plan and baseline support** +- [ ] **Step 7: Commit exact-plan and baseline support** ```bash -rtk git add skills/implementing-staged-plans/scripts/state_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py tests/test_repository_preparation.py tests/test_program_activation.py tests/test_approval_checkpoint.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/state_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py 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 rtk git commit -m "feat: validate delete path states" ``` @@ -488,6 +507,10 @@ def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. In `tests/test_program_discovery.py`, persist an exact v2 diff-acceptance prefix and assert the pre-status prefix is `increment-acceptance-retry-ready`, the byte-exact accepted status is `accepted-stop`, and a substituted v1 binding, reordered state, or changed digest is `increment-acceptance-recovery-required` rather than resume or terminal. +This task owns the chronology assertion deferred from Task 2: drive a setup-v2 first increment containing only Create/Modify/Preserve through v2 review and exact `accept-stop`, with an empty Delete section in its file map/result family, and assert discovery returns `accepted-stop` before any successor or Delete plan is prepared. Use production review and diff writers; do not edit accepted status directly. + +For manifest-v3/setup-v2 discovery, interrupt review preparation after evidence, packet, and verified status, then verify the exact awaiting-diff status written last routes to `resume` only after complete review-state validation. Interrupt acceptance after approval and accepted status. Every byte-exact incomplete review prefix returns `review-preparation-retry-ready`; the exact acceptance approval prefix returns `increment-acceptance-retry-ready`; the exact accepted status returns `accepted-stop`. Packet-before-evidence, changed evidence/packet/status, mixed v1/v2 review or command bytes, and changed/reordered accepted path states return the domain-specific recovery disposition before generic state validation. Repeat one setup-v1 control to prove its prompt bytes and route names are unchanged. + - [ ] **Step 2: Run the focused tests and verify RED** ```bash @@ -502,7 +525,9 @@ Extend execution ownership with a literal `delete` disposition: it requires a no When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. -`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. `program_discovery.py::_exact_acceptance_prefix_disposition(...)` and both accepted-state loaders must recognize only the exact v1 or v2 diff binding/command family, rebuild the matching production candidate, and classify byte-exact v2 accepted-stop and retry prefixes without a hard-coded v1 gate. Keep v1 base-seed construction, prompt bytes, and discovery dispositions unchanged. +`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. Its submitted-prompt parser derives the expected command schema from the persisted review/result family before calling `parse_exact_prompt(...)`; it never accepts caller-selected family substitution. `program_discovery.py::_exact_review_prefix_disposition(...)`, `_exact_acceptance_prefix_disposition(...)`, and accepted-status routing must recognize only the exact v1 or v2 review/diff family, rebuild the matching production candidate, and classify byte-exact v2 accepted-stop and retry prefixes without a hard-coded v1 gate. + +Extend `_load_setup_candidate(...)` so the exact review and acceptance classifiers run on manifest-v3/setup-v2 prefixes before full `validate_state_authority(...)` and before its generic `verified`, `awaiting-diff-approval`, or `accepted` fallbacks. A classifier's recovery-required result is authoritative and cannot be replaced by `invalid`, `resume`, or a generic retry. Keep v1 base-seed construction, prompt bytes, and discovery dispositions unchanged. - [ ] **Step 4: Extend state validation by exact review/diff schema** @@ -610,20 +635,26 @@ rtk git commit -m "feat: preserve delete state in recovery" - Modify: `skills/implementing-staged-plans/scripts/program_rollover.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` - 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_discovery.py` - Test: `tests/test_state_authority.py` **Interfaces:** - Produces: `ProductPathStateV2(path, disposition, final_state, sha256)` without changing `ProductDeltaPath` v1. +- Produces: `ContinuationExtensionV2` carrying `accepted_product_path_states` and `accepted_product_result_sha256` without changing the v1 `ContinuationExtension.accepted_product_delta` contract. +- Produces: `ACCEPTED_STATE_CONTINUATION_SCHEMA_V2 = "implementation-accepted-state-continuation-binding/v2"` and `ContinuationCommandV2`, whose inherited-workspace value embeds the exact accepted product-result schema, digest, and ordered present/absent path states. +- Produces: `build_continuation_extension(...) -> ContinuationExtension | ContinuationExtensionV2 | None`, `build_accept_continue_candidate(acceptance, extension: ContinuationExtension | ContinuationExtensionV2 | None) -> DiffAcceptanceCandidate`, and `_build_accepted_state_command(...) -> ContinuationCommand | ContinuationCommandV2`, all selected by exact persisted family rather than nullable-field presence. - Produces: `implementation-successor-authority-projection/v2`, `implementation-increment-rollover/v2`, `implementation-increment-rollover-binding/v2`, and `implementation-inherited-workspace/v2`. - Produces: accept-and-continue status bindings that retain the exact v1 or v2 diff-disposition family of the accepted stop candidate instead of rewriting v2 acceptance as v1. - Produces: `validated_inherited_path_states(program_root, status, observation) -> tuple[InheritedPathStateV2, ...]` while preserving `validated_inherited_paths(...)` for v1. - Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation, using stable replace-in-place/append ordering rather than lexical resorting. -- Produces test fixture: `ThreeIncrementDeleteFixture` configured as setup/envelope v2 from sequence zero, with `accept_predecessor_create(path)`, `rollover(accepted_status, successor_id)`, `accept_delete(increment_id, path)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_current_plan()` methods that call production writers rather than editing lifecycle artifacts directly. +- Produces: manifest-v3/setup-v2 exact retry/recovery discovery for both immediate and later accepted-state continuation and rollover prefixes before generic state validation. +- Produces test fixture: `ThreeIncrementDeleteFixture` configured as setup/envelope v2 from sequence zero, with `accept_predecessor_create(path)`, `rollover(accepted_status, successor_id)`, `accept_delete(increment_id, path)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_current_plan()` methods that call production writers rather than editing lifecycle artifacts directly. Its same-path Create-then-Delete allocation declares collision `none` for Create and `accepted-predecessor` for Delete. - [ ] **Step 1: Write failing three-increment inheritance tests** @@ -631,22 +662,22 @@ rtk git commit -m "feat: preserve delete state in recovery" def test_late_delete_tombstone_survives_an_unrelated_successor(self) -> None: fixture = ThreeIncrementDeleteFixture() try: - first = fixture.accept_predecessor_create("first.ts") + first = fixture.accept_predecessor_create("legacy.ts") second = fixture.rollover(first, "SECOND") self.assertEqual( - [item["path"] for item in second["inherited_workspace_binding"]["inherited_path_states"]], - ["first.ts"], + second["inherited_workspace_binding"]["inherited_path_states"], + [{ + "path": "legacy.ts", + "final_state": "present", + "disposition": "Create", + "sha256": fixture.sha256("legacy.ts"), + }], ) fixture.accept_delete("SECOND", "legacy.ts") third = fixture.rollover_current("THIRD") self.assertEqual( third["inherited_workspace_binding"]["inherited_path_states"], [{ - "path": "first.ts", - "final_state": "present", - "disposition": "Create", - "sha256": fixture.sha256("first.ts"), - }, { "path": "legacy.ts", "final_state": "absent", "disposition": "Delete", @@ -661,12 +692,16 @@ def test_late_delete_tombstone_survives_an_unrelated_successor(self) -> None: fixture.close() ``` -The first increment's exact file map must be v2 with an empty Delete section, and it must be fully accepted before the SECOND Delete increment is prepared. Assert immediate accept-and-continue and later accepted-state continuation both retain `implementation-diff-disposition-binding/v2`; a hard-coded v1 rewrite must fail before rollover. Add a positive recreation case where a later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. Add an ordering case whose first result has two paths in non-lexical exact-map order and whose second result replaces the first path and adds a new path: the replacement must keep its existing cumulative slot, the untouched state must keep its slot, and the new state must append in current result order. +The first increment's exact file map must be v2 with an empty Delete section, create `legacy.ts` from an absent baseline, and be fully accepted before the SECOND Delete increment is prepared. SECOND must receive `legacy.ts` as inherited-present and validate the Delete allocation's `accepted-predecessor` collision before accepting its absence. Do not substitute unrelated first-path and deleted-path names; this test proves the same path's real later-Delete lifecycle. + +Assert immediate accept-and-continue and later accepted-state continuation both retain `implementation-diff-disposition-binding/v2`; a hard-coded v1 rewrite must fail before rollover. For later continuation, assert the rendered command schema is `implementation-accepted-state-continuation-binding/v2` and its `inherited_workspace.accepted_product_result` is exactly `{schema_version: implementation-product-path-states/v2, sha256, ordered_path_states}`, including `sha256: None` for the absent Delete state. Preserve one byte-exact v1 prompt fixture. Submitting a v1 command to v2 accepted status or a v2 command to v1 accepted status must fail schema parsing before any continuation record is written. + +Add a positive recreation case where a later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 continuation commands and rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. Add an ordering case whose first result has two paths in non-lexical exact-map order and whose second result replaces the first path and adds a new path: the replacement must keep its existing cumulative slot, the untouched state must keep its slot, and the new state must append in current result order. - [ ] **Step 2: Run focused continuation/rollover tests and verify RED** ```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_state_authority -v +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_discovery tests.test_state_authority -v ``` Expected: current continuation coerces `None` to a string and current rollover requires each accepted path to remain a regular file with a digest. @@ -682,28 +717,83 @@ class ProductPathStateV2: disposition: str final_state: str sha256: str | None + +@dataclass(frozen=True) +class ContinuationExtensionV2: + successor_increment_id: str + successor_brief_bytes: bytes + accepted_product_path_states: tuple[ProductPathStateV2, ...] + accepted_product_result_sha256: str + checkpoint_id: str + rollover_authorization_id: str + successor_grant_id: str + successor_projection: Mapping[str, object] + +@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 + accepted_product_result_schema_version: str + accepted_product_result_sha256: str + successor_approval_mode: str + selected_workspace: Mapping[str, object] + inherited_workspace: Mapping[str, object] + allowed_conditional_action_ceiling: tuple[str, ...] +``` + +Never pass v2 entries through `ProductDeltaPath(sha256: str)`, `ContinuationExtension`, or `ContinuationCommand`. Build the v2 inherited-workspace input exactly as: + +```python +{ + "selected_workspace": selected_workspace, + "accepted_product_result": { + "schema_version": "implementation-product-path-states/v2", + "sha256": product_result_sha256, + "ordered_path_states": [asdict(item) for item in path_states], + }, +} ``` -Never pass v2 entries through `ProductDeltaPath(sha256: str)`. In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. +In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding and command rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA` and `DIFF_DISPOSITION_COMMAND_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. `validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review result, diff decision, and cumulative digest. It requires present files to match exact digests and absent files to remain absent. Mixed result families stop before persistence. -- [ ] **Step 4: Consume inherited states in successor baselines** +- [ ] **Step 4: Render and parse exact accepted-state continuation families** + +Keep `ACCEPTED_STATE_CONTINUATION_SCHEMA`, `ContinuationCommand`, `_immediate_base_seed(...)`, and every rendered v1 field and byte unchanged. Add `ACCEPTED_STATE_CONTINUATION_SCHEMA_V2` and construct `ContinuationCommandV2` only when the accepted status has the exact v2 diff binding paired with `implementation-product-path-states/v2`. The v2 base seed and command bind `accepted_product_result_schema_version`, `accepted_product_result_sha256`, and the exact `inherited_workspace.accepted_product_result.ordered_path_states`; no field is inferred from a nullable digest. + +Make `_build_accepted_state_command(...)` return `ContinuationCommand | ContinuationCommandV2` after exact persisted-family dispatch. In both `validate_submitted_continuation_prompt(...)` paths, build the expected command from persisted state first, call `parse_exact_prompt(submitted_prompt, expected.schema_version)`, and then compare `render_exact_prompt(asdict(expected))` byte-for-byte. `program_rollover.py` accepts the union and selects v1 delta or v2 path-state fields only from the command type/schema pair. Reject a prompt schema, diff binding, successor projection, accepted result, or rollover family mismatch before the first action-authorization append. The v1 prompt golden, parser errors, and accepted-state rollover bytes must remain exact. + +- [ ] **Step 5: Consume inherited states in successor baselines** `program_activation.py::_build_plan_candidate(...)` stores validated v2 inherited states in baseline v2 and strips their expected Git dirt from user-work observation. `repository_preparation.py::validate_execution_workspace(...)` validates untouched inherited states throughout the successor. It allows an explicit Create only from inherited absence and Modify/Delete/Preserve only from inherited presence; no current operation means the inherited state must remain exact. `state_authority.py` validates exact v1 or v2 rollover/binding pairs and delegates to the matching inherited validator. Do not modify v1 cumulative-path or digest behavior. -- [ ] **Step 5: Run focused continuation/rollover tests and verify GREEN** +- [ ] **Step 6: Classify exact v2 continuation and rollover prefixes** + +In `program_discovery.py::_load_setup_candidate(...)`, run `inspect_increment_rollover(...)` for manifest-v3/setup-v2 accepted status before full state-authority validation and before generic accepted/resume routing. For both immediate accept-and-continue and later accepted-state continuation, interrupt after the rollover action authorization, successor grant, handoff, successor brief, rollover record, and successor status. Exact early prefixes return `increment-continuation-retry-ready` or `accepted-state-continuation-retry-ready`; exact navigation/record prefixes return `increment-rollover-retry-ready` or `accepted-state-rollover-retry-ready`; completed status returns `resume`. A substituted command/result family, missing/out-of-order record, changed path state/digest, or divergent prefix returns `continuation-recovery-required` or `accepted-state-continuation-recovery-required`, never generic `invalid`, `accepted-stop`, or `resume`. Preserve the existing setup-v1 and manifest-v2 route bytes and names. + +- [ ] **Step 7: Run focused continuation/rollover tests and verify GREEN** Run the Step 2 command. Expected: present identities and absent tombstones survive unrelated increments; explicit recreation is valid; implicit or mixed-family state changes fail before writes. -- [ ] **Step 6: Commit rollover inheritance** +- [ ] **Step 8: Commit rollover inheritance** ```bash -rtk git add skills/implementing-staged-plans/scripts/program_continuation.py skills/implementing-staged-plans/scripts/program_rollover.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_program_continuation.py tests/test_program_rollover.py tests/test_multi_increment_lifecycle.py tests/test_program_activation.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/program_continuation.py skills/implementing-staged-plans/scripts/program_rollover.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_program_continuation.py tests/test_program_rollover.py tests/test_multi_increment_lifecycle.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py rtk git commit -m "feat: inherit accepted delete tombstones" ``` @@ -760,7 +850,7 @@ def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> Non fixture.close() ``` -`accepted_three_increment_delete_program()` must accept FIRST as a non-Delete predecessor under setup/file-map/baseline/result v2 with an empty Delete section, accept the Delete in SECOND, and accept an unrelated THIRD increment before closure. Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. +`accepted_three_increment_delete_program()` must create and accept `legacy.ts` in FIRST under setup/file-map/baseline/result v2 with an empty Delete section, validate it as inherited-present with collision `accepted-predecessor`, accept its Delete in SECOND, and accept an unrelated THIRD increment before closure. Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. In `tests/test_program_discovery.py`, interrupt v2 closure preparation after each persisted reconciliation/packet prefix. Require a byte-exact prefix to return `closure-preparation-retry-ready`, packet-without-reconciliation or any changed/reordered v2 path state/digest to return `closure-preparation-recovery-required`, an exact persisted closure approval before status-last completion to return `closure-approval-retry-ready`, and any substituted v1 closure-preparation/command binding or divergent closed status to return `closure-approval-recovery-required`. Assert the same disposition names and bytes remain unchanged for v1. @@ -795,7 +885,7 @@ Do not put absent paths in `evidence_paths`; bind their typed result and final-s In `program_closure.py::build_closure_preparation(...)`, dispatch on the exact setup family paired with the accepted product-result schema. For v2, enumerate `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted increment in order. Bind each increment's exact reviewed result, review packet, diff decision, and required handoff addendum; merge the final current result into validated cumulative inherited states; perform later-invalidation checks across every accepted increment; then construct v2 reconciliation and packet. -Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. +Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. In `_load_setup_candidate(...)`, run that exact classifier before full state-authority validation and before generic awaiting-closure/terminal routing. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. - [ ] **Step 5: Run closure tests and verify GREEN** @@ -812,7 +902,7 @@ rtk git commit -m "feat: reconcile deleted paths at closure" --- -### Task 7: Replay the PipeFlow Scenario and Synchronize Release Contracts +### Task 7: Add the Final PipeFlow Integration Regression and Synchronize Release Contracts **Files:** - Create: `tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json` @@ -822,6 +912,7 @@ rtk git commit -m "feat: reconcile deleted paths at closure" - Modify: `skills/implementing-staged-plans/SKILL.md` - Modify: `skills/implementing-staged-plans/agents/openai.yaml` - Modify: `skills/implementing-staged-plans/references/program-authority.md` +- Modify: `skills/implementing-staged-plans/references/program-discovery.md` - 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` @@ -843,8 +934,8 @@ rtk git commit -m "feat: reconcile deleted paths at closure" **Interfaces:** - Produces: `load_pipeflow_delete_inventory() -> tuple[str, tuple[str, ...]]` returning the source SHA-256 and exactly 27 normalized paths. -- Produces: a deterministic temporary-repository replay from Delete-capable proposal validation through final closure. -- Produces: `DeleteLifecycleFixture(delete_paths: Sequence[str], source_sha256: str)` configured as setup/envelope v2 from sequence zero, with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_and_accept_predecessor()`, `rollover_to_delete_increment()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_to_unrelated_increment()`, `prepare_and_accept_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. +- Produces: a deterministic temporary-repository replay from an initially absent characterization path through predecessor creation/acceptance, later 27-path Delete, unrelated rollover, and final closure. +- Produces: `DeleteLifecycleFixture(existing_delete_paths: Sequence[str], later_created_delete_path: str, source_sha256: str)` configured as setup/envelope v2 from sequence zero, with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_create_and_accept_predecessor()`, `assert_characterization_is_inherited_present()`, `rollover_to_delete_increment()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_to_unrelated_increment()`, `prepare_and_accept_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. - Produces: package version `0.1.3` on all existing version owners. - Preserves: the external pipeFlow source and workspace as read-only inputs. @@ -891,21 +982,28 @@ Create this exact JSON fixture: The loader rejects a non-27 count, duplicate, unsafe path, wrong order, missing source digest, or directory-like entry. -- [ ] **Step 2: Write the failing proposal-to-closure replay** +- [ ] **Step 2: Write the final proposal-to-closure integration regression** -In `tests/test_delete_operation_lifecycle.py`, build a temporary Git repository with all 27 regular files, a Delete-capable setup/envelope v2 proposal, and a later unrelated increment. Exercise real production writers and validators: +In `tests/test_delete_operation_lifecycle.py`, verify the fixture's final path is exactly `test/legacy/characterization.test.ts`. Build a temporary Git repository where the other 26 Delete targets are existing regular files but that characterization path is absent. Configure a Delete-capable setup/envelope v2 program with a predecessor increment, the later 27-path Delete increment, and an unrelated successor. Exercise real production writers and validators: ```python def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: source_sha256, delete_paths = load_pipeflow_delete_inventory() self.assertEqual(len(delete_paths), 27) - fixture = DeleteLifecycleFixture(delete_paths, source_sha256) + later_created = "test/legacy/characterization.test.ts" + self.assertEqual(delete_paths[-1], later_created) + fixture = DeleteLifecycleFixture(delete_paths[:-1], later_created, source_sha256) try: + self.assertFalse(fixture.repository.joinpath(later_created).exists()) + self.assertTrue( + all(fixture.repository.joinpath(path).is_file() for path in delete_paths[:-1]) + ) fixture.validate_and_publish_proposal() recap = fixture.render_setup_recap() self.assertTrue(all(path in recap for path in delete_paths)) fixture.approve_activate_and_start() - fixture.prepare_and_accept_predecessor() + fixture.prepare_create_and_accept_predecessor() + fixture.assert_characterization_is_inherited_present() fixture.rollover_to_delete_increment() fixture.prepare_and_authorize_delete_plan() fixture.delete_every_target() @@ -922,17 +1020,19 @@ def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: fixture.close() ``` -The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section and reach accepted status before the Delete increment is prepared; assert discovery returns `accepted-stop` at that boundary. The unrelated successor must also use v2 with an empty Delete section. Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. +The proposal contains two exact allocations for the characterization path: Create in the predecessor with absent/none/`None`/none facts, and Delete in the later increment with regular-file/none/`100644`/`accepted-predecessor` facts. The other 26 Delete allocations use collision `existing`. The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section, create the characterization file, reach exact accepted status, and rollover it as inherited-present before the Delete plan is prepared; discovery must return `accepted-stop` at that boundary. The Delete plan then lists all 27 paths in frozen order and its baseline validates the two collision classes separately. The unrelated successor also uses v2 with an empty Delete section. + +Add negatives that pre-create the characterization path before its Create baseline, omit one of the other 26 paths before the Delete baseline, or declare the characterization Delete collision as `existing`; each must fail the production allocation-fact check before the corresponding plan/baseline write. Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. -- [ ] **Step 3: Run the scenario tests and verify RED, then GREEN** +- [ ] **Step 3: Run the final integration regression and verify GREEN** -Initial RED: +Tasks 1–6 already own and test every required schema, writer, validator, retry route, and rollover/closure behavior. Task 7 adds one cross-component regression over those completed contracts; it is not a new behavior RED. Run it immediately after writing the fixture and test: ```bash rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle -v ``` -Expected before the Tasks 1–6 implementation: proposal validation rejects Delete. Expected after Tasks 1–6: the frozen scenario passes and the optional external-source test is skipped. +Expected: the frozen scenario passes on the unchanged Tasks 1–6 implementation and the optional external-source test is skipped. A failure is an integration defect in Tasks 1–6: repair it in the owning earlier task/commit, rerun that task's focused checks, then rerun this final regression. Do not treat a failing final integration test as a new Task 7 feature implementation. Run the live identity replay against the locked read-only source: @@ -956,7 +1056,7 @@ destructive-operation, cleanup, migration, Git, publication, deployment, or external-state authority. ``` -Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. +Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. Update `references/program-discovery.md` to make its existing prefix-before-generic-rejection rule explicit for both setup/envelope families and to enumerate exact setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure retry/recovery routes. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain and final cumulative digest. Do not rewrite older dated design plans; they remain historical version-bound records. @@ -982,7 +1082,7 @@ Expected: the real-scenario fixture, front-door contract, documentation, synchro - [ ] **Step 7: Commit scenario and release contracts** ```bash -rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md implementing-staged-plans-bootstrap-execution-review-runbook.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py +rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/program-discovery.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md implementing-staged-plans-bootstrap-execution-review-runbook.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py rtk git commit -m "feat: release typed delete operation support" ``` @@ -1041,21 +1141,22 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Requirement | Primary owner | Required evidence | Failure signal | | --- | --- | --- | --- | -| Locked implementation baseline | Git preflight | review commit `44ef42ac...` has parent candidate `b5eb689e...`; repaired-plan kickoff is its single clean plan-only child on `repair/delete-operation-support` | stop before edits | +| Locked implementation baseline | Git preflight | first repair `31a04be...` has parent `44ef42ac...`, whose parent is candidate `b5eb689e...`; second-corrections kickoff is the single clean plan-only child of `31a04be...` on `repair/delete-operation-support` | stop before edits | | Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | | Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | | Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | | Exact plan does not misclassify Delete | `repository_preparation.py` | unversioned heading fails; v2 parses ordered Delete section | Delete absorbed as Modify | -| Late Delete uses one program family | setup/activation/preparation/rollover | a setup-v2 predecessor accepts with empty Delete under file-map/baseline/result v2 before the later Delete increment | mixed v1/v2 rollover or closure | +| Late Delete uses one program family | setup/activation/preparation/rollover | `test/legacy/characterization.test.ts` begins absent, is created/accepted by a setup-v2 predecessor with an empty Delete section, becomes inherited-present with collision `accepted-predecessor`, then is deleted with the 26 initially existing targets | pre-created fixture, false collision facts, or mixed v1/v2 rollover/closure | | Baseline proves a real removable file | `program_activation.py`, `inspect_workspace_path(...)` | component `lstat`, workspace containment, existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | -| Ancestor safety is reassessed | `inspect_workspace_path(...)`, `validate_execution_workspace(...)` | baseline symlinked ancestor and post-authorization ancestor swap both fail before external reads | path escapes through ancestor | +| Ancestor safety is reassessed | `inspect_workspace_path(...)`, operation callers | baseline symlinked ancestor and post-authorization ancestor swap fail before external reads; missing suffix remains valid only for Create, accepted/inherited absence, and already-absent user work | path escape, rejected valid absence, or missing required Delete/Modify/Preserve target | | Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest and exact-map ordering | accidental loss, fabricated digest, or reordered state | | Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve | Delete accepted for a control path | | Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | | Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | -| Discovery resumes v2 safely | `program_discovery.py` | v2 accepted-stop plus exact closure preparation/approval retries; divergent prefixes recover | v1-only gate, wrong resume, or terminal route | +| Discovery resumes v2 safely | `program_discovery.py` | setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure exact prefixes classify before generic state validation; divergent prefixes use their domain recovery routes | v1-only gate, invalid/generic route, wrong resume, or terminal route | | Blocked recovery freezes path state | `blocked_recovery.py` | v2 context reproduces exact partial/complete states | post-block change or evidence fabrication | | Rollover preserves ordered tombstones | `program_rollover.py` | accepted predecessor before Delete; replace-in-place/append merge retains absent state through unrelated work | reappearance, omission, reorder, or mixed chain | +| Accepted-state prompts preserve typed results | `program_continuation.py` | exact v1/v2 command dispatch and parsing; v2 embeds ordered path states with null absent digest; v1 golden bytes stay exact | cross-family prompt, `str(None)`, or v1 byte drift | | Recreation is explicit | activation/preparation | later Create owns inherited absent path and baseline agrees | implicit recreation or wrong operation | | Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff decisions plus final cumulative digest | singleton-only or lost tombstone | | Front door does not over-authorize | skill/references/docs | Delete remains local plan-bound `modify-workspace` only | generic destructive/external claim | From 5a7e2726522792a4da8435e89a011d1c622836b4 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sat, 5 Sep 2026 23:46:52 -0400 Subject: [PATCH 04/19] docs: fix delete repair task chronology --- .../plans/2026-09-05-delete-operation-support.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index 11528a7..b844024 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -138,6 +138,7 @@ Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is - Modify: `skills/implementing-staged-plans/scripts/program_setup.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/program_discovery.py` - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` - Modify: `tests/program_bootstrap_support.py` - Test: `tests/test_program_setup.py` @@ -153,6 +154,7 @@ Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is - Produces: `_operation_contract(semantics: Mapping[str, object]) -> tuple[tuple[str, ...], bool]`, returning the exact supported-operation tuple and whether Delete fields are required. - Produces test helpers: `BootstrapFixture.configure_delete_setup_v2(allocation: Mapping[str, object]) -> dict[str, object]`, `configure_v1_envelope_with_delete() -> list[str]`, and `configure_mixed_setup_versions() -> list[str]`; each recomputes the semantic digest after its exact mutation. - Produces: recap, checkpoint, decision, activation-record, program-authority, and state-authority dispatch selected from the exact setup/envelope family before any activation record is written. +- Produces: manifest-v3/setup-v2 sequence-zero activation-prefix discovery that returns `program-activation-retry-ready` for every byte-exact incomplete prefix and `program-activation-recovery-required` for every started mixed, out-of-order, or divergent prefix before generic invalid-state routing. - Preserves: every v1 setup/envelope/recap/decision/activation byte and error route. - [ ] **Step 1: Write failing setup and authority tests** @@ -213,7 +215,7 @@ def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Add a two-increment allocation for one initially absent exact path: `Create` in the first increment with facts `absent`/`none`/`None`/`none`, then `Delete` in its strict successor with facts `regular-file`/`none`/`100644`/`accepted-predecessor`. Require one same-path Create allocation in a transitive predecessor for `accepted-predecessor`, and reject an unrelated, same-increment, later, or absent predecessor allocation; the distinct operations are not a duplicate allocation. Keep an initially present Delete allocation on collision `existing` and reject any other collision/fact combination. -Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. After each byte-exact v2 activation prefix, run discovery and require the existing `program-activation-retry-ready` route; a mixed or divergent prefix must return `program-activation-recovery-required` before generic sequence-zero rejection. +Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. After each byte-exact v2 activation prefix, run discovery and require the existing `program-activation-retry-ready` route. For each started-prefix record class—setup activation decision, required source-gate decision, program approval, and workspace approval—change one bound field, substitute the opposite setup schema where applicable, or place the record out of order; require read-only discovery to return `program-activation-recovery-required`, `required_input == "activation-prefix-recovery"`, and `stop_required is True` without falling through to generic invalid-state or publication recovery. Keep malformed sequence-zero proposals with no activation transaction artifact on their existing invalid route. - [ ] **Step 2: Run the focused tests and verify RED** @@ -257,16 +259,18 @@ For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. In this same task, change `program_activation.py::_build_v3_setup_record(...)` to select and write the matching activation schema instead of importing and unconditionally emitting `SETUP_ACTIVATION_SCHEMA`; update its activation-prefix adoption tests before calling this task GREEN. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS`, `program_setup.py`'s activation-record loaders/validators, and `state_authority.py::SETUP_ONLY_STATUS_SCHEMAS` plus its manifest-v3 family validation without admitting the v2 records to setup-v1 or legacy manifests. +In `program_discovery.py::_single_bootstrap_prefix_disposition(...)` and `_load_setup_candidate(...)`, inspect the sequence-zero transaction prefix before proposal-publication or generic program/state rejection. Preserve the existing pristine `program-setup-ready`, pending-gate `source-gate-approval-ready`, and exact-prefix `program-activation-retry-ready` routes for both setup families. When activation has started and `inspect_sequence_zero_activation_prefix(...)` reports a mixed, out-of-order, or divergent decision, gate, or approval prefix, return `program-activation-recovery-required` with the exact prefix issues for diagnosis; `_single_bootstrap_prefix_disposition(...)` must not relabel that owned activation divergence as `proposal-publication-recovery-required`, and `_load_setup_candidate(...)` must not relabel it as generic invalid. Keep immutable publication-manifest/owner/inventory divergence on `proposal-publication-recovery-required`. The activation recovery route is classification only: it must not rewrite, adopt, append, or delete any prefix byte. A malformed pristine proposal with no activation transaction artifact remains on its existing invalid or publication-recovery route. + - [ ] **Step 4: Run the focused tests and verify GREEN** Run the Step 2 command. -Expected: all setup, authority, and generic proposal-publication tests pass; the recap exposes each Delete fact and legacy bytes stay exact. +Expected: all setup, authority, generic proposal-publication, and sequence-zero discovery tests pass; every exact incomplete v2 activation prefix is retry-ready, every started mixed or divergent prefix is activation-recovery-required without mutation, the recap exposes each Delete fact, and legacy bytes stay exact. - [ ] **Step 5: Commit the setup contract** ```bash -rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py rtk git commit -m "feat: add typed delete setup contracts" ``` @@ -278,7 +282,7 @@ rtk git commit -m "feat: add typed delete setup contracts" - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` - Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` - Modify: `skills/implementing-staged-plans/scripts/program_activation.py` -- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` +- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` (sequence-one-and-later exact-plan routing only; preserve Task 1 sequence-zero activation routing) - Test: `tests/test_repository_preparation.py` - Test: `tests/test_program_activation.py` - Test: `tests/test_approval_checkpoint.py` @@ -291,7 +295,7 @@ rtk git commit -m "feat: add typed delete setup contracts" - Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. - Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. - Produces: v2 product states in operation-section order and exact file-map order; v1 product deltas retain their current lexical ordering and bytes. -- Produces: manifest-v3/setup-v2 `plan-preparation-*` and `plan-materialization-*` retry/recovery classification from exact transaction prefixes before full state-authority validation or generic lifecycle routing. +- Produces: manifest-v3/setup-v2 sequence-one-and-later `plan-preparation-*` and `plan-materialization-*` retry/recovery classification from exact transaction prefixes before full state-authority validation or generic lifecycle routing; Task 1 exclusively owns sequence-zero activation-prefix classification. - Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. - Preserves: public plan preparation/materialization and three-argument future-write signatures. @@ -428,7 +432,7 @@ For v2 Create/Modify results emit `final_state: "present"` with the real digest. - [ ] **Step 5: Route exact setup-v2 plan prefixes before generic rejection** -In `program_discovery.py::_load_setup_candidate(...)`, keep sequence-zero activation routing unchanged. For sequence one and later, load the allocated transaction files and relevant ledgers, derive the fresh observation, and call `_exact_plan_prefix_disposition(...)` before `validate_state_authority(...)` or any generic `resume`/invalid route. Do not accept a prefix by state name alone. For a manifest-v3/setup-v2 program, interrupt standard-mode preparation after the exact plan and awaiting-plan status, and materialization after the plan approval, v2 baseline, and action authorization. Each byte-exact incomplete prefix must return the matching `plan-preparation-retry-ready` or `plan-materialization-retry-ready`; missing/out-of-order records, changed plan bytes, a v1 baseline, or changed v2 path-state order must return the matching recovery-required disposition. After the exact authorized status is written last, discovery returns `resume` only after full state validation. `approval:pre-approve` and `approval:full-increment` must exercise their shorter exact materialization prefixes and completed-status route. The same cases for setup-v1 keep their existing bytes and disposition names. +In `program_discovery.py::_load_setup_candidate(...)`, preserve without reimplementing the exact setup-v1/setup-v2 sequence-zero activation retry/recovery routing completed in Task 1. For sequence one and later only, load the allocated transaction files and relevant ledgers, derive the fresh observation, and call `_exact_plan_prefix_disposition(...)` before `validate_state_authority(...)` or any generic `resume`/invalid route. Do not accept a prefix by state name alone. For a manifest-v3/setup-v2 program, interrupt standard-mode preparation after the exact plan and awaiting-plan status, and materialization after the plan approval, v2 baseline, and action authorization. Each byte-exact incomplete prefix must return the matching `plan-preparation-retry-ready` or `plan-materialization-retry-ready`; missing/out-of-order records, changed plan bytes, a v1 baseline, or changed v2 path-state order must return the matching recovery-required disposition. After the exact authorized status is written last, discovery returns `resume` only after full state validation. `approval:pre-approve` and `approval:full-increment` must exercise their shorter exact materialization prefixes and completed-status route. The same cases for setup-v1 keep their existing bytes and disposition names. - [ ] **Step 6: Run the focused tests and verify GREEN** From 8eb37f44312e8355cc39dcde453cec55212e1955 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sun, 6 Sep 2026 00:15:35 -0400 Subject: [PATCH 05/19] docs: fix delete plan lineage and transitions --- .../2026-09-05-delete-operation-support.md | 109 +++++++++++++++--- 1 file changed, 92 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index b844024..5791f60 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -4,7 +4,7 @@ **Goal:** Add truthful, fail-closed support for exact regular-file `Delete` operations so a program can preserve an accepted absent path through review, diff acceptance, rollover, recovery, and closure without weakening existing `Create`, `Modify`, or `Preserve` contracts. -**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. +**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, execution-transition, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. **Tech Stack:** Python 3 standard library, frozen dataclasses, canonical JSON and SHA-256, `unittest`, temporary Git repositories, existing atomic/no-overwrite/status-last writers. @@ -12,9 +12,9 @@ ## Global Constraints -- The first reviewed plan-repair baseline is commit `31a04be196c5235cd1f75ec931502c0d79f2a46d`; its parent is the first plan commit `44ef42acdef540af72577e224054d763da80fc5f`, whose parent and implementation candidate is exactly `b5eb689e780f48b218b807a4691f0994474e4178`. This second-corrections commit must be the single plan-only child of `31a04be196c5235cd1f75ec931502c0d79f2a46d`. Start implementation only from that clean second-corrections HEAD on branch `repair/delete-operation-support`, with no path other than this plan changed from the candidate. +- The implementation candidate is exactly `b5eb689e780f48b218b807a4691f0994474e4178`. Start implementation only when that candidate is an ancestor of the clean kickoff HEAD on branch `repair/delete-operation-support`, every commit in `b5eb689e780f48b218b807a4691f0994474e4178..HEAD` changes only `docs/superpowers/plans/2026-09-05-delete-operation-support.md`, and the aggregate candidate-to-HEAD diff contains only that plan. Record the actual kickoff HEAD and the plan's actual SHA-256 as execution evidence before Task 1; do not use any correction commit's parent position or hash as a durable prerequisite. - Use `rtk` for every repository command. -- Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. +- Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/execution-transition/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. - Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. - Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; that setup choice fixes the complete program to the nested v2 lifecycle family from its first increment, and mixed v1/v2 nested contracts fail before every write. - A `Delete` target must be one normalized repository-relative path that is a program-owned regular non-symlink, non-hard-linked file beneath the selected workspace when its deletion-increment baseline is captured. Setup may bind either an initially `existing` target or an `accepted-predecessor` target created and accepted by a strict predecessor increment; the latter requires a same-path predecessor `Create` allocation. Directories, symlinks, symlinked ancestors, hard links, special files, absent Delete baselines, external paths, protected paths, and pre-existing user work remain unsupported. @@ -47,6 +47,7 @@ The defect is confirmed at the locked baseline: 9. Current activation and workspace assessment check the final `Path` with `is_symlink()`/`is_file()` but do not share a component walk. A safe final file beneath a later-swapped symlink ancestor can therefore evade the intended workspace-bound path contract. The shared walk must still preserve the current valid absence semantics for a Create target whose parent is not created yet and for already-absent user work; operation callers, not the primitive walk, own required-presence rules. 10. The real pipeFlow lifecycle does not begin with all 27 Task 8 Delete targets. `test/legacy/characterization.test.ts` is absent at setup, created and accepted in Task 1, inherited as present, and deleted with the other 26 legacy files in Task 8. A fixture that pre-creates all 27 paths does not exercise future Delete allocation, predecessor collision facts, or a real late tombstone. 11. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. +12. `program_activation.py::advance_execution_state(...)` writes and retry-adopts only `implementation-execution-transition/v1` with `product_delta_sha256`, while `state_authority.py` accepts only that v1 shape and derives the event identifier from that v1 digest field. A setup-v2 writer output therefore has no exact execution-transition schema, result-family binding, event seed, state-authority route, or discovery retry/recovery contract even though Task 2 claims a complete v2 baseline/result family. The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. @@ -72,13 +73,13 @@ Unsafe alternatives are rejected: - `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md` — state that basic exact regular-file Delete is owned by `0.1.3`, while advanced migration/staging semantics remain pending v4 work. - `skills/implementing-staged-plans/scripts/program_setup.py` — own setup-semantics/envelope v2 validation, pairing, and recap rendering. - `skills/implementing-staged-plans/scripts/program_authority.py` — recognize only the exact new setup authority schemas on manifest-v3 and reject cross-family substitution. -- `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, state bindings, and v1 compatibility rejection. +- `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, execution-transition/result-family bindings, state bindings, and v1 compatibility rejection. - `skills/implementing-staged-plans/scripts/repository_preparation.py` — parse exact-file-map v2, parse baseline v2, and assess present/absent path states. - `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. - `skills/implementing-staged-plans/scripts/program_discovery.py` — route manifest-v3/setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, closure, and divergent prefixes by exact schema family before generic state validation. - `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. - `skills/implementing-staged-plans/scripts/review_coordination.py` — carry and validate the v2 accepted path-state result in review evidence and packets. -- `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings. +- `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings, including the v2 remediating-to-reviewing execution transition. - `skills/implementing-staged-plans/scripts/diff_disposition.py` — bind the exact reviewed v2 product result during acceptance. - `skills/implementing-staged-plans/scripts/blocked_recovery.py` — freeze and revalidate Delete path states across blocked/resume. - `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results and render/parse exact accepted-state-continuation v1/v2 commands without coercing absence to a string digest. @@ -123,12 +124,15 @@ Before Task 1, record and require all of the following without changing the tree ```bash rtk git status --short --branch -rtk git rev-parse HEAD^ HEAD^^ HEAD^^^ -rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD -rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD +rtk git rev-parse HEAD +rtk sha256sum docs/superpowers/plans/2026-09-05-delete-operation-support.md +rtk git merge-base --is-ancestor b5eb689e780f48b218b807a4691f0994474e4178 HEAD +rtk git log --reverse --format='commit %H parents %P' --name-only b5eb689e780f48b218b807a4691f0994474e4178..HEAD +rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178..HEAD +rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178..HEAD ``` -Expected: the branch is `repair/delete-operation-support` and clean; `HEAD^` is `31a04be196c5235cd1f75ec931502c0d79f2a46d`; `HEAD^^` is `44ef42acdef540af72577e224054d763da80fc5f`; `HEAD^^^` is `b5eb689e780f48b218b807a4691f0994474e4178`; the only candidate-to-kickoff path is `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; and `diff --check` is empty. Stop before implementation on any mismatch. +Expected: the branch is `repair/delete-operation-support` and clean; record the exact `rev-parse HEAD` and plan SHA-256 stdout as the immutable kickoff evidence for this execution; `merge-base --is-ancestor` exits `0`; every path printed beneath every commit in the candidate-to-kickoff log is exactly `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; the aggregate diff prints that one path; and `diff --check` is empty. Stop before implementation if the candidate is not an ancestor, any commit or aggregate diff contains a non-plan path, the tree is dirty, the recorded kickoff HEAD is not an ancestor of a later implementation HEAD, or the plan no longer reproduces the recorded kickoff SHA-256. --- @@ -276,7 +280,7 @@ rtk git commit -m "feat: add typed delete setup contracts" --- -### Task 2: Add Exact-Plan, Baseline, and Product Path-State Semantics +### Task 2: Add Exact-Plan, Baseline, Product Path-State, and Execution-Transition Semantics **Files:** - Modify: `skills/implementing-staged-plans/scripts/state_authority.py` @@ -294,8 +298,12 @@ rtk git commit -m "feat: add typed delete setup contracts" - Produces: `file_map_entries(file_map) -> tuple[tuple[str, tuple[str, ...]], ...]` and `file_map_paths(file_map, *, mutable_only: bool) -> tuple[str, ...]` so consumers do not reconstruct operation inventories inconsistently. - Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. - Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. +- Produces: `EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2"` and `ExecutionTransitionReceiptV2`; v2 status bindings use `product_result_schema_version` and `product_result_sha256`, never `product_delta_sha256`. +- Produces: exact baseline/result/transition pairing: baseline v1 + product-delta v1 + execution-transition v1, or baseline v2 + product-path-states v2 + execution-transition v2. A missing, mixed, substituted, or dual-family field set fails before adoption or any status write. +- Produces: an execution-transition event identifier derived from the exact family-specific seed and a retry path that adopts only a fully reproduced binding, `previous_state`, `transition_authority`, result digest, and event identifier. - Produces: v2 product states in operation-section order and exact file-map order; v1 product deltas retain their current lexical ordering and bytes. - Produces: manifest-v3/setup-v2 sequence-one-and-later `plan-preparation-*` and `plan-materialization-*` retry/recovery classification from exact transaction prefixes before full state-authority validation or generic lifecycle routing; Task 1 exclusively owns sequence-zero activation-prefix classification. +- Produces: writer-to-fresh-discovery coverage for implementing and reviewing status plus `execution-transition-recovery-required` classification for v1/v2 transition substitution or digest/event divergence. - Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. - Preserves: public plan preparation/materialization and three-argument future-write signatures. @@ -351,6 +359,10 @@ def test_v2_delete_path_must_transition_from_exact_file_to_absence(self) -> None Add negative cases for a missing Delete target at baseline, unchanged Delete at reviewing, changed-but-present Delete, symlink/hard-link/directory/special-file targets, overlap with recorded user work, duplicate cross-disposition paths, `sha256` on an absent result, and `None` on a present result. Retain the existing assertion that deleting a v1 Modify path fails. +Drive `program_activation.py::advance_execution_state(...)` through `authorized -> implementing -> reviewing` for one setup-v1 program and one setup-v2 program. For each family, pass the production-written implementing and reviewing statuses directly to fresh discovery and require `resume`, with state authority clean. Inject a lost response after each status-last write and call the same transition again: the exact binding must return `recovered is True` without changing status bytes. Recompute each `event_id` from the exact seed specified in Step 5 and compare it with both `execution_transition_binding.event_id` and `transition_authority.event_id`. + +For both target states, substitute a v1 transition into the v2 status and a v2 transition into the v1 status; also try both digest field families together, remove the required result schema, change the result digest, change one seed-bound field, and change only `event_id`. The direct retry must raise `execution-transition-recovery-required: status binding differs`, fresh state authority must report `execution transition binding is invalid` or the family-specific reviewed-result mismatch, discovery must return `execution-transition-recovery-required` with `required_input == "execution-transition-recovery"` and `stop_required is True`, and every rejected case must preserve status bytes. Compare the production v1 transition/status serialization with the existing frozen `tests/fixtures/program-bootstrap/v0.1.1` route byte-for-byte; do not update that fixture. + Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve, including Create for a currently absent exact path, and whose strict successor owns Delete for that same path with collision `accepted-predecessor`. In this task, assert only that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches `authorized` with an exact v2 baseline. Do not fabricate or require accepted predecessor state here: Task 3 owns v2 review/diff acceptance, and Task 5 owns the production rollover into the Delete increment. Assert the inverse family substitution fails for setup v1. For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Cover the same symlinked-ancestor rejection during baseline construction, and assert the external sentinel is unchanged in both cases. Also preserve the current positive cases for an absent v1 Create target below a not-yet-created parent and an already-absent tracked user-work path whose suffix is missing. A missing suffix returns `exists=False`; Delete/Modify/Preserve baseline callers must then reject it as missing, while Create, accepted/inherited absence, and already-absent user-work callers may accept it. @@ -361,7 +373,7 @@ For path traversal, add `nested/legacy.ts` with a real directory ancestor and ca 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: the unversioned parser test exposes the current Delete-to-Modify absorption; v2 imports and absent-result assertions fail; existing v1 tests pass. +Expected: the unversioned parser test exposes the current Delete-to-Modify absorption; v2 imports, absent-result assertions, v2 transition schema, and writer-to-discovery assertions fail; existing v1 tests pass. - [ ] **Step 3: Add versioned file-map and baseline types** @@ -430,17 +442,72 @@ if disposition == "Delete": For v2 Create/Modify results emit `final_state: "present"` with the real digest. Construct v2 results by iterating `file_map_entries(...)` in Create, Modify, Delete, Preserve section order and retaining each section's exact path order; do not sort v2 states after construction. Keep the v1 result object, lexical sort, and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. -- [ ] **Step 5: Route exact setup-v2 plan prefixes before generic rejection** +- [ ] **Step 5: Version execution-transition writes, adoption, and validation** + +Keep `EXECUTION_TRANSITION_SCHEMA`, `ExecutionTransitionReceipt`, the v1 binding fields, and the v1 event seed byte-for-byte unchanged. Add: + +```python +EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2" +PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" + +@dataclass(frozen=True) +class ExecutionTransitionReceiptV2: + prior_state: str + increment_state: str + status_sha256: str + product_result_schema_version: str + product_result_sha256: str + recovered: bool +``` + +For a baseline/result v2 assessment, `advance_execution_state(...)` writes exactly this binding; `review_remediation_sha256` is the only additional field allowed, and is required only for the Task 3 `remediating -> reviewing` writer: + +```python +{ + "schema_version": "implementation-execution-transition/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_result_schema_version": "implementation-product-path-states/v2", + "product_result_sha256": assessment.product_delta_sha256, +} +``` + +`product_result_sha256` is the canonical SHA-256 already computed over the ordered v2 path-state tuple; it must reproduce from `assessment.product_delta` without sorting or coercing `None`. Derive `event_id = _identifier("execution-transition", event_seed)` from exactly: + +```python +{ + "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_result_schema_version": "implementation-product-path-states/v2", + "product_result_sha256": assessment.product_delta_sha256, + "authorization_id": authorization_id, +} +``` + +Append `review_remediation_sha256` to that seed and binding only when `prior_increment_state == "remediating"`. `transition_authority.event_id` must equal the derived identifier and use the same authorization. `previous_state.status_sha256` must equal `prior_status_sha256`, and its sequence must be exactly one below the new status. + +Select v1 or v2 only from the validated manifest setup/envelope, execution-baseline, and assessment result-schema tuple. The v1 binding has `product_delta_sha256` and no product-result fields; the v2 binding has the two product-result fields and no `product_delta_sha256`. In the same-target retry branch, rebuild the expected family, fields, canonical result digest, event seed, `previous_state`, and `transition_authority` before returning a recovered receipt; do not adopt from schema/target/digest alone. A mismatch raises the existing recovery-required error before any write. + +In `state_authority.py`, validate the same exact field sets and family table, recompute the event identifier, and reject a cross-family or dual-family binding. While state is `implementing` or `remediating`, validate the entry digest's shape and seed binding but preserve it while product work may evolve; at `reviewing`, `verified`, `awaiting-diff-approval`, and `accepted`, recompute the v1 delta or canonical v2 path-state digest from the fresh assessment and require equality. Preserve the v1 error text and add `reviewed product result differs from its status binding` for v2. In `program_discovery.py`, classify either transition-invalid message and either reviewed-result mismatch as `execution-transition-recovery-required` before generic invalid routing; an exact production-written v1 or v2 transition proceeds to the existing `resume` route. + +- [ ] **Step 6: Route exact setup-v2 plan prefixes before generic rejection** In `program_discovery.py::_load_setup_candidate(...)`, preserve without reimplementing the exact setup-v1/setup-v2 sequence-zero activation retry/recovery routing completed in Task 1. For sequence one and later only, load the allocated transaction files and relevant ledgers, derive the fresh observation, and call `_exact_plan_prefix_disposition(...)` before `validate_state_authority(...)` or any generic `resume`/invalid route. Do not accept a prefix by state name alone. For a manifest-v3/setup-v2 program, interrupt standard-mode preparation after the exact plan and awaiting-plan status, and materialization after the plan approval, v2 baseline, and action authorization. Each byte-exact incomplete prefix must return the matching `plan-preparation-retry-ready` or `plan-materialization-retry-ready`; missing/out-of-order records, changed plan bytes, a v1 baseline, or changed v2 path-state order must return the matching recovery-required disposition. After the exact authorized status is written last, discovery returns `resume` only after full state validation. `approval:pre-approve` and `approval:full-increment` must exercise their shorter exact materialization prefixes and completed-status route. The same cases for setup-v1 keep their existing bytes and disposition names. -- [ ] **Step 6: Run the focused tests and verify GREEN** +- [ ] **Step 7: Run the focused tests and verify GREEN** Run the Step 2 command. -Expected: the exact parser, baseline, authorization, partial implementation, complete absence, and legacy-negative tests pass. +Expected: the exact parser, baseline, authorization, partial implementation, complete absence, transition writer/retry/discovery, cross-family rejection, and legacy-byte tests pass. -- [ ] **Step 7: Commit exact-plan and baseline support** +- [ ] **Step 8: Commit exact-plan, baseline, and execution-transition support** ```bash rtk git add skills/implementing-staged-plans/scripts/state_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py 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 @@ -469,6 +536,7 @@ rtk git commit -m "feat: validate delete path states" **Interfaces:** - Produces: `implementation-review-evidence/v2`, `implementation-review-packet/v2`, `implementation-review-preparation/v2`, `implementation-review-remediation/v2`, `implementation-diff-disposition-binding/v2`, and `implementation-diff-disposition-command/v2` only for product path-state v2. - Produces: review evidence field `product_result = {schema_version, sha256, ordered_path_states}`. +- Consumes: Task 2's exact execution-transition v1/v2 family; the remediation-return writer uses v2 result fields and seed extension for setup-v2 without redefining the schema. - Produces: exact-family discovery of v2 acceptance prefixes and an `accepted-stop` route for an exact accepted v2 diff binding. - Produces test helpers in `tests/program_bootstrap_support.py`: `BootstrapFixture.observation() -> RepositoryObservation` and `reviewing_delete_program() -> tuple[BootstrapFixture, Path, RepositoryObservation]`, returning a real temporary manifest-v3/setup-v2 program at `reviewing` with `legacy.ts` absent and raw review reports ready. - Preserves: v1 review evidence, packet rendering, remediation, prompt bytes, diff bindings, and approval records. @@ -511,6 +579,8 @@ def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. In `tests/test_program_discovery.py`, persist an exact v2 diff-acceptance prefix and assert the pre-status prefix is `increment-acceptance-retry-ready`, the byte-exact accepted status is `accepted-stop`, and a substituted v1 binding, reordered state, or changed digest is `increment-acceptance-recovery-required` rather than resume or terminal. +Drive one v2 remediation return through the production `program_review.py` writer. Require `implementation-execution-transition/v2`, the exact renewed product-result schema/digest, and an `event_id` derived from the Task 2 seed plus the exact `review_remediation_sha256`. Retry the byte-exact written status and require adoption without mutation. Substitute a v1 transition or v1 `product_delta_sha256` into that v2 return, and a v2 transition into the v1 control; require review retry, state authority, and discovery to fail on the exact transition family before any later review artifact is written. Preserve the existing v1 remediation-transition bytes. + This task owns the chronology assertion deferred from Task 2: drive a setup-v2 first increment containing only Create/Modify/Preserve through v2 review and exact `accept-stop`, with an empty Delete section in its file map/result family, and assert discovery returns `accepted-stop` before any successor or Delete plan is prepared. Use production review and diff writers; do not edit accepted status directly. For manifest-v3/setup-v2 discovery, interrupt review preparation after evidence, packet, and verified status, then verify the exact awaiting-diff status written last routes to `resume` only after complete review-state validation. Interrupt acceptance after approval and accepted status. Every byte-exact incomplete review prefix returns `review-preparation-retry-ready`; the exact acceptance approval prefix returns `increment-acceptance-retry-ready`; the exact accepted status returns `accepted-stop`. Packet-before-evidence, changed evidence/packet/status, mixed v1/v2 review or command bytes, and changed/reordered accepted path states return the domain-specific recovery disposition before generic state validation. Repeat one setup-v1 control to prove its prompt bytes and route names are unchanged. @@ -529,6 +599,8 @@ Extend execution ownership with a literal `delete` disposition: it requires a no When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. +For a v2 remediation return, `program_review.py` must consume Task 2's execution-transition contract and emit the exact v2 binding with `product_result_schema_version`, `product_result_sha256`, and `review_remediation_sha256`; its event seed is the Task 2 v2 seed plus that remediation digest. Its retry/adoption branch must reproduce the full binding, event identifier, previous-state link, transition authority, and renewed assessment before returning recovered. Keep the v1 writer and retry bytes unchanged. + `diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. Its submitted-prompt parser derives the expected command schema from the persisted review/result family before calling `parse_exact_prompt(...)`; it never accepts caller-selected family substitution. `program_discovery.py::_exact_review_prefix_disposition(...)`, `_exact_acceptance_prefix_disposition(...)`, and accepted-status routing must recognize only the exact v1 or v2 review/diff family, rebuild the matching production candidate, and classify byte-exact v2 accepted-stop and retry prefixes without a hard-coded v1 gate. Extend `_load_setup_candidate(...)` so the exact review and acceptance classifiers run on manifest-v3/setup-v2 prefixes before full `validate_state_authority(...)` and before its generic `verified`, `awaiting-diff-approval`, or `accepted` fallbacks. A classifier's recovery-required result is authoritative and cannot be replaced by `invalid`, `resume`, or a generic retry. Keep v1 base-seed construction, prompt bytes, and discovery dispositions unchanged. @@ -1062,6 +1134,8 @@ external-state authority. Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. Update `references/program-discovery.md` to make its existing prefix-before-generic-rejection rule explicit for both setup/envelope families and to enumerate exact setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure retry/recovery routes. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. +In the two version-owning design specs, `references/state-authorization.md`, `references/program-discovery.md`, and the live runbook, document `implementation-execution-transition/v2` as the setup-v2 companion to baseline/result v2: list its exact product-result fields, canonical ordered-state digest, family-specific event seed, conditional remediation-digest extension, retry/adoption checks, fresh-discovery route, and cross-family rejection. Preserve the documented v1 `product_delta_sha256` shape and byte contract. + Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain and final cumulative digest. Do not rewrite older dated design plans; they remain historical version-bound records. - [ ] **Step 5: Synchronize package version `0.1.3`** @@ -1133,7 +1207,7 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla ## Rollback and Failure Semantics - Before any v2 program artifact is persisted, the implementation commits can be reverted normally; v1 programs remain readable throughout. -- After a Delete-capable v2 setup, baseline, review, rollover, blocked context, or closure artifact exists, do not downgrade that program to `0.1.2` or rewrite it as v1. Retain a `0.1.3` reader or ship a forward repair that preserves the v2 bytes. +- After a Delete-capable v2 setup, baseline, execution transition, review, rollover, blocked context, or closure artifact exists, do not downgrade that program to `0.1.2` or rewrite it as v1. Retain a `0.1.3` reader or ship a forward repair that preserves the v2 bytes. - A failure before product mutation preserves the baseline file and exact partial control-plane prefix; retry may adopt only byte-identical owner-bound artifacts. - A failure after a planned Delete while status is `implementing` preserves the absence as a valid partial product result. Recovery may block and resume from the exact bound absence; it does not restore automatically. - A failure after review or diff acceptance must reproduce the same ordered path states and digest. Reappearance, changed content, missing result records, reordered states, or mixed schema families is divergent and stops without cleanup. @@ -1145,7 +1219,7 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Requirement | Primary owner | Required evidence | Failure signal | | --- | --- | --- | --- | -| Locked implementation baseline | Git preflight | first repair `31a04be...` has parent `44ef42ac...`, whose parent is candidate `b5eb689e...`; second-corrections kickoff is the single clean plan-only child of `31a04be...` on `repair/delete-operation-support` | stop before edits | +| Locked implementation baseline | Git preflight | candidate `b5eb689e...` is an ancestor of the clean kickoff HEAD on `repair/delete-operation-support`; every candidate-to-HEAD commit and aggregate path is only this plan; actual kickoff HEAD and plan SHA-256 are recorded | stop before edits | | Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | | Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | | Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | @@ -1154,6 +1228,7 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Baseline proves a real removable file | `program_activation.py`, `inspect_workspace_path(...)` | component `lstat`, workspace containment, existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | | Ancestor safety is reassessed | `inspect_workspace_path(...)`, operation callers | baseline symlinked ancestor and post-authorization ancestor swap fail before external reads; missing suffix remains valid only for Create, accepted/inherited absence, and already-absent user work | path escape, rejected valid absence, or missing required Delete/Modify/Preserve target | | Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest and exact-map ordering | accidental loss, fabricated digest, or reordered state | +| Execution transition matches result family | `program_activation.py`, `program_review.py`, `state_authority.py`, `program_discovery.py` | v1 keeps `product_delta_sha256`; v2 uses `implementation-execution-transition/v2` with exact product-result schema/digest and derived event; production writer output survives fresh discovery and exact retry | mixed/dual family, changed seed or digest, invalid adoption, generic discovery route, or v1 byte drift | | Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve | Delete accepted for a control path | | Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | | Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | From 437770d3b829866c460295390c0ed9336b2ea975 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sun, 6 Sep 2026 00:55:20 -0400 Subject: [PATCH 06/19] docs: close delete plan protection gaps --- .../2026-09-05-delete-operation-support.md | 291 ++++++++++++++++-- 1 file changed, 264 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index 5791f60..c23aa80 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -4,7 +4,7 @@ **Goal:** Add truthful, fail-closed support for exact regular-file `Delete` operations so a program can preserve an accepted absent path through review, diff acceptance, rollover, recovery, and closure without weakening existing `Create`, `Modify`, or `Preserve` contracts. -**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, execution-transition, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. +**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, execution-transition, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. One canonical fail-closed Delete-target validator protects Git metadata, program roots, and manifest-owned control paths at allocation and every later reassessment. Complete-chain closure derives requirement ownership from traceability plus accepted per-increment evidence, validates later accepted deltas, and reuses existing nonfinal handoffs and the final manifest-owned closure artifacts instead of inventing an addendum. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. **Tech Stack:** Python 3 standard library, frozen dataclasses, canonical JSON and SHA-256, `unittest`, temporary Git repositories, existing atomic/no-overwrite/status-last writers. @@ -18,6 +18,7 @@ - Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. - Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; that setup choice fixes the complete program to the nested v2 lifecycle family from its first increment, and mixed v1/v2 nested contracts fail before every write. - A `Delete` target must be one normalized repository-relative path that is a program-owned regular non-symlink, non-hard-linked file beneath the selected workspace when its deletion-increment baseline is captured. Setup may bind either an initially `existing` target or an `accepted-predecessor` target created and accepted by a strict predecessor increment; the latter requires a same-path predecessor `Create` allocation. Directories, symlinks, symlinked ancestors, hard links, special files, absent Delete baselines, external paths, protected paths, and pre-existing user work remain unsupported. +- Every Delete allocation and reassessment must pass the same fail-closed protection context. Reject a lexical `.git` component; an existing path or ancestor that resolves to the worktree `.git` entry, Git directory, or Git common directory; the conventional `implementation-programs` root; the actual or intended manifest program root; and every manifest-resolved logical-role, increment-storage, or closure-storage control path. Use `RepositoryInspection.git_directory` and `.git_common_directory` for normal and linked worktrees, filesystem identity for existing aliases, and exact component boundaries so `.github`, `.gitignore`, and ordinary product names containing `git` remain allowed. Missing, empty, stale, or unresolvable protection metadata is an error, never permission. - Baseline capture and every later reassessment must repeat one shared component-by-component `lstat` walk and workspace-containment proof. Reject any unsafe existing component or containment escape, but return an absent snapshot after the first missing component so a caller may permit an absent suffix for Create, accepted Delete tombstones, inherited absence, or already-absent user work. Delete/Modify/Preserve baseline callers and every present-state caller separately require the ancestors and final regular file their operation needs; no earlier safe observation authorizes a later swapped ancestor. - `Delete` means the approved final state is absent. Never encode absence as `Modify`, `Preserve`, an omitted path, an empty digest, or a fabricated digest. - `authorized` requires every Delete target to remain byte-identical to its baseline; `implementing` permits either the exact baseline file or its absence; `reviewing` and later require absence. A changed-but-present Delete target is always invalid. @@ -48,6 +49,8 @@ The defect is confirmed at the locked baseline: 10. The real pipeFlow lifecycle does not begin with all 27 Task 8 Delete targets. `test/legacy/characterization.test.ts` is absent at setup, created and accepted in Task 1, inherited as present, and deleted with the other 26 legacy files in Task 8. A fixture that pre-creates all 27 paths does not exercise future Delete allocation, predecessor collision facts, or a real late tombstone. 11. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. 12. `program_activation.py::advance_execution_state(...)` writes and retry-adopts only `implementation-execution-transition/v1` with `product_delta_sha256`, while `state_authority.py` accepts only that v1 shape and derives the event identifier from that v1 digest field. A setup-v2 writer output therefore has no exact execution-transition schema, result-family binding, event seed, state-authority route, or discovery retry/recovery contract even though Task 2 claims a complete v2 baseline/result family. +13. `_safe_relative_path(...)` and exact-file-map normalization accept `.git/config`. In a normal checkout that is a regular file; in a linked worktree `.git` itself is a regular gitfile. `RepositoryInspection` already records the resolved Git directory and common directory, but blocked recovery and state authority currently synthesize inspections with both fields empty, while the shared test snapshot intentionally skips `.git`. Path-shape validation and snapshot equality therefore cannot enforce the protected boundary. +14. `program_closure.py::_traceability_context(...)` counts every requirement not assigned to the final increment as unresolved, then fabricates every disposition as `implemented` with the final increment as owner and no accepted evidence. Task 6 also names a required `handoff addendum`, but the manifest increment storage and production rollover transaction create only a review packet, handoff, successor brief, and bound rollover record; the addendum belongs only to the preserved legacy continuity model and has no new-model writer or storage role. The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. @@ -74,7 +77,7 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/scripts/program_setup.py` — own setup-semantics/envelope v2 validation, pairing, and recap rendering. - `skills/implementing-staged-plans/scripts/program_authority.py` — recognize only the exact new setup authority schemas on manifest-v3 and reject cross-family substitution. - `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, execution-transition/result-family bindings, state bindings, and v1 compatibility rejection. -- `skills/implementing-staged-plans/scripts/repository_preparation.py` — parse exact-file-map v2, parse baseline v2, and assess present/absent path states. +- `skills/implementing-staged-plans/scripts/repository_preparation.py` — own the canonical Git/program/control protection context, parse exact-file-map v2, parse baseline v2, and assess present/absent path states. - `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. - `skills/implementing-staged-plans/scripts/program_discovery.py` — route manifest-v3/setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, closure, and divergent prefixes by exact schema family before generic state validation. - `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. @@ -83,9 +86,9 @@ Unsafe alternatives are rejected: - `skills/implementing-staged-plans/scripts/diff_disposition.py` — bind the exact reviewed v2 product result during acceptance. - `skills/implementing-staged-plans/scripts/blocked_recovery.py` — freeze and revalidate Delete path states across blocked/resume. - `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results and render/parse exact accepted-state-continuation v1/v2 commands without coercing absence to a string digest. -- `skills/implementing-staged-plans/scripts/program_rollover.py` — persist v2 rollover records and cumulative inherited present/absent path states. +- `skills/implementing-staged-plans/scripts/program_rollover.py` — persist v2 rollover records with accepted review/diff/handoff bindings and cumulative inherited present/absent path states. - `skills/implementing-staged-plans/scripts/continuity_closure.py` — validate/render versioned closure reconciliation over accepted result bindings and cumulative path states. -- `skills/implementing-staged-plans/scripts/program_closure.py` — build closure from the complete accepted increment chain and final cumulative state. +- `skills/implementing-staged-plans/scripts/program_closure.py` — build closure from complete accepted-increment evidence, traceability-owned requirement dispositions, later-invalidation checks, and final cumulative state. - `skills/implementing-staged-plans/scripts/validate_package.py` — set and enforce package version `0.1.3`. - `skills/implementing-staged-plans/SKILL.md` — route and explain the Delete-capable nested v2 family. - `skills/implementing-staged-plans/agents/openai.yaml` — describe exact local Delete support without implying generic destructive authority. @@ -141,12 +144,14 @@ Expected: the branch is `repair/delete-operation-support` and clean; record the **Files:** - Modify: `skills/implementing-staged-plans/scripts/program_setup.py` - Modify: `skills/implementing-staged-plans/scripts/program_authority.py` +- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` - Modify: `skills/implementing-staged-plans/scripts/program_activation.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` - Test: `tests/test_program_setup.py` - Test: `tests/test_program_authority.py` +- Test: `tests/test_repository_preparation.py` - Test: `tests/test_program_bootstrap.py` - Test: `tests/test_program_activation.py` - Test: `tests/test_program_discovery.py` @@ -156,6 +161,7 @@ Expected: the branch is `repair/delete-operation-support` and clean; record the - Consumes: manifest-v3 `setup_semantics` and the existing immutable setup decision flow. - Produces: `SETUP_SEMANTICS_SCHEMA_V2`, `OPERATION_ENVELOPE_SCHEMA_V2`, `SETUP_RECAP_SCHEMA_V2`, `SETUP_RECAP_CHECKPOINT_SCHEMA_V2`, `SETUP_DECISION_ADAPTER_SCHEMA_V2`, and `SETUP_ACTIVATION_SCHEMA_V2`. - Produces: `_operation_contract(semantics: Mapping[str, object]) -> tuple[tuple[str, ...], bool]`, returning the exact supported-operation tuple and whether Delete fields are required. +- Produces: `DeleteProtectionContext`, `build_delete_protection_context(workspace_root: Path, manifest_program_root: Path, manifest: Mapping[str, object], inspection: RepositoryInspection) -> DeleteProtectionContext`, and `validate_delete_target_path(context: DeleteProtectionContext, relative_path: str) -> None` as the only Delete protection policy used by setup and later tasks. - Produces test helpers: `BootstrapFixture.configure_delete_setup_v2(allocation: Mapping[str, object]) -> dict[str, object]`, `configure_v1_envelope_with_delete() -> list[str]`, and `configure_mixed_setup_versions() -> list[str]`; each recomputes the semantic digest after its exact mutation. - Produces: recap, checkpoint, decision, activation-record, program-authority, and state-authority dispatch selected from the exact setup/envelope family before any activation record is written. - Produces: manifest-v3/setup-v2 sequence-zero activation-prefix discovery that returns `program-activation-retry-ready` for every byte-exact incomplete prefix and `program-activation-recovery-required` for every started mixed, out-of-order, or divergent prefix before generic invalid-state routing. @@ -219,6 +225,8 @@ def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Add a two-increment allocation for one initially absent exact path: `Create` in the first increment with facts `absent`/`none`/`None`/`none`, then `Delete` in its strict successor with facts `regular-file`/`none`/`100644`/`accepted-predecessor`. Require one same-path Create allocation in a transitive predecessor for `accepted-predecessor`, and reject an unrelated, same-increment, later, or absent predecessor allocation; the distinct operations are not a duplicate allocation. Keep an initially present Delete allocation on collision `existing` and reject any other collision/fact combination. +Before accepting either Delete allocation form, exercise the production protection validator in a normal temporary checkout and a real linked worktree. Reject `.git`, `.git/config`, a symlink or case/alias resolving into `.git`, the resolved `git_directory`, the resolved `git_common_directory`, `implementation-programs/**`, the intended `implementation-programs/` publication target, an instruction-declared actual program root, `manifest.json`, every logical-role file, and the increment/closure storage roots even when the allocation says `protected: false`. In the linked worktree specifically prove that the regular `.git` gitfile is rejected. If the platform cannot create a case alias or hard link, skip only that alias variant and retain the symlink and linked-worktree cases. Positive controls must accept ordinary product files such as `.github/legacy.yml`, `.gitignore.backup`, and `src/legitimate-config.ts`; component matching must not become a substring ban. + Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. After each byte-exact v2 activation prefix, run discovery and require the existing `program-activation-retry-ready` route. For each started-prefix record class—setup activation decision, required source-gate decision, program approval, and workspace approval—change one bound field, substitute the opposite setup schema where applicable, or place the record out of order; require read-only discovery to return `program-activation-recovery-required`, `required_input == "activation-prefix-recovery"`, and `stop_required is True` without falling through to generic invalid-state or publication recovery. Keep malformed sequence-zero proposals with no activation transaction artifact on their existing invalid route. - [ ] **Step 2: Run the focused tests and verify RED** @@ -226,7 +234,7 @@ Drive `program_activation.py::activate_program(...)` through the real sequence-z Run: ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_program_bootstrap tests.test_program_activation tests.test_program_discovery tests.test_state_authority -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_repository_preparation tests.test_program_bootstrap tests.test_program_activation tests.test_program_discovery tests.test_state_authority -v ``` Expected: new tests fail because only setup/envelope v1 exists and `Delete` is unsupported; all pre-existing tests remain green. @@ -259,8 +267,151 @@ def _operation_contract( raise ValueError("setup semantics and operation envelope schema families do not match") ``` +In `repository_preparation.py`, add the protection context without changing the persisted repository-inspection v1 bytes: + +```python +@dataclass(frozen=True) +class DeleteProtectionContext: + workspace_root: Path + git_directory: Path + git_common_directory: Path + protected_roots: tuple[Path, ...] + protected_paths: tuple[Path, ...] + +def build_delete_protection_context( + workspace_root: Path, + manifest_program_root: Path, + manifest: Mapping[str, object], + inspection: RepositoryInspection, +) -> DeleteProtectionContext: + workspace = Path(workspace_root) + if workspace.is_symlink() or not workspace.is_dir(): + raise ValueError("Delete protection requires a regular workspace root") + workspace = workspace.resolve(strict=True) + + git_directory = Path(inspection.git_directory) + git_common_directory = Path(inspection.git_common_directory) + if not git_directory.is_absolute() or not git_common_directory.is_absolute(): + raise ValueError("Delete protection requires resolved Git metadata paths") + if not git_directory.exists() or not git_common_directory.exists(): + raise ValueError("Delete protection Git metadata paths are missing") + + program_root = Path(manifest_program_root).absolute() + try: + program_root.relative_to(workspace.absolute()) + except ValueError as error: + raise ValueError("manifest program root escapes the workspace") from error + + def managed_relative(value: object, label: str) -> PurePosixPath: + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError(f"{label} is not a safe relative POSIX path") + relative = PurePosixPath(value) + if relative.is_absolute() or any( + part in {"", ".", ".."} for part in relative.parts + ): + raise ValueError(f"{label} is not a safe relative POSIX path") + return relative + + roles = manifest.get("logical_roles") + increment_storage = manifest.get("increment_storage") + closure_storage = manifest.get("closure_storage") + if not isinstance(roles, Mapping): + raise ValueError("manifest logical_roles must be an object") + if not isinstance(increment_storage, Mapping) or not isinstance( + closure_storage, Mapping + ): + raise ValueError("manifest lifecycle storage descriptors must be objects") + + increment_root = program_root.joinpath( + *managed_relative(increment_storage.get("root"), "increment storage root").parts + ) + closure_root = program_root.joinpath( + *managed_relative(closure_storage.get("root"), "closure storage root").parts + ) + control_paths = [program_root / "manifest.json"] + control_paths.extend( + program_root.joinpath(*managed_relative(value, f"logical role {role}").parts) + for role, value in sorted(roles.items()) + ) + program_binding = manifest.get("program_binding") + if isinstance(program_binding, Mapping): + for field in ("path", "traceability_path"): + control_paths.append( + program_root.joinpath( + *managed_relative( + program_binding.get(field), f"program binding {field}" + ).parts + ) + ) + + protected_roots = ( + workspace / ".git", + git_directory, + git_common_directory, + workspace / "implementation-programs", + program_root, + increment_root, + closure_root, + ) + for protected in (*protected_roots, *control_paths): + if protected.is_symlink(): + raise ValueError("Delete protection metadata contains a symlink") + return DeleteProtectionContext( + workspace_root=workspace, + git_directory=git_directory.resolve(strict=True), + git_common_directory=git_common_directory.resolve(strict=True), + protected_roots=tuple(path.absolute() for path in protected_roots), + protected_paths=tuple(path.absolute() for path in control_paths), + ) + +def validate_delete_target_path( + context: DeleteProtectionContext, + relative_path: str, +) -> None: + relative = PurePosixPath(relative_path) + if ( + not relative_path + or "\\" in relative_path + or relative.is_absolute() + or relative.as_posix() != relative_path + or any(part in {"", ".", ".."} for part in relative.parts) + ): + raise ValueError("Delete target must be one normalized repository-relative path") + if ".git" in relative.parts: + raise ValueError(f"Delete target is protected: {relative_path}") + + candidate = context.workspace_root.joinpath(*relative.parts).absolute() + protected = (*context.protected_roots, *context.protected_paths) + if any(candidate == item or candidate.is_relative_to(item) for item in protected): + raise ValueError(f"Delete target is protected: {relative_path}") + + current = context.workspace_root + for part in relative.parts: + current = current / part + if not current.exists() and not current.is_symlink(): + break + if current.is_symlink(): + raise ValueError(f"Delete target has a symlink component: {relative_path}") + resolved = current.resolve(strict=True) + for protected_path in protected: + protected_resolved = protected_path.resolve(strict=False) + same_file = protected_path.exists() and current.samefile(protected_path) + if ( + same_file + or resolved == protected_resolved + or resolved.is_relative_to(protected_resolved) + ): + raise ValueError(f"Delete target is protected: {relative_path}") +``` + +The builder requires a strict regular non-symlink workspace root, non-empty absolute Git directory/common-directory values from a fresh `inspect_repository(...)`, a manifest program root lexically beneath the workspace, and safe exact manifest path descriptors. Its protected roots are the workspace `.git` entry, both resolved Git metadata directories, the conventional `workspace/implementation-programs` root, the intended or actual manifest program root, and the manifest's increment/closure storage roots. Its protected exact paths include `manifest.json`, all resolved `logical_roles`, and every manifest binding path. Fail closed if any required protection value is missing, ambiguous, escaping, or symlinked. + +The validator first rejects any normalized path with an exact lexical `.git` component. It then proves lexical workspace containment and walks existing components with `lstat`; compare existing filesystem identities with `samefile` where available and compare strict resolved ancestors against every protected root/path. This must catch case aliases, the linked-worktree gitfile, the per-worktree Git directory, the shared common directory, and symlink aliases before reading target bytes. Use exact path-component containment, not string prefixes. An absent suffix may still be classified by Task 2 only after its nearest existing ancestor passes this protection check. + For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Require an exact-path Delete allocation to be program-owned, non-protected, non-user-work, and to declare collision `existing` or `accepted-predecessor`. The latter is valid only when the setup dependency graph contains one same-path `Create` allocation in a strict transitive predecessor; it does not weaken the activation-time exact fact comparison. Preserve all existing ownership, file-kind, link-kind, mode, collision, overlap, and duplicate-allocation checks. +For each setup-v2 Delete allocation, derive a fresh repository inspection from the setup workspace binding, reproduce the persisted workspace observation, build the context with the intended publication root, and call `validate_delete_target_path(...)`. Proposal validation, activation, and every activation retry must repeat this check; an old setup decision is not protection evidence. Use a local import if needed to avoid a `program_setup.py`/`repository_preparation.py` import cycle. Setup-v1 never enters this Delete-only route and retains exact bytes. + Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. In this same task, change `program_activation.py::_build_v3_setup_record(...)` to select and write the matching activation schema instead of importing and unconditionally emitting `SETUP_ACTIVATION_SCHEMA`; update its activation-prefix adoption tests before calling this task GREEN. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS`, `program_setup.py`'s activation-record loaders/validators, and `state_authority.py::SETUP_ONLY_STATUS_SCHEMAS` plus its manifest-v3 family validation without admitting the v2 records to setup-v1 or legacy manifests. In `program_discovery.py::_single_bootstrap_prefix_disposition(...)` and `_load_setup_candidate(...)`, inspect the sequence-zero transaction prefix before proposal-publication or generic program/state rejection. Preserve the existing pristine `program-setup-ready`, pending-gate `source-gate-approval-ready`, and exact-prefix `program-activation-retry-ready` routes for both setup families. When activation has started and `inspect_sequence_zero_activation_prefix(...)` reports a mixed, out-of-order, or divergent decision, gate, or approval prefix, return `program-activation-recovery-required` with the exact prefix issues for diagnosis; `_single_bootstrap_prefix_disposition(...)` must not relabel that owned activation divergence as `proposal-publication-recovery-required`, and `_load_setup_candidate(...)` must not relabel it as generic invalid. Keep immutable publication-manifest/owner/inventory divergence on `proposal-publication-recovery-required`. The activation recovery route is classification only: it must not rewrite, adopt, append, or delete any prefix byte. A malformed pristine proposal with no activation transaction artifact remains on its existing invalid or publication-recovery route. @@ -274,7 +425,7 @@ Expected: all setup, authority, generic proposal-publication, and sequence-zero - [ ] **Step 5: Commit the setup contract** ```bash -rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py +rtk git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_repository_preparation.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py rtk git commit -m "feat: add typed delete setup contracts" ``` @@ -297,6 +448,7 @@ rtk git commit -m "feat: add typed delete setup contracts" - Produces: `ExactFileMapV2`, `ExecutionBaselineV2`, and `InheritedPathStateV2` while retaining `ExactFileMap` and `ExecutionBaseline` as v1 types. - Produces: `file_map_entries(file_map) -> tuple[tuple[str, tuple[str, ...]], ...]` and `file_map_paths(file_map, *, mutable_only: bool) -> tuple[str, ...]` so consumers do not reconstruct operation inventories inconsistently. - Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. +- Consumes: Task 1's `DeleteProtectionContext` and `validate_delete_target_path(...)`; every Delete branch validates protection immediately before its component walk and never accepts an empty/synthetic Git protection context. - Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. - Produces: `EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2"` and `ExecutionTransitionReceiptV2`; v2 status bindings use `product_result_schema_version` and `product_result_sha256`, never `product_delta_sha256`. - Produces: exact baseline/result/transition pairing: baseline v1 + product-delta v1 + execution-transition v1, or baseline v2 + product-path-states v2 + execution-transition v2. A missing, mixed, substituted, or dual-family field set fails before adoption or any status write. @@ -365,7 +517,9 @@ For both target states, substitute a v1 transition into the v2 status and a v2 t Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve, including Create for a currently absent exact path, and whose strict successor owns Delete for that same path with collision `accepted-predecessor`. In this task, assert only that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches `authorized` with an exact v2 baseline. Do not fabricate or require accepted predecessor state here: Task 3 owns v2 review/diff acceptance, and Task 5 owns the production rollover into the Delete increment. Assert the inverse family substitution fails for setup v1. -For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Cover the same symlinked-ancestor rejection during baseline construction, and assert the external sentinel is unchanged in both cases. Also preserve the current positive cases for an absent v1 Create target below a not-yet-created parent and an already-absent tracked user-work path whose suffix is missing. A missing suffix returns `exists=False`; Delete/Modify/Preserve baseline callers must then reject it as missing, while Create, accepted/inherited absence, and already-absent user-work callers may accept it. +For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Repeat the swap with a symlink into `.git` and into the active program root, and where supported with a hard-link/case alias to an existing protected file. Cover the same rejection during baseline construction, and assert every external/control sentinel is unchanged in both cases. Inject a lost response after the baseline and action-authorization prefixes, perform the protected swap, then retry materialization: it must return the exact plan-domain recovery stop without adopting authorization or writing status. A direct v2 baseline or inherited-state tamper that introduces `.git/config` or a manifest control path must also fail before hashing or status writes; repository snapshots that skip `.git` are not sufficient evidence, so assert the protected sentinel bytes and Git identity explicitly. + +Preserve the current positive cases for an absent v1 Create target below a not-yet-created parent and an already-absent tracked user-work path whose suffix is missing. A missing suffix returns `exists=False`; Delete/Modify/Preserve baseline callers must then reject it as missing, while Create, accepted/inherited absence, and already-absent user-work callers may accept it. Add positive v2 Delete baselines for `.github/legacy.yml` and an ordinary nested product file to prove the protection rule does not narrow legitimate product deletion. - [ ] **Step 2: Run the focused tests and verify RED** @@ -413,7 +567,7 @@ Add `implementation-execution-baseline/v2` in `repository_preparation.py` with a Implement `inspect_workspace_path(...)` with `os.lstat`, never `Path.is_file()` or `resolve()` as the symlink test: normalize the relative POSIX path; `lstat` and reject a symlinked/non-directory supplied workspace root before resolving it strictly; prove the lexical candidate is beneath that root; then `lstat` components from the root downward. Every existing ancestor must be a non-symlink directory whose strict resolution remains inside the strict workspace root. If a component is missing, stop walking and return one absent snapshot for the whole remaining suffix without resolving, reading, or creating it. If the final component exists, require a regular non-symlink file, prove its strict resolution remains inside the workspace, and only then return its digest, mode, and link count. Reject every other existing component kind or containment escape. -Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. Callers then enforce their own presence contract: baseline Delete/Modify/Preserve and every state that expects presence require an existing regular file; Create before creation, Delete after removal, inherited tombstones, and recorded already-absent user work permit an absent suffix. A later lifecycle reassessment must repeat the complete walk; an authorization-time result is never reused as current path-safety evidence. +Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. Every branch whose current operation or inherited state is Delete must first rebuild Task 1's protection context from the current manifest and fresh real `RepositoryInspection`, then call `validate_delete_target_path(...)`; a persisted allocation, baseline, action authorization, review, or accepted result is never a waiver. Callers then enforce their own presence contract: baseline Delete/Modify/Preserve and every state that expects presence require an existing regular file; Create before creation, Delete after removal, inherited tombstones, and recorded already-absent user work permit an absent suffix. A later lifecycle reassessment must repeat both the protection check and complete walk; an authorization-time result is never reused as current path-safety evidence. - [ ] **Step 4: Implement Delete-aware candidate and workspace validation** @@ -495,7 +649,7 @@ Append `review_remediation_sha256` to that seed and binding only when `prior_inc Select v1 or v2 only from the validated manifest setup/envelope, execution-baseline, and assessment result-schema tuple. The v1 binding has `product_delta_sha256` and no product-result fields; the v2 binding has the two product-result fields and no `product_delta_sha256`. In the same-target retry branch, rebuild the expected family, fields, canonical result digest, event seed, `previous_state`, and `transition_authority` before returning a recovered receipt; do not adopt from schema/target/digest alone. A mismatch raises the existing recovery-required error before any write. -In `state_authority.py`, validate the same exact field sets and family table, recompute the event identifier, and reject a cross-family or dual-family binding. While state is `implementing` or `remediating`, validate the entry digest's shape and seed binding but preserve it while product work may evolve; at `reviewing`, `verified`, `awaiting-diff-approval`, and `accepted`, recompute the v1 delta or canonical v2 path-state digest from the fresh assessment and require equality. Preserve the v1 error text and add `reviewed product result differs from its status binding` for v2. In `program_discovery.py`, classify either transition-invalid message and either reviewed-result mismatch as `execution-transition-recovery-required` before generic invalid routing; an exact production-written v1 or v2 transition proceeds to the existing `resume` route. +In `state_authority.py`, validate the same exact field sets and family table, recompute the event identifier, and reject a cross-family or dual-family binding. While state is `implementing` or `remediating`, validate the entry digest's shape and seed binding but preserve it while product work may evolve; at `reviewing`, `verified`, `awaiting-diff-approval`, and `accepted`, recompute the v1 delta or canonical v2 path-state digest from the fresh assessment and require equality. Replace the current synthetic `RepositoryInspection(git_directory="", git_common_directory="", ...)` with a fresh `inspect_repository(...)`, require its observation to reproduce the supplied status-current observation, and pass its real Git metadata into Delete protection. Missing or changed Git metadata fails closed. Preserve the v1 error text and add `reviewed product result differs from its status binding` for v2. In `program_discovery.py`, classify either transition-invalid message and either reviewed-result mismatch as `execution-transition-recovery-required` before generic invalid routing; an exact production-written v1 or v2 transition proceeds to the existing `resume` route. - [ ] **Step 6: Route exact setup-v2 plan prefixes before generic rejection** @@ -579,6 +733,8 @@ def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. In `tests/test_program_discovery.py`, persist an exact v2 diff-acceptance prefix and assert the pre-status prefix is `increment-acceptance-retry-ready`, the byte-exact accepted status is `accepted-stop`, and a substituted v1 binding, reordered state, or changed digest is `increment-acceptance-recovery-required` rather than resume or terminal. +After authorization and again after review preparation, replace an allowed Delete target or ancestor with an alias into `.git` or the program control root. Require `build_review_preparation(...)`, remediation return, `build_diff_acceptance_candidate(...)`, direct submission, state authority, and discovery to reject the protected target before reading it, accepting a packet, or appending an approval. Retry after an injected review-evidence or diff-approval prefix must preserve that prefix and return the matching review/acceptance recovery disposition. Keep a normal product Delete positive control through accepted-stop. + Drive one v2 remediation return through the production `program_review.py` writer. Require `implementation-execution-transition/v2`, the exact renewed product-result schema/digest, and an `event_id` derived from the Task 2 seed plus the exact `review_remediation_sha256`. Retry the byte-exact written status and require adoption without mutation. Substitute a v1 transition or v1 `product_delta_sha256` into that v2 return, and a v2 transition into the v1 control; require review retry, state authority, and discovery to fail on the exact transition family before any later review artifact is written. Preserve the existing v1 remediation-transition bytes. This task owns the chronology assertion deferred from Task 2: drive a setup-v2 first increment containing only Create/Modify/Preserve through v2 review and exact `accept-stop`, with an empty Delete section in its file map/result family, and assert discovery returns `accepted-stop` before any successor or Delete plan is prepared. Use production review and diff writers; do not edit accepted status directly. @@ -597,7 +753,7 @@ Expected: new v2 review/result schemas are absent and Delete surfaces cannot be Extend execution ownership with a literal `delete` disposition: it requires a non-empty pre-write fingerprint, exact `post_write_fingerprint == "absent"`, program ownership, and no accepted user-work overlap. Add `deleted` to execution surface changes and require one semantic naming/compatibility record for each deleted path; keep physical `renamed` rejection unchanged. -When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. +When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. Every review, remediation, diff-candidate, diff-submission, retry, state-authority, and discovery entry point must obtain a fresh real repository inspection and repeat Task 1's Delete protection check through `validate_execution_workspace(...)`; do not trust the baseline or prior result to prove that a path is still outside control metadata. For a v2 remediation return, `program_review.py` must consume Task 2's execution-transition contract and emit the exact v2 binding with `product_result_schema_version`, `product_result_sha256`, and `review_remediation_sha256`; its event seed is the Task 2 v2 seed plus that remediation digest. Its retry/adoption branch must reproduce the full binding, event identifier, previous-state link, transition authority, and renewed assessment before returning recovered. Keep the v1 writer and retry bytes unchanged. @@ -675,6 +831,8 @@ def test_reviewing_delete_can_block_and_resume_only_with_the_same_absence(self) Also cover an implementing-state partial deletion, a post-block extra deletion, changed state order, evidence that claims a missing Delete file digest, exact prompt-bound resume, every failure-injection prefix, and v1 context byte compatibility. +Add a post-block protection swap: after blocking a normal product Delete, alias its ancestor into `.git` and separately into the program root. `validate_blocked_context(...)`, resolution-candidate construction, exact resume submission, state authority, and discovery must fail without reading the protected target or appending a resolution. Retry after a persisted blocked prefix must preserve the prefix. Assert protected sentinel bytes directly because the shared repository snapshot omits `.git`. + - [ ] **Step 2: Run the focused tests and verify RED** ```bash @@ -685,7 +843,7 @@ Expected: blocked context v1 has no path-state binding and Delete is not include - [ ] **Step 3: Implement v2 blocked-context binding** -Build a fresh execution assessment before writing blocked status. For a v2 baseline, bind its exact schema, result digest, and ordered current path states into the block identifier. `validate_blocked_context(...)` must reproduce all three from fresh observation before resolution. Include Delete in `blocked_workspace_paths(...)` but exclude absent Delete targets from regular-file evidence bindings. +Build a fresh execution assessment before writing blocked status. For a v2 baseline, bind its exact schema, result digest, and ordered current path states into the block identifier. `validate_blocked_context(...)` must reproduce all three from a fresh real `inspect_repository(...)` result before resolution; remove the synthetic inspection with empty Git directory/common-directory fields. Include Delete in `blocked_workspace_paths(...)` but exclude absent Delete targets from regular-file evidence bindings. The fresh assessment repeats `validate_delete_target_path(...)` before examining any present or absent Delete state. Keep the existing status-last transaction and exact record adoption. Never recreate, restore, remove, or clean a product path during block or resume. @@ -726,6 +884,7 @@ rtk git commit -m "feat: preserve delete state in recovery" - Produces: `ACCEPTED_STATE_CONTINUATION_SCHEMA_V2 = "implementation-accepted-state-continuation-binding/v2"` and `ContinuationCommandV2`, whose inherited-workspace value embeds the exact accepted product-result schema, digest, and ordered present/absent path states. - Produces: `build_continuation_extension(...) -> ContinuationExtension | ContinuationExtensionV2 | None`, `build_accept_continue_candidate(acceptance, extension: ContinuationExtension | ContinuationExtensionV2 | None) -> DiffAcceptanceCandidate`, and `_build_accepted_state_command(...) -> ContinuationCommand | ContinuationCommandV2`, all selected by exact persisted family rather than nullable-field presence. - Produces: `implementation-successor-authority-projection/v2`, `implementation-increment-rollover/v2`, `implementation-increment-rollover-binding/v2`, and `implementation-inherited-workspace/v2`. +- Produces: each v2 rollover record copies the accepted status's exact review-evidence, review-packet, and diff-disposition bindings, plus `accepted_diff_approval_binding = {event_id, sha256}` and the existing manifest-owned `handoff_binding`; these are immutable closure-chain evidence after the status file advances to the successor. - Produces: accept-and-continue status bindings that retain the exact v1 or v2 diff-disposition family of the accepted stop candidate instead of rewriting v2 acceptance as v1. - Produces: `validated_inherited_path_states(program_root, status, observation) -> tuple[InheritedPathStateV2, ...]` while preserving `validated_inherited_paths(...)` for v1. - Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation, using stable replace-in-place/append ordering rather than lexical resorting. @@ -774,6 +933,10 @@ Assert immediate accept-and-continue and later accepted-state continuation both Add a positive recreation case where a later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 continuation commands and rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. Add an ordering case whose first result has two paths in non-lexical exact-map order and whose second result replaces the first path and adds a new path: the replacement must keep its existing cumulative slot, the untouched state must keep its slot, and the new state must append in current result order. +Assert that every completed v2 rollover record contains byte-reproducible accepted review-evidence/packet bindings, the complete v2 diff-disposition binding, one uniquely matching diff-approval record digest, and the existing handoff path/digest. Delete or tamper with the nonfinal review evidence, review packet, diff approval, or handoff and require `_validated_completed_rollover_records(...)`, state authority, discovery, later rollover, and closure preflight to fail. Do not add a handoff addendum field, filename, writer, or schema. + +Before consuming a current result or cumulative inherited state, replace an allowed Delete target with a symlink/alias into `.git` or the program root, and separately inject `.git/config` or a manifest control path into a v2 rollover record. Immediate continuation, later continuation, rollover retry/adoption, state authority, and discovery must repeat the canonical protection check and stop before action/grant/handoff/status writes. Preserve an allowed product tombstone through the same paths as the positive control. + - [ ] **Step 2: Run focused continuation/rollover tests and verify RED** ```bash @@ -784,7 +947,7 @@ Expected: current continuation coerces `None` to a string and current rollover r - [ ] **Step 3: Implement versioned accepted-result consumption and cumulative merge** -For v2, load the exact product result from current review evidence, freshly reassess it, and compare it with the diff binding before constructing continuation authority. Use a separate dataclass: +For v2, load the exact product result from current review evidence, freshly reassess it with a real repository inspection and the canonical Delete protection context, and compare it with the diff binding before constructing continuation authority. Use a separate dataclass: ```python @dataclass(frozen=True) @@ -840,9 +1003,11 @@ Never pass v2 entries through `ProductDeltaPath(sha256: str)`, `ContinuationExte } ``` -In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding and command rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA` and `DIFF_DISPOSITION_COMMAND_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. +In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding and command rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA` and `DIFF_DISPOSITION_COMMAND_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Before the accepted status is replaced, copy its exact `review_evidence_binding`, `review_packet_binding`, and full v2 `diff_disposition_binding`; find the unique canonical approval record named by `approval_event_id` and bind its canonical JSON-line SHA-256. Retain the existing `handoff_binding` and validate its manifest-derived path and bytes during every completed-chain read. These fields belong only to rollover v2; do not change v1 bytes. + +Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. Call `validate_delete_target_path(...)` for every current or inherited Delete state before merge or filesystem reassessment. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. -`validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review result, diff decision, and cumulative digest. It requires present files to match exact digests and absent files to remain absent. Mixed result families stop before persistence. +`validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review evidence, review packet, diff decision, diff approval, handoff, and cumulative digest. It requires present files to match exact digests and absent files to remain absent after fresh protection validation. Mixed result families or an unresolvable/empty Git protection context stop before persistence. - [ ] **Step 4: Render and parse exact accepted-state continuation families** @@ -852,7 +1017,7 @@ Make `_build_accepted_state_command(...)` return `ContinuationCommand | Continua - [ ] **Step 5: Consume inherited states in successor baselines** -`program_activation.py::_build_plan_candidate(...)` stores validated v2 inherited states in baseline v2 and strips their expected Git dirt from user-work observation. `repository_preparation.py::validate_execution_workspace(...)` validates untouched inherited states throughout the successor. It allows an explicit Create only from inherited absence and Modify/Delete/Preserve only from inherited presence; no current operation means the inherited state must remain exact. +`program_activation.py::_build_plan_candidate(...)` stores validated v2 inherited states in baseline v2 and strips their expected Git dirt from user-work observation. `repository_preparation.py::validate_execution_workspace(...)` validates untouched inherited states throughout the successor after repeating the canonical Delete protection check. It allows an explicit Create only from inherited absence and Modify/Delete/Preserve only from inherited presence; no current operation means the inherited state must remain exact. A path becoming protected is invalid even when its absent/present digest is otherwise unchanged. `state_authority.py` validates exact v1 or v2 rollover/binding pairs and delegates to the matching inherited validator. Do not modify v1 cumulative-path or digest behavior. @@ -890,7 +1055,9 @@ rtk git commit -m "feat: inherit accepted delete tombstones" **Interfaces:** - Produces: `implementation-closure-reconciliation/v2`, `implementation-closure-packet/v2`, `implementation-closure-preparation/v2`, `implementation-program-closure-command/v2`, and `implementation-program-closure-command-binding/v2` for a v2 accepted chain. -- Produces: reconciliation fields `accepted_result_bindings`, `final_inherited_path_states`, and `final_inherited_path_states_sha256`. +- Produces: `AcceptedResultBindingV2(increment_id, product_result_schema_version, product_result_sha256, ordered_path_states, review_evidence_path, review_evidence_sha256, review_packet_path, review_packet_sha256, diff_approval_event_id, diff_approval_sha256, handoff_path, handoff_sha256)`; the final increment alone has both handoff fields `None`. +- Produces: reconciliation fields `accepted_result_bindings`, `final_inherited_path_states`, and `final_inherited_path_states_sha256`; `accepted_artifact_bindings` contains review-evidence/review-packet bindings for every accepted increment and the existing handoff binding for every nonfinal increment, never a v2 handoff addendum. +- Produces: `_accepted_increment_chain_v2(...)` and `_requirement_dispositions_v2(...)` as deterministic internal constructors over the exact rollover chain, final accepted status, immutable setup/traceability allocation, accepted review/diff evidence, and later accepted results. - Produces: exact v2 discovery classification for closure-preparation and closure-approval retry/recovery prefixes. - Consumes test helper: `accepted_three_increment_delete_program() -> ThreeIncrementDeleteFixture`, which extends the Task 5 fixture through accepted `THIRD` state with current review/diff evidence intact. - Preserves: all v1 closure dataclasses, renderers, commands, approvals, and singleton first-increment closure bytes. @@ -910,6 +1077,17 @@ def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> Non ["FIRST", "SECOND", "THIRD"], ) self.assertEqual(len(reconciliation["accepted_result_bindings"]), 3) + self.assertEqual( + { + item["requirement_id"]: item["owner"] + for item in reconciliation["requirement_dispositions"] + }, + { + "REQ-FIRST": "FIRST", + "REQ-DELETE": "SECOND", + "REQ-FINAL": "THIRD", + }, + ) self.assertEqual( next( item @@ -926,7 +1104,13 @@ def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> Non fixture.close() ``` -`accepted_three_increment_delete_program()` must create and accept `legacy.ts` in FIRST under setup/file-map/baseline/result v2 with an empty Delete section, validate it as inherited-present with collision `accepted-predecessor`, accept its Delete in SECOND, and accept an unrelated THIRD increment before closure. Add failures for a missing/reordered/duplicated accepted increment, missing earlier review packet or diff decision, changed result digest, lost tombstone, unexpected reappearance, unowned recreation, stale later-invalidation check, mixed v1/v2 chain, and absent path represented as an evidence file. +`accepted_three_increment_delete_program()` must give the immutable traceability three independently owned requirements: `REQ-FIRST` assigned only to FIRST, `REQ-DELETE` assigned only to SECOND, and `REQ-FINAL` assigned only to THIRD. It creates and accepts `legacy.ts` in FIRST under setup/file-map/baseline/result v2 with an empty Delete section, validates it as inherited-present with collision `accepted-predecessor`, accepts its Delete in SECOND, and accepts an unrelated `final.txt` Create in THIRD before closure. The final plan allocates both manifest-owned closure paths. Assert closure succeeds, attributes the three owners exactly, retains FIRST and SECOND evidence, and reaches closure assertions rather than reporting that earlier-only requirements are unallocated from THIRD. + +Add failures for a missing/reordered/duplicated accepted increment; a traceability allocation absent from the accepted chain; missing/tampered earlier review evidence, review packet, diff decision, diff approval, or nonfinal handoff; changed result digest; lost tombstone; unexpected reappearance; stale later-invalidation check; mixed v1/v2 chain; and absent path represented as an evidence file. A later unrelated `final.txt` result must not invalidate REQ-FIRST or REQ-DELETE. A THIRD recreation/change of `legacy.ts` without assigning REQ-DELETE to THIRD or recording a resolved disposition must invalidate REQ-DELETE and block closure; the same change is valid when THIRD is explicitly added to that requirement's immutable allocation and has accepted review/diff evidence. + +Exercise `current_disposition` values explicitly. `allocated`, `implemented`, and `resolved` produce closure `implemented` only with a complete accepted allocation/evidence chain. `amended` requires a decision reference present in both approved and resolved amendment IDs. `deferred` requires one exact deferral with a non-`none` owner and decision reference. `rejected` and `not-applicable` retain their disposition and first traceability-ordered decision reference. A missing assignment/evidence, unsupported disposition, unmatched amendment, ownerless/mismatched deferral, or unresolved later invalidation increments the blocker and stops before writes; never fabricate `implemented`, final-increment ownership, or `approval_reference="none"` for a satisfied implemented requirement. + +For protection closure coverage, accept a normal product tombstone, then alias its path into `.git` and separately into the program root before preparation and before approval retry. `build_closure_preparation(...)`, state authority, discovery, and command construction must fail before reading the protected target or persisting/adopting closure bytes. Assert Git/control sentinels directly. In `tests/test_program_discovery.py`, interrupt v2 closure preparation after each persisted reconciliation/packet prefix. Require a byte-exact prefix to return `closure-preparation-retry-ready`, packet-without-reconciliation or any changed/reordered v2 path state/digest to return `closure-preparation-recovery-required`, an exact persisted closure approval before status-last completion to return `closure-approval-retry-ready`, and any substituted v1 closure-preparation/command binding or divergent closed status to return `closure-approval-recovery-required`. Assert the same disposition names and bytes remain unchanged for v1. @@ -942,6 +1126,27 @@ Expected: current production closure emits only the final increment and has no c Keep `ClosureReconciliation` and `ClosurePacket` unchanged. Add v2 dataclasses with the three new result fields and exact schema-specific constructors/validators/renderers. Canonical validation requires: +```python +@dataclass(frozen=True) +class AcceptedResultBindingV2: + increment_id: str + product_result_schema_version: str + product_result_sha256: str + ordered_path_states: tuple[Mapping[str, object], ...] + review_evidence_path: str + review_evidence_sha256: str + review_packet_path: str + review_packet_sha256: str + diff_approval_event_id: str + diff_approval_sha256: str + handoff_path: str | None + handoff_sha256: str | None +``` + +The validator requires accepted-result bindings in exact accepted-increment order, one unique review evidence/packet and diff approval for each increment, and a handoff path/digest on every nonfinal binding only. For v2, `accepted_artifact_bindings` must equal the ordered labels/digests `increment:review-evidence`, `increment:review-packet`, then `increment:handoff` for each nonfinal increment. Reject optional or complete `handoff-addendum` coverage in v2. Leave the existing v1 validator, legacy `ContinuityHandoff` fields, fixtures, renderers, and addendum rule byte-for-byte unchanged. + +Canonical final-state validation requires: + ```python expected_state_digest = hashlib.sha256( json.dumps( @@ -955,13 +1160,37 @@ if candidate.final_inherited_path_states_sha256 != expected_state_digest: issues.append("final inherited path-state digest mismatch") ``` -Do not put absent paths in `evidence_paths`; bind their typed result and final-state digest instead. +Do not put absent product paths in `evidence_paths`; bind their typed result and final-state digest instead. Requirement evidence paths are only manifest-owned review evidence, review packets, nonfinal handoffs, and the final reconciliation/closure-packet paths. The reconciliation may name the final closure paths, but it must not include its own or the packet's digest in `accepted_artifact_bindings`: the v2 preparation binding, awaiting/closed status, exact command, and approval bind both finalized digests after construction and avoid a circular hash. - [ ] **Step 4: Build closure from the canonical rollover chain** -In `program_closure.py::build_closure_preparation(...)`, dispatch on the exact setup family paired with the accepted product-result schema. For v2, enumerate `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted increment in order. Bind each increment's exact reviewed result, review packet, diff decision, and required handoff addendum; merge the final current result into validated cumulative inherited states; perform later-invalidation checks across every accepted increment; then construct v2 reconciliation and packet. +In `program_closure.py::build_closure_preparation(...)`, dispatch on the exact setup family paired with the accepted product-result schema. For v2, enumerate Task 5's fully validated `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted status in order. The chain must begin at `setup_semantics.first_increment_id`, be contiguous and duplicate-free, end at status-current, contain every traceability-assigned increment exactly where declared, and leave no allocated successor. Build each `AcceptedResultBindingV2` from the rollover record's copied accepted bindings for nonfinal increments and the final status's live bindings for the final increment. Revalidate the manifest-derived review evidence/packet files, unique diff approval, typed result bytes/digest/order, and each nonfinal manifest-owned handoff path/digest before using them. + +Replace `_traceability_context(traceability, final_increment_id)` on the v2 route with deterministic chain-aware construction: + +```python +accepted_ids = tuple(item.increment_id for item in accepted_results) +for requirement in traceability["atomic_requirements"]: + assigned = tuple(requirement["assigned_increments"]) + assigned_in_chain_order = tuple(item for item in accepted_ids if item in assigned) + if assigned != assigned_in_chain_order or not assigned: + unresolved += 1 + continue + owner = assigned[-1] + contributing = tuple( + item for item in accepted_results if item.increment_id in assigned + ) + later = accepted_results[accepted_ids.index(owner) + 1 :] + invalidated = later_result_invalidates(contributing, later, requirement) +``` + +`later_result_invalidates(...)` compares the canonical path states contributed by assigned increments with every later accepted delta. An unrelated new path is non-invalidating. A later change to a contributed path is invalidating unless that later increment is also assigned to the requirement or the traceability carries a closure-valid amended/deferred/rejected/not-applicable disposition and decision reference. Every later increment must still have a valid requirements-scope review, accepted packet, and diff approval; a material finding whose `affected_requirement_or_invariant` names the requirement must be fully repaired and renewed before it can count as checked. Set `later_invalidation_checked=True` only after all later accepted results pass, and keep `later_invalidation_checks` in exact accepted-chain order. + +For `allocated`, `implemented`, or `resolved`, emit closure `implemented` only after all assigned increments have accepted evidence and no later invalidation; set `owner` to the last assigned accepted increment and `approval_reference` to that owner's diff-approval event. For `amended`, require one traceability-ordered decision reference present in both `approved_amendment_ids` and `resolved_amendment_ids`. For `deferred`, require exactly one matching deferral tuple with a non-`none` owner and a decision reference. Preserve `rejected` and `not-applicable` only with a decision reference. Invalid allocation/evidence, unresolved or mismatched amendments, ownerless deferrals, unsupported dispositions, and unhandled invalidation increment the exact closure blocker and stop before writes. -Version closure preparation, prompt, approval, command, and status bindings together. `state_authority.py::_validate_closure_readiness(...)` recomputes the complete chain and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. In `_load_setup_candidate(...)`, run that exact classifier before full state-authority validation and before generic awaiting-closure/terminal routing. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. +Build each requirement's stable de-duplicated evidence paths in accepted-chain order from the assigned increments' review evidence and review packet, the existing handoff for any assigned nonfinal increment, then the manifest-owned final reconciliation and closure packet. Do not substitute product paths, and do not create a handoff addendum. Merge the final current result into the validated cumulative inherited states only after every path repeats the canonical Delete protection check; then construct v2 reconciliation and packet. + +Version closure preparation, prompt, approval, command, and status bindings together. The v2 preparation/status/command binding includes the ordered accepted-result-binding digest, final inherited-state digest, reconciliation digest, and closure-packet digest. `state_authority.py::_validate_closure_readiness(...)` obtains a fresh real repository inspection, repeats protected Delete validation, and recomputes the complete accepted chain, per-requirement owners/evidence/dispositions/later checks, and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. In `_load_setup_candidate(...)`, run that exact classifier before full state-authority validation and before generic awaiting-closure/terminal routing. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. - [ ] **Step 5: Run closure tests and verify GREEN** @@ -1092,11 +1321,18 @@ def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: [item["path"] for item in closure["final_inherited_path_states"] if item["final_state"] == "absent"], list(delete_paths), ) + self.assertEqual( + { + item["requirement_id"]: item["owner"] + for item in closure["requirement_dispositions"] + }, + fixture.expected_requirement_owners, + ) finally: fixture.close() ``` -The proposal contains two exact allocations for the characterization path: Create in the predecessor with absent/none/`None`/none facts, and Delete in the later increment with regular-file/none/`100644`/`accepted-predecessor` facts. The other 26 Delete allocations use collision `existing`. The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section, create the characterization file, reach exact accepted status, and rollover it as inherited-present before the Delete plan is prepared; discovery must return `accepted-stop` at that boundary. The Delete plan then lists all 27 paths in frozen order and its baseline validates the two collision classes separately. The unrelated successor also uses v2 with an empty Delete section. +The proposal contains two exact allocations for the characterization path: Create in the predecessor with absent/none/`None`/none facts, and Delete in the later increment with regular-file/none/`100644`/`accepted-predecessor` facts. The other 26 Delete allocations use collision `existing`. Give the predecessor creation requirement, Delete requirement, and unrelated final requirement distinct traceability ownership so the replay proves PLUG-002's earlier-increment closure semantics. The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section, create the characterization file, reach exact accepted status, and rollover it as inherited-present before the Delete plan is prepared; discovery must return `accepted-stop` at that boundary. The Delete plan then lists all 27 paths in frozen order and its baseline validates the two collision classes separately. The unrelated successor also uses v2 with an empty Delete section, allocates the final closure artifacts, and contributes only an unrelated product result. Closure must reach its intended assertions with the earlier requirements attributed to their actual accepted increments, not fail because they are absent from the final increment's allocation. Add negatives that pre-create the characterization path before its Create baseline, omit one of the other 26 paths before the Delete baseline, or declare the characterization Delete collision as `existing`; each must fail the production allocation-fact check before the corresponding plan/baseline write. Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. @@ -1132,11 +1368,11 @@ destructive-operation, cleanup, migration, Git, publication, deployment, or external-state authority. ``` -Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. Update `references/program-discovery.md` to make its existing prefix-before-generic-rejection rule explicit for both setup/envelope families and to enumerate exact setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure retry/recovery routes. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. +Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. Document the canonical Delete protection boundary: lexical/resolved `.git`, Git directory/common directory in normal and linked worktrees, conventional/actual program roots, and manifest-owned control paths are never Delete targets, while exact ordinary product paths remain allowed. Update `references/program-discovery.md` to make its existing prefix-before-generic-rejection rule explicit for both setup/envelope families and to enumerate exact setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure retry/recovery routes. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. In the two version-owning design specs, `references/state-authorization.md`, `references/program-discovery.md`, and the live runbook, document `implementation-execution-transition/v2` as the setup-v2 companion to baseline/result v2: list its exact product-result fields, canonical ordered-state digest, family-specific event seed, conditional remediation-digest extension, retry/adoption checks, fresh-discovery route, and cross-family rejection. Preserve the documented v1 `product_delta_sha256` shape and byte contract. -Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain and final cumulative digest. Do not rewrite older dated design plans; they remain historical version-bound records. +Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain, real per-requirement owners/evidence/later checks, existing nonfinal handoffs, and final cumulative digest. State explicitly that v2 creates no handoff addendum; the legacy continuity addendum contract remains v1-only. Do not rewrite older dated design plans; they remain historical version-bound records. - [ ] **Step 5: Synchronize package version `0.1.3`** @@ -1212,7 +1448,7 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla - A failure after a planned Delete while status is `implementing` preserves the absence as a valid partial product result. Recovery may block and resume from the exact bound absence; it does not restore automatically. - A failure after review or diff acceptance must reproduce the same ordered path states and digest. Reappearance, changed content, missing result records, reordered states, or mixed schema families is divergent and stops without cleanup. - Rollover merges a Delete tombstone only after exact diff acceptance. An unrelated successor cannot erase it; only a later exact Create operation whose baseline agrees with inherited absence can replace it. -- Closure binds the entire accepted chain and final cumulative state. It cannot close when a tombstone disappeared, a deleted file reappeared, an earlier accepted result is missing, or evidence treats an absent path as a file. +- Closure binds the entire accepted chain, per-requirement allocation/owner/evidence/disposition, every later-invalidation check, existing nonfinal handoffs, and final cumulative state. It cannot close when a tombstone disappeared, a deleted file reappeared, an earlier accepted artifact is missing, an earlier-only requirement is falsely assigned to the final increment, a later result invalidates earlier evidence without a resolved disposition, or evidence treats an absent path as a file. - Recovery bytes are not created by this repair. Source recovery remains a separately authorized manual or Git operation; the plugin never resets, restores, stashes, cleans, or deletes automatically. ## Final Validation Matrix @@ -1222,6 +1458,7 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Locked implementation baseline | Git preflight | candidate `b5eb689e...` is an ancestor of the clean kickoff HEAD on `repair/delete-operation-support`; every candidate-to-HEAD commit and aggregate path is only this plan; actual kickoff HEAD and plan SHA-256 are recorded | stop before edits | | Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | | Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | +| Protected targets are mechanically excluded | `build_delete_protection_context(...)`, `validate_delete_target_path(...)` | normal and linked-worktree tests reject lexical/resolved `.git`, Git directory/common directory, conventional/actual program roots, every manifest control path, symlink/case/hard-link aliases, retries, and post-authorization swaps; allowed product controls pass | missing protection metadata, control-path Delete, alias escape, protected read, or substring over-rejection | | Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | | Exact plan does not misclassify Delete | `repository_preparation.py` | unversioned heading fails; v2 parses ordered Delete section | Delete absorbed as Modify | | Late Delete uses one program family | setup/activation/preparation/rollover | `test/legacy/characterization.test.ts` begins absent, is created/accepted by a setup-v2 predecessor with an empty Delete section, becomes inherited-present with collision `accepted-predecessor`, then is deleted with the 26 initially existing targets | pre-created fixture, false collision facts, or mixed v1/v2 rollover/closure | @@ -1229,15 +1466,15 @@ Report commits, exact changed paths, focused/full check evidence, scenario repla | Ancestor safety is reassessed | `inspect_workspace_path(...)`, operation callers | baseline symlinked ancestor and post-authorization ancestor swap fail before external reads; missing suffix remains valid only for Create, accepted/inherited absence, and already-absent user work | path escape, rejected valid absence, or missing required Delete/Modify/Preserve target | | Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest and exact-map ordering | accidental loss, fabricated digest, or reordered state | | Execution transition matches result family | `program_activation.py`, `program_review.py`, `state_authority.py`, `program_discovery.py` | v1 keeps `product_delta_sha256`; v2 uses `implementation-execution-transition/v2` with exact product-result schema/digest and derived event; production writer output survives fresh discovery and exact retry | mixed/dual family, changed seed or digest, invalid adoption, generic discovery route, or v1 byte drift | -| Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve | Delete accepted for a control path | +| Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve and every Delete control-path collision is rejected independently | Delete accepted for a control path | | Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | | Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | | Discovery resumes v2 safely | `program_discovery.py` | setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure exact prefixes classify before generic state validation; divergent prefixes use their domain recovery routes | v1-only gate, invalid/generic route, wrong resume, or terminal route | | Blocked recovery freezes path state | `blocked_recovery.py` | v2 context reproduces exact partial/complete states | post-block change or evidence fabrication | -| Rollover preserves ordered tombstones | `program_rollover.py` | accepted predecessor before Delete; replace-in-place/append merge retains absent state through unrelated work | reappearance, omission, reorder, or mixed chain | +| Rollover preserves ordered tombstones and evidence | `program_rollover.py` | accepted predecessor before Delete; replace-in-place/append merge retains absent state; v2 record binds accepted review evidence/packet, diff decision/approval, and existing handoff | reappearance, omission, reorder, missing/tampered evidence or handoff, invented addendum, or mixed chain | | Accepted-state prompts preserve typed results | `program_continuation.py` | exact v1/v2 command dispatch and parsing; v2 embeds ordered path states with null absent digest; v1 golden bytes stay exact | cross-family prompt, `str(None)`, or v1 byte drift | | Recreation is explicit | activation/preparation | later Create owns inherited absent path and baseline agrees | implicit recreation or wrong operation | -| Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff decisions plus final cumulative digest | singleton-only or lost tombstone | +| Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff approvals/handoffs plus real traceability owners, evidence paths, disposition handling, later-invalidation checks, final closure bindings, and cumulative digest | singleton-only, final-owner fabrication, unresolved allocation/disposition, invalidated evidence, invented addendum, or lost tombstone | | Front door does not over-authorize | skill/references/docs | Delete remains local plan-bound `modify-workspace` only | generic destructive/external claim | | Operational runbook is current | bootstrap/execution/review runbook | `0.1.3` path states, discovery, and complete-chain closure match canonical owners | live runbook remains at `0.1.2` | | Package is synchronized | manifests/validator/docs | every owner says `0.1.3`; package validation exits `0` | version or inventory mismatch | From 0b0ac191fbe689ab3b2a599063e72de0e028c75f Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sun, 6 Sep 2026 02:00:54 -0400 Subject: [PATCH 07/19] docs: narrow delete support plan to PLUG-001 --- .../2026-09-05-delete-operation-support.md | 1536 +++-------------- 1 file changed, 233 insertions(+), 1303 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index c23aa80..3afa963 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -1,247 +1,101 @@ -# Delete Operation Support Implementation Plan +# PLUG-001 Typed Delete Support Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For implementers:** Execute task by task with strict RED-GREEN sequencing and preserve all legacy bytes. -**Goal:** Add truthful, fail-closed support for exact regular-file `Delete` operations so a program can preserve an accepted absent path through review, diff acceptance, rollover, recovery, and closure without weakening existing `Create`, `Modify`, or `Preserve` contracts. +**Goal:** Add truthful, fail-closed support for an exact regular-file `Delete` operation from setup through accepted result, successor rollover, and fresh discovery. -**Architecture:** Keep manifest/status v1, v2, and existing manifest-v3/setup-v1 programs on their exact current routes. A manifest-v3 program that selects setup-semantics/envelope v2 enters one nested v2 lifecycle family at sequence zero: every increment uses file-map, baseline, product-result, execution-transition, review, diff, blocked-context, rollover, discovery, and closure v2, including an empty ordered Delete section before the increment that first deletes a file. One canonical fail-closed Delete-target validator protects Git metadata, program roots, and manifest-owned control paths at allocation and every later reassessment. Complete-chain closure derives requirement ownership from traceability plus accepted per-increment evidence, validates later accepted deltas, and reuses existing nonfinal handoffs and the final manifest-owned closure artifacts instead of inventing an addendum. Route by exact schema pairs, never by optional-field presence or by whether the current increment happens to contain Delete. The workflow continues to authorize a human or agent to modify the bound local workspace—it does not become an automatic deletion engine, migration engine, cleanup command, or generic destructive-action authority. +**Boundary:** PLUG-001 owns Delete setup, activation, exact-plan parsing, baseline and execution validation, protected-path enforcement, the actual bound-file deletion primitive, 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, or complete-chain closure. -**Tech Stack:** Python 3 standard library, frozen dataclasses, canonical JSON and SHA-256, `unittest`, temporary Git repositories, existing atomic/no-overwrite/status-last writers. +**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. The PLUG-001 replay ends after the Delete result is accepted, rolled into a successor, and rediscovered as resumable. Do not add closure fields, requirement-result schemas, path-overlap heuristics, or fabricated ownership to make this plan appear terminal. -**Spec:** The real consumer is `/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md` at SHA-256 `a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b`; the compatible broader design context is `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` and `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md`. +**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. -## Global Constraints - -- The implementation candidate is exactly `b5eb689e780f48b218b807a4691f0994474e4178`. Start implementation only when that candidate is an ancestor of the clean kickoff HEAD on branch `repair/delete-operation-support`, every commit in `b5eb689e780f48b218b807a4691f0994474e4178..HEAD` changes only `docs/superpowers/plans/2026-09-05-delete-operation-support.md`, and the aggregate candidate-to-HEAD diff contains only that plan. Record the actual kickoff HEAD and the plan's actual SHA-256 as execution evidence before Task 1; do not use any correction commit's parent position or hash as a durable prerequisite. -- Use `rtk` for every repository command. -- Preserve manifest/status v1 and v2 and operation-envelope/setup/file-map/baseline/result/execution-transition/rollover/blocked/closure v1 bytes and behavior; do not rewrite persisted programs or frozen `0.1.1` fixtures. -- Existing manifest-v3 programs with `implementation-program-setup-semantics/v1` and `implementation-operation-envelope/v1` remain exactly `Create`/`Modify`/`Preserve` programs. -- Delete-capable manifest-v3 proposals use `implementation-program-setup-semantics/v2` paired with `implementation-operation-envelope/v2`; that setup choice fixes the complete program to the nested v2 lifecycle family from its first increment, and mixed v1/v2 nested contracts fail before every write. -- A `Delete` target must be one normalized repository-relative path that is a program-owned regular non-symlink, non-hard-linked file beneath the selected workspace when its deletion-increment baseline is captured. Setup may bind either an initially `existing` target or an `accepted-predecessor` target created and accepted by a strict predecessor increment; the latter requires a same-path predecessor `Create` allocation. Directories, symlinks, symlinked ancestors, hard links, special files, absent Delete baselines, external paths, protected paths, and pre-existing user work remain unsupported. -- Every Delete allocation and reassessment must pass the same fail-closed protection context. Reject a lexical `.git` component; an existing path or ancestor that resolves to the worktree `.git` entry, Git directory, or Git common directory; the conventional `implementation-programs` root; the actual or intended manifest program root; and every manifest-resolved logical-role, increment-storage, or closure-storage control path. Use `RepositoryInspection.git_directory` and `.git_common_directory` for normal and linked worktrees, filesystem identity for existing aliases, and exact component boundaries so `.github`, `.gitignore`, and ordinary product names containing `git` remain allowed. Missing, empty, stale, or unresolvable protection metadata is an error, never permission. -- Baseline capture and every later reassessment must repeat one shared component-by-component `lstat` walk and workspace-containment proof. Reject any unsafe existing component or containment escape, but return an absent snapshot after the first missing component so a caller may permit an absent suffix for Create, accepted Delete tombstones, inherited absence, or already-absent user work. Delete/Modify/Preserve baseline callers and every present-state caller separately require the ancestors and final regular file their operation needs; no earlier safe observation authorizes a later swapped ancestor. -- `Delete` means the approved final state is absent. Never encode absence as `Modify`, `Preserve`, an omitted path, an empty digest, or a fabricated digest. -- `authorized` requires every Delete target to remain byte-identical to its baseline; `implementing` permits either the exact baseline file or its absence; `reviewing` and later require absence. A changed-but-present Delete target is always invalid. -- A typed local Delete remains within the exact plan-bound `modify-workspace` action. It does not grant the separately named `destructive-operation`, cleanup, migration, Git, publication, deployment, provider, or external-state actions. -- Keep public `prepare_exact_plan(program_root, exact_plan_bytes, observation)`, `materialize_exact_plan(program_root, submitted_plan_prompt, observation)`, and `required_future_lifecycle_writes(program_root, workspace_root, increment_id)` signatures unchanged. -- Keep deterministic candidate construction, exact-prefix adoption, atomic compare-and-swap, no-overwrite publication, immutable ledgers, and status-last ordering at every existing transaction boundary. -- V2 current results use operation-section order followed by exact file-map order; cumulative v2 states replace an already-owned path in place and append newly owned paths in current-result order. Preserve every v1 lexical ordering rule and byte sequence. -- Add no dependency, generic operation framework, automatic restore, staging engine, Move/Rename, Replace, directory deletion, progress cursor, or v4/v5 manifest/status implementation. -- Release the coherent implementation as package version `0.1.3`; synchronize only the existing version owners. -- Run the full deterministic suite once after the coherent implementation batch. Focused RED/GREEN commands may run per task. -- Do not push, open a pull request, install the plugin, synchronize a cached copy, mutate the pipeFlow worktree, or perform any external action under this plan. - ---- - -## Confirmed Root Cause and Scope Decision - -The defect is confirmed at the locked baseline: - -1. `program_setup.py::SUPPORTED_OPERATIONS` is exactly `("Create", "Modify", "Preserve")`; `validate_setup_semantics(...)` rejects both a v1 envelope listing `Delete` and every allocation whose operation is `Delete`. A real probe returns `operation allocation 0 operation is unsupported` and `operation envelope must support exactly Create/Modify/Preserve`. -2. `repository_preparation.py::parse_exact_file_map(...)` recognizes only `Create`, `Modify`, and `Preserve`. Worse, an unversioned `### Delete` heading is currently ignored and its bullet is absorbed into the preceding `Modify` section. The repair must make this legacy input fail explicitly before adding the versioned v2 route. -3. `program_activation.py::_path_baselines(...)` and `repository_preparation.py::validate_execution_workspace(...)` require every `Modify` path to remain a file. The focused baseline test confirms deletion is rejected as `execution workspace deleted Modify path: `. -4. The accepted product-delta and rollover contracts require a string `sha256` for every result, so they cannot represent a legitimate absent path. `program_rollover.py::_validated_inherited_paths(...)` also requires every inherited path to remain a regular file with the accepted digest. -5. The pipeFlow Task 8 file map contains 27 explicit regular-file Delete paths. Omitting them would make the exact plan incomplete and make their Git deletions unmapped product changes; relabeling them `Modify` would preserve the existing, correct missing-Modify failure. -6. `program_activation.py::_build_v3_setup_record(...)` imports and writes only `SETUP_ACTIVATION_SCHEMA` (`setup-activation-decision/v1`), so a setup-v2 activation cannot be a truthful Task 1 GREEN until that writer and both authority validators dispatch together. -7. The consumer rescan found additional hard-coded v1 edges. `program_discovery.py::_exact_closure_prefix_disposition(...)` requires diff-disposition and closure-preparation v1; its manifest-v3 loader validates full state authority before inspecting plan, review, acceptance, closure, or rollover prefixes and otherwise falls through to generic routes. `program_continuation.py::build_accept_continue_candidate(...)` rewrites its acceptance binding with `DIFF_DISPOSITION_BINDING_SCHEMA` v1, while its accepted-state command, parser, and embedded product values are fixed to `implementation-accepted-state-continuation-binding/v1` and `ProductDeltaPath(sha256: str)`. All must dispatch on exact families for v2 retry, recovery, and continuation to work. -8. Current product deltas and inherited paths are lexically sorted, while a typed v2 result needs one specified order. V2 therefore requires operation-section/exact-map result order and stable cumulative replace-in-place/append semantics while leaving v1 sorting unchanged. -9. Current activation and workspace assessment check the final `Path` with `is_symlink()`/`is_file()` but do not share a component walk. A safe final file beneath a later-swapped symlink ancestor can therefore evade the intended workspace-bound path contract. The shared walk must still preserve the current valid absence semantics for a Create target whose parent is not created yet and for already-absent user work; operation callers, not the primitive walk, own required-presence rules. -10. The real pipeFlow lifecycle does not begin with all 27 Task 8 Delete targets. `test/legacy/characterization.test.ts` is absent at setup, created and accepted in Task 1, inherited as present, and deleted with the other 26 legacy files in Task 8. A fixture that pre-creates all 27 paths does not exercise future Delete allocation, predecessor collision facts, or a real late tombstone. -11. `implementing-staged-plans-bootstrap-execution-review-runbook.md` declares itself the Plan A `0.1.1` plus Plan B `0.1.2` boundary and documents singleton/final-only closure. It is a live operational runbook, so `0.1.3` path states and complete-chain closure must update it rather than reclassifying it as historical. -12. `program_activation.py::advance_execution_state(...)` writes and retry-adopts only `implementation-execution-transition/v1` with `product_delta_sha256`, while `state_authority.py` accepts only that v1 shape and derives the event identifier from that v1 digest field. A setup-v2 writer output therefore has no exact execution-transition schema, result-family binding, event seed, state-authority route, or discovery retry/recovery contract even though Task 2 claims a complete v2 baseline/result family. -13. `_safe_relative_path(...)` and exact-file-map normalization accept `.git/config`. In a normal checkout that is a regular file; in a linked worktree `.git` itself is a regular gitfile. `RepositoryInspection` already records the resolved Git directory and common directory, but blocked recovery and state authority currently synthesize inspections with both fields empty, while the shared test snapshot intentionally skips `.git`. Path-shape validation and snapshot equality therefore cannot enforce the protected boundary. -14. `program_closure.py::_traceability_context(...)` counts every requirement not assigned to the final increment as unresolved, then fabricates every disposition as `implemented` with the final increment as owner and no accepted evidence. Task 6 also names a required `handoff addendum`, but the manifest increment storage and production rollover transaction create only a review packet, handoff, successor brief, and bound rollover record; the addendum belongs only to the preserved legacy continuity model and has no new-model writer or storage role. - -The smallest coherent repair is therefore a versioned Delete-only path-state extension inside manifest-v3. The pending manifest/status-v4 expanded-operations design remains pending for Move/Rename, Replace, migration groups, automated staging/finalization, and expanded Preserve; this repair does not claim to implement it. - -Unsafe alternatives are rejected: - -- **Encode Delete as Modify:** destroys the invariant that every Modify result is present and causes the confirmed missing-Modify failure. -- **Omit deleted paths:** removes them from setup authority, exact-plan ownership, review surfaces, accepted results, rollover inheritance, and closure evidence; it also turns the Git deletion into an unmapped dirty path. -- **Use Preserve:** contradicts both the requested outcome and Preserve's byte-identical present-state contract. -- **Store `""`, zeroes, or `str(None)` as a digest:** fabricates an identity for an absent file and lets existing string-only consumers confuse absence with content. -- **Loosen v1 validators:** reinterprets accepted manifests and fixtures in place and can turn accidental file loss into a valid legacy result. -- **Implement the entire pending expanded-operations engine:** adds unrelated Move/Rename, Replace, staging, leases, cleanup, and migration-group machinery without solving a current requirement that needs only exact regular-file removal and durable tombstones. - -## File Map - -### Create - -- `tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json` — frozen 27-path real-scenario inventory and authoritative source digest. -- `tests/test_delete_operation_lifecycle.py` — one causal proposal-to-closure application-path replay plus the optional live source-identity check. - -### Modify - -- `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` — record the narrow v3 nested-v2 Delete repair between setup v3 and the still-pending expanded v4 design. -- `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md` — state that basic exact regular-file Delete is owned by `0.1.3`, while advanced migration/staging semantics remain pending v4 work. -- `skills/implementing-staged-plans/scripts/program_setup.py` — own setup-semantics/envelope v2 validation, pairing, and recap rendering. -- `skills/implementing-staged-plans/scripts/program_authority.py` — recognize only the exact new setup authority schemas on manifest-v3 and reject cross-family substitution. -- `skills/implementing-staged-plans/scripts/state_authority.py` — own shared versioned file-map types, exact nested-schema routing, execution-transition/result-family bindings, state bindings, and v1 compatibility rejection. -- `skills/implementing-staged-plans/scripts/repository_preparation.py` — own the canonical Git/program/control protection context, parse exact-file-map v2, parse baseline v2, and assess present/absent path states. -- `skills/implementing-staged-plans/scripts/program_activation.py` — construct Delete-aware plan candidates/baselines and bind v2 execution transitions without changing public signatures. -- `skills/implementing-staged-plans/scripts/program_discovery.py` — route manifest-v3/setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, closure, and divergent prefixes by exact schema family before generic state validation. -- `skills/implementing-staged-plans/scripts/execution_discipline.py` — validate deleted ownership and semantic surfaces without treating Delete as a physical rename. -- `skills/implementing-staged-plans/scripts/review_coordination.py` — carry and validate the v2 accepted path-state result in review evidence and packets. -- `skills/implementing-staged-plans/scripts/program_review.py` — persist/revalidate Delete-aware review and remediation bindings, including the v2 remediating-to-reviewing execution transition. -- `skills/implementing-staged-plans/scripts/diff_disposition.py` — bind the exact reviewed v2 product result during acceptance. -- `skills/implementing-staged-plans/scripts/blocked_recovery.py` — freeze and revalidate Delete path states across blocked/resume. -- `skills/implementing-staged-plans/scripts/program_continuation.py` — consume accepted present/absent results and render/parse exact accepted-state-continuation v1/v2 commands without coercing absence to a string digest. -- `skills/implementing-staged-plans/scripts/program_rollover.py` — persist v2 rollover records with accepted review/diff/handoff bindings and cumulative inherited present/absent path states. -- `skills/implementing-staged-plans/scripts/continuity_closure.py` — validate/render versioned closure reconciliation over accepted result bindings and cumulative path states. -- `skills/implementing-staged-plans/scripts/program_closure.py` — build closure from complete accepted-increment evidence, traceability-owned requirement dispositions, later-invalidation checks, and final cumulative state. -- `skills/implementing-staged-plans/scripts/validate_package.py` — set and enforce package version `0.1.3`. -- `skills/implementing-staged-plans/SKILL.md` — route and explain the Delete-capable nested v2 family. -- `skills/implementing-staged-plans/agents/openai.yaml` — describe exact local Delete support without implying generic destructive authority. -- `skills/implementing-staged-plans/references/program-authority.md` — document v1/v2 setup pairing and authority limits. -- `skills/implementing-staged-plans/references/program-discovery.md` — document prefix-first exact v1/v2 discovery, retry, and recovery classification. -- `skills/implementing-staged-plans/references/repository-preparation.md` — own the v2 file-map grammar and baseline path-state rules. -- `skills/implementing-staged-plans/references/execution-discipline.md` — own lifecycle-state behavior for Delete. -- `skills/implementing-staged-plans/references/review-coordination.md` — own review/remediation result binding. -- `skills/implementing-staged-plans/references/state-authorization.md` — own acceptance and rollover version routing. -- `skills/implementing-staged-plans/references/continuity-closure.md` — own cumulative tombstone and closure rules. -- `docs/reference.md`, `docs/workflows.md`, `docs/troubleshooting.md`, `docs/maintainers.md`, `docs/installation.md` — synchronize the user-visible `0.1.3` contract, failure messages, and installation examples. -- `implementing-staged-plans-bootstrap-execution-review-runbook.md` — extend the live bootstrap/execution/review runbook through the `0.1.3` path-state, discovery, rollover, and complete-chain closure contract. -- `.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` — synchronize only the package version. -- `tests/program_bootstrap_support.py` — construct exact v1 and Delete-capable v2 setup fixtures. -- `tests/test_program_setup.py`, `tests/test_program_authority.py`, `tests/test_program_bootstrap.py` — setup, authority, recap, publication, and v1 compatibility coverage. -- `tests/test_repository_preparation.py`, `tests/test_program_activation.py`, `tests/test_approval_checkpoint.py` — parser, baseline, exact-plan, and execution assessment coverage. -- `tests/test_execution_discipline.py`, `tests/test_review_coordination.py`, `tests/test_program_review.py`, `tests/test_diff_disposition.py` — review/diff path-state coverage. -- `tests/test_blocked_recovery.py`, `tests/test_program_discovery.py`, `tests/test_state_authority.py` — recovery and schema-routing coverage. -- `tests/test_program_continuation.py`, `tests/test_program_rollover.py`, `tests/test_multi_increment_lifecycle.py` — cumulative present/absent inheritance coverage. -- `tests/test_continuity_closure.py`, `tests/test_program_closure.py` — complete-chain closure coverage. -- `tests/test_front_door_contract.py`, `tests/test_distribution_documentation.py`, `tests/test_package_validation.py` — contract, documentation, and version synchronization. - -### Preserve - -- `docs/superpowers/plans/2026-09-05-delete-operation-support.md` — use as the locked implementation plan; do not rewrite it while executing the tasks. -- `implementation-programs/ISP-001/**` — historical accepted program/control-plane evidence is not part of this repair. -- `tests/fixtures/program-bootstrap/v0.1.1/**` — frozen compatibility fixtures remain byte-for-byte unchanged. -- `skills/implementing-staged-plans/scripts/program_bootstrap.py`, `program_launch.py`, `approval_checkpoint.py`, and `task_prompt.py` — exercise their existing generic routes in tests; change them only if a focused RED test proves an exact-schema integration defect. -- `/Users/CoveMB/Code/CoveMB/implementation-plugin/**` and `/private/tmp/pipeflow-effect-flow.4Ox4Wl/**` — read-only/out of scope throughout implementation. +**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 Preflight +## Implementation Kickoff Gate -Before Task 1, record and require all of the following without changing the tree: +Before implementation: ```bash +rtk git branch --show-current rtk git status --short --branch -rtk git rev-parse HEAD -rtk sha256sum docs/superpowers/plans/2026-09-05-delete-operation-support.md rtk git merge-base --is-ancestor b5eb689e780f48b218b807a4691f0994474e4178 HEAD -rtk git log --reverse --format='commit %H parents %P' --name-only b5eb689e780f48b218b807a4691f0994474e4178..HEAD -rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178..HEAD -rtk git diff --check 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 ``` -Expected: the branch is `repair/delete-operation-support` and clean; record the exact `rev-parse HEAD` and plan SHA-256 stdout as the immutable kickoff evidence for this execution; `merge-base --is-ancestor` exits `0`; every path printed beneath every commit in the candidate-to-kickoff log is exactly `docs/superpowers/plans/2026-09-05-delete-operation-support.md`; the aggregate diff prints that one path; and `diff --check` is empty. Stop before implementation if the candidate is not an ancestor, any commit or aggregate diff contains a non-plan path, the tree is dirty, the recorded kickoff HEAD is not an ancestor of a later implementation HEAD, or the plan no longer reproduces the recorded kickoff SHA-256. +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. ---- - -### Task 1: Version the Setup-Level Delete Contract - -**Files:** -- Modify: `skills/implementing-staged-plans/scripts/program_setup.py` -- Modify: `skills/implementing-staged-plans/scripts/program_authority.py` -- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` -- Modify: `skills/implementing-staged-plans/scripts/program_activation.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` -- Test: `tests/test_program_setup.py` -- Test: `tests/test_program_authority.py` -- Test: `tests/test_repository_preparation.py` -- Test: `tests/test_program_bootstrap.py` -- Test: `tests/test_program_activation.py` -- Test: `tests/test_program_discovery.py` -- Test: `tests/test_state_authority.py` - -**Interfaces:** -- Consumes: manifest-v3 `setup_semantics` and the existing immutable setup decision flow. -- Produces: `SETUP_SEMANTICS_SCHEMA_V2`, `OPERATION_ENVELOPE_SCHEMA_V2`, `SETUP_RECAP_SCHEMA_V2`, `SETUP_RECAP_CHECKPOINT_SCHEMA_V2`, `SETUP_DECISION_ADAPTER_SCHEMA_V2`, and `SETUP_ACTIVATION_SCHEMA_V2`. -- Produces: `_operation_contract(semantics: Mapping[str, object]) -> tuple[tuple[str, ...], bool]`, returning the exact supported-operation tuple and whether Delete fields are required. -- Produces: `DeleteProtectionContext`, `build_delete_protection_context(workspace_root: Path, manifest_program_root: Path, manifest: Mapping[str, object], inspection: RepositoryInspection) -> DeleteProtectionContext`, and `validate_delete_target_path(context: DeleteProtectionContext, relative_path: str) -> None` as the only Delete protection policy used by setup and later tasks. -- Produces test helpers: `BootstrapFixture.configure_delete_setup_v2(allocation: Mapping[str, object]) -> dict[str, object]`, `configure_v1_envelope_with_delete() -> list[str]`, and `configure_mixed_setup_versions() -> list[str]`; each recomputes the semantic digest after its exact mutation. -- Produces: recap, checkpoint, decision, activation-record, program-authority, and state-authority dispatch selected from the exact setup/envelope family before any activation record is written. -- Produces: manifest-v3/setup-v2 sequence-zero activation-prefix discovery that returns `program-activation-retry-ready` for every byte-exact incomplete prefix and `program-activation-recovery-required` for every started mixed, out-of-order, or divergent prefix before generic invalid-state routing. -- Preserves: every v1 setup/envelope/recap/decision/activation byte and error route. +The plan deliberately does not embed its own digest. Do not infer the expected digest from a parent commit, earlier review, or this prose. -- [ ] **Step 1: Write failing setup and authority tests** +## Stable Constraints -Add these test cases with a helper that rewrites the candidate before recomputing `setup_semantics_sha256`: +- 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 with `sha256: null`; 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 path check followed by hashing or unlinking reopens a race; Delete needs one descriptor-relative identity flow through mutation. +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. -```python -def delete_allocation( - path: str, - increment_id: str, - *, - collision: str = "existing", -) -> dict[str, object]: - return { - "kind": "exact-path", - "path": path, - "operation": "Delete", - "increment_ids": [increment_id], - "inclusions": ["legacy implementation removal"], - "exclusions": ["directories", "user-owned work"], - "ownership": "program", - "protected": False, - "user_work": False, - "file_kind": "regular-file", - "link_kind": "none", - "mode": "100644", - "collision": collision, - "accepted_state": "absent", - "content_disposition": "obsolete", - "rationale": "The approved replacement implementation makes this file obsolete.", - } - -def test_setup_v2_accepts_and_renders_delete(self) -> None: - manifest = self.fixture.configure_delete_setup_v2( - delete_allocation("legacy.ts", "ARCHIVE-INDEX") - ) - self.assertEqual(SETUP.validate_setup_semantics(self.fixture.candidate), []) - recap = SETUP.render_setup_recap(self.fixture.candidate) - self.assertIn("Supported operations: Create, Modify, Delete, Preserve.", recap) - self.assertIn("Delete legacy.ts", recap) - self.assertIn("final state: absent", recap) - self.assertIn("content: obsolete", recap) - self.assertIn("makes this file obsolete", recap) - self.assertEqual( - manifest["setup_semantics"]["schema_version"], - "implementation-program-setup-semantics/v2", - ) - -def test_v1_and_mixed_setup_contracts_reject_delete(self) -> None: - issues = self.fixture.configure_v1_envelope_with_delete() - self.assertIn("operation allocation 0 operation is unsupported", issues) - self.assertIn("operation envelope must support exactly Create/Modify/Preserve", issues) - self.assertIn( - "setup semantics and operation envelope schema families do not match", - self.fixture.configure_mixed_setup_versions(), - ) -``` +## File Map -Also assert proposal validation, publication, recap checkpoint, and setup decision accept the all-v2 nested family and reject a substituted v1 record or v2 record in a v1 setup. Add a two-increment allocation for one initially absent exact path: `Create` in the first increment with facts `absent`/`none`/`None`/`none`, then `Delete` in its strict successor with facts `regular-file`/`none`/`100644`/`accepted-predecessor`. Require one same-path Create allocation in a transitive predecessor for `accepted-predecessor`, and reject an unrelated, same-increment, later, or absent predecessor allocation; the distinct operations are not a duplicate allocation. Keep an initially present Delete allocation on collision `existing` and reject any other collision/fact combination. +### Create -Before accepting either Delete allocation form, exercise the production protection validator in a normal temporary checkout and a real linked worktree. Reject `.git`, `.git/config`, a symlink or case/alias resolving into `.git`, the resolved `git_directory`, the resolved `git_common_directory`, `implementation-programs/**`, the intended `implementation-programs/` publication target, an instruction-declared actual program root, `manifest.json`, every logical-role file, and the increment/closure storage roots even when the allocation says `protected: false`. In the linked worktree specifically prove that the regular `.git` gitfile is rejected. If the platform cannot create a case alias or hard link, skip only that alias variant and retain the symlink and linked-worktree cases. Positive controls must accept ordinary product files such as `.github/legacy.yml`, `.gitignore.backup`, and `src/legitimate-config.ts`; component matching must not become a substring ban. +- `tests/test_delete_operation_lifecycle.py` — production-writer replay through accepted Delete rollover and discovery. -Drive `program_activation.py::activate_program(...)` through the real sequence-zero transaction and assert that it writes `setup-activation-decision/v2`, not `setup-activation-decision/v1`, before the status-last transition; substitute either activation schema across families and require both program and state authority to fail closed. After each byte-exact v2 activation prefix, run discovery and require the existing `program-activation-retry-ready` route. For each started-prefix record class—setup activation decision, required source-gate decision, program approval, and workspace approval—change one bound field, substitute the opposite setup schema where applicable, or place the record out of order; require read-only discovery to return `program-activation-recovery-required`, `required_input == "activation-prefix-recovery"`, and `stop_required is True` without falling through to generic invalid-state or publication recovery. Keep malformed sequence-zero proposals with no activation transaction artifact on their existing invalid route. +### Modify -- [ ] **Step 2: Run the focused tests and verify RED** +- `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. -Run: +--- -```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_program_setup tests.test_program_authority tests.test_repository_preparation tests.test_program_bootstrap tests.test_program_activation tests.test_program_discovery tests.test_state_authority -v -``` +### Task 1: Add the Delete-Capable Setup Family and Own Sequence-Zero Discovery -Expected: new tests fail because only setup/envelope v1 exists and `Delete` is unsupported; all pre-existing tests remain green. +**Files:** -- [ ] **Step 3: Implement exact nested-schema dispatch** +- 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` -Add literal paired contracts; do not mutate the v1 tuple: +**Contract:** ```python SETUP_SEMANTICS_SCHEMA_V2 = "implementation-program-setup-semantics/v2" @@ -250,1233 +104,309 @@ 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"}) - -def _operation_contract( - semantics: Mapping[str, object], -) -> tuple[tuple[str, ...], bool]: - schema = semantics.get("schema_version") - envelope = semantics.get("operation_envelope") - envelope_schema = envelope.get("schema_version") if isinstance(envelope, dict) else None - if (schema, envelope_schema) == (SETUP_SEMANTICS_SCHEMA, OPERATION_ENVELOPE_SCHEMA): - return SUPPORTED_OPERATIONS_V1, False - if (schema, envelope_schema) == (SETUP_SEMANTICS_SCHEMA_V2, OPERATION_ENVELOPE_SCHEMA_V2): - return SUPPORTED_OPERATIONS_V2, True - raise ValueError("setup semantics and operation envelope schema families do not match") -``` - -In `repository_preparation.py`, add the protection context without changing the persisted repository-inspection v1 bytes: - -```python -@dataclass(frozen=True) -class DeleteProtectionContext: - workspace_root: Path - git_directory: Path - git_common_directory: Path - protected_roots: tuple[Path, ...] - protected_paths: tuple[Path, ...] - -def build_delete_protection_context( - workspace_root: Path, - manifest_program_root: Path, - manifest: Mapping[str, object], - inspection: RepositoryInspection, -) -> DeleteProtectionContext: - workspace = Path(workspace_root) - if workspace.is_symlink() or not workspace.is_dir(): - raise ValueError("Delete protection requires a regular workspace root") - workspace = workspace.resolve(strict=True) - - git_directory = Path(inspection.git_directory) - git_common_directory = Path(inspection.git_common_directory) - if not git_directory.is_absolute() or not git_common_directory.is_absolute(): - raise ValueError("Delete protection requires resolved Git metadata paths") - if not git_directory.exists() or not git_common_directory.exists(): - raise ValueError("Delete protection Git metadata paths are missing") - - program_root = Path(manifest_program_root).absolute() - try: - program_root.relative_to(workspace.absolute()) - except ValueError as error: - raise ValueError("manifest program root escapes the workspace") from error - - def managed_relative(value: object, label: str) -> PurePosixPath: - if not isinstance(value, str) or not value or "\\" in value: - raise ValueError(f"{label} is not a safe relative POSIX path") - relative = PurePosixPath(value) - if relative.is_absolute() or any( - part in {"", ".", ".."} for part in relative.parts - ): - raise ValueError(f"{label} is not a safe relative POSIX path") - return relative - - roles = manifest.get("logical_roles") - increment_storage = manifest.get("increment_storage") - closure_storage = manifest.get("closure_storage") - if not isinstance(roles, Mapping): - raise ValueError("manifest logical_roles must be an object") - if not isinstance(increment_storage, Mapping) or not isinstance( - closure_storage, Mapping - ): - raise ValueError("manifest lifecycle storage descriptors must be objects") - - increment_root = program_root.joinpath( - *managed_relative(increment_storage.get("root"), "increment storage root").parts - ) - closure_root = program_root.joinpath( - *managed_relative(closure_storage.get("root"), "closure storage root").parts - ) - control_paths = [program_root / "manifest.json"] - control_paths.extend( - program_root.joinpath(*managed_relative(value, f"logical role {role}").parts) - for role, value in sorted(roles.items()) - ) - program_binding = manifest.get("program_binding") - if isinstance(program_binding, Mapping): - for field in ("path", "traceability_path"): - control_paths.append( - program_root.joinpath( - *managed_relative( - program_binding.get(field), f"program binding {field}" - ).parts - ) - ) - - protected_roots = ( - workspace / ".git", - git_directory, - git_common_directory, - workspace / "implementation-programs", - program_root, - increment_root, - closure_root, - ) - for protected in (*protected_roots, *control_paths): - if protected.is_symlink(): - raise ValueError("Delete protection metadata contains a symlink") - return DeleteProtectionContext( - workspace_root=workspace, - git_directory=git_directory.resolve(strict=True), - git_common_directory=git_common_directory.resolve(strict=True), - protected_roots=tuple(path.absolute() for path in protected_roots), - protected_paths=tuple(path.absolute() for path in control_paths), - ) - -def validate_delete_target_path( - context: DeleteProtectionContext, - relative_path: str, -) -> None: - relative = PurePosixPath(relative_path) - if ( - not relative_path - or "\\" in relative_path - or relative.is_absolute() - or relative.as_posix() != relative_path - or any(part in {"", ".", ".."} for part in relative.parts) - ): - raise ValueError("Delete target must be one normalized repository-relative path") - if ".git" in relative.parts: - raise ValueError(f"Delete target is protected: {relative_path}") - - candidate = context.workspace_root.joinpath(*relative.parts).absolute() - protected = (*context.protected_roots, *context.protected_paths) - if any(candidate == item or candidate.is_relative_to(item) for item in protected): - raise ValueError(f"Delete target is protected: {relative_path}") - - current = context.workspace_root - for part in relative.parts: - current = current / part - if not current.exists() and not current.is_symlink(): - break - if current.is_symlink(): - raise ValueError(f"Delete target has a symlink component: {relative_path}") - resolved = current.resolve(strict=True) - for protected_path in protected: - protected_resolved = protected_path.resolve(strict=False) - same_file = protected_path.exists() and current.samefile(protected_path) - if ( - same_file - or resolved == protected_resolved - or resolved.is_relative_to(protected_resolved) - ): - raise ValueError(f"Delete target is protected: {relative_path}") +DELETE_CONTENT_DISPOSITIONS = frozenset( + {"migrated", "obsolete", "intentional-discard"} +) ``` -The builder requires a strict regular non-symlink workspace root, non-empty absolute Git directory/common-directory values from a fresh `inspect_repository(...)`, a manifest program root lexically beneath the workspace, and safe exact manifest path descriptors. Its protected roots are the workspace `.git` entry, both resolved Git metadata directories, the conventional `workspace/implementation-programs` root, the intended or actual manifest program root, and the manifest's increment/closure storage roots. Its protected exact paths include `manifest.json`, all resolved `logical_roles`, and every manifest binding path. Fail closed if any required protection value is missing, ambiguous, escaping, or symlinked. - -The validator first rejects any normalized path with an exact lexical `.git` component. It then proves lexical workspace containment and walks existing components with `lstat`; compare existing filesystem identities with `samefile` where available and compare strict resolved ancestors against every protected root/path. This must catch case aliases, the linked-worktree gitfile, the per-worktree Git directory, the shared common directory, and symlink aliases before reading target bytes. Use exact path-component containment, not string prefixes. An absent suffix may still be classified by Task 2 only after its nearest existing ancestor passes this protection check. - -For v2, require `accepted_state == "absent"`, one allowed `content_disposition`, and a non-empty rationale only on Delete allocations; reject those fields on non-Delete allocations. Require an exact-path Delete allocation to be program-owned, non-protected, non-user-work, and to declare collision `existing` or `accepted-predecessor`. The latter is valid only when the setup dependency graph contains one same-path `Create` allocation in a strict transitive predecessor; it does not weaken the activation-time exact fact comparison. Preserve all existing ownership, file-kind, link-kind, mode, collision, overlap, and duplicate-allocation checks. - -For each setup-v2 Delete allocation, derive a fresh repository inspection from the setup workspace binding, reproduce the persisted workspace observation, build the context with the intended publication root, and call `validate_delete_target_path(...)`. Proposal validation, activation, and every activation retry must repeat this check; an old setup decision is not protection evidence. Use a local import if needed to avoid a `program_setup.py`/`repository_preparation.py` import cycle. Setup-v1 never enters this Delete-only route and retains exact bytes. +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. -Select recap/checkpoint/adapter/activation schema versions solely from `_operation_contract(...)`. In this same task, change `program_activation.py::_build_v3_setup_record(...)` to select and write the matching activation schema instead of importing and unconditionally emitting `SETUP_ACTIVATION_SCHEMA`; update its activation-prefix adoption tests before calling this task GREEN. Extend `program_authority.py::SETUP_AUTHORITY_RECORD_SCHEMAS`, `program_setup.py`'s activation-record loaders/validators, and `state_authority.py::SETUP_ONLY_STATUS_SCHEMAS` plus its manifest-v3 family validation without admitting the v2 records to setup-v1 or legacy manifests. +#### Step 1: Write RED tests -In `program_discovery.py::_single_bootstrap_prefix_disposition(...)` and `_load_setup_candidate(...)`, inspect the sequence-zero transaction prefix before proposal-publication or generic program/state rejection. Preserve the existing pristine `program-setup-ready`, pending-gate `source-gate-approval-ready`, and exact-prefix `program-activation-retry-ready` routes for both setup families. When activation has started and `inspect_sequence_zero_activation_prefix(...)` reports a mixed, out-of-order, or divergent decision, gate, or approval prefix, return `program-activation-recovery-required` with the exact prefix issues for diagnosis; `_single_bootstrap_prefix_disposition(...)` must not relabel that owned activation divergence as `proposal-publication-recovery-required`, and `_load_setup_candidate(...)` must not relabel it as generic invalid. Keep immutable publication-manifest/owner/inventory divergence on `proposal-publication-recovery-required`. The activation recovery route is classification only: it must not rewrite, adopt, append, or delete any prefix byte. A malformed pristine proposal with no activation transaction artifact remains on its existing invalid or publication-recovery route. - -- [ ] **Step 4: Run the focused tests and verify GREEN** - -Run the Step 2 command. - -Expected: all setup, authority, generic proposal-publication, and sequence-zero discovery tests pass; every exact incomplete v2 activation prefix is retry-ready, every started mixed or divergent prefix is activation-recovery-required without mutation, the recap exposes each Delete fact, and legacy bytes stay exact. - -- [ ] **Step 5: Commit the setup contract** +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 git add skills/implementing-staged-plans/scripts/program_setup.py skills/implementing-staged-plans/scripts/program_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_program_setup.py tests/test_program_authority.py tests/test_repository_preparation.py tests/test_program_bootstrap.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py -rtk git commit -m "feat: add typed delete setup contracts" +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 ``` ---- - -### Task 2: Add Exact-Plan, Baseline, Product Path-State, and Execution-Transition Semantics - -**Files:** -- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` -- Modify: `skills/implementing-staged-plans/scripts/repository_preparation.py` -- Modify: `skills/implementing-staged-plans/scripts/program_activation.py` -- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` (sequence-one-and-later exact-plan routing only; preserve Task 1 sequence-zero activation routing) -- Test: `tests/test_repository_preparation.py` -- Test: `tests/test_program_activation.py` -- Test: `tests/test_approval_checkpoint.py` -- Test: `tests/test_program_discovery.py` -- Test: `tests/test_state_authority.py` - -**Interfaces:** -- Produces: `ExactFileMapV2`, `ExecutionBaselineV2`, and `InheritedPathStateV2` while retaining `ExactFileMap` and `ExecutionBaseline` as v1 types. -- Produces: `file_map_entries(file_map) -> tuple[tuple[str, tuple[str, ...]], ...]` and `file_map_paths(file_map, *, mutable_only: bool) -> tuple[str, ...]` so consumers do not reconstruct operation inventories inconsistently. -- Produces: `WorkspacePathSnapshot(relative_path: str, exists: bool, sha256: str | None, mode: str | None, link_count: int | None)` and `inspect_workspace_path(workspace_root: Path, relative_path: str) -> WorkspacePathSnapshot`, the single component-by-component path-safety and containment check used at baseline and every reassessment. -- Consumes: Task 1's `DeleteProtectionContext` and `validate_delete_target_path(...)`; every Delete branch validates protection immediately before its component walk and never accepts an empty/synthetic Git protection context. -- Produces: `product_result_schema_version` on `ExecutionWorkspaceAssessment`; v1 remains `implementation-product-delta/v1`, v2 is `implementation-product-path-states/v2`. -- Produces: `EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2"` and `ExecutionTransitionReceiptV2`; v2 status bindings use `product_result_schema_version` and `product_result_sha256`, never `product_delta_sha256`. -- Produces: exact baseline/result/transition pairing: baseline v1 + product-delta v1 + execution-transition v1, or baseline v2 + product-path-states v2 + execution-transition v2. A missing, mixed, substituted, or dual-family field set fails before adoption or any status write. -- Produces: an execution-transition event identifier derived from the exact family-specific seed and a retry path that adopts only a fully reproduced binding, `previous_state`, `transition_authority`, result digest, and event identifier. -- Produces: v2 product states in operation-section order and exact file-map order; v1 product deltas retain their current lexical ordering and bytes. -- Produces: manifest-v3/setup-v2 sequence-one-and-later `plan-preparation-*` and `plan-materialization-*` retry/recovery classification from exact transaction prefixes before full state-authority validation or generic lifecycle routing; Task 1 exclusively owns sequence-zero activation-prefix classification. -- Produces: writer-to-fresh-discovery coverage for implementing and reviewing status plus `execution-transition-recovery-required` classification for v1/v2 transition substitution or digest/event divergence. -- Produces test helpers on `ExecutionWorkspaceValidationTests`: `delete_baseline(path: str) -> ExecutionBaselineV2` and `assess_v2(baseline: ExecutionBaselineV2, state: str) -> ExecutionWorkspaceAssessment`; both use the class's temporary `workspace` path. -- Preserves: public plan preparation/materialization and three-argument future-write signatures. - -- [ ] **Step 1: Write failing parser and assessment tests** - -Add exact parser and lifecycle assertions: - -```python -DELETE_MAP = """# Delete plan -## File map -Schema: `implementation-exact-file-map/v2` - -### Create -- `review/evidence.json` -### Modify -- `state/status.json` -### Delete -- `legacy.ts` -### Preserve -- `catalog.txt` -""" - -def test_unversioned_delete_heading_is_rejected_instead_of_absorbed_as_modify(self) -> None: - unversioned = DELETE_MAP.replace( - "Schema: `implementation-exact-file-map/v2`\n\n", "" - ) - with self.assertRaisesRegex( - ValueError, "unversioned exact-file map contains unsupported heading: Delete" - ): - PREPARATION.parse_exact_file_map(unversioned) - -def test_v2_delete_path_must_transition_from_exact_file_to_absence(self) -> None: - baseline = self.delete_baseline("legacy.ts") - self.assertTrue(self.assess_v2(baseline, "authorized").valid) - self.workspace.joinpath("legacy.ts").write_text("changed\n", encoding="utf-8") - self.assertIn( - "execution workspace changed Delete path before removal: legacy.ts", - self.assess_v2(baseline, "implementing").issues, - ) - self.workspace.joinpath("legacy.ts").unlink() - reviewing = self.assess_v2(baseline, "reviewing") - self.assertTrue(reviewing.valid, reviewing.issues) - self.assertEqual( - reviewing.product_delta, - ({ - "path": "legacy.ts", - "disposition": "Delete", - "final_state": "absent", - "sha256": None, - },), - ) -``` - -Add negative cases for a missing Delete target at baseline, unchanged Delete at reviewing, changed-but-present Delete, symlink/hard-link/directory/special-file targets, overlap with recorded user work, duplicate cross-disposition paths, `sha256` on an absent result, and `None` on a present result. Retain the existing assertion that deleting a v1 Modify path fails. - -Drive `program_activation.py::advance_execution_state(...)` through `authorized -> implementing -> reviewing` for one setup-v1 program and one setup-v2 program. For each family, pass the production-written implementing and reviewing statuses directly to fresh discovery and require `resume`, with state authority clean. Inject a lost response after each status-last write and call the same transition again: the exact binding must return `recovered is True` without changing status bytes. Recompute each `event_id` from the exact seed specified in Step 5 and compare it with both `execution_transition_binding.event_id` and `transition_authority.event_id`. - -For both target states, substitute a v1 transition into the v2 status and a v2 transition into the v1 status; also try both digest field families together, remove the required result schema, change the result digest, change one seed-bound field, and change only `event_id`. The direct retry must raise `execution-transition-recovery-required: status binding differs`, fresh state authority must report `execution transition binding is invalid` or the family-specific reviewed-result mismatch, discovery must return `execution-transition-recovery-required` with `required_input == "execution-transition-recovery"` and `stop_required is True`, and every rejected case must preserve status bytes. Compare the production v1 transition/status serialization with the existing frozen `tests/fixtures/program-bootstrap/v0.1.1` route byte-for-byte; do not update that fixture. +Expected RED: setup rejects Delete and has no v2 activation family. -Add one manifest-v3/setup-v2 program whose first increment contains only Create/Modify/Preserve, including Create for a currently absent exact path, and whose strict successor owns Delete for that same path with collision `accepted-predecessor`. In this task, assert only that the first increment rejects a v1 or unversioned file map, accepts file-map/baseline/result v2 with an empty Delete section, and reaches `authorized` with an exact v2 baseline. Do not fabricate or require accepted predecessor state here: Task 3 owns v2 review/diff acceptance, and Task 5 owns the production rollover into the Delete increment. Assert the inverse family substitution fails for setup v1. +#### Step 2: Implement and verify GREEN -For path traversal, add `nested/legacy.ts` with a real directory ancestor and capture an authorized baseline. Replace `nested` after authorization with a symlink to a temporary directory outside the workspace, then require the next `validate_execution_workspace(...)` call to report `execution path has symlinked ancestor: nested/legacy.ts` before reading or hashing the external target. Repeat the swap with a symlink into `.git` and into the active program root, and where supported with a hard-link/case alias to an existing protected file. Cover the same rejection during baseline construction, and assert every external/control sentinel is unchanged in both cases. Inject a lost response after the baseline and action-authorization prefixes, perform the protected swap, then retry materialization: it must return the exact plan-domain recovery stop without adopting authorization or writing status. A direct v2 baseline or inherited-state tamper that introduces `.git/config` or a manifest control path must also fail before hashing or status writes; repository snapshots that skip `.git` are not sufficient evidence, so assert the protected sentinel bytes and Git identity explicitly. +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. -Preserve the current positive cases for an absent v1 Create target below a not-yet-created parent and an already-absent tracked user-work path whose suffix is missing. A missing suffix returns `exists=False`; Delete/Modify/Preserve baseline callers must then reject it as missing, while Create, accepted/inherited absence, and already-absent user-work callers may accept it. Add positive v2 Delete baselines for `.github/legacy.yml` and an ordinary nested product file to prove the protection rule does not narrow legitimate product deletion. - -- [ ] **Step 2: Run the focused tests and verify RED** +--- -```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 -``` +### Task 2: Add Descriptor-Bound Delete, Exact Plan/Baseline/Result, and Transition v2 -Expected: the unversioned parser test exposes the current Delete-to-Modify absorption; v2 imports, absent-result assertions, v2 transition schema, and writer-to-discovery assertions fail; existing v1 tests pass. +**Files:** -- [ ] **Step 3: Add versioned file-map and baseline types** +- 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` -In `state_authority.py`, retain `ExactFileMap` unchanged and add: +**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" @dataclass(frozen=True) -class ExactFileMapV2: - schema_version: str - create: tuple[str, ...] - modify: tuple[str, ...] - delete: tuple[str, ...] - preserve: tuple[str, ...] - -def file_map_entries( - file_map: ExactFileMap | ExactFileMapV2, -) -> tuple[tuple[str, tuple[str, ...]], ...]: - if isinstance(file_map, ExactFileMapV2): - return ( - ("Create", file_map.create), - ("Modify", file_map.modify), - ("Delete", file_map.delete), - ("Preserve", file_map.preserve), - ) - return ( - ("Create", file_map.create), - ("Modify", file_map.modify), - ("Preserve", file_map.preserve), - ) -``` - -`parse_exact_file_map(...)` must first reject every unrecognized `###` heading within the v1 file-map body. Select v2 only from the exact schema marker, then require one ordered Create/Modify/Delete/Preserve heading. The Delete section may be empty for any increment in a setup-v2 program; the other required sections retain their current non-empty contract. Duplicate and unsafe path rejection remains global across all sections. - -Add `implementation-execution-baseline/v2` in `repository_preparation.py` with an exact v2 file-map object, current path baselines, user-work baselines, and ordered `inherited_path_states`. Dispatch `execution_baseline_from_value(...)` on the exact baseline schema. In `program_activation.py::_build_plan_candidate(...)`, select file-map and baseline v2 for every increment solely when the manifest's exact setup/envelope pair is v2, even when Delete is empty and no inherited state exists; reject v1/v2 substitutions in both directions before persistence. Do not add fields to the v1 serialization. - -Implement `inspect_workspace_path(...)` with `os.lstat`, never `Path.is_file()` or `resolve()` as the symlink test: normalize the relative POSIX path; `lstat` and reject a symlinked/non-directory supplied workspace root before resolving it strictly; prove the lexical candidate is beneath that root; then `lstat` components from the root downward. Every existing ancestor must be a non-symlink directory whose strict resolution remains inside the strict workspace root. If a component is missing, stop walking and return one absent snapshot for the whole remaining suffix without resolving, reading, or creating it. If the final component exists, require a regular non-symlink file, prove its strict resolution remains inside the workspace, and only then return its digest, mode, and link count. Reject every other existing component kind or containment escape. - -Use this helper in activation allocation-fact checks, `_path_baselines(...)`, `_user_work_baselines(...)`, and every current, inherited, and user-work branch of `validate_execution_workspace(...)`. Every branch whose current operation or inherited state is Delete must first rebuild Task 1's protection context from the current manifest and fresh real `RepositoryInspection`, then call `validate_delete_target_path(...)`; a persisted allocation, baseline, action authorization, review, or accepted result is never a waiver. Callers then enforce their own presence contract: baseline Delete/Modify/Preserve and every state that expects presence require an existing regular file; Create before creation, Delete after removal, inherited tombstones, and recorded already-absent user work permit an absent suffix. A later lifecycle reassessment must repeat both the protection check and complete walk; an authorization-time result is never reused as current path-safety evidence. - -- [ ] **Step 4: Implement Delete-aware candidate and workspace validation** - -In `program_activation.py::_build_plan_candidate(...)`, require file-map v2 for the complete setup-v2 program family from its first increment. Match every non-managed current path, including each exact Delete path, to exactly one current-increment setup allocation. Keep lifecycle-managed writes limited to Create/Modify/Preserve. For the later-created Delete path, the first increment's Create facts must be absent/none/`None`/none; after accepted rollover, the Delete allocation must reproduce regular-file/none/mode/`accepted-predecessor`. Initially present Delete targets reproduce collision `existing`. A mismatched collision, ownership, file kind, link kind, mode, or inherited-state fact fails before plan or baseline persistence. - -Use the shared operation iterator in `_path_baselines(...)`, `_user_work_baselines(...)`, `validate_required_managed_file_map(...)`, and `validate_execution_workspace(...)`. Include Delete in both the program-owned-operation check and the claimed-path/user-work-overlap set currently applied to Create/Modify. Enforce: - -```python -if disposition == "Delete": - if increment_state == "authorized" and (actual is None or actual != entry.sha256): - issues.append(f"authorized workspace changed Delete path: {relative}") - elif increment_state == "implementing" and actual not in {None, entry.sha256}: - issues.append( - f"execution workspace changed Delete path before removal: {relative}" - ) - elif increment_state in later_states and actual is not None: - issues.append(f"reviewing workspace still contains Delete path: {relative}") - elif actual is None: - product_delta.append({ - "path": relative, - "disposition": "Delete", - "final_state": "absent", - "sha256": None, - }) -``` - -For v2 Create/Modify results emit `final_state: "present"` with the real digest. Construct v2 results by iterating `file_map_entries(...)` in Create, Modify, Delete, Preserve section order and retaining each section's exact path order; do not sort v2 states after construction. Keep the v1 result object, lexical sort, and hash byte-for-byte unchanged. Include Delete paths in mapped product dirt and claimed paths, but never in managed lifecycle requirements. - -- [ ] **Step 5: Version execution-transition writes, adoption, and validation** - -Keep `EXECUTION_TRANSITION_SCHEMA`, `ExecutionTransitionReceipt`, the v1 binding fields, and the v1 event seed byte-for-byte unchanged. Add: - -```python -EXECUTION_TRANSITION_SCHEMA_V2 = "implementation-execution-transition/v2" -PRODUCT_PATH_STATES_SCHEMA_V2 = "implementation-product-path-states/v2" +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 ExecutionTransitionReceiptV2: - prior_state: str - increment_state: str - status_sha256: str - product_result_schema_version: str - product_result_sha256: str - recovered: bool +class DeleteReceipt: + path: str + baseline_sha256: str + device: int + inode: int + final_state: str ``` -For a baseline/result v2 assessment, `advance_execution_state(...)` writes exactly this binding; `review_remediation_sha256` is the only additional field allowed, and is required only for the Task 3 `remediating -> reviewing` writer: +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. -```python -{ - "schema_version": "implementation-execution-transition/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_result_schema_version": "implementation-product-path-states/v2", - "product_result_sha256": assessment.product_delta_sha256, -} -``` - -`product_result_sha256` is the canonical SHA-256 already computed over the ordered v2 path-state tuple; it must reproduce from `assessment.product_delta` without sorting or coercing `None`. Derive `event_id = _identifier("execution-transition", event_seed)` from exactly: +#### Step 1: Write RED tests -```python -{ - "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_result_schema_version": "implementation-product-path-states/v2", - "product_result_sha256": assessment.product_delta_sha256, - "authorization_id": authorization_id, -} -``` +Test v2 parsing including empty Delete; unversioned Delete rejection; present regular-file baseline; typed absent/null-digest result; 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. -Append `review_remediation_sha256` to that seed and binding only when `prior_increment_state == "remediating"`. `transition_authority.event_id` must equal the derived identifier and use the same authorization. `previous_state.status_sha256` must equal `prior_status_sha256`, and its sequence must be exactly one below the new status. +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. Allow `.github`, `.gitignore`, and ordinary names containing `git`. -Select v1 or v2 only from the validated manifest setup/envelope, execution-baseline, and assessment result-schema tuple. The v1 binding has `product_delta_sha256` and no product-result fields; the v2 binding has the two product-result fields and no `product_delta_sha256`. In the same-target retry branch, rebuild the expected family, fields, canonical result digest, event seed, `previous_state`, and `transition_authority` before returning a recovered receipt; do not adopt from schema/target/digest alone. A mismatch raises the existing recovery-required error before any write. +```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 +``` -In `state_authority.py`, validate the same exact field sets and family table, recompute the event identifier, and reject a cross-family or dual-family binding. While state is `implementing` or `remediating`, validate the entry digest's shape and seed binding but preserve it while product work may evolve; at `reviewing`, `verified`, `awaiting-diff-approval`, and `accepted`, recompute the v1 delta or canonical v2 path-state digest from the fresh assessment and require equality. Replace the current synthetic `RepositoryInspection(git_directory="", git_common_directory="", ...)` with a fresh `inspect_repository(...)`, require its observation to reproduce the supplied status-current observation, and pass its real Git metadata into Delete protection. Missing or changed Git metadata fails closed. Preserve the v1 error text and add `reviewed product result differs from its status binding` for v2. In `program_discovery.py`, classify either transition-invalid message and either reviewed-result mismatch as `execution-transition-recovery-required` before generic invalid routing; an exact production-written v1 or v2 transition proceeds to the existing `resume` route. +Expected RED: no v2 map/baseline/result/transition or protected descriptor path exists. -- [ ] **Step 6: Route exact setup-v2 plan prefixes before generic rejection** +#### Step 2: Implement one descriptor-relative identity path -In `program_discovery.py::_load_setup_candidate(...)`, preserve without reimplementing the exact setup-v1/setup-v2 sequence-zero activation retry/recovery routing completed in Task 1. For sequence one and later only, load the allocated transaction files and relevant ledgers, derive the fresh observation, and call `_exact_plan_prefix_disposition(...)` before `validate_state_authority(...)` or any generic `resume`/invalid route. Do not accept a prefix by state name alone. For a manifest-v3/setup-v2 program, interrupt standard-mode preparation after the exact plan and awaiting-plan status, and materialization after the plan approval, v2 baseline, and action authorization. Each byte-exact incomplete prefix must return the matching `plan-preparation-retry-ready` or `plan-materialization-retry-ready`; missing/out-of-order records, changed plan bytes, a v1 baseline, or changed v2 path-state order must return the matching recovery-required disposition. After the exact authorized status is written last, discovery returns `resume` only after full state validation. `approval:pre-approve` and `approval:full-increment` must exercise their shorter exact materialization prefixes and completed-status route. The same cases for setup-v1 keep their existing bytes and disposition names. +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. -- [ ] **Step 7: Run the focused tests and verify GREEN** +No setup-v2 authorization may use `Path.resolve()`, `is_file()`, `read_bytes()`, or a separate check-then-open target. -Run the Step 2 command. +Actual deletion uses production `delete_bound_regular_file(...)`, never test-side `Path.unlink()`. It receives the exact v2 baseline identity and fresh protection context, repeats the held walk/hash, requires matching device/inode/mode/digest and one link, revalidates the final name immediately before `os.unlink(name, dir_fd=parent_fd)`, then verifies the held inode lost its link, the name is absent, and ancestors are identical. A swap before unlink fails before the syscall. Syscall-boundary divergence never returns an accepted receipt and enters deterministic recovery. The helper runs only for the current authorized setup-v2 exact-plan Delete and grants no authority itself. -Expected: the exact parser, baseline, authorization, partial implementation, complete absence, transition writer/retry/discovery, cross-family rejection, and legacy-byte tests pass. +If required primitives are unavailable, fail before v2 artifacts or mutation with `descriptor-relative no-follow Delete is unsupported on this platform`. No path fallback; legacy families do not call the primitive. -- [ ] **Step 8: Commit exact-plan, baseline, and execution-transition support** +#### Step 3: Implement exact baseline/result/transition and verify GREEN -```bash -rtk git add skills/implementing-staged-plans/scripts/state_authority.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/program_discovery.py 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 -rtk git commit -m "feat: validate delete path states" -``` +Add v2 dataclasses rather than widening v1. Pair only baseline v1 + delta v1 + transition v1, or baseline v2 + path-states v2 + transition v2. Reconstruct the pair at activation, reassessment, retry, authority, and discovery. Run the Step 1 command. --- -### Task 3: Carry Absent Results Through Review and Diff Acceptance +### 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` -- Modify: `tests/program_bootstrap_support.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` -**Interfaces:** -- Produces: `implementation-review-evidence/v2`, `implementation-review-packet/v2`, `implementation-review-preparation/v2`, `implementation-review-remediation/v2`, `implementation-diff-disposition-binding/v2`, and `implementation-diff-disposition-command/v2` only for product path-state v2. -- Produces: review evidence field `product_result = {schema_version, sha256, ordered_path_states}`. -- Consumes: Task 2's exact execution-transition v1/v2 family; the remediation-return writer uses v2 result fields and seed extension for setup-v2 without redefining the schema. -- Produces: exact-family discovery of v2 acceptance prefixes and an `accepted-stop` route for an exact accepted v2 diff binding. -- Produces test helpers in `tests/program_bootstrap_support.py`: `BootstrapFixture.observation() -> RepositoryObservation` and `reviewing_delete_program() -> tuple[BootstrapFixture, Path, RepositoryObservation]`, returning a real temporary manifest-v3/setup-v2 program at `reviewing` with `legacy.ts` absent and raw review reports ready. -- Preserves: v1 review evidence, packet rendering, remediation, prompt bytes, diff bindings, and approval records. - -- [ ] **Step 1: Write failing review/remediation/diff tests** - -Add a reviewing fixture with one absent Delete path and assert: - -```python -def test_delete_result_is_reviewed_and_accepted_as_absent(self) -> None: - fixture, program_root, observation = reviewing_delete_program() - try: - candidate = REVIEW.build_review_preparation(program_root, observation) - evidence = json.loads(candidate.evidence_bytes) - self.assertEqual(evidence["schema_version"], "implementation-review-evidence/v2") - self.assertEqual( - evidence["product_result"]["ordered_path_states"], - [{ - "path": "legacy.ts", - "disposition": "Delete", - "final_state": "absent", - "sha256": None, - }], - ) - REVIEW.persist_review_preparation(program_root, observation) - accepted = DIFF.build_diff_acceptance_candidate(program_root, observation) - self.assertEqual( - accepted.accepted_status["diff_disposition_binding"]["schema_version"], - "implementation-diff-disposition-binding/v2", - ) - self.assertEqual( - accepted.accepted_status["diff_disposition_binding"][ - "product_result_schema_version" - ], - "implementation-product-path-states/v2", - ) - finally: - fixture.close() -``` - -Add failures for a reappeared Delete target, changed path-state order, `final_state: present`, non-null absent digest, omitted Delete state, extra path state, v1/v2 review substitution, and remediation that restores or changes the deleted target without a renewed v2 assessment and review. In `tests/test_program_discovery.py`, persist an exact v2 diff-acceptance prefix and assert the pre-status prefix is `increment-acceptance-retry-ready`, the byte-exact accepted status is `accepted-stop`, and a substituted v1 binding, reordered state, or changed digest is `increment-acceptance-recovery-required` rather than resume or terminal. - -After authorization and again after review preparation, replace an allowed Delete target or ancestor with an alias into `.git` or the program control root. Require `build_review_preparation(...)`, remediation return, `build_diff_acceptance_candidate(...)`, direct submission, state authority, and discovery to reject the protected target before reading it, accepting a packet, or appending an approval. Retry after an injected review-evidence or diff-approval prefix must preserve that prefix and return the matching review/acceptance recovery disposition. Keep a normal product Delete positive control through accepted-stop. - -Drive one v2 remediation return through the production `program_review.py` writer. Require `implementation-execution-transition/v2`, the exact renewed product-result schema/digest, and an `event_id` derived from the Task 2 seed plus the exact `review_remediation_sha256`. Retry the byte-exact written status and require adoption without mutation. Substitute a v1 transition or v1 `product_delta_sha256` into that v2 return, and a v2 transition into the v1 control; require review retry, state authority, and discovery to fail on the exact transition family before any later review artifact is written. Preserve the existing v1 remediation-transition bytes. - -This task owns the chronology assertion deferred from Task 2: drive a setup-v2 first increment containing only Create/Modify/Preserve through v2 review and exact `accept-stop`, with an empty Delete section in its file map/result family, and assert discovery returns `accepted-stop` before any successor or Delete plan is prepared. Use production review and diff writers; do not edit accepted status directly. - -For manifest-v3/setup-v2 discovery, interrupt review preparation after evidence, packet, and verified status, then verify the exact awaiting-diff status written last routes to `resume` only after complete review-state validation. Interrupt acceptance after approval and accepted status. Every byte-exact incomplete review prefix returns `review-preparation-retry-ready`; the exact acceptance approval prefix returns `increment-acceptance-retry-ready`; the exact accepted status returns `accepted-stop`. Packet-before-evidence, changed evidence/packet/status, mixed v1/v2 review or command bytes, and changed/reordered accepted path states return the domain-specific recovery disposition before generic state validation. Repeat one setup-v1 control to prove its prompt bytes and route names are unchanged. - -- [ ] **Step 2: Run the focused tests and verify RED** - -```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_discovery tests.test_state_authority -v -``` - -Expected: new v2 review/result schemas are absent and Delete surfaces cannot be represented. +**Contracts:** -- [ ] **Step 3: Implement typed review result persistence** +- `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 -Extend execution ownership with a literal `delete` disposition: it requires a non-empty pre-write fingerprint, exact `post_write_fingerprint == "absent"`, program ownership, and no accepted user-work overlap. Add `deleted` to execution surface changes and require one semantic naming/compatibility record for each deleted path; keep physical `renamed` rejection unchanged. +Review evidence v2 binds `{schema_version, sha256, ordered_path_states}` as `product_result`. It contains no requirement-result object; PLUG-002 owns that evidence. -When `assessment.product_result_schema_version` is v2, `program_review.py` writes v2 review evidence containing the exact ordered states and v2 preparation/remediation bindings. `review_coordination.py` validates that the result digest is the canonical digest of those exact states and renders absent paths as absent—never as files with digests. Every review, remediation, diff-candidate, diff-submission, retry, state-authority, and discovery entry point must obtain a fresh real repository inspection and repeat Task 1's Delete protection check through `validate_execution_workspace(...)`; do not trust the baseline or prior result to prove that a path is still outside control metadata. - -For a v2 remediation return, `program_review.py` must consume Task 2's execution-transition contract and emit the exact v2 binding with `product_result_schema_version`, `product_result_sha256`, and `review_remediation_sha256`; its event seed is the Task 2 v2 seed plus that remediation digest. Its retry/adoption branch must reproduce the full binding, event identifier, previous-state link, transition authority, and renewed assessment before returning recovered. Keep the v1 writer and retry bytes unchanged. - -`diff_disposition.py` loads that exact reviewed result, freshly reassesses the workspace, compares schema/digest/states, and emits v2 binding/command schemas containing `product_result_schema_version`. Its submitted-prompt parser derives the expected command schema from the persisted review/result family before calling `parse_exact_prompt(...)`; it never accepts caller-selected family substitution. `program_discovery.py::_exact_review_prefix_disposition(...)`, `_exact_acceptance_prefix_disposition(...)`, and accepted-status routing must recognize only the exact v1 or v2 review/diff family, rebuild the matching production candidate, and classify byte-exact v2 accepted-stop and retry prefixes without a hard-coded v1 gate. - -Extend `_load_setup_candidate(...)` so the exact review and acceptance classifiers run on manifest-v3/setup-v2 prefixes before full `validate_state_authority(...)` and before its generic `verified`, `awaiting-diff-approval`, or `accepted` fallbacks. A classifier's recovery-required result is authoritative and cannot be replaced by `invalid`, `resume`, or a generic retry. Keep v1 base-seed construction, prompt bytes, and discovery dispositions unchanged. - -- [ ] **Step 4: Extend state validation by exact review/diff schema** - -In `state_authority.py`, pair baseline v1 with review/diff v1 and baseline v2 with review/diff v2. Reject mixed families, missing result schemas, changed state order, or a digest that does not reproduce from `ordered_path_states`. Preserve the existing source-gate and status-last checks. - -- [ ] **Step 5: Run the focused tests and verify GREEN** - -Run the Step 2 command. - -Expected: Delete absence is visible and immutable from review preparation through accepted status; every v1 golden remains exact. - -- [ ] **Step 6: Commit review and diff support** - -```bash -rtk git add 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_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/program_bootstrap_support.py tests/test_execution_discipline.py tests/test_review_coordination.py tests/test_program_review.py tests/test_diff_disposition.py tests/test_program_discovery.py tests/test_state_authority.py -rtk git commit -m "feat: bind deleted results through review" -``` - ---- - -### Task 4: Freeze Delete State Across Blocked Recovery - -**Files:** -- Modify: `skills/implementing-staged-plans/scripts/blocked_recovery.py` -- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` -- Test: `tests/test_blocked_recovery.py` -- Test: `tests/test_program_discovery.py` -- Test: `tests/test_state_authority.py` - -**Interfaces:** -- Produces: `implementation-blocked-context/v2` with `product_result_schema_version`, `product_result_sha256`, and exact `ordered_path_states` captured at the block boundary. -- Produces: `blocked_workspace_paths(...)` including current Delete paths for v2 baselines. -- Consumes test helper: `reviewing_delete_program()` from `tests/program_bootstrap_support.py`; expose its fixture observation as `BootstrapFixture.observation() -> RepositoryObservation` there. -- Preserves: blocked-context/resolution/command v1 and the existing prohibition on entering blocked from `remediating`. - -- [ ] **Step 1: Write failing block/resume tests** +Exact setup-v2 accept-stop approval order: ```python -def test_reviewing_delete_can_block_and_resume_only_with_the_same_absence(self) -> None: - fixture, program_root, observation = reviewing_delete_program() - try: - receipt = BLOCKED.block_current_program( - program_root, - BLOCKED.BlockedTransitionRequest( - reason_code="review-evidence-unavailable", - recovery_criteria=("Review evidence is available.",), - evidence_bindings=(), - ), - observation, - ) - self.assertEqual(receipt.increment_state, "blocked") - status = fixture.load_json("state/status.json") - self.assertEqual( - status["blocked_context"]["schema_version"], - "implementation-blocked-context/v2", - ) - self.assertEqual( - status["blocked_context"]["ordered_path_states"][0]["final_state"], - "absent", - ) - fixture.repository.joinpath("legacy.ts").write_text("restored\n", encoding="utf-8") - self.assertIn( - "blocked product path states changed", - BLOCKED.validate_blocked_context(program_root, status, fixture.observation()), - ) - finally: - fixture.close() +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", +) ``` -Also cover an implementing-state partial deletion, a post-block extra deletion, changed state order, evidence that claims a missing Delete file digest, exact prompt-bound resume, every failure-injection prefix, and v1 context byte compatibility. +Accept-continue adds only `successor_increment_id` and `successor_authority_projection_sha256`. -Add a post-block protection swap: after blocking a normal product Delete, alias its ancestor into `.git` and separately into the program root. `validate_blocked_context(...)`, resolution-candidate construction, exact resume submission, state authority, and discovery must fail without reading the protected target or appending a resolution. Retry after a persisted blocked prefix must preserve the prefix. Assert protected sentinel bytes directly because the shared repository snapshot omits `.git`. +#### Step 1: Write RED tests -- [ ] **Step 2: Run the focused tests and verify RED** +Drive one absent result through production review/diff writers. Assert exact state through evidence, packet, preparation, command, approval, and status. Reject reappearance/change/reorder/omission, stale remediation, prompt mismatch, malformed/dual records, approval-v2 on setup-v2, and approval-v3 on setup-v1. Approval v3 binds the product-result pair and omits `accepted_product_delta_sha256`. Review/acceptance prefixes classify before generic routing; setup-v1 bytes remain exact. Repeat protected assessment at each entry. ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_blocked_recovery tests.test_program_discovery tests.test_state_authority -v +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: blocked context v1 has no path-state binding and Delete is not included in plan-owned workspace paths. - -- [ ] **Step 3: Implement v2 blocked-context binding** - -Build a fresh execution assessment before writing blocked status. For a v2 baseline, bind its exact schema, result digest, and ordered current path states into the block identifier. `validate_blocked_context(...)` must reproduce all three from a fresh real `inspect_repository(...)` result before resolution; remove the synthetic inspection with empty Git directory/common-directory fields. Include Delete in `blocked_workspace_paths(...)` but exclude absent Delete targets from regular-file evidence bindings. The fresh assessment repeats `validate_delete_target_path(...)` before examining any present or absent Delete state. +Expected RED: v2 review/diff and result-bound approval do not exist. -Keep the existing status-last transaction and exact record adoption. Never recreate, restore, remove, or clean a product path during block or resume. +#### Step 2: Implement and verify GREEN -- [ ] **Step 4: Run the focused tests and verify GREEN** - -Run the Step 2 command. - -Expected: exact absence/partial-state prefixes resume; any post-block path-state change is preserved and fails closed. - -- [ ] **Step 5: Commit blocked recovery support** - -```bash -rtk git add skills/implementing-staged-plans/scripts/blocked_recovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_blocked_recovery.py tests/test_program_discovery.py tests/test_state_authority.py -rtk git commit -m "feat: preserve delete state in recovery" -``` +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 5: Carry Tombstones Through Successor Rollover +### 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` -**Interfaces:** -- Produces: `ProductPathStateV2(path, disposition, final_state, sha256)` without changing `ProductDeltaPath` v1. -- Produces: `ContinuationExtensionV2` carrying `accepted_product_path_states` and `accepted_product_result_sha256` without changing the v1 `ContinuationExtension.accepted_product_delta` contract. -- Produces: `ACCEPTED_STATE_CONTINUATION_SCHEMA_V2 = "implementation-accepted-state-continuation-binding/v2"` and `ContinuationCommandV2`, whose inherited-workspace value embeds the exact accepted product-result schema, digest, and ordered present/absent path states. -- Produces: `build_continuation_extension(...) -> ContinuationExtension | ContinuationExtensionV2 | None`, `build_accept_continue_candidate(acceptance, extension: ContinuationExtension | ContinuationExtensionV2 | None) -> DiffAcceptanceCandidate`, and `_build_accepted_state_command(...) -> ContinuationCommand | ContinuationCommandV2`, all selected by exact persisted family rather than nullable-field presence. -- Produces: `implementation-successor-authority-projection/v2`, `implementation-increment-rollover/v2`, `implementation-increment-rollover-binding/v2`, and `implementation-inherited-workspace/v2`. -- Produces: each v2 rollover record copies the accepted status's exact review-evidence, review-packet, and diff-disposition bindings, plus `accepted_diff_approval_binding = {event_id, sha256}` and the existing manifest-owned `handoff_binding`; these are immutable closure-chain evidence after the status file advances to the successor. -- Produces: accept-and-continue status bindings that retain the exact v1 or v2 diff-disposition family of the accepted stop candidate instead of rewriting v2 acceptance as v1. -- Produces: `validated_inherited_path_states(program_root, status, observation) -> tuple[InheritedPathStateV2, ...]` while preserving `validated_inherited_paths(...)` for v1. -- Produces: cumulative last-writer-wins path states only when the later increment explicitly owns the same path under a valid operation, using stable replace-in-place/append ordering rather than lexical resorting. -- Produces: manifest-v3/setup-v2 exact retry/recovery discovery for both immediate and later accepted-state continuation and rollover prefixes before generic state validation. -- Produces test fixture: `ThreeIncrementDeleteFixture` configured as setup/envelope v2 from sequence zero, with `accept_predecessor_create(path)`, `rollover(accepted_status, successor_id)`, `accept_delete(increment_id, path)`, `accept_unrelated_create(increment_id, path)`, `rollover_current(successor_id)`, and `prepare_current_plan()` methods that call production writers rather than editing lifecycle artifacts directly. Its same-path Create-then-Delete allocation declares collision `none` for Create and `accepted-predecessor` for Delete. - -- [ ] **Step 1: Write failing three-increment inheritance tests** - -```python -def test_late_delete_tombstone_survives_an_unrelated_successor(self) -> None: - fixture = ThreeIncrementDeleteFixture() - try: - first = fixture.accept_predecessor_create("legacy.ts") - second = fixture.rollover(first, "SECOND") - self.assertEqual( - second["inherited_workspace_binding"]["inherited_path_states"], - [{ - "path": "legacy.ts", - "final_state": "present", - "disposition": "Create", - "sha256": fixture.sha256("legacy.ts"), - }], - ) - fixture.accept_delete("SECOND", "legacy.ts") - third = fixture.rollover_current("THIRD") - self.assertEqual( - third["inherited_workspace_binding"]["inherited_path_states"], - [{ - "path": "legacy.ts", - "final_state": "absent", - "disposition": "Delete", - "sha256": None, - }], - ) - fixture.prepare_current_plan() - fixture.repository.joinpath("legacy.ts").write_text("reappeared\n", encoding="utf-8") - with self.assertRaisesRegex(ValueError, "inherited absent path reappeared: legacy.ts"): - fixture.prepare_current_plan() - finally: - fixture.close() -``` - -The first increment's exact file map must be v2 with an empty Delete section, create `legacy.ts` from an absent baseline, and be fully accepted before the SECOND Delete increment is prepared. SECOND must receive `legacy.ts` as inherited-present and validate the Delete allocation's `accepted-predecessor` collision before accepting its absence. Do not substitute unrelated first-path and deleted-path names; this test proves the same path's real later-Delete lifecycle. - -Assert immediate accept-and-continue and later accepted-state continuation both retain `implementation-diff-disposition-binding/v2`; a hard-coded v1 rewrite must fail before rollover. For later continuation, assert the rendered command schema is `implementation-accepted-state-continuation-binding/v2` and its `inherited_workspace.accepted_product_result` is exactly `{schema_version: implementation-product-path-states/v2, sha256, ordered_path_states}`, including `sha256: None` for the absent Delete state. Preserve one byte-exact v1 prompt fixture. Submitting a v1 command to v2 accepted status or a v2 command to v1 accepted status must fail schema parsing before any continuation record is written. - -Add a positive recreation case where a later exact plan explicitly owns `legacy.ts` as Create from an inherited absent baseline. Add negative cases for implicit recreation, Delete against inherited absence, Modify/Preserve against absence, Create against inherited presence, omitted/reordered/duplicated state, mixed v1/v2 continuation commands and rollover chains, `str(None)`, and a current result that is not the exact reviewed/diff-accepted v2 result. Add an ordering case whose first result has two paths in non-lexical exact-map order and whose second result replaces the first path and adds a new path: the replacement must keep its existing cumulative slot, the untouched state must keep its slot, and the new state must append in current result order. - -Assert that every completed v2 rollover record contains byte-reproducible accepted review-evidence/packet bindings, the complete v2 diff-disposition binding, one uniquely matching diff-approval record digest, and the existing handoff path/digest. Delete or tamper with the nonfinal review evidence, review packet, diff approval, or handoff and require `_validated_completed_rollover_records(...)`, state authority, discovery, later rollover, and closure preflight to fail. Do not add a handoff addendum field, filename, writer, or schema. - -Before consuming a current result or cumulative inherited state, replace an allowed Delete target with a symlink/alias into `.git` or the program root, and separately inject `.git/config` or a manifest control path into a v2 rollover record. Immediate continuation, later continuation, rollover retry/adoption, state authority, and discovery must repeat the canonical protection check and stop before action/grant/handoff/status writes. Preserve an allowed product tombstone through the same paths as the positive control. - -- [ ] **Step 2: Run focused continuation/rollover tests and verify RED** - -```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_discovery tests.test_state_authority -v -``` - -Expected: current continuation coerces `None` to a string and current rollover requires each accepted path to remain a regular file with a digest. - -- [ ] **Step 3: Implement versioned accepted-result consumption and cumulative merge** - -For v2, load the exact product result from current review evidence, freshly reassess it with a real repository inspection and the canonical Delete protection context, and compare it with the diff binding before constructing continuation authority. Use a separate dataclass: - -```python -@dataclass(frozen=True) -class ProductPathStateV2: - path: str - disposition: str - final_state: str - sha256: str | None - -@dataclass(frozen=True) -class ContinuationExtensionV2: - successor_increment_id: str - successor_brief_bytes: bytes - accepted_product_path_states: tuple[ProductPathStateV2, ...] - accepted_product_result_sha256: str - checkpoint_id: str - rollover_authorization_id: str - successor_grant_id: str - successor_projection: Mapping[str, object] - -@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 - accepted_product_result_schema_version: str - accepted_product_result_sha256: str - successor_approval_mode: str - selected_workspace: Mapping[str, object] - inherited_workspace: Mapping[str, object] - allowed_conditional_action_ceiling: tuple[str, ...] -``` - -Never pass v2 entries through `ProductDeltaPath(sha256: str)`, `ContinuationExtension`, or `ContinuationCommand`. Build the v2 inherited-workspace input exactly as: - -```python -{ - "selected_workspace": selected_workspace, - "accepted_product_result": { - "schema_version": "implementation-product-path-states/v2", - "sha256": product_result_sha256, - "ordered_path_states": [asdict(item) for item in path_states], - }, -} -``` - -In `program_continuation.py::build_accept_continue_candidate(...)`, dispatch from the exact accepted-stop binding schema and emit the matching v2 binding and command rather than unconditionally importing/writing `DIFF_DISPOSITION_BINDING_SCHEMA` and `DIFF_DISPOSITION_COMMAND_SCHEMA`; reject a mixed acceptance/projection family. The v2 rollover record carries the accepted current result plus the canonical cumulative `inherited_path_states` and digest. Before the accepted status is replaced, copy its exact `review_evidence_binding`, `review_packet_binding`, and full v2 `diff_disposition_binding`; find the unique canonical approval record named by `approval_event_id` and bind its canonical JSON-line SHA-256. Retain the existing `handoff_binding` and validate its manifest-derived path and bytes during every completed-chain read. These fields belong only to rollover v2; do not change v1 bytes. - -Each current result already follows operation-section order plus exact file-map order. Merge accepted increments without sorting: start with the prior cumulative list; for each current state in order, replace an existing path in its current list position only when the current exact operation inventory owns that path and its baseline agrees with the inherited state; append a newly owned path at the end. Reject duplicate paths in either input. Call `validate_delete_target_path(...)` for every current or inherited Delete state before merge or filesystem reassessment. This deterministic replace-in-place/append rule is part of the v2 digest contract; preserve the v1 lexical merge and bytes unchanged. - -`validated_inherited_path_states(...)` validates every completed v2 rollover record, action, grant, review evidence, review packet, diff decision, diff approval, handoff, and cumulative digest. It requires present files to match exact digests and absent files to remain absent after fresh protection validation. Mixed result families or an unresolvable/empty Git protection context stop before persistence. - -- [ ] **Step 4: Render and parse exact accepted-state continuation families** - -Keep `ACCEPTED_STATE_CONTINUATION_SCHEMA`, `ContinuationCommand`, `_immediate_base_seed(...)`, and every rendered v1 field and byte unchanged. Add `ACCEPTED_STATE_CONTINUATION_SCHEMA_V2` and construct `ContinuationCommandV2` only when the accepted status has the exact v2 diff binding paired with `implementation-product-path-states/v2`. The v2 base seed and command bind `accepted_product_result_schema_version`, `accepted_product_result_sha256`, and the exact `inherited_workspace.accepted_product_result.ordered_path_states`; no field is inferred from a nullable digest. - -Make `_build_accepted_state_command(...)` return `ContinuationCommand | ContinuationCommandV2` after exact persisted-family dispatch. In both `validate_submitted_continuation_prompt(...)` paths, build the expected command from persisted state first, call `parse_exact_prompt(submitted_prompt, expected.schema_version)`, and then compare `render_exact_prompt(asdict(expected))` byte-for-byte. `program_rollover.py` accepts the union and selects v1 delta or v2 path-state fields only from the command type/schema pair. Reject a prompt schema, diff binding, successor projection, accepted result, or rollover family mismatch before the first action-authorization append. The v1 prompt golden, parser errors, and accepted-state rollover bytes must remain exact. - -- [ ] **Step 5: Consume inherited states in successor baselines** - -`program_activation.py::_build_plan_candidate(...)` stores validated v2 inherited states in baseline v2 and strips their expected Git dirt from user-work observation. `repository_preparation.py::validate_execution_workspace(...)` validates untouched inherited states throughout the successor after repeating the canonical Delete protection check. It allows an explicit Create only from inherited absence and Modify/Delete/Preserve only from inherited presence; no current operation means the inherited state must remain exact. A path becoming protected is invalid even when its absent/present digest is otherwise unchanged. - -`state_authority.py` validates exact v1 or v2 rollover/binding pairs and delegates to the matching inherited validator. Do not modify v1 cumulative-path or digest behavior. - -- [ ] **Step 6: Classify exact v2 continuation and rollover prefixes** - -In `program_discovery.py::_load_setup_candidate(...)`, run `inspect_increment_rollover(...)` for manifest-v3/setup-v2 accepted status before full state-authority validation and before generic accepted/resume routing. For both immediate accept-and-continue and later accepted-state continuation, interrupt after the rollover action authorization, successor grant, handoff, successor brief, rollover record, and successor status. Exact early prefixes return `increment-continuation-retry-ready` or `accepted-state-continuation-retry-ready`; exact navigation/record prefixes return `increment-rollover-retry-ready` or `accepted-state-rollover-retry-ready`; completed status returns `resume`. A substituted command/result family, missing/out-of-order record, changed path state/digest, or divergent prefix returns `continuation-recovery-required` or `accepted-state-continuation-recovery-required`, never generic `invalid`, `accepted-stop`, or `resume`. Preserve the existing setup-v1 and manifest-v2 route bytes and names. - -- [ ] **Step 7: Run focused continuation/rollover tests and verify GREEN** - -Run the Step 2 command. +**Contracts:** -Expected: present identities and absent tombstones survive unrelated increments; explicit recreation is valid; implicit or mixed-family state changes fail before writes. +- `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` -- [ ] **Step 8: Commit rollover inheritance** - -```bash -rtk git add skills/implementing-staged-plans/scripts/program_continuation.py skills/implementing-staged-plans/scripts/program_rollover.py skills/implementing-staged-plans/scripts/program_activation.py skills/implementing-staged-plans/scripts/repository_preparation.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_program_continuation.py tests/test_program_rollover.py tests/test_multi_increment_lifecycle.py tests/test_program_activation.py tests/test_program_discovery.py tests/test_state_authority.py -rtk git commit -m "feat: inherit accepted delete tombstones" -``` - ---- - -### Task 6: Reconcile the Complete Accepted Path-State Chain at Closure - -**Files:** -- Modify: `skills/implementing-staged-plans/scripts/continuity_closure.py` -- Modify: `skills/implementing-staged-plans/scripts/program_closure.py` -- Modify: `skills/implementing-staged-plans/scripts/program_discovery.py` -- Modify: `skills/implementing-staged-plans/scripts/state_authority.py` -- Test: `tests/test_continuity_closure.py` -- Test: `tests/test_program_closure.py` -- Test: `tests/test_program_discovery.py` -- Test: `tests/test_multi_increment_lifecycle.py` -- Test: `tests/test_state_authority.py` - -**Interfaces:** -- Produces: `implementation-closure-reconciliation/v2`, `implementation-closure-packet/v2`, `implementation-closure-preparation/v2`, `implementation-program-closure-command/v2`, and `implementation-program-closure-command-binding/v2` for a v2 accepted chain. -- Produces: `AcceptedResultBindingV2(increment_id, product_result_schema_version, product_result_sha256, ordered_path_states, review_evidence_path, review_evidence_sha256, review_packet_path, review_packet_sha256, diff_approval_event_id, diff_approval_sha256, handoff_path, handoff_sha256)`; the final increment alone has both handoff fields `None`. -- Produces: reconciliation fields `accepted_result_bindings`, `final_inherited_path_states`, and `final_inherited_path_states_sha256`; `accepted_artifact_bindings` contains review-evidence/review-packet bindings for every accepted increment and the existing handoff binding for every nonfinal increment, never a v2 handoff addendum. -- Produces: `_accepted_increment_chain_v2(...)` and `_requirement_dispositions_v2(...)` as deterministic internal constructors over the exact rollover chain, final accepted status, immutable setup/traceability allocation, accepted review/diff evidence, and later accepted results. -- Produces: exact v2 discovery classification for closure-preparation and closure-approval retry/recovery prefixes. -- Consumes test helper: `accepted_three_increment_delete_program() -> ThreeIncrementDeleteFixture`, which extends the Task 5 fixture through accepted `THIRD` state with current review/diff evidence intact. -- Preserves: all v1 closure dataclasses, renderers, commands, approvals, and singleton first-increment closure bytes. - -- [ ] **Step 1: Write failing closure-chain tests** +Exact setup-v2 rollover action order: ```python -def test_v2_closure_binds_every_accepted_result_and_final_tombstone(self) -> None: - fixture = accepted_three_increment_delete_program() - try: - candidate = CLOSURE.build_closure_preparation( - fixture.program_root, fixture.observation() - ) - reconciliation = json.loads(candidate.reconciliation_bytes) - self.assertEqual( - reconciliation["accepted_increment_ids"], - ["FIRST", "SECOND", "THIRD"], - ) - self.assertEqual(len(reconciliation["accepted_result_bindings"]), 3) - self.assertEqual( - { - item["requirement_id"]: item["owner"] - for item in reconciliation["requirement_dispositions"] - }, - { - "REQ-FIRST": "FIRST", - "REQ-DELETE": "SECOND", - "REQ-FINAL": "THIRD", - }, - ) - self.assertEqual( - next( - item - for item in reconciliation["final_inherited_path_states"] - if item["path"] == "legacy.ts" - )["final_state"], - "absent", - ) - self.assertNotIn( - "legacy.ts", - reconciliation["requirement_dispositions"][0]["evidence_paths"], - ) - finally: - fixture.close() +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", +) ``` -`accepted_three_increment_delete_program()` must give the immutable traceability three independently owned requirements: `REQ-FIRST` assigned only to FIRST, `REQ-DELETE` assigned only to SECOND, and `REQ-FINAL` assigned only to THIRD. It creates and accepts `legacy.ts` in FIRST under setup/file-map/baseline/result v2 with an empty Delete section, validates it as inherited-present with collision `accepted-predecessor`, accepts its Delete in SECOND, and accepts an unrelated `final.txt` Create in THIRD before closure. The final plan allocates both manifest-owned closure paths. Assert closure succeeds, attributes the three owners exactly, retains FIRST and SECOND evidence, and reaches closure assertions rather than reporting that earlier-only requirements are unallocated from THIRD. +#### Step 1: Write RED rollover and application-path tests -Add failures for a missing/reordered/duplicated accepted increment; a traceability allocation absent from the accepted chain; missing/tampered earlier review evidence, review packet, diff decision, diff approval, or nonfinal handoff; changed result digest; lost tombstone; unexpected reappearance; stale later-invalidation check; mixed v1/v2 chain; and absent path represented as an evidence file. A later unrelated `final.txt` result must not invalidate REQ-FIRST or REQ-DELETE. A THIRD recreation/change of `legacy.ts` without assigning REQ-DELETE to THIRD or recording a resolved disposition must invalidate REQ-DELETE and block closure; the same change is valid when THIRD is explicitly added to that requirement's immutable allocation and has accepted review/diff evidence. +Prove immediate/later continuation retains v2 diff binding and embeds the exact result; cumulative states replace owned paths in place and append in result order; tombstones survive unrelated successors; only explicit Create from inherited absence recreates; rollover v2 copies review evidence/packet, full diff binding, unique approval-v3 binding, existing handoff, result pair, 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. -Exercise `current_disposition` values explicitly. `allocated`, `implemented`, and `resolved` produce closure `implemented` only with a complete accepted allocation/evidence chain. `amended` requires a decision reference present in both approved and resolved amendment IDs. `deferred` requires one exact deferral with a non-`none` owner and decision reference. `rejected` and `not-applicable` retain their disposition and first traceability-ordered decision reference. A missing assignment/evidence, unsupported disposition, unmatched amendment, ownerless/mismatched deferral, or unresolved later invalidation increments the blocker and stops before writes; never fabricate `implemented`, final-increment ownership, or `approval_reference="none"` for a satisfied implemented requirement. - -For protection closure coverage, accept a normal product tombstone, then alias its path into `.git` and separately into the program root before preparation and before approval retry. `build_closure_preparation(...)`, state authority, discovery, and command construction must fail before reading the protected target or persisting/adopting closure bytes. Assert Git/control sentinels directly. - -In `tests/test_program_discovery.py`, interrupt v2 closure preparation after each persisted reconciliation/packet prefix. Require a byte-exact prefix to return `closure-preparation-retry-ready`, packet-without-reconciliation or any changed/reordered v2 path state/digest to return `closure-preparation-recovery-required`, an exact persisted closure approval before status-last completion to return `closure-approval-retry-ready`, and any substituted v1 closure-preparation/command binding or divergent closed status to return `closure-approval-recovery-required`. Assert the same disposition names and bytes remain unchanged for v1. - -- [ ] **Step 2: Run closure tests and verify RED** +Add one replay using production writers: setup-v2 publication and activation, authorized Delete plan, `delete_bound_regular_file("legacy.ts")`, production review and accept-continue, completed rollover, exact inherited absent state, fresh authority, and discovery `resume`. The fixture never directly unlinks or hand-writes records. Negative variants cover hard links, symlinks, ancestor/final swaps, protected paths, changed content, and reappearance. ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_continuity_closure tests.test_program_closure tests.test_program_discovery tests.test_multi_increment_lifecycle tests.test_state_authority -v -``` - -Expected: current production closure emits only the final increment and has no cumulative path-state binding. - -- [ ] **Step 3: Add versioned closure values and validators** - -Keep `ClosureReconciliation` and `ClosurePacket` unchanged. Add v2 dataclasses with the three new result fields and exact schema-specific constructors/validators/renderers. Canonical validation requires: - -```python -@dataclass(frozen=True) -class AcceptedResultBindingV2: - increment_id: str - product_result_schema_version: str - product_result_sha256: str - ordered_path_states: tuple[Mapping[str, object], ...] - review_evidence_path: str - review_evidence_sha256: str - review_packet_path: str - review_packet_sha256: str - diff_approval_event_id: str - diff_approval_sha256: str - handoff_path: str | None - handoff_sha256: str | None -``` - -The validator requires accepted-result bindings in exact accepted-increment order, one unique review evidence/packet and diff approval for each increment, and a handoff path/digest on every nonfinal binding only. For v2, `accepted_artifact_bindings` must equal the ordered labels/digests `increment:review-evidence`, `increment:review-packet`, then `increment:handoff` for each nonfinal increment. Reject optional or complete `handoff-addendum` coverage in v2. Leave the existing v1 validator, legacy `ContinuityHandoff` fields, fixtures, renderers, and addendum rule byte-for-byte unchanged. - -Canonical final-state validation requires: - -```python -expected_state_digest = hashlib.sha256( - json.dumps( - list(candidate.final_inherited_path_states), - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") -).hexdigest() -if candidate.final_inherited_path_states_sha256 != expected_state_digest: - issues.append("final inherited path-state digest mismatch") -``` - -Do not put absent product paths in `evidence_paths`; bind their typed result and final-state digest instead. Requirement evidence paths are only manifest-owned review evidence, review packets, nonfinal handoffs, and the final reconciliation/closure-packet paths. The reconciliation may name the final closure paths, but it must not include its own or the packet's digest in `accepted_artifact_bindings`: the v2 preparation binding, awaiting/closed status, exact command, and approval bind both finalized digests after construction and avoid a circular hash. - -- [ ] **Step 4: Build closure from the canonical rollover chain** - -In `program_closure.py::build_closure_preparation(...)`, dispatch on the exact setup family paired with the accepted product-result schema. For v2, enumerate Task 5's fully validated `program_rollover.py::_validated_completed_rollover_records(...)` plus the final accepted status in order. The chain must begin at `setup_semantics.first_increment_id`, be contiguous and duplicate-free, end at status-current, contain every traceability-assigned increment exactly where declared, and leave no allocated successor. Build each `AcceptedResultBindingV2` from the rollover record's copied accepted bindings for nonfinal increments and the final status's live bindings for the final increment. Revalidate the manifest-derived review evidence/packet files, unique diff approval, typed result bytes/digest/order, and each nonfinal manifest-owned handoff path/digest before using them. - -Replace `_traceability_context(traceability, final_increment_id)` on the v2 route with deterministic chain-aware construction: - -```python -accepted_ids = tuple(item.increment_id for item in accepted_results) -for requirement in traceability["atomic_requirements"]: - assigned = tuple(requirement["assigned_increments"]) - assigned_in_chain_order = tuple(item for item in accepted_ids if item in assigned) - if assigned != assigned_in_chain_order or not assigned: - unresolved += 1 - continue - owner = assigned[-1] - contributing = tuple( - item for item in accepted_results if item.increment_id in assigned - ) - later = accepted_results[accepted_ids.index(owner) + 1 :] - invalidated = later_result_invalidates(contributing, later, requirement) +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 ``` -`later_result_invalidates(...)` compares the canonical path states contributed by assigned increments with every later accepted delta. An unrelated new path is non-invalidating. A later change to a contributed path is invalidating unless that later increment is also assigned to the requirement or the traceability carries a closure-valid amended/deferred/rejected/not-applicable disposition and decision reference. Every later increment must still have a valid requirements-scope review, accepted packet, and diff approval; a material finding whose `affected_requirement_or_invariant` names the requirement must be fully repaired and renewed before it can count as checked. Set `later_invalidation_checked=True` only after all later accepted results pass, and keep `later_invalidation_checks` in exact accepted-chain order. - -For `allocated`, `implemented`, or `resolved`, emit closure `implemented` only after all assigned increments have accepted evidence and no later invalidation; set `owner` to the last assigned accepted increment and `approval_reference` to that owner's diff-approval event. For `amended`, require one traceability-ordered decision reference present in both `approved_amendment_ids` and `resolved_amendment_ids`. For `deferred`, require exactly one matching deferral tuple with a non-`none` owner and a decision reference. Preserve `rejected` and `not-applicable` only with a decision reference. Invalid allocation/evidence, unresolved or mismatched amendments, ownerless deferrals, unsupported dispositions, and unhandled invalidation increment the exact closure blocker and stop before writes. - -Build each requirement's stable de-duplicated evidence paths in accepted-chain order from the assigned increments' review evidence and review packet, the existing handoff for any assigned nonfinal increment, then the manifest-owned final reconciliation and closure packet. Do not substitute product paths, and do not create a handoff addendum. Merge the final current result into the validated cumulative inherited states only after every path repeats the canonical Delete protection check; then construct v2 reconciliation and packet. +Expected RED: accepted absence cannot survive current continuation/rollover. -Version closure preparation, prompt, approval, command, and status bindings together. The v2 preparation/status/command binding includes the ordered accepted-result-binding digest, final inherited-state digest, reconciliation digest, and closure-packet digest. `state_authority.py::_validate_closure_readiness(...)` obtains a fresh real repository inspection, repeats protected Delete validation, and recomputes the complete accepted chain, per-requirement owners/evidence/dispositions/later checks, and exact final-state digest. Change `program_discovery.py::_exact_closure_prefix_disposition(...)` to accept only the exact v1 diff/preparation/command family or exact v2 family, rebuild the matching closure candidate for retry classification, and route every divergent partial v2 prefix to the existing preparation/approval recovery dispositions. In `_load_setup_candidate(...)`, run that exact classifier before full state-authority validation and before generic awaiting-closure/terminal routing. Remove its hard-coded v1 diff-binding and closure-preparation gates without using field presence as schema inference. Existing v1 closure remains on its current singleton or legacy route. +#### Step 2: Implement and verify GREEN -- [ ] **Step 5: Run closure tests and verify GREEN** +Load accepted results only from exact review/diff/approval-v3 state. Carry the result pair 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 and absent tombstones. Validate existing handoff and copied evidence on every completed-chain read. Classify exact prefixes before full authority and generic routing. -Run the Step 2 command. - -Expected: closure succeeds only when all accepted increments and the final cumulative present/absent state are exact; every tamper fails before closure persistence. - -- [ ] **Step 6: Commit closure reconciliation** - -```bash -rtk git add skills/implementing-staged-plans/scripts/continuity_closure.py skills/implementing-staged-plans/scripts/program_closure.py skills/implementing-staged-plans/scripts/program_discovery.py skills/implementing-staged-plans/scripts/state_authority.py tests/test_continuity_closure.py tests/test_program_closure.py tests/test_program_discovery.py tests/test_multi_increment_lifecycle.py tests/test_state_authority.py -rtk git commit -m "feat: reconcile deleted paths at closure" -``` +Do not add requirement ownership or closure. Run the Step 1 command. --- -### Task 7: Add the Final PipeFlow Integration Regression and Synchronize Release Contracts +### Task 5: Synchronize PLUG-001 Documentation and Package Version **Files:** -- Create: `tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json` -- Create: `tests/test_delete_operation_lifecycle.py` -- Modify: `docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md` -- Modify: `docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md` -- Modify: `skills/implementing-staged-plans/SKILL.md` -- Modify: `skills/implementing-staged-plans/agents/openai.yaml` -- Modify: `skills/implementing-staged-plans/references/program-authority.md` -- Modify: `skills/implementing-staged-plans/references/program-discovery.md` + +- 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/continuity-closure.md` -- Modify: `docs/reference.md` -- Modify: `docs/workflows.md` -- Modify: `docs/troubleshooting.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/maintainers.md` -- Modify: `docs/installation.md` -- Modify: `.codex-plugin/plugin.json` -- Modify: `.claude-plugin/plugin.json` -- Modify: `.claude-plugin/marketplace.json` +- 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` -- Test: `tests/test_distribution_documentation.py` -- Test: `tests/test_package_validation.py` - -**Interfaces:** -- Produces: `load_pipeflow_delete_inventory() -> tuple[str, tuple[str, ...]]` returning the source SHA-256 and exactly 27 normalized paths. -- Produces: a deterministic temporary-repository replay from an initially absent characterization path through predecessor creation/acceptance, later 27-path Delete, unrelated rollover, and final closure. -- Produces: `DeleteLifecycleFixture(existing_delete_paths: Sequence[str], later_created_delete_path: str, source_sha256: str)` configured as setup/envelope v2 from sequence zero, with the exact production-writer methods used in Step 2: `validate_and_publish_proposal()`, `render_setup_recap()`, `approve_activate_and_start()`, `prepare_create_and_accept_predecessor()`, `assert_characterization_is_inherited_present()`, `rollover_to_delete_increment()`, `prepare_and_authorize_delete_plan()`, `delete_every_target()`, `review_and_accept_delete_result()`, `rollover_to_unrelated_increment()`, `prepare_and_accept_unrelated_increment()`, `assert_every_target_is_inherited_absent()`, and `prepare_final_closure()`. -- Produces: package version `0.1.3` on all existing version owners. -- Preserves: the external pipeFlow source and workspace as read-only inputs. - -- [ ] **Step 1: Create the frozen real-scenario inventory** - -Create this exact JSON fixture: - -```json -{ - "schema_version": "pipeflow-delete-scenario/v1", - "source_plan_sha256": "a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b", - "source_task": "Task 8: Migrate Use Cases and Delete the Legacy Architecture", - "delete_paths": [ - "src/pipeFlow.ts", - "src/helpers/helpers-error.ts", - "src/helpers/index.ts", - "src/helpers/utils.ts", - "src/types/context.ts", - "src/types/error.ts", - "src/types/flow.ts", - "src/types/helpers.ts", - "src/types/index.ts", - "src/types/internals.ts", - "src/types/middleware.ts", - "src/utils/const.ts", - "src/utils/context.ts", - "src/utils/fp.ts", - "src/utils/guards-messages.ts", - "src/utils/guards-reasons.ts", - "__tests__/compatibility.test.ts", - "__tests__/error.test.ts", - "__tests__/flow.test.ts", - "__tests__/gard.test.ts", - "__tests__/pipeFlow.integration.test.ts", - "__tests__/public-api.types.ts", - "__tests__/subFlow.ts", - "__tests__/utils.test.ts", - "__tests__/fixtures/data.ts", - "__tests__/fixtures/helpers.ts", - "test/legacy/characterization.test.ts" - ] -} -``` - -The loader rejects a non-27 count, duplicate, unsafe path, wrong order, missing source digest, or directory-like entry. - -- [ ] **Step 2: Write the final proposal-to-closure integration regression** - -In `tests/test_delete_operation_lifecycle.py`, verify the fixture's final path is exactly `test/legacy/characterization.test.ts`. Build a temporary Git repository where the other 26 Delete targets are existing regular files but that characterization path is absent. Configure a Delete-capable setup/envelope v2 program with a predecessor increment, the later 27-path Delete increment, and an unrelated successor. Exercise real production writers and validators: - -```python -def test_pipeflow_delete_inventory_replays_proposal_to_closure(self) -> None: - source_sha256, delete_paths = load_pipeflow_delete_inventory() - self.assertEqual(len(delete_paths), 27) - later_created = "test/legacy/characterization.test.ts" - self.assertEqual(delete_paths[-1], later_created) - fixture = DeleteLifecycleFixture(delete_paths[:-1], later_created, source_sha256) - try: - self.assertFalse(fixture.repository.joinpath(later_created).exists()) - self.assertTrue( - all(fixture.repository.joinpath(path).is_file() for path in delete_paths[:-1]) - ) - fixture.validate_and_publish_proposal() - recap = fixture.render_setup_recap() - self.assertTrue(all(path in recap for path in delete_paths)) - fixture.approve_activate_and_start() - fixture.prepare_create_and_accept_predecessor() - fixture.assert_characterization_is_inherited_present() - fixture.rollover_to_delete_increment() - fixture.prepare_and_authorize_delete_plan() - fixture.delete_every_target() - fixture.review_and_accept_delete_result() - fixture.rollover_to_unrelated_increment() - fixture.prepare_and_accept_unrelated_increment() - fixture.assert_every_target_is_inherited_absent() - closure = fixture.prepare_final_closure() - self.assertEqual( - [item["path"] for item in closure["final_inherited_path_states"] if item["final_state"] == "absent"], - list(delete_paths), - ) - self.assertEqual( - { - item["requirement_id"]: item["owner"] - for item in closure["requirement_dispositions"] - }, - fixture.expected_requirement_owners, - ) - finally: - fixture.close() -``` - -The proposal contains two exact allocations for the characterization path: Create in the predecessor with absent/none/`None`/none facts, and Delete in the later increment with regular-file/none/`100644`/`accepted-predecessor` facts. The other 26 Delete allocations use collision `existing`. Give the predecessor creation requirement, Delete requirement, and unrelated final requirement distinct traceability ownership so the replay proves PLUG-002's earlier-increment closure semantics. The predecessor exact plan must use file-map/baseline/result v2 with an empty Delete section, create the characterization file, reach exact accepted status, and rollover it as inherited-present before the Delete plan is prepared; discovery must return `accepted-stop` at that boundary. The Delete plan then lists all 27 paths in frozen order and its baseline validates the two collision classes separately. The unrelated successor also uses v2 with an empty Delete section, allocates the final closure artifacts, and contributes only an unrelated product result. Closure must reach its intended assertions with the earlier requirements attributed to their actual accepted increments, not fail because they are absent from the final increment's allocation. +- Test: `tests/test_front_door_contract.py`, `tests/test_distribution_documentation.py`, `tests/test_package_validation.py` -Add negatives that pre-create the characterization path before its Create baseline, omit one of the other 26 paths before the Delete baseline, or declare the characterization Delete collision as `existing`; each must fail the production allocation-fact check before the corresponding plan/baseline write. Add `test_external_pipeflow_source_matches_frozen_inventory`, guarded only by `PIPEFLOW_PLAN_PATH`; when supplied, it computes the exact SHA-256, extracts Task 8's Delete bullets, and compares the ordered 27-path tuple with the fixture. The deterministic suite uses the frozen fixture and never requires the external path. +Set existing version owners to `0.1.3` without changing plugin identity or manifest field sets. -- [ ] **Step 3: Run the final integration regression and verify GREEN** +Document once at canonical owners: v1 versus v2 operations; descriptor-bound mutation and local authority limit; exact transition/review/diff/continuation/rollover families; approval-v3/action-v3 result bindings; Git/program/control protection and unsupported platforms; absent results, recovery stops, tombstones, explicit recreation; reuse of existing handoff; and PLUG-002 requirement-evidence/closure dependency. -Tasks 1–6 already own and test every required schema, writer, validator, retry route, and rollover/closure behavior. Task 7 adds one cross-component regression over those completed contracts; it is not a new behavior RED. Run it immediately after writing the fixture and test: +#### Step 1: Update expectations and observe RED ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle -v +rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_front_door_contract tests.test_distribution_documentation tests.test_package_validation -v ``` -Expected: the frozen scenario passes on the unchanged Tasks 1–6 implementation and the optional external-source test is skipped. A failure is an integration defect in Tasks 1–6: repair it in the owning earlier task/commit, rerun that task's focused checks, then rerun this final regression. Do not treat a failing final integration test as a new Task 7 feature implementation. - -Run the live identity replay against the locked read-only source: - -```bash -rtk env PYTHONDONTWRITEBYTECODE=1 PIPEFLOW_PLAN_PATH=/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md python3 -m unittest tests.test_delete_operation_lifecycle -v -``` - -Expected: no skip; source digest and ordered Task 8 inventory match; all 27 tombstones survive the unrelated rollover and final closure. - -- [ ] **Step 4: Update canonical documentation without overstating authority** - -Document both grammars exactly: - -```markdown -Legacy setup/envelope v1 and unversioned exact-file maps support Create, Modify, -and Preserve only. Delete-capable programs use setup/envelope v2 and an exact -file map marked `implementation-exact-file-map/v2` with ordered Create, Modify, -Delete, and Preserve sections. Delete authorizes only the named regular-file -absence inside the bound local `modify-workspace` action; it is not generic -destructive-operation, cleanup, migration, Git, publication, deployment, or -external-state authority. -``` - -Record authorized/implementing/reviewing state rules, typed absent results, blocked recovery, cumulative tombstones, explicit recreation, and complete-chain closure once at their canonical references; link from the skill and reader docs. Document the canonical Delete protection boundary: lexical/resolved `.git`, Git directory/common directory in normal and linked worktrees, conventional/actual program roots, and manifest-owned control paths are never Delete targets, while exact ordinary product paths remain allowed. Update `references/program-discovery.md` to make its existing prefix-before-generic-rejection rule explicit for both setup/envelope families and to enumerate exact setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure retry/recovery routes. State that advanced Move/Rename, Replace, migration groups, automatic staging/finalization, and expanded Preserve remain pending under the broader v4 design. - -In the two version-owning design specs, `references/state-authorization.md`, `references/program-discovery.md`, and the live runbook, document `implementation-execution-transition/v2` as the setup-v2 companion to baseline/result v2: list its exact product-result fields, canonical ordered-state digest, family-specific event seed, conditional remediation-digest extension, retry/adoption checks, fresh-discovery route, and cross-family rejection. Preserve the documented v1 `product_delta_sha256` shape and byte contract. - -Synchronize `implementing-staged-plans-bootstrap-execution-review-runbook.md` as a current `0.1.3` operational runbook, not historical evidence: retain its 0.1.1/0.1.2 guarantees, add setup-v2's from-first-increment file-map/baseline/result family and empty Delete sections before late Delete, document v2 accepted-stop and divergent-prefix discovery, and require closure to bind the complete accepted path-state chain, real per-requirement owners/evidence/later checks, existing nonfinal handoffs, and final cumulative digest. State explicitly that v2 creates no handoff addendum; the legacy continuity addendum contract remains v1-only. Do not rewrite older dated design plans; they remain historical version-bound records. - -- [ ] **Step 5: Synchronize package version `0.1.3`** - -Set: - -```python -PACKAGE_VERSION = "0.1.3" -``` - -Update the three plugin/marketplace manifests, installation archive examples, maintainers' current-version text, front-door snapshot, distribution tests, and package-validator expectations to the same literal version. Change no plugin name, skill path, invocation policy, marketplace owner, repository identity, or manifest field set. - -- [ ] **Step 6: Run focused package and documentation checks** +#### Step 2: Synchronize and verify GREEN ```bash -rtk env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_delete_operation_lifecycle tests.test_front_door_contract tests.test_distribution_documentation tests.test_package_validation -v +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 . ``` -Expected: the real-scenario fixture, front-door contract, documentation, synchronized version, package inventory, and package validator pass. - -- [ ] **Step 7: Commit scenario and release contracts** - -```bash -rtk git add tests/fixtures/delete-operation/pipeflow-task-8-delete-paths.json tests/test_delete_operation_lifecycle.py docs/superpowers/specs/2026-08-22-program-setup-approval-and-refactor-operations-design.md docs/superpowers/specs/2026-08-23-expanded-local-refactor-operations-design.md skills/implementing-staged-plans/SKILL.md skills/implementing-staged-plans/agents/openai.yaml skills/implementing-staged-plans/references/program-authority.md skills/implementing-staged-plans/references/program-discovery.md skills/implementing-staged-plans/references/repository-preparation.md skills/implementing-staged-plans/references/execution-discipline.md skills/implementing-staged-plans/references/review-coordination.md skills/implementing-staged-plans/references/state-authorization.md skills/implementing-staged-plans/references/continuity-closure.md docs/reference.md docs/workflows.md docs/troubleshooting.md implementing-staged-plans-bootstrap-execution-review-runbook.md docs/maintainers.md docs/installation.md .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json skills/implementing-staged-plans/scripts/validate_package.py tests/test_front_door_contract.py tests/test_distribution_documentation.py tests/test_package_validation.py -rtk git commit -m "feat: release typed delete operation support" -``` - --- -## Final Verification, Review, and Claim Gate +## Final Focused Verification and Claim Gate -- [ ] **Step 1: Reconfirm exact scope before final checks** - -```bash -rtk git status --short --branch -rtk git diff --name-only b5eb689e780f48b218b807a4691f0994474e4178...HEAD -rtk git diff --check b5eb689e780f48b218b807a4691f0994474e4178...HEAD -``` - -Expected: only this locked plan plus the File Map Create/Modify paths are changed; the tree is clean; `diff --check` reports no errors; frozen v0.1.1 and historical ISP-001 paths are absent from the diff. - -- [ ] **Step 2: Run the complete deterministic suite once on the unchanged candidate** +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 env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v +rtk git diff --check ``` -Expected: package validation passes and the complete test process exits `0`. Record the exact executed test count and skipped-platform limitations; do not convert an interrupted or partial run into a pass. - -- [ ] **Step 3: Run the locked live scenario once without changing either repository** - -```bash -rtk shasum -a 256 /private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md -rtk env PYTHONDONTWRITEBYTECODE=1 PIPEFLOW_PLAN_PATH=/private/tmp/pipeflow-effect-flow.4Ox4Wl/planning/plans/2026-09-05-effect-flow-redesign.md python3 -m unittest tests.test_delete_operation_lifecycle -v -``` - -Expected: SHA-256 is `a0dfa0574f972c1b7378b36f6021f45c8cf2b042c332a223f45d64fa0e50230b`; all scenario tests pass; the pipeFlow worktree has no writes. - -- [ ] **Step 4: Perform one bounded independent material review** - -Review only `b5eb689e780f48b218b807a4691f0994474e4178...HEAD` for correctness, fail-closed path-state behavior, compatibility, recovery, and test protection. Verify every finding against current code. Fix only confirmed material defects, rerun only affected focused checks, then rerun the full suite only if a relevant input changed after Step 2. If no material issue exists, record exactly: `No material improvements recommended.` +Record exact counts, skips, and platform limitations. Interrupted or partial output is not a pass. Do not run the unrelated full suite. -- [ ] **Step 5: Stop before publication or consumer mutation** +Limit completion claims to exact regular-file Delete under setup/envelope v2; descriptor-bound local mutation; typed absence through review, acceptance, rollover, and discovery; exact result-bound records; and focused legacy compatibility. -Report commits, exact changed paths, focused/full check evidence, scenario replay evidence, compatibility limitations, and residual risks. Do not push, open a pull request, install, update caches, edit pipeFlow, or claim Move/Rename, Replace, directory deletion, automatic rollback, external migration, production safety, or platform behavior not exercised by the completed checks. +Do not claim PLUG-002 requirement ownership, semantic invalidation, terminal closure, Move/Rename, Replace, directory deletion, automatic rollback, external migration, deployment, or untested-platform support. -## Rollback and Failure Semantics +## Failure and Recovery -- Before any v2 program artifact is persisted, the implementation commits can be reverted normally; v1 programs remain readable throughout. -- After a Delete-capable v2 setup, baseline, execution transition, review, rollover, blocked context, or closure artifact exists, do not downgrade that program to `0.1.2` or rewrite it as v1. Retain a `0.1.3` reader or ship a forward repair that preserves the v2 bytes. -- A failure before product mutation preserves the baseline file and exact partial control-plane prefix; retry may adopt only byte-identical owner-bound artifacts. -- A failure after a planned Delete while status is `implementing` preserves the absence as a valid partial product result. Recovery may block and resume from the exact bound absence; it does not restore automatically. -- A failure after review or diff acceptance must reproduce the same ordered path states and digest. Reappearance, changed content, missing result records, reordered states, or mixed schema families is divergent and stops without cleanup. -- Rollover merges a Delete tombstone only after exact diff acceptance. An unrelated successor cannot erase it; only a later exact Create operation whose baseline agrees with inherited absence can replace it. -- Closure binds the entire accepted chain, per-requirement allocation/owner/evidence/disposition, every later-invalidation check, existing nonfinal handoffs, and final cumulative state. It cannot close when a tombstone disappeared, a deleted file reappeared, an earlier accepted artifact is missing, an earlier-only requirement is falsely assigned to the final increment, a later result invalidates earlier evidence without a resolved disposition, or evidence treats an absent path as a file. -- Recovery bytes are not created by this repair. Source recovery remains a separately authorized manual or Git operation; the plugin never resets, restores, stashes, cleans, or deletes automatically. +- Before product mutation, adopt only byte-identical prefixes; divergence stops without cleanup. +- Failure before bound unlink leaves the product file unchanged. +- Unlink-boundary divergence never yields an accepted receipt; preserve the control prefix and require recovery inspection. +- Successful Delete absence is a valid implementing partial result; no automatic restore occurs. +- Review, approval, and rollover reproduce the ordered absent state. Reappearance, omission, reorder, mixed schemas, or changed evidence stops. +- Rollover preserves the tombstone until a later exact Create owns the path from inherited absence. +- PLUG-001 does not close the program; PLUG-002 must add requirement-specific accepted-chain evidence first. -## Final Validation Matrix +## Validation Matrix -| Requirement | Primary owner | Required evidence | Failure signal | +| Requirement | Owner | Required evidence | Failure signal | | --- | --- | --- | --- | -| Locked implementation baseline | Git preflight | candidate `b5eb689e...` is an ancestor of the clean kickoff HEAD on `repair/delete-operation-support`; every candidate-to-HEAD commit and aggregate path is only this plan; actual kickoff HEAD and plan SHA-256 are recorded | stop before edits | -| Locked real source | scenario fixture/live replay | SHA-256 `a0dfa057...` and exact ordered 27-path Task 8 inventory | source drift; no claim | -| Setup can state Delete truthfully | `program_setup.py` | envelope/setup v2 validates and recap renders path, absent state, disposition, rationale | unsupported or mixed schema | -| Protected targets are mechanically excluded | `build_delete_protection_context(...)`, `validate_delete_target_path(...)` | normal and linked-worktree tests reject lexical/resolved `.git`, Git directory/common directory, conventional/actual program roots, every manifest control path, symlink/case/hard-link aliases, retries, and post-authorization swaps; allowed product controls pass | missing protection metadata, control-path Delete, alias escape, protected read, or substring over-rejection | -| Legacy setup unchanged | `program_setup.py`, `program_authority.py` | v1 golden bytes and cross-family negatives | any v1 byte/result drift | -| Exact plan does not misclassify Delete | `repository_preparation.py` | unversioned heading fails; v2 parses ordered Delete section | Delete absorbed as Modify | -| Late Delete uses one program family | setup/activation/preparation/rollover | `test/legacy/characterization.test.ts` begins absent, is created/accepted by a setup-v2 predecessor with an empty Delete section, becomes inherited-present with collision `accepted-predecessor`, then is deleted with the 26 initially existing targets | pre-created fixture, false collision facts, or mixed v1/v2 rollover/closure | -| Baseline proves a real removable file | `program_activation.py`, `inspect_workspace_path(...)` | component `lstat`, workspace containment, existing regular-file digest; unsafe/user-owned targets rejected | missing/unsafe/overlap issue | -| Ancestor safety is reassessed | `inspect_workspace_path(...)`, operation callers | baseline symlinked ancestor and post-authorization ancestor swap fail before external reads; missing suffix remains valid only for Create, accepted/inherited absence, and already-absent user work | path escape, rejected valid absence, or missing required Delete/Modify/Preserve target | -| Lifecycle path-state semantics | `validate_execution_workspace(...)` | authorized exact; implementing exact-or-absent; reviewing absent; v2 result with null digest and exact-map ordering | accidental loss, fabricated digest, or reordered state | -| Execution transition matches result family | `program_activation.py`, `program_review.py`, `state_authority.py`, `program_discovery.py` | v1 keeps `product_delta_sha256`; v2 uses `implementation-execution-transition/v2` with exact product-result schema/digest and derived event; production writer output survives fresh discovery and exact retry | mixed/dual family, changed seed or digest, invalid adoption, generic discovery route, or v1 byte drift | -| Managed lifecycle writes stay separate | `state_authority.py` | required writes remain only Create/Modify/Preserve and every Delete control-path collision is rejected independently | Delete accepted for a control path | -| Review and remediation bind absence | `program_review.py`, `review_coordination.py` | v2 evidence has exact ordered states/digest and renewed result after repair | stale/missing/mixed result | -| Diff acceptance binds reviewed result | `diff_disposition.py` | v2 binding/command matches fresh review result | prompt or result mismatch | -| Discovery resumes v2 safely | `program_discovery.py` | setup-v2 plan preparation/materialization, review, acceptance, immediate/later rollover, and closure exact prefixes classify before generic state validation; divergent prefixes use their domain recovery routes | v1-only gate, invalid/generic route, wrong resume, or terminal route | -| Blocked recovery freezes path state | `blocked_recovery.py` | v2 context reproduces exact partial/complete states | post-block change or evidence fabrication | -| Rollover preserves ordered tombstones and evidence | `program_rollover.py` | accepted predecessor before Delete; replace-in-place/append merge retains absent state; v2 record binds accepted review evidence/packet, diff decision/approval, and existing handoff | reappearance, omission, reorder, missing/tampered evidence or handoff, invented addendum, or mixed chain | -| Accepted-state prompts preserve typed results | `program_continuation.py` | exact v1/v2 command dispatch and parsing; v2 embeds ordered path states with null absent digest; v1 golden bytes stay exact | cross-family prompt, `str(None)`, or v1 byte drift | -| Recreation is explicit | activation/preparation | later Create owns inherited absent path and baseline agrees | implicit recreation or wrong operation | -| Closure covers the complete chain | `program_closure.py`, `continuity_closure.py` | all accepted results/reviews/diff approvals/handoffs plus real traceability owners, evidence paths, disposition handling, later-invalidation checks, final closure bindings, and cumulative digest | singleton-only, final-owner fabrication, unresolved allocation/disposition, invalidated evidence, invented addendum, or lost tombstone | -| Front door does not over-authorize | skill/references/docs | Delete remains local plan-bound `modify-workspace` only | generic destructive/external claim | -| Operational runbook is current | bootstrap/execution/review runbook | `0.1.3` path states, discovery, and complete-chain closure match canonical owners | live runbook remains at `0.1.2` | -| Package is synchronized | manifests/validator/docs | every owner says `0.1.3`; package validation exits `0` | version or inventory mismatch | -| Full regression | complete suite | one completed exit `0`, exact count recorded | failure, interruption, or partial output | -| External boundary | final status/diff | no push, PR, install, cache sync, pipeFlow edit, or provider action | any unauthorized external mutation | +| 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, link/swap coverage | protected access | +| Actual Delete | bound delete helper | held identity/digest, dirfd unlink, absent postcondition | 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 and approval-v3 | stale/legacy binding | +| Rollover | continuation/rollover | action-v3, existing evidence/handoff, tombstone | 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 | From a3a779c54c7bd13feb616f755709120ca4be86ac Mon Sep 17 00:00:00 2001 From: CoveMB Date: Sun, 6 Sep 2026 02:15:30 -0400 Subject: [PATCH 08/19] docs: make Delete boundary lossless --- .../2026-09-05-delete-operation-support.md | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-delete-operation-support.md b/docs/superpowers/plans/2026-09-05-delete-operation-support.md index 3afa963..ad3b725 100644 --- a/docs/superpowers/plans/2026-09-05-delete-operation-support.md +++ b/docs/superpowers/plans/2026-09-05-delete-operation-support.md @@ -4,9 +4,9 @@ **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 actual bound-file deletion primitive, 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, or complete-chain closure. +**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. The PLUG-001 replay ends after the Delete result is accepted, rolled into a successor, and rediscovered as resumable. Do not add closure fields, requirement-result schemas, path-overlap heuristics, or fabricated ownership to make this plan appear terminal. +**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. @@ -38,7 +38,7 @@ The plan deliberately does not embed its own digest. Do not infer the expected d - 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 with `sha256: null`; never encode it as Modify, Preserve, omission, an empty digest, or a fabricated digest. +- 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. @@ -55,7 +55,7 @@ The plan deliberately does not embed its own digest. Do not infer the expected d 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 path check followed by hashing or unlinking reopens a race; Delete needs one descriptor-relative identity flow through mutation. +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. @@ -144,6 +144,7 @@ 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: @@ -156,11 +157,17 @@ class WorkspacePathSnapshot: link_count: int | None @dataclass(frozen=True) -class DeleteReceipt: +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 ``` @@ -168,9 +175,9 @@ V2 exact maps have ordered Create, Modify, Delete, and Preserve sections. Unvers #### Step 1: Write RED tests -Test v2 parsing including empty Delete; unversioned Delete rejection; present regular-file baseline; typed absent/null-digest result; 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. +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. Allow `.github`, `.gitignore`, and ordinary names containing `git`. +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 @@ -184,13 +191,19 @@ From a fresh repository inspection, normalize one relative POSIX path; reject ab No setup-v2 authorization may use `Path.resolve()`, `is_file()`, `read_bytes()`, or a separate check-then-open target. -Actual deletion uses production `delete_bound_regular_file(...)`, never test-side `Path.unlink()`. It receives the exact v2 baseline identity and fresh protection context, repeats the held walk/hash, requires matching device/inode/mode/digest and one link, revalidates the final name immediately before `os.unlink(name, dir_fd=parent_fd)`, then verifies the held inode lost its link, the name is absent, and ancestors are identical. A swap before unlink fails before the syscall. Syscall-boundary divergence never returns an accepted receipt and enters deterministic recovery. The helper runs only for the current authorized setup-v2 exact-plan Delete and grants no authority itself. +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. -If required primitives are unavailable, fail before v2 artifacts or mutation with `descriptor-relative no-follow Delete is unsupported on this platform`. No path fallback; legacy families do not call the primitive. +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. Pair only baseline v1 + delta v1 + transition v1, or baseline v2 + path-states v2 + transition v2. Reconstruct the pair at activation, reassessment, retry, authority, and discovery. Run the Step 1 command. +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. --- @@ -223,7 +236,7 @@ Add v2 dataclasses rather than widening v1. Pair only baseline v1 + delta v1 + t - `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}` as `product_result`. It contains no requirement-result object; PLUG-002 owns that evidence. +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: @@ -248,7 +261,7 @@ Accept-continue adds only `successor_increment_id` and `successor_authority_proj #### Step 1: Write RED tests -Drive one absent result through production review/diff writers. Assert exact state through evidence, packet, preparation, command, approval, and status. Reject reappearance/change/reorder/omission, stale remediation, prompt mismatch, malformed/dual records, approval-v2 on setup-v2, and approval-v3 on setup-v1. Approval v3 binds the product-result pair and omits `accepted_product_delta_sha256`. Review/acceptance prefixes classify before generic routing; setup-v1 bytes remain exact. Repeat protected assessment at each entry. +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 @@ -312,9 +325,9 @@ SETUP_V2_ROLLOVER_ACTION_FIELDS = ( #### Step 1: Write RED rollover and application-path tests -Prove immediate/later continuation retains v2 diff binding and embeds the exact result; cumulative states replace owned paths in place and append in result order; tombstones survive unrelated successors; only explicit Create from inherited absence recreates; rollover v2 copies review evidence/packet, full diff binding, unique approval-v3 binding, existing handoff, result pair, 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. +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, `delete_bound_regular_file("legacy.ts")`, production review and accept-continue, completed rollover, exact inherited absent state, fresh authority, and discovery `resume`. The fixture never directly unlinks or hand-writes records. Negative variants cover hard links, symlinks, ancestor/final swaps, protected paths, changed content, and reappearance. +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 @@ -324,7 +337,7 @@ 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 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 and absent tombstones. Validate existing handoff and copied evidence on every completed-chain read. Classify exact prefixes before full authority and generic routing. +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. @@ -350,7 +363,7 @@ Do not add requirement ownership or closure. Run the Step 1 command. 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; descriptor-bound mutation and local authority limit; exact transition/review/diff/continuation/rollover families; approval-v3/action-v3 result bindings; Git/program/control protection and unsupported platforms; absent results, recovery stops, tombstones, explicit recreation; reuse of existing handoff; and PLUG-002 requirement-evidence/closure dependency. +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 @@ -379,19 +392,20 @@ 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 Delete under setup/envelope v2; descriptor-bound local mutation; typed absence through review, acceptance, rollover, and discovery; exact result-bound records; and focused legacy compatibility. +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 PLUG-002 requirement ownership, semantic invalidation, terminal closure, Move/Rename, Replace, directory deletion, automatic rollback, external migration, deployment, or untested-platform support. +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. -- Failure before bound unlink leaves the product file unchanged. -- Unlink-boundary divergence never yields an accepted receipt; preserve the control prefix and require recovery inspection. -- Successful Delete absence is a valid implementing partial result; no automatic restore occurs. -- Review, approval, and rollover reproduce the ordered absent state. Reappearance, omission, reorder, mixed schemas, or changed evidence stops. -- Rollover preserves the tombstone until a later exact Create owns the path from inherited absence. -- PLUG-001 does not close the program; PLUG-002 must add requirement-specific accepted-chain evidence first. +- 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 @@ -400,12 +414,12 @@ Do not claim PLUG-002 requirement ownership, semantic invalidation, terminal clo | 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, link/swap coverage | protected access | -| Actual Delete | bound delete helper | held identity/digest, dirfd unlink, absent postcondition | recovery-required | +| 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 and approval-v3 | stale/legacy binding | -| Rollover | continuation/rollover | action-v3, existing evidence/handoff, tombstone | addendum/stale state | +| 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 | From 0931a85de69038e1938662ba96d048bdc37ccb2f Mon Sep 17 00:00:00 2001 From: CoveMB Date: Mon, 7 Sep 2026 03:20:06 -0400 Subject: [PATCH 09/19] feat: add typed Delete operation support --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- docs/installation.md | 4 +- docs/maintainers.md | 16 +- docs/reference.md | 27 +- docs/troubleshooting.md | 21 +- docs/workflows.md | 25 +- ...lans-bootstrap-execution-review-runbook.md | 81 +- skills/implementing-staged-plans/SKILL.md | 8 +- .../agents/openai.yaml | 4 +- .../references/execution-discipline.md | 10 +- .../references/program-authority.md | 6 + .../references/program-discovery.md | 6 + .../references/repository-preparation.md | 12 +- .../references/review-coordination.md | 6 + .../references/state-authorization.md | 10 +- .../scripts/diff_disposition.py | 110 +- .../scripts/program_activation.py | 466 ++- .../scripts/program_authority.py | 28 + .../scripts/program_continuation.py | 564 +++- .../scripts/program_discovery.py | 201 +- .../scripts/program_review.py | 279 +- .../scripts/program_rollover.py | 838 +++++- .../scripts/program_setup.py | 271 +- .../scripts/repository_preparation.py | 597 +++- .../scripts/review_coordination.py | 69 +- .../scripts/state_authority.py | 2489 ++++++++++++++++- .../scripts/validate_package.py | 2 +- tests/program_bootstrap_support.py | 148 +- tests/test_delete_operation_lifecycle.py | 766 +++++ tests/test_diff_disposition.py | 247 ++ tests/test_distribution_documentation.py | 91 +- tests/test_front_door_contract.py | 19 +- tests/test_package_validation.py | 4 +- tests/test_program_activation.py | 151 +- tests/test_program_authority.py | 49 +- tests/test_program_closure.py | 6 + tests/test_program_discovery.py | 225 ++ tests/test_program_review.py | 299 ++ tests/test_program_setup.py | 339 +++ tests/test_repository_preparation.py | 143 + tests/test_review_coordination.py | 29 + tests/test_state_authority.py | 713 ++++- 44 files changed, 8910 insertions(+), 475 deletions(-) create mode 100644 tests/test_delete_operation_lifecycle.py 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/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..9f68f3e 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 @@ -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 @@ -134,7 +150,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 +173,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..cd8f8dc 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,19 @@ 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. program approval; +3. workspace-selection approval; and +4. 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 +57,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 +85,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: @@ -94,18 +112,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 @@ -149,9 +171,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 +186,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; @@ -180,12 +207,14 @@ Before accepting the 0.1.2 candidate, verify: 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..132663f 100644 --- a/skills/implementing-staged-plans/SKILL.md +++ b/skills/implementing-staged-plans/SKILL.md @@ -39,12 +39,16 @@ 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 @@ -59,7 +63,9 @@ Replaying `accept-stop` only recovers or reports the same accepted-stop state. A 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. -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. +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. + +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 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/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..fb220c9 100644 --- a/skills/implementing-staged-plans/references/program-authority.md +++ b/skills/implementing-staged-plans/references/program-authority.md @@ -65,6 +65,12 @@ Every requirement must be allocated. A group-level allocation may guide preparat 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..25e2d71 100644 --- a/skills/implementing-staged-plans/references/program-discovery.md +++ b/skills/implementing-staged-plans/references/program-discovery.md @@ -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..7166b34 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,7 +55,7 @@ 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. @@ -71,9 +71,11 @@ The file map must classify approvals, status, action authorizations, increment g 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 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). 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/diff_disposition.py b/skills/implementing-staged-plans/scripts/diff_disposition.py index e15fb29..d3c86fb 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 diff --git a/skills/implementing-staged-plans/scripts/program_activation.py b/skills/implementing-staged-plans/scripts/program_activation.py index a559e4b..c09ae72 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,21 +41,33 @@ 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_state_authority, @@ -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,7 +1327,18 @@ 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"]) ) @@ -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_required_managed_file_map(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,140 @@ 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) + blocking_issues = [ + issue + for issue in state_issues + if "Delete" not in issue and "quarantine" not in issue + ] + 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 +2289,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 +2312,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 +2340,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 +2360,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 +2370,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..99cb5ec 100644 --- a/skills/implementing-staged-plans/scripts/program_authority.py +++ b/skills/implementing-staged-plans/scripts/program_authority.py @@ -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", } ) @@ -1571,6 +1574,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_continuation.py b/skills/implementing-staged-plans/scripts/program_continuation.py index bfc5f8b..10d63d5 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,7 +13,11 @@ 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, ) @@ -24,24 +29,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 +77,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 +106,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() @@ -176,7 +222,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 +236,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 +289,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 +539,7 @@ def _continuation_inputs( dict[str, object], str, bytes, - tuple[ProductDeltaPath, ...], + tuple[ProductDeltaPath, ...] | dict[str, object], str, dict[str, object], tuple[str, ...], @@ -309,14 +574,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 +628,8 @@ def _continuation_inputs( workspace, successor, brief_bytes, - product_delta, - product_delta_sha256, + accepted_product, + accepted_product_sha256, inherited_workspace, tuple(allowed), ) @@ -396,6 +674,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, @@ -422,8 +740,8 @@ def _build_continuation_extension( _workspace, successor, brief_bytes, - product_delta, - product_delta_sha256, + accepted_product, + accepted_product_sha256, inherited_workspace, allowed, ) = _continuation_inputs( @@ -441,17 +759,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 +813,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 +827,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 +851,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, @@ -567,26 +919,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 +982,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 +1009,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 +1095,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 +1133,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 +1160,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 +1203,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 +1272,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 +1283,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..0782f5e 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"), @@ -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,8 +1383,16 @@ 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)) ) @@ -1379,8 +1400,18 @@ def _load_setup_candidate( increment_state = status.get("current_increment_state") 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 +1447,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" + ) + if setup_v2: + authority_root = root.resolve() + has_rollover_prefix = bool(rollovers) or isinstance( + status.get("rollover_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 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 if setup_v2 else validate_state_authority(root, observation) + ) 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 +1910,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 +2199,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..5c2134c 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,15 +32,21 @@ ) 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_state_authority, ) @@ -48,6 +55,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) @@ -404,7 +428,12 @@ 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), @@ -427,28 +456,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 +534,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 +573,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 +598,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 +640,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 +693,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 +715,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 +749,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 +810,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 +828,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 +903,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 +914,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 +1006,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 +1099,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 +1289,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 +1417,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 +1479,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 @@ -1331,7 +1580,10 @@ def _validated_completed_rollover_records( ) expected_current: str | None = None 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") @@ -1362,9 +1614,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 +1628,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 +1693,196 @@ 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") + 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 +1905,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 +1954,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..e13a224 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") @@ -628,40 +698,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 +771,77 @@ 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" + ] + 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 +972,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 +987,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 +1031,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 +1067,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 +1109,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 +1435,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 +1669,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 +1828,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 +1875,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..1d81038 100644 --- a/skills/implementing-staged-plans/scripts/repository_preparation.py +++ b/skills/implementing-staged-plans/scripts/repository_preparation.py @@ -25,8 +25,14 @@ from state_authority import ( ActionBinding, ExactFileMap, + ExactFileMapV2, RepositoryObservation, + WorkspacePathSnapshot, decide_action_authorization, + classify_delete_quarantine_recovery, + delete_quarantine_allocation, + descriptor_protection_context, + inspect_workspace_path, validate_state_authority, ) @@ -34,6 +40,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 +181,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 +999,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 +1013,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 +1054,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 +1071,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 +1679,198 @@ 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": + receipt_snapshot = inspect_workspace_path( + program_root, + str(binding["receipt_path"]), + ) + 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..067ee82 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) +_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 @@ -60,6 +75,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 +114,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 +279,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) @@ -296,6 +396,8 @@ def required_future_lifecycle_writes( program_root: Path, workspace_root: Path, increment_id: str, + *, + delete_quarantine_bindings: Sequence[dict[str, object]] = (), ) -> tuple[ManagedWriteRequirement, ...]: """Derive disposition-aware current and future control-plane allocations.""" if ( @@ -434,6 +536,20 @@ def allocated_increment_file(target_increment: str, field: str) -> Path: _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 +563,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}" @@ -1223,6 +1344,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 +1619,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 +1649,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 +1657,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 +2060,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 +2372,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 +2558,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 +2589,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" + ) + 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 ) - assessment = validate_execution_workspace( - root, - baseline, - inspection, - increment_state=str(status["current_increment_state"]), + 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 +2666,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 +2746,7 @@ def validate_state_authority( ) == assessment.product_delta_sha256 ) - ) + ) if transition_valid: event_seed = { "program_id": status["program_id"], @@ -2303,11 +2763,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 +2796,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 +2838,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 +2877,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 +2937,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 +3048,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 +3076,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 +3090,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 +3257,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 +3309,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 +3319,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,77 +3725,1631 @@ 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: - if _kernel32 is None: - raise OSError("Windows file locking is unavailable") - handle = _kernel32.CreateMutexW(None, False, _windows_mutex_name(path)) - if not handle: - raise _windows_api_error("CreateMutexW failed") - wait_result = _kernel32.WaitForSingleObject(handle, 0xFFFFFFFF) - if wait_result in {0x00000000, 0x00000080}: - return handle - _kernel32.CloseHandle(handle) - raise OSError(f"WaitForSingleObject failed with result {wait_result}") - if _fcntl is None: - raise OSError("POSIX file locking is unavailable") - if parent.descriptor is None: - raise OSError("POSIX parent directory binding is unavailable") - descriptor = os.open( - path.name, - os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0), - dir_fd=parent.descriptor, +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), ) - try: - _fcntl.flock(descriptor, _fcntl.LOCK_EX) - except BaseException: - os.close(descriptor) - raise - return descriptor -def _release_advisory_lock(lock: object) -> None: - if _WINDOWS: - if _kernel32 is None: - raise OSError("Windows file locking is unavailable") - released = _kernel32.ReleaseMutex(lock) - closed = _kernel32.CloseHandle(lock) - if not released: - raise _windows_api_error("ReleaseMutex failed") - if not closed: - raise _windows_api_error("CloseHandle failed") - return - if _fcntl is None: - raise OSError("POSIX file locking is unavailable") - descriptor = int(lock) - try: - _fcntl.flock(descriptor, _fcntl.LOCK_UN) - finally: - os.close(descriptor) +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 _atomic_replace_bytes( - path: Path, payload: bytes, expected_sha256: str -) -> AtomicWriteReceipt: - _validate_atomic_target(path) - if not path.is_file(): - raise ValueError(f"{path}: target must be an existing regular file") - parent = _open_atomic_parent(path.parent) - try: - if not _parent_path_matches(parent): +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)) + if not handle: + raise _windows_api_error("CreateMutexW failed") + wait_result = _kernel32.WaitForSingleObject(handle, 0xFFFFFFFF) + if wait_result in {0x00000000, 0x00000080}: + return handle + _kernel32.CloseHandle(handle) + raise OSError(f"WaitForSingleObject failed with result {wait_result}") + if _fcntl is None: + raise OSError("POSIX file locking is unavailable") + if parent.descriptor is None: + raise OSError("POSIX parent directory binding is unavailable") + descriptor = os.open( + path.name, + os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0), + dir_fd=parent.descriptor, + ) + try: + _fcntl.flock(descriptor, _fcntl.LOCK_EX) + except BaseException: + os.close(descriptor) + raise + return descriptor + + +def _release_advisory_lock(lock: object) -> None: + if _WINDOWS: + if _kernel32 is None: + raise OSError("Windows file locking is unavailable") + released = _kernel32.ReleaseMutex(lock) + closed = _kernel32.CloseHandle(lock) + if not released: + raise _windows_api_error("ReleaseMutex failed") + if not closed: + raise _windows_api_error("CloseHandle failed") + return + if _fcntl is None: + raise OSError("POSIX file locking is unavailable") + descriptor = int(lock) + try: + _fcntl.flock(descriptor, _fcntl.LOCK_UN) + finally: + os.close(descriptor) + + +def _atomic_replace_bytes( + path: Path, payload: bytes, expected_sha256: str +) -> AtomicWriteReceipt: + _validate_atomic_target(path) + if not path.is_file(): + raise ValueError(f"{path}: target must be an existing regular file") + parent = _open_atomic_parent(path.parent) + try: + if not _parent_path_matches(parent): raise ValueError(f"{path}: parent changed before atomic replacement") advisory_lock = _acquire_advisory_lock(path, parent) try: @@ -3222,7 +5486,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 +5522,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/program_bootstrap_support.py b/tests/program_bootstrap_support.py index 20fdcb8..540d0d6 100644 --- a/tests/program_bootstrap_support.py +++ b/tests/program_bootstrap_support.py @@ -715,6 +715,64 @@ 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.", + ) -> None: + """Configure the manifest-v3 fixture with the Delete-capable setup pair.""" + self.configure_setup_v3(source_gate_definitions=source_gate_definitions) + 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 +961,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 +1001,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 +1089,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_delete_operation_lifecycle.py b/tests/test_delete_operation_lifecycle.py new file mode 100644 index 0000000..9c83b28 --- /dev/null +++ b/tests/test_delete_operation_lifecycle.py @@ -0,0 +1,766 @@ +import hashlib +import json +import unittest +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 +): + 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) + if 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") + 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 +): + fixture, legacy_bytes = _authorized_delete_program_with_successor( + recreate_in_successor=recreate_in_successor, + third_successor=third_successor, + ) + 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_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() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diff_disposition.py b/tests/test_diff_disposition.py index f1a23fd..1a9b174 100644 --- a/tests/test_diff_disposition.py +++ b/tests/test_diff_disposition.py @@ -6,6 +6,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, canonical_json, repository_snapshot, run_program_discovery, @@ -15,6 +16,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 +60,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: 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_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..874f714 100644 --- a/tests/test_program_activation.py +++ b/tests/test_program_activation.py @@ -2,6 +2,7 @@ import shutil import subprocess import sys +import tempfile import unittest from pathlib import Path from unittest import mock @@ -20,6 +21,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 +91,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 +139,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 +210,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 +448,95 @@ def test_apply_cli_uses_fresh_repository_observation(self) -> None: class ExactPlanMaterializationTests(unittest.TestCase): + 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..a810d5f 100644 --- a/tests/test_program_authority.py +++ b/tests/test_program_authority.py @@ -12,7 +12,10 @@ 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, +) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -1096,6 +1099,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 +1163,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..e399a4a 100644 --- a/tests/test_program_closure.py +++ b/tests/test_program_closure.py @@ -77,6 +77,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 +137,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_discovery.py b/tests/test_program_discovery.py index 300b665..800f8a6 100644 --- a/tests/test_program_discovery.py +++ b/tests/test_program_discovery.py @@ -11,11 +11,14 @@ from tests.test_program_authority import ProgramAuthorityFixture from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, 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 +58,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) @@ -73,6 +129,175 @@ 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) + 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..94fd026 100644 --- a/tests/test_program_setup.py +++ b/tests/test_program_setup.py @@ -113,6 +113,230 @@ 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_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 +738,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], 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..606deb8 100644 --- a/tests/test_state_authority.py +++ b/tests/test_state_authority.py @@ -16,7 +16,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 +38,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 +71,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: @@ -377,6 +577,515 @@ 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") + + shutil.rmtree(self.workspace) + self.setUp() + root = self.program_root / "increments/DELETE-1" + root.mkdir(parents=True) + (root / "delete-quarantine").mkdir(mode=0o755) + 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_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() From a3168c367633c960534d84bb47d1632123c1ea24 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Mon, 7 Sep 2026 23:50:23 -0400 Subject: [PATCH 10/19] fix: retain Delete activation recovery after upstream replay --- .../implementing-staged-plans/scripts/program_discovery.py | 3 ++- tests/test_program_discovery.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/skills/implementing-staged-plans/scripts/program_discovery.py b/skills/implementing-staged-plans/scripts/program_discovery.py index 0782f5e..5596b6b 100644 --- a/skills/implementing-staged-plans/scripts/program_discovery.py +++ b/skills/implementing-staged-plans/scripts/program_discovery.py @@ -1351,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", @@ -1398,6 +1398,7 @@ def _load_setup_candidate( ) 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) prefix_issues = [str(issue) for issue in prefix.get("issues", [])] diff --git a/tests/test_program_discovery.py b/tests/test_program_discovery.py index 800f8a6..7d514d3 100644 --- a/tests/test_program_discovery.py +++ b/tests/test_program_discovery.py @@ -244,6 +244,13 @@ def fail_after_workspace(label: str) -> None: 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: From 7a178d97131e61b63f2e2676e67b3c7188db37fc Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 00:19:31 -0400 Subject: [PATCH 11/19] docs: plan Delete activation final review repairs --- ...-delete-activation-final-review-repairs.md | 559 ++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-delete-activation-final-review-repairs.md 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. From b031fe442efc685f459f6b49a102767d6ede061b Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 01:23:59 -0400 Subject: [PATCH 12/19] fix: harden Delete activation and review bindings --- .../scripts/program_activation.py | 11 +- .../scripts/program_rollover.py | 13 ++ .../scripts/state_authority.py | 2 +- tests/test_delete_operation_lifecycle.py | 197 ++++++++++++++++++ tests/test_state_authority.py | 52 +++++ 5 files changed, 271 insertions(+), 4 deletions(-) diff --git a/skills/implementing-staged-plans/scripts/program_activation.py b/skills/implementing-staged-plans/scripts/program_activation.py index c09ae72..0e78148 100644 --- a/skills/implementing-staged-plans/scripts/program_activation.py +++ b/skills/implementing-staged-plans/scripts/program_activation.py @@ -2198,10 +2198,15 @@ def advance_execution_state( 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 "Delete" not in issue and "quarantine" not in issue + issue for issue in state_issues if issue not in recoverable_source_issues ] if blocking_issues: raise ValueError("; ".join(blocking_issues)) diff --git a/skills/implementing-staged-plans/scripts/program_rollover.py b/skills/implementing-staged-plans/scripts/program_rollover.py index 5c2134c..45637c4 100644 --- a/skills/implementing-staged-plans/scripts/program_rollover.py +++ b/skills/implementing-staged-plans/scripts/program_rollover.py @@ -1823,6 +1823,19 @@ def _validated_completed_rollover_records( 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 ( diff --git a/skills/implementing-staged-plans/scripts/state_authority.py b/skills/implementing-staged-plans/scripts/state_authority.py index 067ee82..c9f8765 100644 --- a/skills/implementing-staged-plans/scripts/state_authority.py +++ b/skills/implementing-staged-plans/scripts/state_authority.py @@ -19,7 +19,7 @@ from typing import Any _ORIGINAL_OS_RENAME = os.rename -_LIBC = _ctypes.CDLL(None, use_errno=True) +_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 = [ diff --git a/tests/test_delete_operation_lifecycle.py b/tests/test_delete_operation_lifecycle.py index 9c83b28..f89dd44 100644 --- a/tests/test_delete_operation_lifecycle.py +++ b/tests/test_delete_operation_lifecycle.py @@ -1,3 +1,4 @@ +import copy import hashlib import json import unittest @@ -199,6 +200,202 @@ def _product_result(states, receipts): class DeleteOperationLifecycleTests(unittest.TestCase): + 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: diff --git a/tests/test_state_authority.py b/tests/test_state_authority.py index 606deb8..a77e031 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 @@ -2719,5 +2722,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() From f361718c291ef784512911098bb92ed1516f3b87 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 02:19:46 -0400 Subject: [PATCH 13/19] Plan Delete receipt race review repairs --- ...9-08-delete-receipt-race-review-repairs.md | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-delete-receipt-race-review-repairs.md 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. From 76b8d442f2452f943cc6ce03d0c9ecbe9f4d4c7c Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 03:12:03 -0400 Subject: [PATCH 14/19] Reject invalid Delete receipt bindings --- ...lans-bootstrap-execution-review-runbook.md | 7 +- .../scripts/repository_preparation.py | 29 ++- tests/test_delete_operation_lifecycle.py | 176 ++++++++++++++++++ tests/test_state_authority.py | 43 ++++- 4 files changed, 240 insertions(+), 15 deletions(-) diff --git a/implementing-staged-plans-bootstrap-execution-review-runbook.md b/implementing-staged-plans-bootstrap-execution-review-runbook.md index cd8f8dc..82f740d 100644 --- a/implementing-staged-plans-bootstrap-execution-review-runbook.md +++ b/implementing-staged-plans-bootstrap-execution-review-runbook.md @@ -40,9 +40,10 @@ For manifest v3, activation appends or adopts these separate typed records in order: 1. setup decision; -2. program approval; -3. workspace-selection approval; and -4. active/awaiting-first-increment status last. +2. any due pre-activation gate decisions; +3. program approval; +4. workspace-selection approval; and +5. active/awaiting-first-increment status last. The returned semantic handoff is navigation only. Its direct submission in a fresh task can append or adopt the first-increment grant and write diff --git a/skills/implementing-staged-plans/scripts/repository_preparation.py b/skills/implementing-staged-plans/scripts/repository_preparation.py index 1d81038..2d263cc 100644 --- a/skills/implementing-staged-plans/scripts/repository_preparation.py +++ b/skills/implementing-staged-plans/scripts/repository_preparation.py @@ -1848,11 +1848,30 @@ def snapshot(path: str) -> WorkspacePathSnapshot: 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": - receipt_snapshot = inspect_workspace_path( - program_root, - str(binding["receipt_path"]), - ) - bindings.append({"path": relative, "receipt_path": binding["receipt_path"], "receipt_sha256": receipt_snapshot.sha256}) + 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, + }) 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: diff --git a/tests/test_delete_operation_lifecycle.py b/tests/test_delete_operation_lifecycle.py index f89dd44..a5e0458 100644 --- a/tests/test_delete_operation_lifecycle.py +++ b/tests/test_delete_operation_lifecycle.py @@ -1,7 +1,10 @@ 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 @@ -959,5 +962,178 @@ def test_second_rollover_replaces_tombstone_in_place_and_keeps_history( 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"] + 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) + ), + ), [] + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_state_authority.py b/tests/test_state_authority.py index a77e031..5206d6d 100644 --- a/tests/test_state_authority.py +++ b/tests/test_state_authority.py @@ -892,16 +892,45 @@ def test_quarantine_destination_collision_fails_without_replacing_bytes(self) -> ) self.assertEqual(recovery.disposition, "recovery-required") - shutil.rmtree(self.workspace) - self.setUp() - root = self.program_root / "increments/DELETE-1" - root.mkdir(parents=True) - (root / "delete-quarantine").mkdir(mode=0o755) - with self.assertRaises(ValueError): + 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(), b"bytes retained by quarantine\n") + 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 = { From fda4de0d0ab573d1aa3cefdee88e03b972f623d0 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 03:46:15 -0400 Subject: [PATCH 15/19] Plan Delete receipt content revalidation --- ...-08-delete-receipt-content-revalidation.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-delete-receipt-content-revalidation.md 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. From 8bbf2148afe0d000d64e15c93cf9891af06fe5f6 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 04:39:09 -0400 Subject: [PATCH 16/19] Revalidate final Delete receipt content --- .../scripts/repository_preparation.py | 11 ++ tests/test_delete_operation_lifecycle.py | 102 +++++++++++++++--- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/skills/implementing-staged-plans/scripts/repository_preparation.py b/skills/implementing-staged-plans/scripts/repository_preparation.py index 2d263cc..5b63de0 100644 --- a/skills/implementing-staged-plans/scripts/repository_preparation.py +++ b/skills/implementing-staged-plans/scripts/repository_preparation.py @@ -28,6 +28,7 @@ ExactFileMapV2, RepositoryObservation, WorkspacePathSnapshot, + _delete_receipt_bytes, decide_action_authorization, classify_delete_quarantine_recovery, delete_quarantine_allocation, @@ -1866,6 +1867,16 @@ def snapshot(path: str) -> WorkspacePathSnapshot: 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, diff --git a/tests/test_delete_operation_lifecycle.py b/tests/test_delete_operation_lifecycle.py index a5e0458..fa464c5 100644 --- a/tests/test_delete_operation_lifecycle.py +++ b/tests/test_delete_operation_lifecycle.py @@ -999,19 +999,57 @@ 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-link" + 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 in {"missing", "symlink"}: + 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): @@ -1026,30 +1064,42 @@ def inspect_receipt(root, relative, **kwargs): 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: - 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 + 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): - for kind in ("missing", "symlink", "oserror", "null-sha", "invalid-sha"): + 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"]) @@ -1059,12 +1109,31 @@ def test_receipt_reinspection_failure_returns_invalid_assessment(self): 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", @@ -1078,6 +1147,7 @@ def assert_reviewing_rejected(self, kind): 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( From 2c870f9bfe7d5acaff947d5aac0cac8336fff6e7 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 11:02:03 -0400 Subject: [PATCH 17/19] Resolve program successors from approved manifest order --- docs/workflows.md | 10 +- ...lans-bootstrap-execution-review-runbook.md | 12 +- skills/implementing-staged-plans/SKILL.md | 6 +- .../references/continuity-closure.md | 6 +- .../references/program-authority.md | 2 + .../references/program-discovery.md | 2 +- .../references/state-authorization.md | 12 +- .../scripts/approval_checkpoint.py | 4 +- .../scripts/continuity_closure.py | 48 +--- .../scripts/diff_disposition.py | 4 + .../scripts/program_activation.py | 6 +- .../scripts/program_authority.py | 224 ++++++++++++++++-- .../scripts/program_closure.py | 31 ++- .../scripts/program_continuation.py | 49 +--- .../scripts/program_discovery.py | 114 ++++----- .../scripts/program_rollover.py | 36 +-- .../scripts/program_setup.py | 58 +---- .../scripts/state_authority.py | 208 ++++++++++------ .../pipeflow-successor-allocations.json | 41 ++++ tests/program_bootstrap_support.py | 66 +++++- tests/test_continuity_closure.py | 6 +- tests/test_delete_operation_lifecycle.py | 42 +++- tests/test_diff_disposition.py | 67 +++--- tests/test_multi_increment_lifecycle.py | 175 ++++++++++++++ tests/test_program_activation.py | 111 +++++++++ tests/test_program_authority.py | 181 ++++++++++++++ tests/test_program_closure.py | 45 ++++ tests/test_program_continuation.py | 10 +- tests/test_program_setup.py | 8 +- tests/test_state_authority.py | 29 +-- 30 files changed, 1243 insertions(+), 370 deletions(-) create mode 100644 tests/fixtures/continuity-closure/pipeflow-successor-allocations.json diff --git a/docs/workflows.md b/docs/workflows.md index 9f68f3e..8fa728e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -92,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 @@ -132,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 diff --git a/implementing-staged-plans-bootstrap-execution-review-runbook.md b/implementing-staged-plans-bootstrap-execution-review-runbook.md index 82f740d..a427ceb 100644 --- a/implementing-staged-plans-bootstrap-execution-review-runbook.md +++ b/implementing-staged-plans-bootstrap-execution-review-runbook.md @@ -99,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. @@ -140,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. @@ -202,7 +204,7 @@ Before accepting the 0.1.3 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; diff --git a/skills/implementing-staged-plans/SKILL.md b/skills/implementing-staged-plans/SKILL.md index 132663f..b66246d 100644 --- a/skills/implementing-staged-plans/SKILL.md +++ b/skills/implementing-staged-plans/SKILL.md @@ -53,7 +53,7 @@ Review preparation stops at the exact diff-disposition prompt. Questions or disc ## 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 @@ -61,7 +61,7 @@ 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. @@ -73,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/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/program-authority.md b/skills/implementing-staged-plans/references/program-authority.md index fb220c9..8572c24 100644 --- a/skills/implementing-staged-plans/references/program-authority.md +++ b/skills/implementing-staged-plans/references/program-authority.md @@ -61,6 +61,8 @@ 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. diff --git a/skills/implementing-staged-plans/references/program-discovery.md b/skills/implementing-staged-plans/references/program-discovery.md index 25e2d71..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. diff --git a/skills/implementing-staged-plans/references/state-authorization.md b/skills/implementing-staged-plans/references/state-authorization.md index 7166b34..51f8cf3 100644 --- a/skills/implementing-staged-plans/references/state-authorization.md +++ b/skills/implementing-staged-plans/references/state-authorization.md @@ -61,11 +61,15 @@ Approval policies contain no action grants. Approval of any mode never authorize ## 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 @@ -75,7 +79,7 @@ For setup/envelope v2, `implementation-execution-baseline/v2` and `implementatio 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 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 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 d3c86fb..e447ff8 100644 --- a/skills/implementing-staged-plans/scripts/diff_disposition.py +++ b/skills/implementing-staged-plans/scripts/diff_disposition.py @@ -596,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 0e78148..57419d9 100644 --- a/skills/implementing-staged-plans/scripts/program_activation.py +++ b/skills/implementing-staged-plans/scripts/program_activation.py @@ -69,7 +69,7 @@ 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 @@ -1342,7 +1342,7 @@ def _build_plan_candidate( 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 @@ -1506,7 +1506,7 @@ def _build_plan_candidate( str(status["current_increment_id"]), delete_quarantine_bindings=delete_quarantine_bindings, ) - 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 managed_issues: raise ValueError("; ".join(sorted(set(managed_issues)))) baseline_observation = replace( diff --git a/skills/implementing-staged-plans/scripts/program_authority.py b/skills/implementing-staged-plans/scripts/program_authority.py index 99cb5ec..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" @@ -183,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() @@ -1120,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 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 10d63d5..142f9e8 100644 --- a/skills/implementing-staged-plans/scripts/program_continuation.py +++ b/skills/implementing-staged-plans/scripts/program_continuation.py @@ -21,7 +21,6 @@ DiffAcceptanceCandidate, _render_accept_continue_envelope, ) -from continuity_closure import select_unique_satisfied_successor from program_activation import ( _canonical_json_bytes, _canonical_json_line, @@ -152,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], @@ -180,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, @@ -738,7 +707,7 @@ def _build_continuation_extension( _manifest, status, _workspace, - successor, + input_successor, brief_bytes, accepted_product, accepted_product_sha256, @@ -750,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" diff --git a/skills/implementing-staged-plans/scripts/program_discovery.py b/skills/implementing-staged-plans/scripts/program_discovery.py index 5596b6b..4637386 100644 --- a/skills/implementing-staged-plans/scripts/program_discovery.py +++ b/skills/implementing-staged-plans/scripts/program_discovery.py @@ -1462,69 +1462,69 @@ def _load_setup_candidate( and setup_envelope.get("schema_version") == "implementation-operation-envelope/v2" ) - if setup_v2: - authority_root = root.resolve() - has_rollover_prefix = bool(rollovers) or isinstance( - status.get("rollover_binding"), dict - ) - if has_rollover_prefix: - from program_rollover import inspect_increment_rollover + 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( + rollover = inspect_increment_rollover( authority_root, observation ) - if 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 rollover.disposition is not None and ( + rollover.disposition != "resume" or rollover.issues ): - 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 - ) + 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( - authority_issues if setup_v2 else validate_state_authority(root, observation) + 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: diff --git a/skills/implementing-staged-plans/scripts/program_rollover.py b/skills/implementing-staged-plans/scripts/program_rollover.py index 45637c4..4b03845 100644 --- a/skills/implementing-staged-plans/scripts/program_rollover.py +++ b/skills/implementing-staged-plans/scripts/program_rollover.py @@ -48,6 +48,7 @@ classify_delete_quarantine_recovery, inspect_workspace_path, required_future_lifecycle_writes, + validate_program_lifecycle_file_map, validate_state_authority, ) @@ -256,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)) @@ -440,7 +432,7 @@ def _build_rollover_candidate( 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) @@ -1579,6 +1571,11 @@ 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): record_is_v2 = record.get("schema_version") == ROLLOVER_RECORD_SCHEMA_V2 if record.get("schema_version") != ( @@ -1600,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 = [ diff --git a/skills/implementing-staged-plans/scripts/program_setup.py b/skills/implementing-staged-plans/scripts/program_setup.py index e13a224..243f854 100644 --- a/skills/implementing-staged-plans/scripts/program_setup.py +++ b/skills/implementing-staged-plans/scripts/program_setup.py @@ -605,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) diff --git a/skills/implementing-staged-plans/scripts/state_authority.py b/skills/implementing-staged-plans/scripts/state_authority.py index c9f8765..2a48f9e 100644 --- a/skills/implementing-staged-plans/scripts/state_authority.py +++ b/skills/implementing-staged-plans/scripts/state_authority.py @@ -59,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, @@ -358,38 +360,65 @@ 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( @@ -398,6 +427,7 @@ def required_future_lifecycle_writes( 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 ( @@ -455,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", @@ -487,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 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 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 resolution.kind == "unavailable": + raise ValueError(f"successor allocation unavailable: {resolution.reason}") + successor = resolution.successor_increment_id + if resolution.kind == "successor": requirements.extend( ( ManagedWriteRequirement( @@ -516,21 +539,8 @@ 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" @@ -595,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]: 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 540d0d6..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": { @@ -725,9 +788,10 @@ def configure_delete_setup_v2( 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) + 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"] = ( 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 index fa464c5..7e66876 100644 --- a/tests/test_delete_operation_lifecycle.py +++ b/tests/test_delete_operation_lifecycle.py @@ -53,7 +53,7 @@ def _fresh_observation(fixture: BootstrapFixture): def _authorized_delete_program_with_successor( - *, recreate_in_successor=False, third_successor=False + *, recreate_in_successor=False, third_successor=False, sparse=False ): fixture = BootstrapFixture() legacy_bytes = b"legacy implementation\n" @@ -65,13 +65,17 @@ def _authorized_delete_program_with_successor( workspace["implementation_workspace"]["base_commit"] = fixture.head workspace["implementation_workspace"]["head_commit_at_selection"] = fixture.head fixture.write_json("state/workspace.json", workspace) - if third_successor: + 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") + fixture.configure_delete_setup_v2(path="legacy.ts", increments=increments) if recreate_in_successor: manifest = fixture.load_json("manifest.json") semantics = manifest["setup_semantics"] @@ -138,11 +142,12 @@ def _authorized_delete_program_with_successor( def _reviewed_delete_program( - *, recreate_in_successor=False, third_successor=False + *, 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( @@ -203,6 +208,35 @@ def _product_result(states, receipts): 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): diff --git a/tests/test_diff_disposition.py b/tests/test_diff_disposition.py index 1a9b174..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 @@ -388,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_multi_increment_lifecycle.py b/tests/test_multi_increment_lifecycle.py index 87ccbc5..d5db773 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,180 @@ 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_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_program_activation.py b/tests/test_program_activation.py index 874f714..823a17a 100644 --- a/tests/test_program_activation.py +++ b/tests/test_program_activation.py @@ -9,6 +9,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, + _exact_plan_bytes, canonical_json, repository_snapshot, run_program_discovery, @@ -448,6 +449,116 @@ 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) diff --git a/tests/test_program_authority.py b/tests/test_program_authority.py index a810d5f..20d6ede 100644 --- a/tests/test_program_authority.py +++ b/tests/test_program_authority.py @@ -6,6 +6,8 @@ import os import re import shutil +import subprocess +import sys import tempfile import unittest from contextlib import redirect_stdout @@ -15,6 +17,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, canonical_compact_sha256, + successor_allocation_fixture, ) @@ -1065,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() @@ -1079,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() diff --git a/tests/test_program_closure.py b/tests/test_program_closure.py index e399a4a..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) 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_setup.py b/tests/test_program_setup.py index 94fd026..1622f8a 100644 --- a/tests/test_program_setup.py +++ b/tests/test_program_setup.py @@ -1985,14 +1985,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, @@ -2029,6 +2030,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_state_authority.py b/tests/test_state_authority.py index 5206d6d..a58c615 100644 --- a/tests/test_state_authority.py +++ b/tests/test_state_authority.py @@ -338,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() @@ -509,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" From 0a672aaf0f07d684a4b821dcef48409fc5f2fea4 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 14:12:57 -0400 Subject: [PATCH 18/19] Restore continuation prompt on rollover retry --- .../scripts/program_continuation.py | 61 ++++++++------ tests/test_multi_increment_lifecycle.py | 84 +++++++++++++++++++ 2 files changed, 119 insertions(+), 26 deletions(-) diff --git a/skills/implementing-staged-plans/scripts/program_continuation.py b/skills/implementing-staged-plans/scripts/program_continuation.py index 142f9e8..1aac2bb 100644 --- a/skills/implementing-staged-plans/scripts/program_continuation.py +++ b/skills/implementing-staged-plans/scripts/program_continuation.py @@ -840,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: diff --git a/tests/test_multi_increment_lifecycle.py b/tests/test_multi_increment_lifecycle.py index d5db773..d4985ff 100644 --- a/tests/test_multi_increment_lifecycle.py +++ b/tests/test_multi_increment_lifecycle.py @@ -311,6 +311,90 @@ def test_sparse_exact_plan_prefixes_are_discovered_and_replayed(self): 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"): From 568165bc205ed44dfa28eec3fa98a87598c84655 Mon Sep 17 00:00:00 2001 From: CoveMB Date: Tue, 8 Sep 2026 16:13:31 -0400 Subject: [PATCH 19/19] Return setup allocation issues before ancestry scanning --- .../scripts/program_setup.py | 1 + tests/test_program_discovery.py | 83 +++++++++++++++++++ tests/test_program_setup.py | 51 ++++++++++++ 3 files changed, 135 insertions(+) diff --git a/skills/implementing-staged-plans/scripts/program_setup.py b/skills/implementing-staged-plans/scripts/program_setup.py index 243f854..cc3eca9 100644 --- a/skills/implementing-staged-plans/scripts/program_setup.py +++ b/skills/implementing-staged-plans/scripts/program_setup.py @@ -772,6 +772,7 @@ def validate_setup_semantics(program_root: Path) -> list[str]: 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 ( diff --git a/tests/test_program_discovery.py b/tests/test_program_discovery.py index 7d514d3..4aa8c2c 100644 --- a/tests/test_program_discovery.py +++ b/tests/test_program_discovery.py @@ -12,6 +12,7 @@ from tests.program_bootstrap_support import ( BootstrapFixture, _exact_plan_bytes, + canonical_compact_sha256, canonical_json, repository_snapshot, write_raw_review_reports, @@ -118,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() diff --git a/tests/test_program_setup.py b/tests/test_program_setup.py index 1622f8a..ade0c28 100644 --- a/tests/test_program_setup.py +++ b/tests/test_program_setup.py @@ -317,6 +317,57 @@ def test_accepted_predecessor_delete_requires_same_path_create_in_ancestry( 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"]