diff --git a/docs/plans/2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md b/docs/plans/2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md new file mode 100644 index 0000000..eeaacab --- /dev/null +++ b/docs/plans/2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md @@ -0,0 +1,212 @@ +--- +schema: plan/v1 +title: Legacy Handoff V1 Evidence +type: fix +status: approved +body_seal: 5ac590d6c382d74e6559ac4a5dd5b6d089911815b6fa2acc78c6b7853239d051 +date: 2026-08-31 +execution: code +origin: docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md +--- + +# Legacy Handoff V1 Evidence Implementation Plan + +## Goal + +Transfer accepted V1 evidence with a legacy release-loop ledger. +Reject unowned or malformed V1 state before marker creation. +Archive V1 with the completed ledger so no orphan blocks the next run. + +## Architecture notes + +- `pre_merge_verification` remains the sole acceptance authority. +- A dedicated structured frontmatter reader validates only the `pre_merge_verification` and `v1` mappings. +- The existing flat reader continues to serve unrelated progress fields. +- This targeted reader avoids changing established flat-field consumers while adding fail-closed nested validation without a YAML dependency. +- The ownership reader rejects duplicate, missing, empty, and unknown mapping keys. +- Six exact V1 file paths and optional `history/` form the closed V1 root. +- Receipt integrity uses ledger, embedded, and computed canonical-prefix digests. +- Generation manifest integrity uses whole-file SHA-256. +- The existing active manifest binds every transferred V1 byte. +- `handoff-after-copy-one-file` creates a real partial directory in disposable tests. +- Legacy archive moves V1 before the `progress.md` commit point. +- No dependency is added. Python standard-library parsing and hashing are sufficient. +- Known Pattern: `docs/solutions/workflow-issues/spec-measurement-contract-integrity.md` requires exact declared test selectors. +- Known Pattern: `docs/solutions/workflow-issues/procedural-skill-text-stateful-archive-contract.md` requires executable interruption and recovery evidence. + +## Assumption Recheck + +| Approved claim | Fresh command evidence | Outcome | +|---|---|---| +| Legacy active state excludes `v1`. | `sed -n '44,48p' skills/release-loop/scripts/run-artifact-integrity.py` at `2026-08-31T02:50:12Z` showed that `LEGACY_ACTIVE_ALL` omits `v1`. | match | +| Ship stores accepted ownership paths under `.release-loop/v1`. | `rg -n '\.release-loop/v1' /Users/teslamint/.t3/worktrees/resume/t3code-754baa7d/.release-loop/progress.md` at `2026-08-31T02:50:12Z` returned all six canonical paths. | match | +| The observed ledger records the exact V1 rejection. | `rg -n 'unexpected source entry \.release-loop/v1' /Users/teslamint/.t3/worktrees/resume/t3code-754baa7d/.release-loop/progress.md` at `2026-08-31T02:50:12Z` returned the Ship blocker. | match | +| Issue #31 requires valid transfer and malformed-state coverage. | `gh issue view 31 --json body --jq '.body'` at `2026-08-31T02:50:12Z` retained all five acceptance bullets. | match | + +## File structure + +| File | Responsibility | +|---|---| +| `skills/release-loop/scripts/run-artifact-integrity.py` | Structured V1 ownership validation, active-state classification, file-level fault injection, transfer, and archive selection. | +| `scripts/test-run-artifact-integrity.sh` | Exact selectors, disposable fixtures, state-table mutants, interruption probes, and aggregate registration. | +| `skills/release-loop/references/transition-hooks.md` | V1 ownership, transfer, and cleanup contract. | +| `skills/release-loop/references/resume-and-archive.md` | Legacy terminal archive ownership of V1. | +| `skills/release-loop/references/progress-schema.md` | Structured V1 ownership and acceptance relationship. | + +Files run without planned edits: `skills/release-loop/SKILL.md`, +`skills/shipping/SKILL.md`, and `scripts/validate.sh`. + +## Requirements-to-units trace + +| Spec success criterion | Units | +|---|---| +| SC1 nested V1 transfer | U1, U2 | +| SC2 file-level interrupted resume | U2 | +| SC3 destination mismatch rejection | U2 | +| SC4 V1 symlink rejection | U1, U2 | +| SC5 terminal archive | U3 | +| SC6 V1 ownership rejection | U1 | +| SC7 aggregate selector registration | U1, U2, U3 | +| SC8 existing legacy controls | U2, U3 | +| SC9 complete validation | U3 | + +## Scenario coverage map + +| S-ID | Unit chain | Integration test scenario | +|---|---|---| +| S1 transfer accepted evidence | U1 -> U2 | `legacy_handoff_v1_success` validates ownership, transfers V1, compares manifests, and confirms exact base discovery. Covers S1. | +| S2 resume interrupted transfer | U2 | `legacy_handoff_v1_partial_directory_rerun` stops after one nested file and resumes the exact subset. Covers S2. | +| S3 reject unsafe V1 state | U1 -> U2 | `legacy_handoff_v1_ownership`, `legacy_handoff_v1_destination_mismatch`, and `legacy_handoff_v1_symlinks` preserve both roots without cleanup authority. Covers S3. | +| S4 preserve persistent children | U2 -> U3 | Existing persistent-child controls and `legacy_archive_v1_evidence` preserve `archive`, `.handoff`, and `runs`. Covers S4. | +| S5 archive completed evidence | U3 | `legacy_archive_v1_evidence` moves V1 before progress and leaves no active V1 orphan. Covers S5. | +| S6 reject malformed ownership | U1 | `legacy_handoff_v1_ownership` walks every ownership state-table row and digest mutant. Covers S6. | + +## Implementation Units + +Order: U1 -> U2 -> U3. +U1 defines the acceptance boundary. +U2 consumes it for handoff and recovery. +U3 closes the lifecycle through terminal archive and documentation. + +## U1: Validate structured V1 ownership before marker creation + +Execution note: test-first +Files: + Create: none + Modify: `skills/release-loop/scripts/run-artifact-integrity.py`, `scripts/test-run-artifact-integrity.sh` + Test: `scripts/test-run-artifact-integrity.sh` +Interfaces: + Consumes: UTF-8 `progress.md`, `pre_merge_verification`, `v1`, and `.release-loop/v1` + Produces: validated V1 ownership metadata or one named blocked diagnostic before marker creation +Test scenarios: + happy: `legacy_handoff_v1_ownership` accepts the exact six files, optional `history`, official acceptance, and three matching digests. + edge: the same selector permits only `absent/absent/absent` pre-V1 compatibility and covers every state-table row. + error: duplicate top-level ownership blocks, duplicate mapping keys, malformed indentation, missing or unknown keys, aliases, renamed files, outside paths, unexpected children, invalid digest shapes, and each receipt three-way mismatch block without a marker. + integration: `legacy_handoff_v1_ownership` validates the real Issue #31 record shape before the transfer path. Covers S6. Covers SC6. +Steps: + 1. Register `legacy_handoff_v1_ownership` in `CASES` and its dispatch branch. Add disposable accepted, pre-V1, partial, duplicate top-level block, duplicate nested key, unknown, alias, path, child, symlink, and digest fixtures. + 2. Run `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_ownership`; confirm RED because `v1` is rejected before ownership validation. + 3. Add a structured mapping reader for only `pre_merge_verification` and `v1`. Reject duplicate keys within a mapping while allowing the same nested key name across mappings. + 4. Validate the exact key sets, official acceptance relationship, six canonical distinct paths, optional `history`, regular-file boundary, receipt canonical-prefix digest equality, and whole-file manifest digest. + 5. Call ownership validation before legacy marker lookup or creation. Preserve the old path when all three V1 components are absent. + 6. Run the focused selector and existing legacy marker, schema, collision, and symlink controls. Commit: `fix(release-loop): validate legacy V1 ownership`. +Acceptance: the focused selector exits zero. Every rejected fixture proves no marker, unchanged source/base manifests, and no cleanup success payload. + +## U2: Transfer and resume V1 under the active manifest + +Execution note: test-first +Files: + Create: none + Modify: `skills/release-loop/scripts/run-artifact-integrity.py`, `scripts/test-run-artifact-integrity.sh` + Test: `scripts/test-run-artifact-integrity.sh` +Interfaces: + Consumes: validated V1 ownership, `legacy_handoff()`, `legacy_copy_child()`, and the handoff v2 marker + Produces: exact V1 destination bytes, resumable partial directory state, or preserved blocked state +Test scenarios: + happy: `legacy_handoff_v1_success` transfers nested V1 and optional history, returns cleanup authority, and discovers the exact base progress record. + edge: `legacy_handoff_v1_partial_directory_rerun`, `legacy_handoff_source_changed`, and `legacy_handoff_complete_rerun` cover file-level interruption, changed source rejection, and complete idempotency. + error: `legacy_handoff_v1_destination_mismatch` covers marker-absent filesystem/index V1, incomplete subset/extra/changed bytes; `legacy_handoff_v1_symlinks` covers source/base root and nested symlinks. + integration: the success and partial selectors walk validated ownership through marker, recursive copy, retry, manifest equality, discovery, and cleanup authority. Covers S1, S2, S3. Covers SC1, SC2, SC3, SC4. +Steps: + 1. Register the six named U2 selectors in `CASES` and dispatch: V1 success, partial rerun, destination mismatch, symlinks, plus the missing `legacy_handoff_source_changed` and `legacy_handoff_complete_rerun` carry-forward selectors. + 2. Add RED fixtures with exact marker/source/base pre-state fingerprints. Require nonzero paths to prove no JSON cleanup result. + 3. Add exact `v1` active classification. Keep `archive`, `.handoff`, and `runs` persistent-only. + 4. Add `handoff-after-copy-one-file` inside recursive regular-file copy. Retain `handoff-after-copy-one` compatibility. + 5. Resume only exact destination subsets. Recompute source and destination manifests before completion. Reject extra, changed, index-only, and symlink states. + 6. Run all U2 selectors and existing legacy/scoped controls. Commit: `fix(release-loop): transfer legacy V1 evidence safely`. +Acceptance: all named U2 selectors and existing handoff controls exit zero. A success manifest is byte-identical. Every forced failure preserves its exact pre-state or documented partial subset. + +## U3: Archive V1 and publish the lifecycle contract + +Execution note: test-first +Files: + Create: none + Modify: `skills/release-loop/scripts/run-artifact-integrity.py`, `scripts/test-run-artifact-integrity.sh`, `skills/release-loop/references/transition-hooks.md`, `skills/release-loop/references/resume-and-archive.md`, `skills/release-loop/references/progress-schema.md` + Test: `scripts/test-run-artifact-integrity.sh`, `scripts/validate.sh` +Interfaces: + Consumes: a completed legacy ledger, validated V1 root, persisted archive destination, and archive publication journal + Produces: terminal archive containing V1 and progress, progress-last commit semantics, the exact retained `archive_path`, and synchronized consumer documentation +Test scenarios: + happy: `legacy_archive_v1_evidence` moves V1 and other active state into the exact terminal archive. + edge: the existing first-child interruption moves only `.tmp`; rerun uses the same destination, then moves V1 and moves progress last. + error: V1 archive symlink, changed source, foreign destination, and publication-journal mismatch preserve the selected progress record and block completion. + integration: handoff success followed by base terminal archive leaves no active V1 orphan and preserves persistent children. Covers S4, S5. Covers SC5. +Steps: + 1. Register `legacy_archive_v1_evidence` in `CASES` and dispatch. Add success, first-child interruption, symlink, changed-source, foreign-destination, and journal-mismatch fixtures. + 2. Run the focused selector; confirm RED because legacy archive excludes `v1`. + 3. Add `v1` to legacy archive children after ownership validation. Preserve archive manifest publication and `progress.md` as the commit point. Retain and verify the exact terminal `archive_path` at the release-loop completion gate. + 4. Update the three reference documents with the exact ownership, digest, transfer, recovery, and terminal archive rules. Add contract mutation assertions to the focused selector. + 5. Run all six new selectors, both missing carry-forward selectors, existing legacy/scoped controls, `bash scripts/test-run-artifact-integrity.sh all`, and `bash scripts/validate.sh`. + 6. Commit: `fix(release-loop): archive legacy V1 evidence`. +Acceptance: focused archive cases pass. The aggregate suite executes every new selector. `bash scripts/validate.sh` prints `ALL CHECKS PASSED`. + +## Mutation/failure-state matrix + +Evidence root: `.release-loop/runs/legacy-handoff-v1-evidence/evidence/U/`. +All probes use disposable repositories with local identity, disabled signing, and no remote. + +| Transition | Success | Forced failure with expected post-state | Rerun | Rollback or compensation | Headless | Cancellation or abort | Owner / evidence | +|---|---|---|---|---|---|---|---| +| T1 V1 ownership admission | Exact accepted metadata returns validation and creates no marker. | Run `legacy_handoff_v1_ownership` with one-axis metadata mutants before marker creation. Exit is nonzero; source/base fingerprints and marker absence remain exact. The fixture root and named mutant prove the intended guard fired. | Repeating the rejected input returns the same diagnostic and unchanged fingerprints. | Remove only the disposable mutant or recreate the fixture. Production inputs are never modified. | Local validation has no outward target and may run headlessly with identical state. | Abort before validation changes nothing. Validation performs no durable write. | U1 / `evidence/U1/` | +| T2 legacy V1 handoff | Incomplete marker becomes complete only after exact transfer and base discovery. | Run `legacy_handoff_v1_partial_directory_rerun` with `handoff-after-copy-one-file`. Post-state is one incomplete owner marker plus an exact destination subset; source is unchanged. Mechanism evidence names the copied file and hook. | The same owner, source digest, and subset copy only missing bytes and complete once. Foreign or changed state blocks. | Preserve both roots and marker for retry. Manual removal is forbidden without owner and manifest proof. | The local transfer can run headlessly because fixtures have no remote or outward target. | Abort before marker changes nothing. Abort after marker preserves the incomplete marker and subset. | U2 / `evidence/U2/` | +| T3 legacy terminal archive | V1 and active children move to one persisted archive; progress moves last and the completion gate retains its exact `archive_path`. | Run `legacy_archive_v1_evidence` with `archive-after-first`. The existing hook fires after `.tmp`, so post-state is the persisted destination containing only `.tmp`, while source V1 and progress remain present. The hook and manifests prove the boundary. | The same destination resumes remaining children, moves V1, moves progress once, and reports the retained `archive_path`. | Preserve source progress, destination journal, and partial archive. Resume or perform owner-proven manual recovery; never allocate another suffix. | Archive is local-only and may run headlessly after proving no outward target. | Abort before the first move preserves source. Abort after `.tmp` preserves source V1 and progress plus the one-child destination. | U3 / `evidence/U3/` | + +Changing a matrix row after approval requires the deviation process in +`docs/solutions/workflow-issues/review-introduced-state-machine-deviation.md`. + +## Carry-forward trigger audit + +| Tracker row | Trigger class | What fired it | Disposition | +|---|---|---|---| +| A success criterion that fires after Retro cannot be measured inside that Retro | event-based | The approved design declares terminal V1 archive proof after Retro. | Fold into U3: the release-loop completion gate retains and verifies the exact terminal `archive_path`; Retro does not claim this proof. | +| Forced-failure matrices can omit exact partial state and retain invalid shell syntax | edit-based, latched | PR #15 fired the row; this plan also defines a mutation/failure-state matrix. | Defer the reusable planning-contract change. This plan supplies executable probes, exact partial states, rerun behavior, and compensation owners without editing that contract. | +| Review verifies conformance instead of attacking the claimed invariant | edit-based, latched | PR #15 fired the row. | Defer because this plan does not edit the `reviewing` dispatch contract. The independent plan review still attacks the V1 ownership invariant. | +| Finding severity follows code blast radius instead of threatened success criteria | edit-based, latched | PR #15 fired the row. | Defer because this plan does not edit the review triage contract. | +| Dispatched committing agents lose `SSH_AUTH_SOCK` | edit-based, latched | PR #15 fired the row. | Fold into U1-U3 execution conditionally: any dispatched committer receives the socket explicitly and each resulting commit must report `%G? = G`. Inline commits do not activate this condition. | +| A retro committed on a feature branch must be merged or pushed with its ledger update | edit-based, latched | The run-artifact-integrity recovery fired the row. | Defer to the Shipping and Retro lifecycle because this implementation plan does not change that publication boundary. | +| `gh pr merge --delete-branch` can merge remotely before local cleanup fails | edit-based, latched | PRs #22 and #23 fired the row again. | Defer because this plan does not change Shipping merge commands. | +| Shipping lacks a per-fingerprint terminal-disposition gate | edit-based, latched | PR #22 partially fired the row. | Defer because this plan changes legacy artifact integrity, not the Shipping or release-loop merge gate. | +| Missing `legacy_handoff_source_changed` and `legacy_handoff_complete_rerun` cases | edit-based | The plan edits `scripts/test-run-artifact-integrity.sh` legacy handoff cases. | Fold into U2 so SC3 and SC4 receive their declared selectors. | +| Add `handoff-after-copy-one-file` | edit-based | The plan changes handoff fault injection and partial recovery. | Fold into U2 and T2 evidence. | + +Audited `ROADMAP.md` at `59dcff696ba2aa3bfa24a46ed77baee5c41c2ac6`: 14 open rows, 10 fired, 0 unobservable. + +## Deferred to Follow-Up Work + +- Semantic validation of undigested approval and generation receipt content remains outside Issue #31. It requires a separate V1 authority design. +- Generic legacy-to-scoped migration remains excluded by the approved spec. +- Reusable forced-failure matrix syntax belongs to the next planning-contract change. +- Review invariant attacks and success-criterion severity belong to the next reviewing dispatch and triage contract changes. +- Retro publication and merge-command recovery belong to their next Shipping or Retro lifecycle changes. +- Shipping per-fingerprint terminal disposition remains tied to a future shipping merge-gate change. + +## Open unknowns + +### Planning-time + +None. + +### Implementation-time + +- The structured parser helper name may follow the nearest local parser naming during U1. +- The exact failure diagnostic suffixes may follow existing `invalid progress` and `legacy handoff source` vocabulary. diff --git a/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md b/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md new file mode 100644 index 0000000..f1b88f2 --- /dev/null +++ b/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md @@ -0,0 +1,252 @@ +--- +title: Legacy Handoff V1 Evidence +status: approved +date: 2026-08-31 +schema: spec/v1 +--- + +# Legacy Handoff V1 Evidence Design + +_Created 2026-08-31._ + +## Overview + +Legacy handoff rejects `.release-loop/v1` even though Ship stores required V1 evidence there. +Treat that exact directory as active legacy state. +Transfer and archive it under the existing lifecycle contract. + +## User Scenarios + +### S1: Transfer accepted V1 evidence + +A legacy release-loop reaches Ship with accepted V1 receipts and a generation manifest. +The operator runs the existing legacy handoff command. +The base checkout receives the ledger and the exact `.release-loop/v1` tree. + +### S2: Resume an interrupted V1 transfer + +The handoff stops after copying one file inside the V1 tree. +The operator repeats the same command. +The handoff validates the marker and source manifest before it copies missing bytes. + +### S3: Reject unsafe V1 state + +The V1 directory contains a symlink, or the destination contains foreign V1 bytes. +The handoff rejects the transfer and preserves both roots. +Cleanup remains forbidden. + +### S4: Preserve persistent legacy children + +The base checkout contains archives, handoff markers, or scoped runs. +The handoff ignores these persistent children. +The source still rejects these persistent children. + +### S5: Archive completed V1 evidence + +Retro completes after the base resumes the transferred legacy ledger. +The archive procedure moves the V1 tree with the other run-bound active state. +No orphan V1 directory blocks the next legacy handoff. + +### S6: Reject missing or malformed V1 ownership + +An accepted V1 ledger references missing ownership metadata, an outside path, or a mismatched digest. +The handoff rejects before marker creation. +The source and base remain unchanged. + +## Scope + +### In + +- Add `v1` to the closed active legacy child set. +- Parse the V1 and pre-merge blocks structurally before marker creation. +- Validate V1 ownership metadata against the accepted pre-merge record. +- Bind every V1 directory and file to the existing SHA-256 manifest. +- Add a deterministic file-level interruption inside recursive directory copy. +- Reuse the existing partial-directory recovery logic for V1 retries. +- Preserve source and destination symlink checks. +- Preserve destination collision checks. +- Move V1 with the selected ledger during a legacy terminal archive. +- Add focused success, interruption, mismatch, symlink, and archive tests. +- Update the transition contract to identify V1 as active legacy state. + +### Out + +- A generic extension directory or configurable allowlist. +- A legacy-to-scoped ledger migration. +- Changes to the V1 evidence schema or authority rules. +- Changes to handoff marker or scoped-run semantics. +- Deletion or movement of V1 evidence before a successful handoff. + +## Assumptions and Preconditions + +| Claim | Command | Observed at | Observed result | Evidence source | +|---|---|---|---|---| +| Legacy handoff excludes `v1` from active state. | `sed -n '44,48p' skills/release-loop/scripts/run-artifact-integrity.py` | `2026-08-31T01:57:27Z` | `LEGACY_ACTIVE_ALL` omits `v1`. | Working tree at `59dcff6` | +| Ship requires V1 evidence under `.release-loop/v1`. | `rg -n '\.release-loop/v1' /Users/teslamint/.t3/worktrees/resume/t3code-754baa7d/.release-loop/progress.md` | `2026-08-31T01:57:27Z` | The accepted ledger references pilot, full, receipt, and generation files under that directory. | Resume portfolio worktree ledger | +| The observed handoff blocks on that directory. | `rg -n 'unexpected source entry \.release-loop/v1' /Users/teslamint/.t3/worktrees/resume/t3code-754baa7d/.release-loop/progress.md` | `2026-08-31T01:57:27Z` | The Ship log records the exact legacy handoff rejection. | Resume portfolio worktree ledger | +| Issue #31 requires missing and malformed V1 coverage. | `gh issue view 31 --json body` | `2026-08-31T02:04:13Z` | The acceptance criteria require valid transfer, byte equality, exact resume, missing artifacts, malformed artifacts, and collisions. | GitHub issue #31 | + +## Architecture + +The handoff CLI remains the only transfer authority. +The active legacy child set gains the exact name `v1`. +No prefix, pattern, or project configuration can extend the set. + +V1 gains a versioned ownership check before handoff marker creation. +`pre_merge_verification` remains the only acceptance authority. +The `v1` block contains ownership metadata and does not grant acceptance. +The parser rejects duplicate keys, malformed indentation, and partial structured blocks. +Duplicate detection applies within each mapping. +The same nested key name in different mappings is not a duplicate. + +`pre_merge_verification` has exactly four required keys: `id`, `status`, +`generation_sha256`, and `updated`. +The `updated` timestamp records recency but grants no authority. + +The `v1` ownership block requires `status`, six path keys, two receipt digest keys, +`generation_manifest_sha256`, and `accepted_at`. +The `accepted_at` timestamp records history but grants no authority. +Both blocks reject missing, empty, duplicate, or unknown keys. + +An accepted record requires `pre_merge_verification.id: V1` and `status: accepted`. +Its generation digest must equal the ownership block's generation digest. +The ownership block must also record `status: accepted`. + +The six ownership paths are distinct canonical literals: + +- `.release-loop/v1/pilot-approval.md` +- `.release-loop/v1/pilot-receipt.md` +- `.release-loop/v1/full-approval.md` +- `.release-loop/v1/full-receipt.md` +- `.release-loop/v1/generation-receipt.md` +- `.release-loop/v1/generation-manifest.sha256` + +Each path must be a regular file with no symlink component. +The V1 root permits only these six files and the optional `history` directory. +Aliases, renamed files, and unexpected direct children fail closed. + +Pilot and full receipts use their existing `receipt_sha256_scope` rule. +The recorded digest covers canonical bytes before the `receipt_sha256` field. +The ledger digest, embedded receipt digest, and computed canonical-prefix digest must match. +The generation manifest uses whole-file SHA-256. +Approval files and the generation receipt have no ledger digest. +The handoff checks their ownership and presence but does not invent a content digest. + +The optional `.release-loop/v1/history` directory remains owned V1 state. +The active manifest binds its complete tree. +The handoff applies this state table: + +| Pre-merge block | Ownership block | V1 directory | Result | +|---|---|---|---| +| absent | absent | absent | Allow a pre-V1 legacy handoff. | +| absent | present | any | Reject inconsistent ownership. | +| absent | absent | present | Reject unowned V1 state. | +| started | any | any | Reject because V1 is not accepted. | +| accepted | missing, partial, or non-accepted | any | Reject missing ownership proof. | +| accepted | accepted and matching | absent or malformed | Reject missing or malformed ownership. | +| accepted | accepted and matching | exact valid tree | Continue to manifest and handoff. | + +An official accepted block without the ownership block fails closed. +This behavior protects evidence that the handoff cannot reconstruct. + +The existing recursive manifest includes the complete V1 tree. +Each entry records its relative path, kind, and file digest. +The existing marker binds the resulting manifest digest. + +The existing copy and retry path handles V1 like `briefs` or `evidence`. +A retry fills only missing entries in an existing matching directory. +It rejects extra entries and changed file bytes. + +A test-only failure hook stops after one regular file copy inside a directory. +The hook creates a real partial V1 directory without changing production behavior. + +Persistent children remain `archive`, `.handoff`, and `runs`. +The base skips them during collision scans. +The source rejects them before marker creation. + +The legacy archive moves V1 with the other run-bound active children. +It moves `progress.md` last as the archive commit point. +An interrupted archive retains the existing recovery behavior. + +## Interface + +The CLI syntax does not change: + +```text +run-artifact-integrity.py handoff \ + --repo \ + --base-repo \ + --progress-path .release-loop/progress.md \ + --legacy-destination .release-loop +``` + +The command transfers `.release-loop/v1` when that directory exists. +The command remains compatible when no V1 directory exists. +An invalid V1 ownership record fails before the handoff writes a marker. + +Malformed V1 evidence means malformed ownership metadata, path shape, digest shape, +receipt canonical-prefix digest, or generation manifest digest. +It does not mean semantic revalidation of undigested approval or generation receipt content. +Such validation requires a separate V1 authority contract. + +## Testing + +- Add a fixture that transfers a nested V1 evidence tree. +- Add `handoff-after-copy-one-file` for deterministic mid-directory interruption. +- Add a fixture that uses that hook inside the V1 tree and resumes. +- Cover marker absence with filesystem and index-only foreign V1 state. +- Cover an incomplete marker with subset, extra-entry, and changed-byte V1 state. +- Cover source and destination V1 root and nested symlinks. +- Cover duplicate keys, malformed indentation, partial blocks, and block digest disagreement. +- Cover missing required V1 files, outside-root paths, invalid digests, and digest mismatches. +- Cover canonical-prefix receipt mutations and whole-file generation manifest mutations. +- Cover ledger, embedded, and computed receipt digest mismatches independently. +- Cover aliases, renamed files, and unexpected V1 direct children. +- Cover an existing V1 directory without accepted V1 ledger evidence. +- Cover every row in the pre-merge, ownership, and directory state table. +- Assert marker preservation, both-root preservation, and denied cleanup on every rejection. +- Add a fixture that archives V1 after a completed legacy run. +- Register every new selector in `CASES` and its `run_case` dispatch branch. +- Verify that `all` executes every new selector. +- Keep persistent-child rejection tests unchanged. +- Keep legacy handoff behavior without V1 unchanged. +- Run the focused handoff cases and the complete artifact-integrity suite. +- Run the repository validation gate. + +## Risks + +| Risk | Mitigation | +|---|---| +| An arbitrary directory gains transfer authority. | Add only the exact child name `v1` to the closed set. | +| A retry accepts incomplete V1 evidence. | Require exact source and destination manifests before completion. | +| Foreign destination evidence is overwritten. | Preserve subset, mismatch, and collision rejection. | +| A symlink escapes the repository boundary. | Preserve recursive source and destination symlink checks. | +| A directory name grants authority to malformed evidence. | Require official acceptance, structured ownership metadata, canonical paths, and existing digest rules. | +| Handoff invents new V1 authority semantics. | Do not semantically validate content that the accepted ledger does not digest. | +| V1 remains after terminal archive. | Move V1 before the archive commits by moving `progress.md`. | +| Existing legacy runs change behavior without V1. | Retain current fixtures as compatibility controls. | + +## Success Criteria + +1. Legacy handoff transfers a nested `.release-loop/v1` tree and permits cleanup. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_success` exits 0. +2. An interrupted V1 directory transfer resumes with exact manifest equality. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_partial_directory_rerun` exits 0. +3. Foreign or changed V1 destination bytes block cleanup and preserve both roots. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_destination_mismatch` exits 0. +4. V1 symlinks fail before cleanup authority is returned. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_symlinks` exits 0. +5. A completed legacy archive moves V1 and commits by moving `progress.md` last. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_archive_v1_evidence` exits 0. +6. Missing or malformed accepted V1 ownership blocks before marker creation. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_ownership` exits 0. +7. Every new selector is registered and included in the aggregate suite. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh all` exits 0 after executing all six named cases. +8. Existing legacy handoff controls remain green. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh legacy_handoff_success && bash scripts/test-run-artifact-integrity.sh legacy_handoff_source_persistent_children` exits 0. +9. The complete artifact-integrity and repository gates remain green. + - **Measured by**: `bash scripts/test-run-artifact-integrity.sh all && bash scripts/validate.sh` exits 0. + +## Open Decisions + +None. diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index cd9883e..77eaaad 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -150,6 +150,7 @@ CASES = ( "scoped_feature_mismatch", "interrupted_archive", "interrupted_legacy_archive", + "legacy_archive_v1_evidence", "ignored_orphan", "occupied_scope_blocked", "tracked_scope_target", @@ -165,6 +166,13 @@ CASES = ( "handoff_mismatch_preserves_both", "handoff_same_checkout", "legacy_handoff_success", + "legacy_handoff_v1_ownership", + "legacy_handoff_v1_success", + "legacy_handoff_v1_partial_directory_rerun", + "legacy_handoff_v1_destination_mismatch", + "legacy_handoff_v1_symlinks", + "legacy_handoff_source_changed", + "legacy_handoff_complete_rerun", "legacy_handoff_cli_contract", "legacy_handoff_incomplete_rerun", "legacy_handoff_collision", @@ -360,6 +368,11 @@ def require_contract(texts: dict[str, str] | None = None, check_invocations: boo (selected["SCHEMA"], "Reject every symlink in each existing source or destination component"), (selected["ARCHIVE"], "Move scoped `progress.md` last as the archive commit point."), (selected["ARCHIVE"], "reuse the exact recorded archive destination"), + (selected["ARCHIVE"], "It accepts V1 in either source or destination, but never both."), + (selected["ARCHIVE"], "Never move `archive`, `.handoff`, or `runs` as active state."), + (selected["ARCHIVE"], "For a scoped record (`artifact_root: .release-loop/runs/`), verify the terminal record, Retro evidence, destination marker, and exact `archive_path`; do not apply V1 tree checks."), + (selected["ARCHIVE"], "For a legacy record (`artifact_root: .release-loop`), also verify the live V1 tree is absent and the archived V1 tree is present at the exact returned path."), + (selected["ARCHIVE"], "Move the selected root `progress.md` last. This move is the archive commit point."), (selected["ARCHIVE"], "Mid-move cancellation leaves the selected progress record in the source scope."), (selected["HOOKS"], "`.release-loop/.handoff` is the fixed handoff root"), (selected["HOOKS"], "Make the base owner discover and resume that exact progress path."), @@ -367,10 +380,15 @@ def require_contract(texts: dict[str, str] | None = None, check_invocations: boo (selected["HOOKS"], "are never active transfer bytes"), (selected["HOOKS"], "`recovery-authority/` and `recovery-backups/` are persistent siblings"), (selected["HOOKS"], "At the source, legacy handoff rejects every persistent sibling"), + (selected["HOOKS"], "Accepted legacy V1 state is active state."), + (selected["HOOKS"], "Handoff and archive preserve the exact V1 bytes."), (selected["SKILL"], "adds `--legacy-destination .release-loop`"), (selected["SKILL"], "directory containing the loaded `SKILL.md`"), (selected["SKILL"], "temporary regular file under `/.tmp/`"), (selected["SCHEMA"], "temporary path under `/.tmp/`"), + (selected["SCHEMA"], "`pre_merge_verification` is the sole acceptance authority."), + (selected["SCHEMA"], "The `v1` block binds the six canonical files and their required digests."), + (selected["SCHEMA"], "A legacy archive includes accepted V1 evidence but excludes persistent `archive`, `.handoff`, and `runs` siblings."), ) missing = [fragment for text, fragment in required if fragment not in text] if check_invocations: @@ -681,6 +699,46 @@ def write_legacy(repo: Path, feature: str = "legacy") -> Path: return legacy +def write_legacy_v1(repo: Path, feature: str = "legacy") -> Path: + legacy = write_legacy(repo, feature) + root = legacy.parent / "v1" + root.mkdir() + for name in ("pilot-approval.md", "full-approval.md", "generation-receipt.md"): + (root / name).write_text(f"# {name}\n", encoding="utf-8") + receipt_digests = {} + for name in ("pilot", "full"): + prefix = f"# {name} receipt\n\n- verdict: pass\n- receipt_sha256_scope: canonical bytes before this field\n" + digest = hashlib.sha256(prefix.encode()).hexdigest() + (root / f"{name}-receipt.md").write_text( + prefix + f"- receipt_sha256: {digest}\n", encoding="utf-8" + ) + receipt_digests[name] = digest + manifest = root / "generation-manifest.sha256" + manifest.write_text("0" * 64 + " generated.txt\n", encoding="utf-8") + generation_digest = hashlib.sha256(manifest.read_bytes()).hexdigest() + blocks = ( + "v1:\n" + " status: accepted\n" + " pilot_approval_path: .release-loop/v1/pilot-approval.md\n" + " pilot_receipt_path: .release-loop/v1/pilot-receipt.md\n" + f" pilot_receipt_sha256: {receipt_digests['pilot']}\n" + " full_approval_path: .release-loop/v1/full-approval.md\n" + " full_receipt_path: .release-loop/v1/full-receipt.md\n" + f" full_receipt_sha256: {receipt_digests['full']}\n" + " generation_receipt_path: .release-loop/v1/generation-receipt.md\n" + " generation_manifest_path: .release-loop/v1/generation-manifest.sha256\n" + f" generation_manifest_sha256: {generation_digest}\n" + " accepted_at: 2026-08-23T00:00:00Z\n" + "pre_merge_verification:\n" + " id: V1\n" + " status: accepted\n" + f" generation_sha256: {generation_digest}\n" + " updated: 2026-08-23T00:00:00Z\n" + ) + legacy.write_text(legacy.read_text(encoding="utf-8").replace("final_action:\n", blocks + "final_action:\n"), encoding="utf-8") + return legacy + + def populate_legacy_active_state(root: Path) -> None: (root / ".tmp").mkdir(parents=True, exist_ok=True) (root / ".tmp/scratch.tmp").write_text("scratch\n", encoding="utf-8") @@ -1017,6 +1075,8 @@ def archive_scope( failure="archive-after-first" if fail_after_first else None, ) assert set(payload) == {"archive_path", "moved", "progress_path", "state"}, payload + if destination is not None: + assert payload["archive_path"] == destination, payload expected_state = "archived" if mode == "completed" else "archived-incomplete" assert payload["state"] == expected_state, payload return list(payload["moved"]) @@ -1281,6 +1341,19 @@ def assert_blocked_preserves(action, sentinel_path: Path, before: bytes, diagnos assert sentinel_path.read_bytes() == before +def assert_blocked_snapshots(action, source: Path, base: Path, diagnostic: str) -> None: + source_before = matrix_fixture_snapshot(source) + base_before = matrix_fixture_snapshot(base) + try: + action() + except Blocked as exc: + assert diagnostic in str(exc), str(exc) + else: + raise AssertionError("attack did not block") + assert matrix_fixture_snapshot(source) == source_before + assert matrix_fixture_snapshot(base) == base_before + + def require_phase_consumer_contract(texts: dict[str, str] | None = None) -> None: selected = texts or PHASE_CONSUMERS shared = ("exact repo-relative `progress_path`", "`artifact_root = dirname(progress_path)`") @@ -6478,6 +6551,214 @@ def run_case(name: str) -> None: assert f"archive-destination: {destination}" in legacy.read_text(encoding="utf-8") assert archive_scope(repo, str(legacy.relative_to(repo)), None)[-1] == "progress.md" assert (repo / destination / "progress.md").is_file() + elif name == "legacy_archive_v1_evidence": + source = new_repo(tmp, "legacy-v1-archive-source") + legacy = write_legacy_v1(source) + populate_legacy_active_state(legacy.parent) + (legacy.parent / "v1/history").mkdir() + (legacy.parent / "v1/history/prior.md").write_text("prior\n", encoding="utf-8") + expected_v1 = filesystem_manifest(legacy.parent / "v1") + destination = ".release-loop/archive/2026-08-23-legacy-v1" + order = archive_scope(source, str(legacy.relative_to(source)), destination) + assert order[-1] == "progress.md" + assert order.index("v1") < order.index("progress.md") + assert filesystem_manifest(source / destination / "v1") == expected_v1 + assert not (source / ".release-loop/v1").exists() + + interrupted = new_repo(tmp, "legacy-v1-archive-interrupted") + interrupted_progress = write_legacy_v1(interrupted) + populate_legacy_active_state(interrupted_progress.parent) + interrupted_destination = ".release-loop/archive/2026-08-23-legacy-v1-interrupted" + try: + archive_scope( + interrupted, + str(interrupted_progress.relative_to(interrupted)), + interrupted_destination, + fail_after_first=True, + ) + except Blocked as exc: + assert "injected archive interruption" in str(exc) + else: + raise AssertionError("legacy V1 archive interruption did not fire") + assert interrupted_progress.is_file() + assert (interrupted / interrupted_destination / ".tmp").is_dir() + assert (interrupted / ".release-loop/v1").is_dir() + resumed_order = archive_scope( + interrupted, + str(interrupted_progress.relative_to(interrupted)), + None, + ) + assert resumed_order[-1] == "progress.md" + assert (interrupted / interrupted_destination / "v1").is_dir() + + destination_side = new_repo(tmp, "legacy-v1-archive-destination-side") + destination_side_progress = write_legacy_v1(destination_side) + destination_side_path = ".release-loop/archive/2026-08-23-legacy-v1-destination-side" + persist_archive_evidence(destination_side_progress, destination_side_path, "completed") + try: + run_cli( + "archive", + "--repo", str(destination_side), + "--progress-path", str(destination_side_progress.relative_to(destination_side)), + "--destination", destination_side_path, + failure="archive-after-journal", + ) + except Blocked as exc: + assert "injected archive interruption" in str(exc) + else: + raise AssertionError("destination-side V1 interruption did not fire") + assert destination_side_progress.is_file() + assert not (destination_side / ".release-loop/v1").exists() + assert (destination_side / destination_side_path / "v1").is_dir() + resumed_payload = run_cli( + "archive", + "--repo", str(destination_side), + "--progress-path", str(destination_side_progress.relative_to(destination_side)), + ) + assert resumed_payload["archive_path"] == destination_side_path, resumed_payload + assert resumed_payload["moved"][-1] == "progress.md", resumed_payload + terminal_payload = run_cli( + "archive", + "--repo", str(destination_side), + "--progress-path", str(destination_side_progress.relative_to(destination_side)), + "--destination", destination_side_path, + ) + assert terminal_payload["archive_path"] == destination_side_path, terminal_payload + assert terminal_payload["moved"] == [], terminal_payload + archived_manifest = destination_side / destination_side_path / "v1/generation-manifest.sha256" + archived_manifest.write_text("changed\n", encoding="utf-8") + terminal_before = matrix_fixture_snapshot(destination_side) + assert_blocked_preserves( + lambda: run_cli( + "archive", + "--repo", str(destination_side), + "--progress-path", str(destination_side_progress.relative_to(destination_side)), + "--destination", destination_side_path, + ), + sent, + before, + "legacy V1 ownership", + ) + assert matrix_fixture_snapshot(destination_side) == terminal_before + + def rejected_archive(slug, mutate, diagnostic): + candidate = new_repo(tmp, f"legacy-v1-archive-reject-{slug}") + candidate_progress = write_legacy_v1(candidate) + candidate_destination = f".release-loop/archive/2026-08-23-reject-{slug}" + mutate(candidate, candidate_progress, candidate_destination) + persist_archive_evidence(candidate_progress, candidate_destination, "completed") + progress_before = candidate_progress.read_bytes() + candidate_before = matrix_fixture_snapshot(candidate) + destination_root = candidate / candidate_destination + destination_before = filesystem_manifest(destination_root) + assert_blocked_preserves( + lambda: archive_scope( + candidate, + str(candidate_progress.relative_to(candidate)), + candidate_destination, + persist_authority=False, + ), + sent, + before, + diagnostic, + ) + assert candidate_progress.read_bytes() == progress_before + assert matrix_fixture_snapshot(candidate) == candidate_before + assert filesystem_manifest(destination_root) == destination_before + + rejected_archive( + "symlink", + lambda candidate, progress_path, destination_path: ( + (candidate / ".release-loop/v1").rename(candidate / ".release-loop/v1-real"), + (candidate / ".release-loop/v1").symlink_to( + candidate / ".release-loop/v1-real", target_is_directory=True + ), + ), + "legacy V1 ownership", + ) + rejected_archive( + "changed-source", + lambda candidate, progress_path, destination_path: ( + candidate / ".release-loop/v1/generation-manifest.sha256" + ).write_text("changed\n", encoding="utf-8"), + "legacy V1 ownership", + ) + rejected_archive( + "foreign-destination", + lambda candidate, progress_path, destination_path: ( + (candidate / destination_path / "v1").mkdir(parents=True), + (candidate / destination_path / "v1/foreign.md").write_text( + "foreign\n", encoding="utf-8" + ), + ), + "archive destination conflict", + ) + rejected_archive( + "journal-mismatch", + lambda candidate, progress_path, destination_path: ( + candidate / ".release-loop/.phase-artifact-ownership.json" + ).write_text( + json.dumps( + { + "schema": "phase-artifact-ownership/v1", + "owned": {"missing.md": hashlib.sha256(b"missing\n").hexdigest()}, + "pending": None, + }, + sort_keys=True, + separators=(",", ":"), + ) + + "\n", + encoding="utf-8", + ), + "artifact ownership", + ) + + handoff_source = new_repo(tmp, "legacy-v1-archive-handoff-source") + handoff_base = new_repo(tmp, "legacy-v1-archive-handoff-base") + (handoff_base / ".release-loop/runs").mkdir(parents=True) + handoff_progress = write_legacy_v1(handoff_source) + populate_legacy_active_state(handoff_progress.parent) + handoff_scope( + handoff_source, + handoff_base, + str(handoff_progress.relative_to(handoff_source)), + legacy_destination=".release-loop", + ) + base_progress = handoff_base / ".release-loop/progress.md" + handoff_destination = ".release-loop/archive/2026-08-23-handoff-v1" + archive_scope( + handoff_base, + str(base_progress.relative_to(handoff_base)), + handoff_destination, + ) + assert not (handoff_base / ".release-loop/v1").exists() + assert (handoff_base / handoff_destination / "v1").is_dir() + assert (handoff_base / ".release-loop/.handoff").is_dir() + assert (handoff_base / ".release-loop/runs").is_dir() + + lifecycle_contract = {"SKILL": SKILL, "SCHEMA": SCHEMA, "ARCHIVE": ARCHIVE, "HOOKS": HOOKS} + lifecycle_mutations = ( + ("ARCHIVE", "It accepts V1 in either source or destination, but never both.", "It accepts V1 only in the source."), + ("ARCHIVE", "Never move `archive`, `.handoff`, or `runs` as active state.", "Move `archive`, `.handoff`, and `runs` as active state."), + ("ARCHIVE", "For a scoped record (`artifact_root: .release-loop/runs/`), verify the terminal record, Retro evidence, destination marker, and exact `archive_path`; do not apply V1 tree checks.", "For a scoped record (`artifact_root: .release-loop/runs/`), apply V1 tree checks."), + ("ARCHIVE", "For a legacy record (`artifact_root: .release-loop`), also verify the live V1 tree is absent and the archived V1 tree is present at the exact returned path.", "For a legacy record (`artifact_root: .release-loop`), skip V1 tree checks."), + ("ARCHIVE", "Move the selected root `progress.md` last. This move is the archive commit point.", "Move the selected root `progress.md` first. This move is the archive start point."), + ("HOOKS", "Accepted legacy V1 state is active state.", "Accepted legacy V1 state is persistent state."), + ("HOOKS", "Handoff and archive preserve the exact V1 bytes.", "Handoff and archive may rewrite V1 bytes."), + ("SCHEMA", "`pre_merge_verification` is the sole acceptance authority.", "The `v1` block is an acceptance authority."), + ("SCHEMA", "The `v1` block binds the six canonical files and their required digests.", "The `v1` block permits arbitrary files."), + ("SCHEMA", "A legacy archive includes accepted V1 evidence but excludes persistent `archive`, `.handoff`, and `runs` siblings.", "A legacy archive includes persistent siblings."), + ) + for key, original, mutation in lifecycle_mutations: + texts = dict(lifecycle_contract) + texts[key] = texts[key].replace(original, mutation, 1) + assert texts[key] != lifecycle_contract[key], original + try: + require_contract(texts, check_invocations=False) + except AssertionError as exc: + assert original in str(exc), str(exc) + else: + raise AssertionError(f"legacy V1 lifecycle mutation escaped: {original}") elif name in {"ignored_orphan", "occupied_scope_blocked"}: orphan = repo / ".release-loop/runs/alpha/orphan.txt" orphan.parent.mkdir(parents=True) @@ -6629,6 +6910,387 @@ def run_case(name: str) -> None: "schema", "feature", "progress_path", "artifact_root", "source_worktree", "base_owner", "destination", "manifest_sha256", "status", }, payload + elif name == "legacy_handoff_v1_ownership": + spec = importlib.util.spec_from_file_location("legacy_v1_frontmatter_guard", CLI) + assert spec is not None and spec.loader is not None + integrity = importlib.util.module_from_spec(spec) + spec.loader.exec_module(integrity) + try: + integrity.structured_progress_blocks("feature: legacy\n") + except integrity.Blocked as exc: + assert "progress frontmatter is missing" in str(exc), str(exc) + else: + raise AssertionError("missing frontmatter did not block safely") + + valid_source = repo + valid_base = new_repo(tmp, "v1-valid-base") + valid_progress = write_legacy_v1(valid_source) + + def edit_progress(path, old, new): + path.write_text(path.read_text(encoding="utf-8").replace(old, new, 1), encoding="utf-8") + + edit_progress(valid_progress, "v1:\n", "v1:\n\n") + assert handoff_scope( + valid_source, + valid_base, + str(valid_progress.relative_to(valid_source)), + legacy_destination=".release-loop", + )["cleanup_permitted"] is True + + valid_source = new_repo(tmp, "v1-valid-source") + valid_base = new_repo(tmp, "v1-valid-base-without-blank-line") + valid_progress = write_legacy_v1(valid_source) + + edit_progress( + valid_progress, + "final_action:\n", + 'future-field: accepted\nfuture.field: accepted\n"future_field": accepted\nfinal_action:\n', + ) + (valid_progress.parent / "v1/history").mkdir() + (valid_progress.parent / "v1/history/prior.md").write_text("prior\n", encoding="utf-8") + result = handoff_scope( + valid_source, valid_base, str(valid_progress.relative_to(valid_source)), + legacy_destination=".release-loop", + ) + assert result["cleanup_permitted"] is True + assert filesystem_manifest(valid_source / ".release-loop/v1") == filesystem_manifest( + valid_base / ".release-loop/v1" + ) + + nested_unknown_source = new_repo(tmp, "v1-nested-unknown-source") + nested_unknown_base = new_repo(tmp, "v1-nested-unknown-base") + nested_unknown_progress = write_legacy_v1(nested_unknown_source) + edit_progress( + nested_unknown_progress, + "final_action:\n", + 'future:\n "v1": {status: started}\nfinal_action:\n', + ) + assert handoff_scope( + nested_unknown_source, + nested_unknown_base, + str(nested_unknown_progress.relative_to(nested_unknown_source)), + legacy_destination=".release-loop", + )["cleanup_permitted"] is True + + pre_v1_source = new_repo(tmp, "v1-pre-v1-source") + pre_v1_base = new_repo(tmp, "v1-pre-v1-base") + pre_v1_progress = write_legacy(pre_v1_source) + assert handoff_scope( + pre_v1_source, pre_v1_base, str(pre_v1_progress.relative_to(pre_v1_source)), + legacy_destination=".release-loop", + )["cleanup_permitted"] is True + + def rejected_v1(slug, mutate, diagnostic="legacy V1 ownership"): + source = new_repo(tmp, f"v1-reject-source-{slug}") + base = new_repo(tmp, f"v1-reject-base-{slug}") + progress_path = write_legacy_v1(source) + mutate(source, progress_path) + source_before = filesystem_manifest(source) + base_before = filesystem_manifest(base) + marker = base / ".release-loop/.handoff/legacy.json" + try: + handoff_scope( + source, base, str(progress_path.relative_to(source)), + legacy_destination=".release-loop", + ) + except Blocked as exc: + assert diagnostic in str(exc), str(exc) + else: + raise AssertionError(f"V1 ownership case {slug} did not block") + assert not marker.exists(), slug + assert filesystem_manifest(source) == source_before, slug + assert filesystem_manifest(base) == base_before, slug + + def replace_digest(path, prefix): + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] + assert len(matches) == 1, (path, prefix, matches) + lines[matches[0]] = prefix + "1" * 64 + "\n" + path.write_text("".join(lines), encoding="utf-8") + + def remove_progress_block(path, name): + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + start = lines.index(f"{name}:\n") + end = start + 1 + while end < len(lines) and lines[end].startswith(" "): + end += 1 + path.write_text("".join(lines[:start] + lines[end:]), encoding="utf-8") + + def remove_all_ownership(source, path): + remove_progress_block(path, "v1") + remove_progress_block(path, "pre_merge_verification") + + mutations = ( + ("unowned-tree", remove_all_ownership), + ("ownership-without-official", lambda source, path: edit_progress(path, "pre_merge_verification:\n", "pre_merge_verification_missing:\n")), + ("accepted-without-ownership", lambda source, path: edit_progress(path, "v1:\n", "v1_missing:\n")), + ("accepted-without-tree", lambda source, path: shutil.rmtree(source / ".release-loop/v1")), + ("started", lambda source, path: edit_progress(path, " status: accepted\n", " status: started\n")), + ("official-started", lambda source, path: edit_progress(path, "pre_merge_verification:\n id: V1\n status: accepted\n", "pre_merge_verification:\n id: V1\n status: started\n")), + ("duplicate-top", lambda source, path: edit_progress(path, "final_action:\n", "v1:\n status: accepted\nfinal_action:\n")), + ("duplicate-top-v1-whitespace", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\nv1 :\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-whitespace", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\npre_merge_verification :\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-nbsp", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\nv1\N{NO-BREAK SPACE}:\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-nbsp", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\npre_merge_verification\N{NO-BREAK SPACE}:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-leading-space", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n v1:\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-leading-space", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n pre_merge_verification:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-leading-tab", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n\tv1:\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-leading-tab", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n\tpre_merge_verification:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-single-quoted", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n'v1':\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-double-quoted", lambda source, path: edit_progress(path, "final_action:\n", 'shadow: sentinel\n"pre_merge_verification":\n status: started\nfinal_action:\n')), + ("duplicate-top-v1-explicit", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n? v1\n:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-tagged", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n!!str v1:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-anchored", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n&owned v1:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-alias", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n*v1:\n status: started\nfinal_action:\n")), + ("duplicate-top-pre-merge-explicit-tagged", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n? !!str pre_merge_verification\n:\n status: started\nfinal_action:\n")), + ("duplicate-top-v1-inline-double-quoted", lambda source, path: edit_progress(path, "final_action:\n", 'shadow: sentinel\n"v1": {status: started}\nfinal_action:\n')), + ("duplicate-top-v1-inline-double-quoted-comment", lambda source, path: edit_progress(path, "final_action:\n", 'shadow: sentinel\n"v1": # duplicate\nfinal_action:\n')), + ("duplicate-top-v1-inline-tagged", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n!!str v1: {status: started}\nfinal_action:\n")), + ("duplicate-top-v1-inline-anchored", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\n&owned v1: {status: started}\nfinal_action:\n")), + ("duplicate-top-v1-escaped-double-quoted", lambda source, path: edit_progress(path, "final_action:\n", 'shadow: sentinel\n"v\\x31": {status: started}\nfinal_action:\n')), + ("duplicate-top-v1-anchored-alias", lambda source, path: edit_progress(path, "final_action:\n", "shadow: sentinel\nseed: &owned v1\n*owned: {status: started}\nfinal_action:\n")), + ("duplicate-nested", lambda source, path: edit_progress(path, " accepted_at:", " status: accepted\n accepted_at:")), + ("malformed-indent", lambda source, path: edit_progress(path, " pilot_approval_path:", " pilot_approval_path:")), + ("missing-key", lambda source, path: edit_progress(path, " accepted_at: 2026-08-23T00:00:00Z\n", "")), + ("empty-key", lambda source, path: edit_progress(path, " accepted_at: 2026-08-23T00:00:00Z", " accepted_at:")), + ("unknown-key", lambda source, path: edit_progress(path, " accepted_at:", " unknown: value\n accepted_at:")), + ("alias-path", lambda source, path: edit_progress(path, "pilot-approval.md", "v1/pilot-approval.md")), + ("renamed-path", lambda source, path: edit_progress(path, "pilot-approval.md", "pilot-approval-renamed.md")), + ("outside-path", lambda source, path: edit_progress(path, ".release-loop/v1/pilot-approval.md", "../pilot-approval.md")), + ("duplicate-path", lambda source, path: edit_progress(path, ".release-loop/v1/full-approval.md", ".release-loop/v1/pilot-approval.md")), + ("unexpected-child", lambda source, path: (source / ".release-loop/v1/foreign.md").write_text("foreign\n", encoding="utf-8")), + ("missing-file", lambda source, path: (source / ".release-loop/v1/full-approval.md").unlink()), + ("invalid-digest", lambda source, path: edit_progress(path, " pilot_receipt_sha256: ", " pilot_receipt_sha256: xyz # ")), + ("ledger-receipt-mismatch", lambda source, path: replace_digest(path, " pilot_receipt_sha256: ")), + ("embedded-receipt-mismatch", lambda source, path: replace_digest(source / ".release-loop/v1/full-receipt.md", "- receipt_sha256: ")), + ("computed-receipt-mismatch", lambda source, path: edit_progress(source / ".release-loop/v1/pilot-receipt.md", "- verdict: pass\n", "- verdict: changed\n")), + ("generation-ledger-mismatch", lambda source, path: edit_progress(path, " generation_sha256: ", " generation_sha256: " + "0" * 64 + " # ")), + ("generation-file-mismatch", lambda source, path: (source / ".release-loop/v1/generation-manifest.sha256").write_text("changed\n", encoding="utf-8")), + ("root-symlink", lambda source, path: ((source / ".release-loop/v1").rename(source / ".release-loop/v1-real"), (source / ".release-loop/v1").symlink_to(source / ".release-loop/v1-real", target_is_directory=True))), + ("file-symlink", lambda source, path: ((source / ".release-loop/v1/pilot-approval.md").unlink(), (source / ".release-loop/v1/pilot-approval.md").symlink_to(source / "README.md"))), + ) + for slug, mutate in mutations: + rejected_v1(slug, mutate) + elif name == "legacy_handoff_v1_success": + source = repo + base = new_repo(tmp, "v1-success-base") + legacy_path = write_legacy_v1(source) + history = legacy_path.parent / "v1/history/nested" + history.mkdir(parents=True) + (history / "prior.md").write_bytes(b"prior\x00bytes\n") + source_manifest = filesystem_manifest(legacy_path.parent / "v1") + result = handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + assert result["cleanup_permitted"] is True + assert filesystem_manifest(base / ".release-loop/v1") == source_manifest + assert (base / ".release-loop/progress.md").read_bytes() == legacy_path.read_bytes() + assert discover(base, ".release-loop/progress.md") == ( + "resume", base / ".release-loop/progress.md", + ) + elif name == "legacy_handoff_v1_partial_directory_rerun": + source = repo + base = new_repo(tmp, "v1-partial-base") + legacy_path = write_legacy_v1(source) + history = legacy_path.parent / "v1/history" + history.mkdir() + (history / "prior.md").write_text("prior\n", encoding="utf-8") + source_before = matrix_fixture_snapshot(source) + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", failure="handoff-after-copy-one-file", + ) + except Blocked as exc: + assert "injected handoff interruption" in str(exc), str(exc) + else: + raise AssertionError("V1 file-level interruption did not fire") + assert matrix_fixture_snapshot(source) == source_before + marker = base / ".release-loop/.handoff/legacy.json" + marker_before_retry = marker.read_bytes() + assert json.loads(marker_before_retry)["status"] == "incomplete" + partial = filesystem_manifest(base / ".release-loop/v1") + complete = filesystem_manifest(source / ".release-loop/v1") + assert partial and partial != complete + assert all(complete.get(relative) == value for relative, value in partial.items()) + result = handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + assert result["cleanup_permitted"] is True + assert filesystem_manifest(base / ".release-loop/v1") == complete + elif name == "legacy_handoff_v1_destination_mismatch": + markerless_source = new_repo(tmp, "v1-mismatch-source-markerless") + markerless_base = new_repo(tmp, "v1-mismatch-base-markerless") + markerless_progress = write_legacy_v1(markerless_source) + markerless_target = markerless_base / ".release-loop/v1" + markerless_target.mkdir(parents=True) + (markerless_target / "pilot-approval.md").write_bytes( + (markerless_source / ".release-loop/v1/pilot-approval.md").read_bytes() + ) + assert_blocked_snapshots( + lambda: handoff_scope( + markerless_source, markerless_base, + str(markerless_progress.relative_to(markerless_source)), + legacy_destination=".release-loop", + ), + markerless_source, markerless_base, "collision", + ) + assert not (markerless_base / ".release-loop/.handoff/legacy.json").exists() + + index_source = new_repo(tmp, "v1-mismatch-source-markerless-index") + index_base = new_repo(tmp, "v1-mismatch-base-markerless-index") + index_progress = write_legacy_v1(index_source) + indexed = index_base / ".release-loop/v1/index-only.md" + indexed.parent.mkdir(parents=True) + indexed.write_text("indexed\n", encoding="utf-8") + git(index_base, "add", "-f", str(indexed.relative_to(index_base))) + indexed.unlink() + indexed.parent.rmdir() + assert not (index_base / ".release-loop/v1").exists() + assert_blocked_snapshots( + lambda: handoff_scope( + index_source, index_base, str(index_progress.relative_to(index_source)), + legacy_destination=".release-loop", + ), + index_source, index_base, "collision", + ) + assert not (index_base / ".release-loop/.handoff/legacy.json").exists() + for mutation in ("changed", "extra", "index-only"): + source = new_repo(tmp, f"v1-mismatch-source-{mutation}") + base = new_repo(tmp, f"v1-mismatch-base-{mutation}") + legacy_path = write_legacy_v1(source) + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", failure="handoff-after-copy-one-file", + ) + except Blocked: + pass + else: + raise AssertionError("V1 file-level interruption did not fire") + if mutation == "changed": + partial_file = next( + path for path in (base / ".release-loop/v1").rglob("*") + if path.is_file() + ) + partial_file.write_bytes(b"changed\n") + elif mutation == "extra": + (base / ".release-loop/v1/extra.md").write_text("extra\n", encoding="utf-8") + else: + indexed = base / ".release-loop/v1/index-only.md" + indexed.write_text("indexed\n", encoding="utf-8") + git(base, "add", "-f", str(indexed.relative_to(base))) + indexed.unlink() + assert_blocked_snapshots( + lambda: handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ), + source, base, "collision" if mutation == "index-only" else "mismatch", + ) + elif name == "legacy_handoff_v1_symlinks": + for mutation in ("root", "nested"): + source = new_repo(tmp, f"v1-symlink-source-{mutation}") + base = new_repo(tmp, f"v1-symlink-base-{mutation}") + legacy_path = write_legacy_v1(source) + if mutation == "root": + root = source / ".release-loop/v1" + root.rename(source / ".release-loop/v1-real") + root.symlink_to(source / ".release-loop/v1-real", target_is_directory=True) + else: + history = source / ".release-loop/v1/history" + history.mkdir() + (history / "linked.md").symlink_to(source / "README.md") + assert_blocked_snapshots( + lambda: handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ), + source, base, "V1 root" if mutation == "root" else "symlink", + ) + assert not (base / ".release-loop/.handoff/legacy.json").exists() + + destination_root_source = new_repo(tmp, "v1-destination-root-symlink-source") + destination_root_base = new_repo(tmp, "v1-destination-root-symlink-base") + destination_root_progress = write_legacy_v1(destination_root_source) + destination_real = destination_root_base / ".release-loop/v1-real" + destination_real.mkdir(parents=True) + (destination_root_base / ".release-loop/v1").symlink_to( + destination_real, target_is_directory=True + ) + assert_blocked_snapshots( + lambda: handoff_scope( + destination_root_source, destination_root_base, + str(destination_root_progress.relative_to(destination_root_source)), + legacy_destination=".release-loop", + ), + destination_root_source, destination_root_base, "symlink", + ) + assert not (destination_root_base / ".release-loop/.handoff/legacy.json").exists() + + destination_nested_source = new_repo(tmp, "v1-destination-nested-symlink-source") + destination_nested_base = new_repo(tmp, "v1-destination-nested-symlink-base") + destination_nested_progress = write_legacy_v1(destination_nested_source) + try: + handoff_scope( + destination_nested_source, destination_nested_base, + str(destination_nested_progress.relative_to(destination_nested_source)), + legacy_destination=".release-loop", failure="handoff-after-copy-one-file", + ) + except Blocked: + pass + else: + raise AssertionError("V1 file-level interruption did not fire") + nested_link = destination_nested_base / ".release-loop/v1/linked.md" + nested_link.symlink_to(destination_nested_base / "README.md") + assert_blocked_snapshots( + lambda: handoff_scope( + destination_nested_source, destination_nested_base, + str(destination_nested_progress.relative_to(destination_nested_source)), + legacy_destination=".release-loop", + ), + destination_nested_source, destination_nested_base, "symlink", + ) + elif name == "legacy_handoff_source_changed": + source = repo + base = new_repo(tmp, "v1-source-changed-base") + legacy_path = write_legacy_v1(source) + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", failure="handoff-after-copy-one-file", + ) + except Blocked: + pass + else: + raise AssertionError("V1 file-level interruption did not fire") + (source / ".release-loop/v1/generation-receipt.md").write_text("changed\n", encoding="utf-8") + assert_blocked_snapshots( + lambda: handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ), + source, base, "active manifest changed", + ) + elif name == "legacy_handoff_complete_rerun": + source = repo + base = new_repo(tmp, "v1-complete-rerun-base") + legacy_path = write_legacy_v1(source) + first = handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + before = filesystem_manifest(base / ".release-loop") + git(base, "add", "-f", ".release-loop/progress.md", ".release-loop/v1") + git(base, "commit", "-qm", "fixture committed legacy handoff") + second = handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + assert first["cleanup_permitted"] is True and second["cleanup_permitted"] is True + assert filesystem_manifest(base / ".release-loop") == before elif name == "legacy_handoff_cli_contract": missing_source = new_repo(tmp, "cli-missing-source") missing_base = new_repo(tmp, "cli-missing-base") diff --git a/skills/release-loop/references/progress-schema.md b/skills/release-loop/references/progress-schema.md index 77b5d02..ccc1cfd 100644 --- a/skills/release-loop/references/progress-schema.md +++ b/skills/release-loop/references/progress-schema.md @@ -33,6 +33,19 @@ pre_merge_verification: status: started | accepted generation_sha256: updated: +# Required ownership metadata when accepted legacy V1 files exist. +v1: + status: accepted + pilot_approval_path: .release-loop/v1/pilot-approval.md + pilot_receipt_path: .release-loop/v1/pilot-receipt.md + pilot_receipt_sha256: + full_approval_path: .release-loop/v1/full-approval.md + full_receipt_path: .release-loop/v1/full-receipt.md + full_receipt_sha256: + generation_receipt_path: .release-loop/v1/generation-receipt.md + generation_manifest_path: .release-loop/v1/generation-manifest.sha256 + generation_manifest_sha256: + accepted_at: archive_verification: id: V2 status: started | accepted @@ -168,9 +181,9 @@ The CLI path is `skills/release-loop/scripts/run-artifact-integrity.py`. ## Rules - `artifact_root` equals the exact repo-relative directory that contains the selected progress record. New scoped records require this field. Legacy records require `artifact_root: .release-loop`. A legacy record without that field cannot resume. -- The four ordinary lifecycle artifact-root families are scoped active state, legacy active state, terminal archives, and transition handoff. +- The four ordinary lifecycle artifact-root families are the four closed physical-root families: scoped active state, legacy active state, terminal archives, and transition handoff. - `recovery-authority/` and `recovery-backups/` are fixed internal persistent recovery families under `.release-loop/`. Legacy handoff preserves them at the base and rejects them at the source. They are never active transfer roots. -- Scoped active state permits only the selected `.release-loop/runs/` root. Legacy active state permits the root progress file and its four known sibling directories. +- Scoped active state permits only the selected `.release-loop/runs/` root. Legacy active state includes the root progress file, known working directories, publisher state, corrupt backups, and accepted V1 evidence. - Terminal archive state permits only the collision-resolved `.release-loop/archive/` root. Handoff state permits only `.release-loop/.handoff`. - Reject every symlink in each existing source or destination component. Also reject absolute paths, parent escapes, and physical parents outside the applicable closed root. - Before the first scope write, inspect filesystem entries and `git ls-files -- `. A nonempty scope requires one matching valid progress record. Any other ignored or tracked entry is an artifact-scope collision. @@ -184,13 +197,13 @@ The CLI path is `skills/release-loop/scripts/run-artifact-integrity.py`. - Resume sends one answer only when the gate ID, phase, answer class, issue timestamp, absent approval, and absent receipt match. Missing, duplicate, stale, mismatched, unknown, already-approved, or previously reserved state blocks without sending an answer. - Legacy archive recovery is a separate local gate. The orchestrator records the first-hand current-session answer in its pinned gate ledger, and the artifact CLI snapshots that receipt before publishing approval. Python validates custody and digests; it does not authenticate a human. - The recovery gate stays pending until both `gate-receipt.json` and `approval.json` exist. Clear the gate and its live receipt atomically after both create-once records validate. A cancelled answer records cancellation and starts no backup. -- V1 acceptance is required before `shipping` starts. V2 acceptance is required before `phase: done`. Each record uses the exact generation digest and transitions only from `started` to `accepted` with Log evidence. - An ordinary lifecycle still requires its declared supported pre-archive contract. Recovery cannot replace V2 or a later registered contract. - Recovery uses the fixed sequence G0, receipt R, G1, G2, and G3. G1 and G2 keep the phase nonterminal. Only recovery generation G3 may use the terminal archive exception. - A recovery receipt pins the request, approval snapshot, accepted audit, backup record, source archive, restored G0 digest, and pre-receipt generation. Each later generation pins its predecessor and the same recovery ID. - Each G1, G2, and G3 write fetches a fresh timestamp and atomically updates `updated`. Its evidence records `prior-updated` for predecessor reconstruction. G1 cannot precede G0 `updated` or `request.issued_at`. A retry reuses an exact durable progress temporary only when its timestamp satisfies that bound. - Legacy archive recovery audit requires `ship_approved.at <= final_action.updated == ship: merged <= retro: committed == updated <= request.issued_at <= audit.timestamp`. It rejects every missing, malformed, future, nonmonotonic, or unequal atomic-write timestamp before restore. - Missing, stale, malformed, duplicate, replayed, swapped, or caller-overridden recovery evidence blocks before the terminal exception. +- V1 acceptance is required before `shipping` starts. `pre_merge_verification` is the sole acceptance authority. The `v1` block binds the six canonical files and their required digests. Its optional `history/` tree is also owned. Handoff and archive preserve these bytes. V2 acceptance is required before `phase: done`. Each verification record uses the exact generation digest and transitions only from `started` to `accepted` with Log evidence. - Timestamps are ISO-8601 with timezone, **fetched fresh via command (`date -u +%Y-%m-%dT%H:%M:%SZ`) at each write — never estimated or interpolated** (pilot-proven: estimated timestamps produced a non-monotonic log). - **Status flips are atomic with their evidence**: changing `phase`/`phase_status` and writing the explaining Log line (plus `blocked_reason` when the status is blocked) happen in the same edit — a bare `blocked` with `blocked_reason: null` is a schema violation, not a placeholder. - Corrupt/unparsable file on resume → rebuild frontmatter from git evidence (branch, committed artifacts, PR state via `gh pr view`), keep the old file as `progress.md.corrupt-`, and note the rebuild in the Log. A stored `feature:` that fails the `feature_slug` invariant is the same class of corruption. @@ -237,7 +250,7 @@ The CLI path is `skills/release-loop/scripts/run-artifact-integrity.py`. - Incomplete archive evidence is ` archived-incomplete: archive-destination: `. Both phase fields are mandatory and must use the schema's closed vocabularies. Require `phase != done` and `phase_status != complete`. The transition never flips either field. - Exactly one archive-evidence mode may exist. Duplicate, mixed-mode, phase-mismatched, or destination-mismatched evidence blocks. - A completed record's terminal home is `.release-loop/archive/-/`. The canonical archive Log line must name that containing directory. One qualifying record reports completion. Zero records trigger reconstruction. Multiple records block as ambiguous. -- Move all remaining children from the selected artifact root before the selected progress record. Move `progress.md` last as the commit point. An interrupted archive reuses its logged destination and moves only remaining children. +- Move all remaining children from the selected artifact root before the selected progress record. A legacy archive includes accepted V1 evidence but excludes persistent `archive`, `.handoff`, and `runs` siblings. Move `progress.md` last as the commit point. An interrupted archive reuses its logged destination and moves only remaining children. - Before an archive move, validate the phase-artifact journal and every owned final. A pending publication blocks until recovery or compensation. - Archive the ownership journal and applicable `.tmp` state with the selected run. Leave no live publisher authority outside the terminal archive. - **The `final_action` record is preparation evidence, never approval**: possession of the command is not authorization to run it. Approval evidence lives only in `ship_approved`. `enforces: P7` diff --git a/skills/release-loop/references/resume-and-archive.md b/skills/release-loop/references/resume-and-archive.md index bf86e22..77938ca 100644 --- a/skills/release-loop/references/resume-and-archive.md +++ b/skills/release-loop/references/resume-and-archive.md @@ -22,8 +22,9 @@ A **Loop archive** moves a loop's local working state to its terminal home. Run 5. Run `python3 "$release_loop_skill_root/scripts/run-artifact-integrity.py" archive --repo . --progress-path --destination ` only after step 4 persists the canonical evidence. The CLI validates one exact marker and its matching phase state before a move. It never writes ledger evidence or changes phase. It returns `state: archived` for completed evidence and `state: archived-incomplete` for incomplete evidence. On a pre-commit rerun, omit `--destination`; the command reads the persisted path. If the progress commit point already moved, repeat the exact canonical `--destination` so the CLI can validate the terminal archive and finish scoped cleanup. 6. A valid done record with one completed destination marks an interrupted completed archive. A nonterminal record with one incomplete destination marks an interrupted user-directed incomplete archive. For either mode, reuse the exact recorded archive destination. Do not calculate another suffix. Duplicate, mixed-mode, phase-mismatched, or argument-mismatched evidence blocks before movement. 7. For a scoped run, move only the remaining children of its exact `artifact_root`. Move scoped `progress.md` last as the archive commit point. -8. For a legacy run, move only `briefs`, `reports`, `reviews`, `evidence`, and its corrupt backups. Move the selected root `progress.md` last. -9. Verify the terminal record against the retained destination. A rerun reads the destination from the source ledger. It moves only remaining children. It never reverses a terminal archive. +8. For a legacy run, validate the accepted V1 ownership block and its exact `.release-loop/v1` tree. Move V1 with `briefs`, `reports`, `reviews`, `evidence`, publisher state, and corrupt backups. Never move `archive`, `.handoff`, or `runs` as active state. +9. Move the selected root `progress.md` last. This move is the archive commit point. +10. Verify the terminal record and V1 tree against the exact returned `archive_path`. A rerun uses the persisted destination. It accepts V1 in either source or destination, but never both. It moves only remaining children. ### Recovering an archived-incomplete scoped run @@ -79,4 +80,4 @@ blocks the resume. The archive transition is local-only and can run headlessly after it proves that no outward target exists. Pre-move cancellation preserves the source and recorded destination. Mid-move cancellation leaves the selected progress record in the source scope. This rule applies to scoped and legacy archives. The next invocation reads that record and finishes the same destination. Ambiguous bytes require manual recovery without source deletion. -After Retro's exit condition holds, run the Archive procedure before reporting done. Retain the exact returned `archive_path`. Verify that the selected live progress path is absent. Verify the terminal record, Retro evidence, and destination marker. The completion report names that path. +After Retro's exit condition holds, run the Archive procedure before reporting done. Retain the exact returned `archive_path`. Verify that the selected live progress path is absent. For a scoped record (`artifact_root: .release-loop/runs/`), verify the terminal record, Retro evidence, destination marker, and exact `archive_path`; do not apply V1 tree checks. For a legacy record (`artifact_root: .release-loop`), also verify the live V1 tree is absent and the archived V1 tree is present at the exact returned path. The completion report names that path. diff --git a/skills/release-loop/references/transition-hooks.md b/skills/release-loop/references/transition-hooks.md index a986f25..8ccb546 100644 --- a/skills/release-loop/references/transition-hooks.md +++ b/skills/release-loop/references/transition-hooks.md @@ -13,7 +13,7 @@ A post-approval deviation never overrides a transition by discovery alone. When On every Ship entry or resume, inspect the base checkout's handoff state before trusting a progress record. Run `python3 "$release_loop_skill_root/scripts/run-artifact-integrity.py" handoff --repo --base-repo --progress-path `. Resolve both checkout paths physically. If they are identical, block before marker creation. `.release-loop/.handoff` is the fixed handoff root. Never derive the allowed root from the marker or destination. Reject absolute paths, parent escapes, symlinks, and physical parents outside each fixed root family. -A legacy record (`artifact_root: .release-loop`) adds `--legacy-destination .release-loop` to that same invocation: `python3 "$release_loop_skill_root/scripts/run-artifact-integrity.py" handoff --repo --base-repo --progress-path --legacy-destination .release-loop`. A scoped record never passes `--legacy-destination`; the CLI rejects either flag on the wrong record shape. Under `.release-loop`, `archive/`, `.handoff/`, and `runs/` are persistent siblings. The `recovery-authority/` and `recovery-backups/` are persistent siblings too. They are never active transfer bytes. At the base destination, legacy handoff skips every persistent sibling during the collision scan. At the source, legacy handoff rejects every persistent sibling; a source worktree must contain only active state under `.release-loop`. +A legacy record (`artifact_root: .release-loop`) adds `--legacy-destination .release-loop` to that same invocation: `python3 "$release_loop_skill_root/scripts/run-artifact-integrity.py" handoff --repo --base-repo --progress-path --legacy-destination .release-loop`. A scoped record never passes `--legacy-destination`; the CLI rejects either flag on the wrong record shape. Accepted legacy V1 state is active state. Handoff validates its ledger ownership and exact `.release-loop/v1` tree before copying it. Under `.release-loop`, `archive/`, `.handoff/`, and `runs/` are persistent siblings. The `recovery-authority/` and `recovery-backups/` are persistent siblings too. They are never active transfer bytes. At the base destination, legacy handoff skips every persistent sibling during the collision scan. At the source, legacy handoff rejects every persistent sibling; a source worktree must contain only active state under `.release-loop`. Each handoff operation records the exact repo-relative source progress path, `artifact_root`, feature, source worktree, base owner, and destination. Create the marker before transfer. Copy the exact active scope to that base destination. A matching incomplete marker resumes the same transfer. A missing or mismatched marker blocks Ship and preserves both scopes. @@ -25,7 +25,7 @@ If the authoritative base ledger records `phase: ship` and `merged: true`, resum ## Release-loop pre-merge verification V1: Produce the approved generation -After Review returns `clean`, the first-hand release-loop orchestrator runs the approved plan's V1 section before invoking `shipping`. It requires a current-session pilot approval packet and receipt, a complete pilot, a separately approved full packet and receipt, and one verified complete generation. Persist V1 start and acceptance with the exact generation manifest SHA-256. Missing, failed, interrupted, stale, or unverifiable V1 state blocks Ship before the merge gate. Resume retains completed calls but never reuses a receipt from another session. +After Review returns `clean`, the first-hand release-loop orchestrator runs the approved plan's V1 section before invoking `shipping`. It requires a current-session pilot approval packet and receipt, a complete pilot, a separately approved full packet and receipt, and one verified complete generation. Persist V1 start and acceptance with the exact generation manifest SHA-256. The `pre_merge_verification` block is the sole acceptance authority. The `v1` block records ownership paths and digests. Handoff and archive preserve the exact V1 bytes. Missing, failed, interrupted, stale, or unverifiable V1 state blocks Ship before the merge gate. Resume retains completed calls but never reuses a receipt from another session. ## Release-loop pre-archive verification V2: Verify the archived generation diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index e0be802..63c63d1 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -45,6 +45,7 @@ "archive-after-manifest-final", "handoff-after-marker", "handoff-after-copy-one", + "handoff-after-copy-one-file", "publish-before-final", "recovery-backup-create", "recovery-before-copy-ancestor", @@ -84,7 +85,7 @@ RECOVERY_GATE_ANSWER_CLASS = "approve-exact-recovery-or-cancel" LEGACY_DESTINATION = ".release-loop" LEGACY_MARKER_SCHEMA = "release-loop-handoff/v2" -LEGACY_ACTIVE_ALL = frozenset(("progress.md", ".tmp", JOURNAL_NAME, "briefs", "reports", "reviews", "evidence")) +LEGACY_ACTIVE_ALL = frozenset(("progress.md", ".tmp", JOURNAL_NAME, "briefs", "reports", "reviews", "evidence", "v1")) LEGACY_PERSISTENT_NAMES = frozenset(( "archive", ".handoff", "runs", "recovery-authority", "recovery-backups", )) @@ -129,6 +130,19 @@ "loop live and resumable." ) CONTRACT_INTRODUCTION_COMMIT = "08e12a82752847b3bead5a96fd251b4ad58eae1b" +V1_PRE_MERGE_KEYS = frozenset(("id", "status", "generation_sha256", "updated")) +V1_OWNERSHIP_PATHS = { + "pilot_approval_path": ".release-loop/v1/pilot-approval.md", + "pilot_receipt_path": ".release-loop/v1/pilot-receipt.md", + "full_approval_path": ".release-loop/v1/full-approval.md", + "full_receipt_path": ".release-loop/v1/full-receipt.md", + "generation_receipt_path": ".release-loop/v1/generation-receipt.md", + "generation_manifest_path": ".release-loop/v1/generation-manifest.sha256", +} +V1_OWNERSHIP_KEYS = frozenset(( + "status", *V1_OWNERSHIP_PATHS, "pilot_receipt_sha256", "full_receipt_sha256", + "generation_manifest_sha256", "accepted_at", +)) class Blocked(RuntimeError): @@ -346,6 +360,8 @@ def parse_frontmatter( key, value = line.split(":", 1) key = key.strip() if key in values: + if key in PROTECTED_V1_KEYS: + reject("legacy V1 ownership", f"duplicate top-level field {key}") reject(duplicate_kind, f"duplicate top-level field {key}") values[key] = value.strip() schema = values.get("schema", "") @@ -551,6 +567,350 @@ def legacy_manifest_digest(entries: list[dict[str, object]]) -> str: return hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() +PROTECTED_V1_KEYS = frozenset(("pre_merge_verification", "v1")) +YAML_DOUBLE_QUOTED_ESCAPES = { + "0": "\0", + "a": "\a", + "b": "\b", + "t": "\t", + "n": "\n", + "v": "\v", + "f": "\f", + "r": "\r", + "e": "\x1b", + " ": " ", + '"': '"', + "/": "/", + "\\": "\\", + "N": "\u0085", + "_": "\u00a0", + "L": "\u2028", + "P": "\u2029", +} + + +def _yaml_double_quoted_value(value: str, start: int = 0) -> tuple[str, int] | None: + if start >= len(value) or value[start] != '"': + return None + result: list[str] = [] + index = start + 1 + while index < len(value): + character = value[index] + if character == '"': + return "".join(result), index + 1 + if character != "\\": + result.append(character) + index += 1 + continue + index += 1 + if index >= len(value): + return None + escape = value[index] + if escape in YAML_DOUBLE_QUOTED_ESCAPES: + result.append(YAML_DOUBLE_QUOTED_ESCAPES[escape]) + index += 1 + continue + width = {"x": 2, "u": 4, "U": 8}.get(escape) + if width is None or index + width >= len(value): + return None + digits = value[index + 1:index + width + 1] + if not re.fullmatch(rf"[0-9A-Fa-f]{{{width}}}", digits): + return None + result.append(chr(int(digits, 16))) + index += width + 1 + return None + + +def _yaml_single_quoted_value(value: str, start: int = 0) -> tuple[str, int] | None: + if start >= len(value) or value[start] != "'": + return None + result: list[str] = [] + index = start + 1 + while index < len(value): + character = value[index] + if character != "'": + result.append(character) + index += 1 + continue + if index + 1 < len(value) and value[index + 1] == "'": + result.append("'") + index += 2 + continue + return "".join(result), index + 1 + return None + + +def _yaml_prefix(value: str, start: int) -> tuple[str, str, int] | None: + if start >= len(value) or value[start] not in "!&*": + return None + prefix = value[start] + index = start + 1 + if prefix == "!" and index < len(value) and value[index] == "<": + end = value.find(">", index + 1) + if end < 0: + return None + return prefix, value[start:end + 1], end + 1 + while index < len(value) and not value[index].isspace(): + index += 1 + return prefix, value[start:index], index + + +def _yaml_plain_token(value: str, start: int) -> tuple[str, int] | None: + index = start + while index < len(value) and not value[index].isspace(): + index += 1 + token = value[start:index] + return (token, index) if token else None + + +def _yaml_scalar(value: str, start: int, anchors: dict[str, str]) -> tuple[str, int] | None: + while start < len(value) and value[start].isspace(): + start += 1 + if start >= len(value): + return None + if value[start] == '"': + return _yaml_double_quoted_value(value, start) + if value[start] == "'": + return _yaml_single_quoted_value(value, start) + if value[start] == "*": + prefix = _yaml_prefix(value, start) + if prefix is None: + return None + alias = prefix[1][1:] + resolved = anchors.get(alias) + return (resolved, prefix[2]) if resolved is not None else None + return _yaml_plain_token(value, start) + + +def _yaml_key(value: str, anchors: dict[str, str]) -> tuple[str, bool, str | None] | None: + start = 0 + prefixes: list[tuple[str, str]] = [] + while True: + while start < len(value) and value[start].isspace(): + start += 1 + prefix = _yaml_prefix(value, start) + if prefix is None or prefix[0] == "*": + break + prefixes.append((prefix[0], prefix[1])) + start = prefix[2] + while start < len(value) and value[start].isspace(): + start += 1 + if start < len(value) and value[start] == "*": + prefix = _yaml_prefix(value, start) + if prefix is None: + return None + alias = prefix[1][1:] + resolved = anchors.get(alias, alias if alias in PROTECTED_V1_KEYS else "") + if not resolved: + return None + anchor = next((token[1][1:] for token in prefixes if token[0] == "&"), None) + return resolved, False, anchor + scalar = _yaml_scalar(value, start, anchors) + if scalar is None: + return None + resolved, _end = scalar + anchor = next((token[1][1:] for token in prefixes if token[0] == "&"), None) + if not resolved: + return None + canonical = not prefixes and value == resolved + return resolved, canonical, anchor + + +def _yaml_mapping_colon(line: str) -> int | None: + quote: str | None = None + index = 0 + while index < len(line): + character = line[index] + if quote is not None: + if quote == '"' and character == "\\": + index += 2 + continue + if character == quote: + if quote == "'" and index + 1 < len(line) and line[index + 1] == "'": + index += 2 + continue + quote = None + index += 1 + continue + if character in "'\"": + quote = character + elif character == "#" and (index == 0 or line[index - 1].isspace()): + return None + elif character == ":" and (index + 1 == len(line) or line[index + 1].isspace() or line[index + 1] in "{}[],"): + return index + index += 1 + return None + + +def _yaml_top_level_anchors(line: str, anchors: dict[str, str]) -> None: + if line[:1].isspace(): + return + colon = _yaml_mapping_colon(line) + if colon is None: + return + value = line[colon + 1:] + start = 0 + anchor: str | None = None + while True: + while start < len(value) and value[start].isspace(): + start += 1 + prefix = _yaml_prefix(value, start) + if prefix is None or prefix[0] == "*": + break + if prefix[0] == "&": + anchor = prefix[1][1:] + start = prefix[2] + if anchor is None: + return + scalar = _yaml_scalar(value, start, anchors) + if scalar is not None: + anchors[anchor] = scalar[0] + + +def validate_v1_top_level_syntax(frontmatter_text: str) -> None: + anchors: dict[str, str] = {} + for line in frontmatter_text.splitlines(): + if not line or line.lstrip().startswith("#"): + continue + _yaml_top_level_anchors(line, anchors) + indentation = len(line) - len(line.lstrip(" \t")) + if indentation > 0 and indentation != 1 and not line.startswith("\t"): + continue + candidate = line.lstrip(" \t") if indentation else line + explicit = candidate.startswith("?") + if explicit: + candidate = candidate[1:].lstrip() + colon = _yaml_mapping_colon(candidate) if not explicit else None + key_text = candidate[:colon] if colon is not None else candidate + parsed = _yaml_key(key_text, anchors) + if parsed is None: + continue + resolved, canonical, anchor = parsed + if anchor is not None: + anchors[anchor] = resolved + if resolved not in PROTECTED_V1_KEYS: + continue + if not canonical or indentation == 1 or line.startswith("\t") or explicit: + reject("legacy V1 ownership", "noncanonical top-level key") + + +def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: + frontmatter_text, _body = split_frontmatter( + text, + failure_kind="legacy V1 ownership", + detail="progress frontmatter is missing", + ) + validate_v1_top_level_syntax(frontmatter_text) + selected = {"pre_merge_verification": None, "v1": None} + active = None + for line in frontmatter_text.splitlines(): + raw_key, separator, _ = line.partition(":") + normalized_key = raw_key.strip() + if separator and normalized_key in selected and raw_key != normalized_key: + reject("legacy V1 ownership", f"malformed block {normalized_key}") + top = re.fullmatch(r"([A-Za-z0-9_]+):(.*)", line) + if top: + active = None + name, suffix = top.groups() + if name in selected: + if selected[name] is not None: + reject("legacy V1 ownership", f"duplicate block {name}") + if suffix.strip(): + reject("legacy V1 ownership", f"malformed block {name}") + selected[name] = {} + active = name + continue + if active is not None and line and not line[0].isspace() and ":" in line: + active = None + continue + if not line.strip(): + continue + if active is None: + continue + nested = re.fullmatch(r" ([A-Za-z0-9_]+):(?: (.*))?", line) + if nested is None: + reject("legacy V1 ownership", f"malformed indentation in {active}") + key, value = nested.groups() + mapping = selected[active] + assert mapping is not None + if key in mapping: + reject("legacy V1 ownership", f"duplicate key {active}.{key}") + mapping[key] = value or "" + return selected + + +def validate_v1_receipt(path: Path, expected_digest: str, label: str) -> None: + try: + payload = path.read_bytes() + text = payload.decode("utf-8") + except (OSError, UnicodeError): + reject("legacy V1 ownership", f"unreadable {label} receipt") + scope_lines = re.findall(r"^- receipt_sha256_scope: (.*)$", text, re.MULTILINE) + digest_lines = re.findall(r"^- receipt_sha256: (.*)$", text, re.MULTILINE) + marker = b"- receipt_sha256:" + if scope_lines != ["canonical bytes before this field"] or len(digest_lines) != 1 or payload.count(marker) != 1: + reject("legacy V1 ownership", f"malformed {label} receipt") + embedded = digest_lines[0] + computed = hashlib.sha256(payload.split(marker, 1)[0]).hexdigest() + if not SHA_PATTERN.fullmatch(embedded) or len({expected_digest, embedded, computed}) != 1: + reject("legacy V1 ownership", f"{label} receipt digest mismatch") + + +def validate_legacy_v1_ownership(repo: Path, text: str, root: Path | None = None) -> None: + blocks = structured_progress_blocks(text) + pre_merge = blocks["pre_merge_verification"] + ownership = blocks["v1"] + root = root or repo / ".release-loop/v1" + root_present = root.exists() or root.is_symlink() + if pre_merge is None and ownership is None and not root_present: + return + if pre_merge is None or ownership is None: + reject("legacy V1 ownership", "official acceptance, ownership, and V1 tree must agree") + if set(pre_merge) != V1_PRE_MERGE_KEYS or any(not value for value in pre_merge.values()): + reject("legacy V1 ownership", "invalid pre_merge_verification keys") + if set(ownership) != V1_OWNERSHIP_KEYS or any(not value for value in ownership.values()): + reject("legacy V1 ownership", "invalid v1 keys") + if pre_merge["id"] != "V1" or pre_merge["status"] != "accepted": + reject("legacy V1 ownership", "pre_merge_verification is not accepted V1") + if ownership["status"] != "accepted": + reject("legacy V1 ownership", "v1 ownership is not accepted") + digests = ( + pre_merge["generation_sha256"], ownership["generation_manifest_sha256"], + ownership["pilot_receipt_sha256"], ownership["full_receipt_sha256"], + ) + if any(not SHA_PATTERN.fullmatch(digest) for digest in digests): + reject("legacy V1 ownership", "invalid digest") + if pre_merge["generation_sha256"] != ownership["generation_manifest_sha256"]: + reject("legacy V1 ownership", "generation digest mismatch") + if any(ownership[key] != expected for key, expected in V1_OWNERSHIP_PATHS.items()): + reject("legacy V1 ownership", "non-canonical ownership path") + if len({ownership[key] for key in V1_OWNERSHIP_PATHS}) != len(V1_OWNERSHIP_PATHS): + reject("legacy V1 ownership", "duplicate ownership path") + if root.is_symlink() or not root.is_dir(): + reject("legacy V1 ownership", "missing or invalid V1 root") + expected_children = {PurePosixPath(path).name for path in V1_OWNERSHIP_PATHS.values()} + observed_children = {child.name for child in root.iterdir()} + extras = observed_children - expected_children - {"history"} + missing = expected_children - observed_children + if extras or missing: + reject("legacy V1 ownership", "unexpected or missing V1 child") + history = root / "history" + if history.exists() or history.is_symlink(): + if history.is_symlink() or not history.is_dir(): + reject("legacy V1 ownership", "invalid V1 history") + tree_manifest(history) + paths = {} + for key, relative in V1_OWNERSHIP_PATHS.items(): + candidate = root / PurePosixPath(relative).name + if candidate.is_symlink() or not candidate.is_file(): + reject("legacy V1 ownership", f"missing regular file {relative}") + paths[key] = candidate + validate_v1_receipt(paths["pilot_receipt_path"], ownership["pilot_receipt_sha256"], "pilot") + validate_v1_receipt(paths["full_receipt_path"], ownership["full_receipt_sha256"], "full") + if phase_artifact_sha256(paths["generation_manifest_path"]) != ownership["generation_manifest_sha256"]: + reject("legacy V1 ownership", "generation manifest digest mismatch") + + def legacy_is_subset(observed: list[dict[str, object]], full: list[dict[str, object]]) -> bool: return all(entry in full for entry in observed) @@ -594,7 +954,7 @@ def legacy_git_active_paths(repo: Path) -> list[str]: return active -def legacy_copy_child(child: Path, destination_root: Path) -> None: +def legacy_copy_child(child: Path, destination_root: Path, inject_after_file: bool = False) -> None: destination_root.mkdir(parents=True, exist_ok=True) target = destination_root / child.name if target.exists() or target.is_symlink(): @@ -611,6 +971,8 @@ def legacy_copy_child(child: Path, destination_root: Path) -> None: else: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(path.read_bytes()) + if inject_after_file: + reject("injected handoff interruption", path.relative_to(child).as_posix()) else: target.write_bytes(child.read_bytes()) @@ -623,6 +985,7 @@ def legacy_handoff( marker_relative: str, ) -> tuple[Path, Path]: run_id = values["feature"] + validate_legacy_v1_ownership(repo, progress_file.read_text(encoding="utf-8")) marker = guard(base_repo, marker_relative, ".release-loop/.handoff") source = repo / ".release-loop" source_children = legacy_scan_children(repo, source, allow_persistent=False) @@ -648,6 +1011,8 @@ def legacy_handoff( if observed_entries != manifest_entries: reject("legacy handoff collision", "destination active state no longer matches complete marker") return legacy_confirm(base_repo, destination, marker) + if legacy_git_active_paths(base_repo): + reject("legacy handoff collision", "base active legacy state is present in the index") if not legacy_is_subset(observed_entries, manifest_entries): reject("handoff target mismatch", ".release-loop") else: @@ -658,11 +1023,12 @@ def legacy_handoff( legacy_write_marker(marker, {**expected_fields, "manifest_sha256": digest, "status": "incomplete"}) present_names = {child.name for child in destination_children} inject_after_copy = test_failure("handoff-after-copy-one") + inject_after_file = test_failure("handoff-after-copy-one-file") for child in source_children: if child.name not in present_names: - legacy_copy_child(child, destination) + legacy_copy_child(child, destination, inject_after_file) elif child.is_dir(): - copy_missing(child, destination / child.name) + copy_missing(child, destination / child.name, inject_after_file) else: continue if inject_after_copy: @@ -4533,6 +4899,8 @@ def recover_terminal_archive( destination, allow_recovery_terminal=True, ) + if source_rel == ".release-loop": + validate_legacy_v1_ownership(repo, text, destination_path / "v1") _, _, publication = validate_archive_publication(repo, source_rel, selected) if publication["pending"] is not None: reject("archive destination conflict", "terminal archive has pending publication") @@ -4577,6 +4945,16 @@ def archive( if source_rel == ".release-loop": guard(repo, progress_path, ".release-loop") source = repo / ".release-loop" + source_v1 = source / "v1" + destination_v1 = destination_path / "v1" + present_v1 = [ + root + for root in (source_v1, destination_v1) + if root.exists() or root.is_symlink() + ] + if len(present_v1) > 1: + reject("archive destination conflict", "V1 exists in source and destination") + validate_legacy_v1_ownership(repo, text, present_v1[0] if present_v1 else source_v1) children = [] controls = (source / ".tmp", journal) if journal.parent == source else (source / ".tmp",) for child in controls: @@ -4588,6 +4966,10 @@ def archive( if child.exists() or child.is_symlink(): guard(repo, child.relative_to(repo).as_posix(), ".release-loop") children.append(child) + v1 = source / "v1" + if v1.exists() or v1.is_symlink(): + guard(repo, v1.relative_to(repo).as_posix(), ".release-loop") + children.append(v1) for child in sorted(source.glob("progress.md.corrupt-*")): guard(repo, child.relative_to(repo).as_posix(), ".release-loop") children.append(child) @@ -4640,7 +5022,7 @@ def tree_manifest(root: Path) -> dict[str, bytes | None]: return manifest -def copy_missing(source: Path, target: Path) -> None: +def copy_missing(source: Path, target: Path, inject_after_file: bool = False) -> None: source_manifest = tree_manifest(source) target_manifest = tree_manifest(target) if target.exists() else {} extras = sorted(set(target_manifest) - set(source_manifest)) @@ -4659,6 +5041,8 @@ def copy_missing(source: Path, target: Path) -> None: elif relative not in target_manifest: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(data) + if inject_after_file: + reject("injected handoff interruption", relative) def handoff( diff --git a/tests/conformance/release-loop/baseline-policy.json b/tests/conformance/release-loop/baseline-policy.json index efb0afa..41389ed 100644 --- a/tests/conformance/release-loop/baseline-policy.json +++ b/tests/conformance/release-loop/baseline-policy.json @@ -3,6 +3,6 @@ "state": "bootstrap", "approved_spec": "docs/specs/2026-08-24-release-loop-conformance-fuzzing-design.md", "approved_spec_sha256": "2cde033379b87d6c8eb92ea32ea3800a82625d86056da496343a91cf0bd8930b", - "source_generation": "c357fbe4924d57718f4e7160385c7433bfd5f71a769b49e248921daeeca01cab", + "source_generation": "0397746ca681e86784ca2ba717da01934c7788df982836a0caac3541e3498345", "roadmap_item": "Conformance suite" } diff --git a/tests/conformance/release-loop/corpus.json b/tests/conformance/release-loop/corpus.json index 2514055..052a907 100644 --- a/tests/conformance/release-loop/corpus.json +++ b/tests/conformance/release-loop/corpus.json @@ -1,6 +1,6 @@ { "schema": "release-loop-conformance-corpus/v1", - "source_generation": "c357fbe4924d57718f4e7160385c7433bfd5f71a769b49e248921daeeca01cab", + "source_generation": "0397746ca681e86784ca2ba717da01934c7788df982836a0caac3541e3498345", "harnesses": [ "claude", "codex" diff --git a/tests/conformance/release-loop/source-manifest.json b/tests/conformance/release-loop/source-manifest.json index b1afd03..606ce9e 100644 --- a/tests/conformance/release-loop/source-manifest.json +++ b/tests/conformance/release-loop/source-manifest.json @@ -42,14 +42,14 @@ "path": "skills/release-loop/references/progress-schema.md", "heading": "## Rules", "text": "- Resume sends one answer only when the gate ID, phase, answer class, issue timestamp, absent approval, and absent receipt match.", - "sha256": "e7b6dfa323361d2e39db3593e302236e4ce373a342937e347db2774eb416fdb4" + "sha256": "c21a6c64f1b07f32a9f5212446db2d55897701df2a41c4e47311a534bde5b6ef" }, { "id": "recovery-root-family-distinction", "path": "skills/release-loop/references/progress-schema.md", "heading": "## Rules", "text": "`recovery-authority/` and `recovery-backups/` are fixed internal persistent recovery families", - "sha256": "e7b6dfa323361d2e39db3593e302236e4ce373a342937e347db2774eb416fdb4" + "sha256": "c21a6c64f1b07f32a9f5212446db2d55897701df2a41c4e47311a534bde5b6ef" }, { "id": "design-pending-gate", @@ -70,14 +70,14 @@ "path": "skills/release-loop/references/transition-hooks.md", "heading": "## Approved-plan transition hooks", "text": "The `recovery-authority/` and `recovery-backups/` are persistent siblings too.", - "sha256": "9e1e43127adf5d71735e96d612b37cce98734605d3a7be55fd2849b89293f2e2" + "sha256": "1cc4539f3d5580aa14fee102d43c922fcb498af55f9ff8fdec951bc2442b1704" }, { "id": "pre-merge-verification-v1", "path": "skills/release-loop/references/transition-hooks.md", "heading": "## Release-loop pre-merge verification V1: Produce the approved generation", "text": "After Review returns `clean`, the first-hand release-loop orchestrator runs the approved plan's V1 section before invoking `shipping`.", - "sha256": "d01a1b431783c0d996701d1e5ad9cfccdcb518c2160dcf13fadab70766964131" + "sha256": "edff2a9eaeccbc104a0303c2ea818e6c976b142a55a40ef333bd450d7438a195" }, { "id": "pre-archive-verification-v2", @@ -91,7 +91,7 @@ "path": "skills/release-loop/references/resume-and-archive.md", "heading": "### Archive procedure", "text": "7. For a scoped run, move only the remaining children of its exact `artifact_root`. Move scoped `progress.md` last as the archive commit point.", - "sha256": "d16fa80f540ccb1e75c06662e3657d1a1a39302fc05bfa06627d499d3989e77b" + "sha256": "d777c357f70ff775d389e4b0c6bf58a1675b7d8c133265c144c53ee7ec029937" } ] }