From 103365dc7125c50c39b3a7136e483bb04c236223 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:03:11 +0900 Subject: [PATCH 01/23] docs(design): preserve V1 evidence across legacy lifecycle Treat the exact V1 directory as run-bound active state during handoff and terminal archive. Constraint: Ship requires accepted V1 receipts before legacy worktree cleanup Rejected: generic allowlist configuration | expands transfer authority without a current requirement Confidence: high Scope-risk: moderate Directive: keep V1 bound to the existing manifest and progress-last archive commit point Tested: bash scripts/validate.sh; independent design review clean Not-tested: implementation fixtures do not exist before planning and TDD Assisted-by: GPT-5.6 Sol with T3 Code --- ...08-31-legacy-handoff-v1-evidence-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md 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..f4eb7ba --- /dev/null +++ b/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md @@ -0,0 +1,169 @@ +--- +title: Legacy Handoff V1 Evidence +status: draft +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. + +## Scope + +### In + +- Add `v1` to the closed active legacy child set. +- 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 | + +## 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. + +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. + +## 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. +- 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. | +| 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. 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 five named cases. +7. 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. +8. 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. From 6209f6548a4b53dba6db538ef2f58dc3471a0761 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:12:39 +0900 Subject: [PATCH 02/23] docs(design): bind issue 31 to V1 ownership evidence Keep pre-merge verification as the sole acceptance authority. Validate only paths and digests the accepted ledger can prove. Constraint: issue 31 requires malformed and missing V1 coverage without inventing new authority Rejected: allow v1 by directory name alone | malformed ownership could gain transfer authority Rejected: semantic validation of undigested files | changes the existing V1 authority contract Confidence: high Scope-risk: moderate Directive: preserve structured block parsing and the receipt three-way digest comparison Tested: git diff --check; live issue and ledger grounding; independent delta review clean Not-tested: executable V1 handoff cases await approved planning and TDD Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- ...08-31-legacy-handoff-v1-evidence-design.md | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) 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 index f4eb7ba..fed237a 100644 --- a/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md +++ b/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md @@ -47,11 +47,19 @@ 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. @@ -76,6 +84,7 @@ No orphan V1 directory blocks the next legacy handoff. | 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 @@ -83,6 +92,63 @@ 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. @@ -116,6 +182,12 @@ run-artifact-integrity.py handoff \ 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 @@ -125,6 +197,13 @@ The command remains compatible when no V1 directory exists. - 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. @@ -142,6 +221,8 @@ The command remains compatible when no V1 directory exists. | 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. | @@ -157,11 +238,13 @@ The command remains compatible when no V1 directory exists. - **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. 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 five named cases. -7. Existing legacy handoff controls remain green. +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. -8. The complete artifact-integrity and repository gates remain green. +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 From b1d317236ae7cb7ce9106c2751c7540c878eac65 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:45:53 +0900 Subject: [PATCH 03/23] docs(design): approve V1 evidence lifecycle contract Persist the USER-approved design before planning begins. Constraint: implementation cannot begin before explicit Design approval Rejected: treating independent review as approval | release-loop requires a USER gate Confidence: high Scope-risk: narrow Directive: preserve issue 31 ownership and archive requirements during planning Tested: git diff --check; approved frontmatter readback Not-tested: implementation behavior remains outside the Design phase Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fed237a..f1b88f2 100644 --- a/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md +++ b/docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md @@ -1,6 +1,6 @@ --- title: Legacy Handoff V1 Evidence -status: draft +status: approved date: 2026-08-31 schema: spec/v1 --- From 01622d95615ee9ffade2feb2e00753b70afc0ea5 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:00:15 +0900 Subject: [PATCH 04/23] docs(plan): define legacy V1 evidence lifecycle Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- ...001-fix-legacy-handoff-v1-evidence-plan.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/plans/2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md 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..bfcd2e7 --- /dev/null +++ b/docs/plans/2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md @@ -0,0 +1,211 @@ +--- +schema: plan/v1 +title: Legacy Handoff V1 Evidence +type: fix +status: draft +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. From 41f826912249e5819a9c3a4e0b640f44a0d6a55f Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:00:51 +0900 Subject: [PATCH 05/23] docs(plan): approve legacy V1 evidence lifecycle Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- .../2026-08-31-001-fix-legacy-handoff-v1-evidence-plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index bfcd2e7..eeaacab 100644 --- 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 @@ -2,7 +2,8 @@ schema: plan/v1 title: Legacy Handoff V1 Evidence type: fix -status: draft +status: approved +body_seal: 5ac590d6c382d74e6559ac4a5dd5b6d089911815b6fa2acc78c6b7853239d051 date: 2026-08-31 execution: code origin: docs/specs/2026-08-31-legacy-handoff-v1-evidence-design.md From 94e5c1e461c5eb7d43d6319046170dda660a61a0 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:14:57 +0900 Subject: [PATCH 06/23] fix(release-loop): validate legacy V1 ownership Validate accepted V1 evidence before creating a legacy handoff marker. Preserve pre-V1 compatibility only when all V1 state is absent. Constraint: pre_merge_verification remains the sole acceptance authority Rejected: generic YAML parsing or configurable evidence roots | expands the contract beyond Issue 31 Confidence: high Scope-risk: narrow Directive: keep V1 ownership validation before legacy marker creation Tested: legacy_handoff_v1_ownership, legacy handoff controls, scripts/validate.sh Not-tested: aggregate artifact-integrity suite is assigned to final integration Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 130 ++++++++++++++++++ .../scripts/run-artifact-integrity.py | 120 +++++++++++++++- 2 files changed, 249 insertions(+), 1 deletion(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index cd9883e..1cb4f5a 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -165,6 +165,7 @@ CASES = ( "handoff_mismatch_preserves_both", "handoff_same_checkout", "legacy_handoff_success", + "legacy_handoff_v1_ownership", "legacy_handoff_cli_contract", "legacy_handoff_incomplete_rerun", "legacy_handoff_collision", @@ -681,6 +682,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") @@ -6629,6 +6670,95 @@ 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": + valid_source = repo + valid_base = new_repo(tmp, "v1-valid-base") + valid_progress = write_legacy_v1(valid_source) + (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" + ) + + 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 edit_progress(path, old, new): + path.write_text(path.read_text(encoding="utf-8").replace(old, new, 1), 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-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: edit_progress(path, " pilot_receipt_sha256: ", " pilot_receipt_sha256: " + "0" * 64 + " # ")), + ("embedded-receipt-mismatch", lambda source, path: edit_progress(path, " full_receipt_sha256: ", " full_receipt_sha256: " + "0" * 64 + " # ")), + ("computed-receipt-mismatch", lambda source, path: (source / ".release-loop/v1/pilot-receipt.md").write_text("changed\n- receipt_sha256: " + "0" * 64 + "\n", encoding="utf-8")), + ("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_cli_contract": missing_source = new_repo(tmp, "cli-missing-source") missing_base = new_repo(tmp, "cli-missing-base") diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index e0be802..15d5dfe 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -84,7 +84,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 +129,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): @@ -551,6 +564,110 @@ def legacy_manifest_digest(entries: list[dict[str, object]]) -> str: return hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() +def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: + frontmatter_text = text.split("---", 2)[1] + selected = {"pre_merge_verification": None, "v1": None} + active = None + for line in frontmatter_text.splitlines(): + 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 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) -> None: + blocks = structured_progress_blocks(text) + pre_merge = blocks["pre_merge_verification"] + ownership = blocks["v1"] + root = 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 = repo / relative + if candidate.is_symlink() or not candidate.is_file(): + reject("legacy V1 ownership", f"missing regular file {relative}") + candidate = guard(repo, relative, ".release-loop/v1") + 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) @@ -623,6 +740,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) From 9c9e1a1d31df0de5a82890f7c352e0076b49c2ad Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:31:59 +0900 Subject: [PATCH 07/23] fix(release-loop): close V1 ownership review gaps Reject non-canonical ownership block spellings and keep receipt mismatch fixtures independent across ledger, embedded, and computed axes. Refs: #31 Constraint: pre_merge_verification remains the sole acceptance authority Rejected: permissive YAML parsing | broadens the targeted ownership grammar Confidence: high Scope-risk: narrow Directive: preserve independent receipt mismatch mutations Tested: focused V1 ownership selector and bash scripts/validate.sh Not-tested: none Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 6 ++++-- skills/release-loop/scripts/run-artifact-integrity.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 1cb4f5a..e3e1d58 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6737,6 +6737,8 @@ def run_case(name: str) -> None: ("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-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", "")), @@ -6750,8 +6752,8 @@ def run_case(name: str) -> None: ("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: edit_progress(path, " pilot_receipt_sha256: ", " pilot_receipt_sha256: " + "0" * 64 + " # ")), - ("embedded-receipt-mismatch", lambda source, path: edit_progress(path, " full_receipt_sha256: ", " full_receipt_sha256: " + "0" * 64 + " # ")), - ("computed-receipt-mismatch", lambda source, path: (source / ".release-loop/v1/pilot-receipt.md").write_text("changed\n- receipt_sha256: " + "0" * 64 + "\n", encoding="utf-8")), + ("embedded-receipt-mismatch", lambda source, path: edit_progress(source / ".release-loop/v1/full-receipt.md", "- receipt_sha256: ", "- receipt_sha256: " + "0" * 64 + " # ")), + ("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))), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 15d5dfe..3c8698b 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -569,6 +569,9 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: selected = {"pre_merge_verification": None, "v1": None} active = None for line in frontmatter_text.splitlines(): + selected_variant = re.fullmatch(r"(pre_merge_verification|v1)[ \t]+:.*", line) + if selected_variant: + reject("legacy V1 ownership", f"malformed block {selected_variant.group(1)}") top = re.fullmatch(r"([A-Za-z0-9_]+):(.*)", line) if top: active = None From af7ef7e081a951554883630e5d38005d3cc3ecc2 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:43:12 +0900 Subject: [PATCH 08/23] fix(release-loop): enforce canonical V1 ownership keys Reject Unicode-whitespace aliases and exercise receipt equality with valid independent digest mutations. Refs: #31 Constraint: selected ownership keys require exact canonical spelling Rejected: ASCII-only whitespace matching | misses equivalent Unicode keys Confidence: high Scope-risk: narrow Directive: keep ledger, embedded, and computed digest mutations independent Tested: focused V1 ownership selector and bash scripts/validate.sh Not-tested: none Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 13 +++++++++++-- .../release-loop/scripts/run-artifact-integrity.py | 7 ++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index e3e1d58..dcb123a 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6717,6 +6717,13 @@ def run_case(name: str) -> None: def edit_progress(path, old, new): path.write_text(path.read_text(encoding="utf-8").replace(old, new, 1), encoding="utf-8") + 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") @@ -6739,6 +6746,8 @@ def run_case(name: str) -> None: ("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-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", "")), @@ -6751,8 +6760,8 @@ def run_case(name: str) -> None: ("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: edit_progress(path, " pilot_receipt_sha256: ", " pilot_receipt_sha256: " + "0" * 64 + " # ")), - ("embedded-receipt-mismatch", lambda source, path: edit_progress(source / ".release-loop/v1/full-receipt.md", "- receipt_sha256: ", "- receipt_sha256: " + "0" * 64 + " # ")), + ("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")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 3c8698b..37ef68a 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -569,9 +569,10 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: selected = {"pre_merge_verification": None, "v1": None} active = None for line in frontmatter_text.splitlines(): - selected_variant = re.fullmatch(r"(pre_merge_verification|v1)[ \t]+:.*", line) - if selected_variant: - reject("legacy V1 ownership", f"malformed block {selected_variant.group(1)}") + raw_key, separator, _ = line.partition(":") + normalized_key = raw_key.strip() + if separator and not line[:1].isspace() 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 From 5cdf83d0ef804fd16312926ee073d047a426a34b Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:55:50 +0900 Subject: [PATCH 09/23] fix(release-loop): reject indented V1 ownership keys Treat leading whitespace as a malformed spelling of selected ownership blocks. This closes the remaining duplicate parser bypass. Refs: #31 Constraint: selected ownership blocks require canonical column-zero keys Rejected: skipping indented keys | lets the flat reader and ownership reader disagree Confidence: high Scope-risk: narrow Directive: reject every non-canonical raw spelling of selected keys Tested: focused V1 ownership selector and bash scripts/validate.sh Not-tested: none Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 4 ++++ skills/release-loop/scripts/run-artifact-integrity.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index dcb123a..7a01d11 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6748,6 +6748,10 @@ def run_case(name: str) -> None: ("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-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", "")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 37ef68a..1e51c4e 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -571,7 +571,7 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: for line in frontmatter_text.splitlines(): raw_key, separator, _ = line.partition(":") normalized_key = raw_key.strip() - if separator and not line[:1].isspace() and normalized_key in selected and raw_key != normalized_key: + 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: From 0f22ef095d4f2242c47fff53ace8538b1dea9685 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:12:24 +0900 Subject: [PATCH 10/23] fix(release-loop): transfer legacy V1 evidence safely Resume only byte-identical destination subsets under the bound legacy manifest. Reject index-only state before completing a resumed handoff. Refs #31 Constraint: V1 remains active run-bound state under the existing acceptance authority Rejected: generic migration and semantic validation | ownership and byte integrity are sufficient Confidence: high Scope-risk: moderate Directive: preserve both file-level and child-level interruption hooks Tested: six V1 selectors, existing legacy controls, and scripts/validate.sh Not-tested: no cross-host filesystem test Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 170 ++++++++++++++++++ .../scripts/run-artifact-integrity.py | 16 +- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 7a01d11..e8292ef 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -166,6 +166,12 @@ CASES = ( "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", @@ -6774,6 +6780,170 @@ def run_case(name: str) -> None: ) 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") + 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") + marker = base / ".release-loop/.handoff/legacy.json" + assert json.loads(marker.read_text(encoding="utf-8"))["status"] == "incomplete" + partial = filesystem_manifest(base / ".release-loop/v1") + complete = filesystem_manifest(source / ".release-loop/v1") + assert partial and partial != complete + 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() + ) + try: + handoff_scope( + markerless_source, markerless_base, + str(markerless_progress.relative_to(markerless_source)), + legacy_destination=".release-loop", + ) + except Blocked as exc: + assert "collision" in str(exc), str(exc) + else: + raise AssertionError("markerless V1 subset did not block") + assert not (markerless_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() + marker = base / ".release-loop/.handoff/legacy.json" + marker_before = marker.read_bytes() + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + except Blocked as exc: + assert "mismatch" in str(exc) or "collision" in str(exc), str(exc) + else: + raise AssertionError(f"V1 destination {mutation} did not block") + assert marker.read_bytes() == marker_before + 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") + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + except Blocked as exc: + assert "symlink" in str(exc) or "V1 root" in str(exc), str(exc) + else: + raise AssertionError(f"V1 {mutation} symlink did not block") + assert not (base / ".release-loop/.handoff/legacy.json").exists() + 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") + try: + handoff_scope( + source, base, str(legacy_path.relative_to(source)), + legacy_destination=".release-loop", + ) + except Blocked as exc: + assert "active manifest changed" in str(exc), str(exc) + else: + raise AssertionError("changed V1 source did not block") + 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") + 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/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 1e51c4e..9186adb 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", @@ -715,7 +716,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(): @@ -732,6 +733,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()) @@ -764,6 +767,8 @@ def legacy_handoff( payload = legacy_read_marker(marker, expected_fields) if payload["manifest_sha256"] != digest: reject("legacy handoff source", "active manifest changed since marker creation") + if legacy_git_active_paths(base_repo): + reject("legacy handoff collision", "base active legacy state is present in the index") destination_children = legacy_scan_children(base_repo, destination, allow_persistent=True) observed_entries = archive_manifest_entries(destination, destination_children) if payload["status"] == "complete": @@ -780,11 +785,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: @@ -4762,7 +4768,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)) @@ -4781,6 +4787,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( From 11b0803ce9fab5caa5916d25da2fd3f21e7858ff Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:29:54 +0900 Subject: [PATCH 11/23] test(release-loop): prove V1 handoff rejection boundaries Bind markerless index state and destination symlink attacks to named U2 selectors. Compare exact source and base snapshots across every blocked retry. Refs #31 Constraint: rejected handoffs must preserve files, index state, and marker bytes Rejected: production changes | existing behavior already blocks each counterexample Confidence: high Scope-risk: narrow Directive: keep failure assertions at the CLI boundary to forbid cleanup JSON Tested: six U2 selectors, bash syntax, diff check, and scripts/validate.sh Not-tested: no cross-host filesystem test Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 132 +++++++++++++++++++------ 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index e8292ef..0d49897 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -1328,6 +1328,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)`") @@ -6805,6 +6818,7 @@ def run_case(name: str) -> None: 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)), @@ -6814,11 +6828,17 @@ def run_case(name: str) -> None: 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" - assert json.loads(marker.read_text(encoding="utf-8"))["status"] == "incomplete" + 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()) + base_before_retry = matrix_fixture_snapshot(base) + assert marker.read_bytes() == marker_before_retry + assert matrix_fixture_snapshot(base) == base_before_retry result = handoff_scope( source, base, str(legacy_path.relative_to(source)), legacy_destination=".release-loop", @@ -6834,17 +6854,32 @@ def run_case(name: str) -> None: (markerless_target / "pilot-approval.md").write_bytes( (markerless_source / ".release-loop/v1/pilot-approval.md").read_bytes() ) - try: - handoff_scope( + assert_blocked_snapshots( + lambda: handoff_scope( markerless_source, markerless_base, str(markerless_progress.relative_to(markerless_source)), legacy_destination=".release-loop", - ) - except Blocked as exc: - assert "collision" in str(exc), str(exc) - else: - raise AssertionError("markerless V1 subset did not block") + ), + 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() + 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}") @@ -6871,18 +6906,13 @@ def run_case(name: str) -> None: indexed.write_text("indexed\n", encoding="utf-8") git(base, "add", "-f", str(indexed.relative_to(base))) indexed.unlink() - marker = base / ".release-loop/.handoff/legacy.json" - marker_before = marker.read_bytes() - try: - handoff_scope( + assert_blocked_snapshots( + lambda: handoff_scope( source, base, str(legacy_path.relative_to(source)), legacy_destination=".release-loop", - ) - except Blocked as exc: - assert "mismatch" in str(exc) or "collision" in str(exc), str(exc) - else: - raise AssertionError(f"V1 destination {mutation} did not block") - assert marker.read_bytes() == marker_before + ), + 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}") @@ -6896,16 +6926,56 @@ def run_case(name: str) -> None: history = source / ".release-loop/v1/history" history.mkdir() (history / "linked.md").symlink_to(source / "README.md") - try: - handoff_scope( + assert_blocked_snapshots( + lambda: handoff_scope( source, base, str(legacy_path.relative_to(source)), legacy_destination=".release-loop", - ) - except Blocked as exc: - assert "symlink" in str(exc) or "V1 root" in str(exc), str(exc) - else: - raise AssertionError(f"V1 {mutation} symlink did not block") + ), + 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") @@ -6920,15 +6990,13 @@ def run_case(name: str) -> None: else: raise AssertionError("V1 file-level interruption did not fire") (source / ".release-loop/v1/generation-receipt.md").write_text("changed\n", encoding="utf-8") - try: - handoff_scope( + assert_blocked_snapshots( + lambda: handoff_scope( source, base, str(legacy_path.relative_to(source)), legacy_destination=".release-loop", - ) - except Blocked as exc: - assert "active manifest changed" in str(exc), str(exc) - else: - raise AssertionError("changed V1 source did not block") + ), + source, base, "active manifest changed", + ) elif name == "legacy_handoff_complete_rerun": source = repo base = new_repo(tmp, "v1-complete-rerun-base") From 4156458b2e13b0de2eea625047b5f76e17bf30d8 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:43:12 +0900 Subject: [PATCH 12/23] test(release-loop): isolate markerless V1 index state Remove the empty filesystem parent so the fixture reaches the index-only guard. Delete immediate snapshot comparisons that observed no transition. Refs #31 Constraint: the selector must fail when the markerless index guard is removed Rejected: retaining an empty V1 directory | it short-circuits the intended boundary Confidence: high Scope-risk: narrow Directive: assert the V1 root is absent before the markerless index probe Tested: six U2 selectors, bash syntax, diff check, and scripts/validate.sh Not-tested: no cross-host filesystem test Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 0d49897..59457e9 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6836,9 +6836,6 @@ def run_case(name: str) -> None: complete = filesystem_manifest(source / ".release-loop/v1") assert partial and partial != complete assert all(complete.get(relative) == value for relative, value in partial.items()) - base_before_retry = matrix_fixture_snapshot(base) - assert marker.read_bytes() == marker_before_retry - assert matrix_fixture_snapshot(base) == base_before_retry result = handoff_scope( source, base, str(legacy_path.relative_to(source)), legacy_destination=".release-loop", @@ -6872,6 +6869,8 @@ def run_case(name: str) -> None: 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)), From cbc91e0e484898e3ce4a5f14b78e1bb51ae3fcde Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:10:13 +0900 Subject: [PATCH 13/23] fix(release-loop): archive legacy V1 evidence Treat accepted legacy V1 files as run-bound active state. Validate and move them before the progress commit point. Constraint: progress.md remains the final archive commit point Rejected: leave V1 in the live root | creates an orphan after terminal archive Confidence: high Scope-risk: moderate Directive: preserve persistent archive, .handoff, and runs siblings Tested: artifact suite and scripts/validate.sh Not-tested: no live production worktree archive Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 132 ++++++++++++++++++ .../references/progress-schema.md | 21 ++- .../references/resume-and-archive.md | 7 +- .../references/transition-hooks.md | 4 +- .../scripts/run-artifact-integrity.py | 23 ++- .../release-loop/source-manifest.json | 2 +- 6 files changed, 175 insertions(+), 14 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 59457e9..6efc97a 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", @@ -1064,6 +1065,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"]) @@ -6538,6 +6541,135 @@ 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() + + 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() + 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 + + 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() elif name in {"ignored_orphan", "occupied_scope_blocked"}: orphan = repo / ".release-loop/runs/alpha/orphan.txt" orphan.parent.mkdir(parents=True) diff --git a/skills/release-loop/references/progress-schema.md b/skills/release-loop/references/progress-schema.md index 77b5d02..12c1fa8 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 closed physical-root families are 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..525aeb5 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 and live V1 tree are absent. Verify the terminal record, V1 tree, Retro evidence, and destination marker. 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 9186adb..7bdf490 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -617,11 +617,11 @@ def validate_v1_receipt(path: Path, expected_digest: str, label: str) -> None: reject("legacy V1 ownership", f"{label} receipt digest mismatch") -def validate_legacy_v1_ownership(repo: Path, text: str) -> None: +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 = repo / ".release-loop/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 @@ -662,10 +662,9 @@ def validate_legacy_v1_ownership(repo: Path, text: str) -> None: tree_manifest(history) paths = {} for key, relative in V1_OWNERSHIP_PATHS.items(): - candidate = repo / relative + candidate = root / PurePosixPath(relative).name if candidate.is_symlink() or not candidate.is_file(): reject("legacy V1 ownership", f"missing regular file {relative}") - candidate = guard(repo, relative, ".release-loop/v1") 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") @@ -4661,6 +4660,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") @@ -4705,6 +4706,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: @@ -4716,6 +4727,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) diff --git a/tests/conformance/release-loop/source-manifest.json b/tests/conformance/release-loop/source-manifest.json index b1afd03..1c5574e 100644 --- a/tests/conformance/release-loop/source-manifest.json +++ b/tests/conformance/release-loop/source-manifest.json @@ -77,7 +77,7 @@ "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", From 4df6f8fa782e8c5deecbfe72a55250716258b898 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:34:18 +0900 Subject: [PATCH 14/23] test(release-loop): prove legacy V1 archive recovery Exercise destination-side recovery, terminal path retention, rejection preservation, and lifecycle contract mutations. Constraint: keep the sealed U3 runtime contract unchanged Rejected: sentinel-only rejection checks | do not prove repository preservation Confidence: high Scope-risk: narrow Directive: preserve focused one-axis mutations for lifecycle prose Tested: focused selector, aggregate artifact suite, and scripts/validate.sh Not-tested: no live production worktree archive Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 6efc97a..c64177e 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -368,6 +368,9 @@ 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"], "Verify the terminal record and V1 tree against the exact returned `archive_path`."), (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."), @@ -375,10 +378,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: @@ -6581,6 +6589,56 @@ def run_case(name: str) -> 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) @@ -6588,6 +6646,9 @@ def run_case(name: str) -> None: 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, @@ -6600,6 +6661,8 @@ def run_case(name: str) -> None: 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", @@ -6670,6 +6733,28 @@ def run_case(name: str) -> None: 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", "Verify the terminal record and V1 tree against the exact returned `archive_path`.", "Recalculate the terminal archive path."), + ("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) From abae0b6cc596cfde439fd54318e5d334087ca4f6 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:50:34 +0900 Subject: [PATCH 15/23] test(release-loop): lock legacy progress commit point Require the exact legacy progress-last contract and kill its one-axis first-move mutation. Constraint: keep the sealed U3 runtime and documentation unchanged Rejected: scoped-only progress-last coverage | misses the legacy rule Confidence: high Scope-risk: narrow Directive: retain separate scoped and legacy commit-point assertions Tested: focused selector, aggregate artifact suite, and scripts/validate.sh Not-tested: no live production worktree archive Refs #31 Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index c64177e..00378e9 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -371,6 +371,7 @@ def require_contract(texts: dict[str, str] | None = None, check_invocations: boo (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"], "Verify the terminal record and V1 tree against the exact returned `archive_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."), @@ -6739,6 +6740,7 @@ def run_case(name: str) -> None: ("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", "Verify the terminal record and V1 tree against the exact returned `archive_path`.", "Recalculate the terminal archive path."), + ("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."), From a76c96495a6822e972e224d63b94371571722fda Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:26:55 +0900 Subject: [PATCH 16/23] fix(release-loop): reject disguised V1 ownership keys Quoted and explicit YAML keys can resolve to protected ownership names. Normalize those scalar forms before accepting the canonical blocks. Refs: #31 Constraint: V1 ownership blocks require exact canonical top-level spelling Rejected: adding a YAML dependency | the boundary needs only two protected scalar names Confidence: high Scope-risk: narrow Directive: reject new YAML-equivalent spellings before creating a handoff marker Tested: focused ownership selector, aggregate artifact suite, full validation gate Not-tested: no external YAML parser differential suite Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 3 +++ .../scripts/run-artifact-integrity.py | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 00378e9..def0dbe 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6990,6 +6990,9 @@ def run_case(name: str) -> None: ("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-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", "")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 7bdf490..b424f34 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -565,6 +565,18 @@ def legacy_manifest_digest(entries: list[dict[str, object]]) -> str: return hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() +def yaml_scalar_key(raw: str) -> str | None: + if len(raw) >= 2 and raw[0] == raw[-1] == "'": + return raw[1:-1].replace("''", "'") + if len(raw) >= 2 and raw[0] == raw[-1] == '"': + try: + value = json.loads(raw) + except json.JSONDecodeError: + return None + return value if isinstance(value, str) else None + return raw + + def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: frontmatter_text = text.split("---", 2)[1] selected = {"pre_merge_verification": None, "v1": None} @@ -572,8 +584,13 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | 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}") + candidate = yaml_scalar_key(normalized_key) + if separator and candidate in selected and raw_key != candidate: + reject("legacy V1 ownership", f"malformed block {candidate}") + if line.startswith("? "): + candidate = yaml_scalar_key(line[2:].strip()) + if candidate in selected: + reject("legacy V1 ownership", f"malformed block {candidate}") top = re.fullmatch(r"([A-Za-z0-9_]+):(.*)", line) if top: active = None From 2afc719323c4d63f239363440e5c82876c6eae21 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:53:31 +0900 Subject: [PATCH 17/23] fix(release-loop): close V1 top-level key syntax V1-bearing frontmatter now accepts only plain canonical top-level key syntax. This rejects tags, anchors, aliases, quotes, and explicit-key decorations through one guard. Refs: #31 Constraint: protected ownership keys must have one canonical top-level form Rejected: enumerating YAML decorators | new syntax forms would reopen the boundary Confidence: high Scope-risk: narrow Directive: keep V1 top-level key syntax closed before parsing ownership blocks Tested: focused ownership selector, aggregate artifact suite, full validation gate Not-tested: no external YAML parser differential suite Assisted-by: GPT-5.6 Sol with T3 Code --- scripts/test-run-artifact-integrity.sh | 3 ++ .../scripts/run-artifact-integrity.py | 29 ++++++++----------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index def0dbe..a2e1fa4 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6993,6 +6993,9 @@ def run_case(name: str) -> None: ("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-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-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", "")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index b424f34..02ae79e 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -565,32 +565,27 @@ def legacy_manifest_digest(entries: list[dict[str, object]]) -> str: return hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() -def yaml_scalar_key(raw: str) -> str | None: - if len(raw) >= 2 and raw[0] == raw[-1] == "'": - return raw[1:-1].replace("''", "'") - if len(raw) >= 2 and raw[0] == raw[-1] == '"': - try: - value = json.loads(raw) - except json.JSONDecodeError: - return None - return value if isinstance(value, str) else None - return raw +def validate_v1_top_level_syntax(frontmatter_text: str) -> None: + protected = r"(?:pre_merge_verification|v1)" + if re.search(rf"^{protected}:\s*$", frontmatter_text, re.MULTILINE) is None: + return + for line in frontmatter_text.splitlines(): + if not line or line[0].isspace() or line.startswith("#"): + continue + if re.fullmatch(r"[A-Za-z0-9_]+:.*", line) is None: + reject("legacy V1 ownership", "noncanonical top-level key") def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: frontmatter_text = text.split("---", 2)[1] + 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() - candidate = yaml_scalar_key(normalized_key) - if separator and candidate in selected and raw_key != candidate: - reject("legacy V1 ownership", f"malformed block {candidate}") - if line.startswith("? "): - candidate = yaml_scalar_key(line[2:].strip()) - if candidate in selected: - reject("legacy V1 ownership", f"malformed block {candidate}") + 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 From 1d566a12a5202338c41b8de99987eae547a5ecd4 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:25:05 +0900 Subject: [PATCH 18/23] fix(release-loop): permit unrelated V1 top-level fields Treat only noncanonical aliases and key forms for V1 ownership blocks as invalid. Preserve unrelated future fields during legacy handoff. Constraint: legacy V1 ownership must fail closed without restricting extension fields Rejected: global canonical-key enforcement | rejects supported unknown fields Confidence: high Scope-risk: narrow Directive: keep protected-key parsing separate from unknown top-level fields Refs: #31 Tested: legacy_handoff_v1_ownership; artifact test suite and validation running Not-tested: final full-suite completion pending Assisted-by: GPT-5.6 Terra with T3 Code --- scripts/test-run-artifact-integrity.sh | 13 +++++++--- .../scripts/run-artifact-integrity.py | 26 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index a2e1fa4..42a4711 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6912,6 +6912,15 @@ def run_case(name: str) -> None: 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, + "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( @@ -6952,9 +6961,6 @@ def run_case(name: str) -> None: assert filesystem_manifest(source) == source_before, slug assert filesystem_manifest(base) == base_before, slug - def edit_progress(path, old, new): - path.write_text(path.read_text(encoding="utf-8").replace(old, new, 1), encoding="utf-8") - 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)] @@ -6995,6 +7001,7 @@ def run_case(name: str) -> None: ("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-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:")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 02ae79e..bf51198 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -569,10 +569,29 @@ def validate_v1_top_level_syntax(frontmatter_text: str) -> None: protected = r"(?:pre_merge_verification|v1)" if re.search(rf"^{protected}:\s*$", frontmatter_text, re.MULTILINE) is None: return + protected_aliases = set() for line in frontmatter_text.splitlines(): - if not line or line[0].isspace() or line.startswith("#"): + explicit = re.fullmatch(rf"\?\s+(?:!!\S+\s+)?&([^\s]+)\s+({protected})", line) + if explicit: + protected_aliases.add(explicit.group(1)) + for line in frontmatter_text.splitlines(): + if not line or line.startswith("#"): + continue + if re.fullmatch(r"[A-Za-z0-9_]+:.*", line): continue - if re.fullmatch(r"[A-Za-z0-9_]+:.*", line) is None: + if re.fullmatch(rf"\s*(?:{protected})\s*:", line): + reject("legacy V1 ownership", "noncanonical top-level key") + if re.fullmatch(rf"\s*['\"]{protected}['\"]\s*:", line): + reject("legacy V1 ownership", "noncanonical top-level key") + if re.fullmatch(rf"\s*(?:(?:!!\S+|&\S+)\s+)+{protected}\s*:", line): + reject("legacy V1 ownership", "noncanonical top-level key") + explicit = re.fullmatch(rf"\s*\?\s+(?:(?:!!\S+|&\S+)\s+)*({protected}|\*[^\s]+)\s*", line) + if explicit and ( + explicit.group(1) in {"v1", "pre_merge_verification", "*v1", "*pre_merge_verification"} + or explicit.group(1)[1:] in protected_aliases + ): + reject("legacy V1 ownership", "noncanonical top-level key") + if re.fullmatch(rf"\s*\*{protected}\s*:", line): reject("legacy V1 ownership", "noncanonical top-level key") @@ -598,6 +617,9 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: selected[name] = {} active = name continue + if active is not None and line and not line[0].isspace() and ":" in line: + active = None + continue if active is None: continue nested = re.fullmatch(r" ([A-Za-z0-9_]+):(?: (.*))?", line) From 365d8b62e88e6aced6e6dfdbb158254281672dc5 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:56 +0900 Subject: [PATCH 19/23] fix(release-loop): close noncanonical V1 key bypasses Tokenize only top-level protected ownership keys so quoted, escaped, tagged, anchored, and aliased forms cannot bypass legacy V1 handoff validation. Preserve unrelated unknown fields and nested keys. Refs: #31 Constraint: Keep the existing dependency-free structured reader and preserve unknown-field compatibility Rejected: Global canonical-key enforcement | It rejects unrelated forward-compatible progress fields Confidence: high Scope-risk: narrow Directive: Extend the protected-key tokenizer only when a new ownership representation is proven Tested: focused ownership regression, full artifact-integrity suite, bash scripts/validate.sh Not-tested: no external YAML parser compatibility matrix Assisted-by: GPT-5.6 Luna with T3 Code --- scripts/test-run-artifact-integrity.sh | 21 ++ .../scripts/run-artifact-integrity.py | 241 ++++++++++++++++-- 2 files changed, 240 insertions(+), 22 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 42a4711..d81d3f7 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6932,6 +6932,21 @@ def run_case(name: str) -> None: 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) @@ -7003,6 +7018,12 @@ def run_case(name: str) -> None: ("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", "")), diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index bf51198..25f1751 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -565,33 +565,230 @@ def legacy_manifest_digest(entries: list[dict[str, object]]) -> str: return hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() -def validate_v1_top_level_syntax(frontmatter_text: str) -> None: - protected = r"(?:pre_merge_verification|v1)" - if re.search(rf"^{protected}:\s*$", frontmatter_text, re.MULTILINE) is None: +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 - protected_aliases = set() + 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(): - explicit = re.fullmatch(rf"\?\s+(?:!!\S+\s+)?&([^\s]+)\s+({protected})", line) + 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: - protected_aliases.add(explicit.group(1)) - for line in frontmatter_text.splitlines(): - if not line or line.startswith("#"): + 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 - if re.fullmatch(r"[A-Za-z0-9_]+:.*", line): + resolved, canonical, anchor = parsed + if anchor is not None: + anchors[anchor] = resolved + if resolved not in PROTECTED_V1_KEYS: continue - if re.fullmatch(rf"\s*(?:{protected})\s*:", line): - reject("legacy V1 ownership", "noncanonical top-level key") - if re.fullmatch(rf"\s*['\"]{protected}['\"]\s*:", line): - reject("legacy V1 ownership", "noncanonical top-level key") - if re.fullmatch(rf"\s*(?:(?:!!\S+|&\S+)\s+)+{protected}\s*:", line): - reject("legacy V1 ownership", "noncanonical top-level key") - explicit = re.fullmatch(rf"\s*\?\s+(?:(?:!!\S+|&\S+)\s+)*({protected}|\*[^\s]+)\s*", line) - if explicit and ( - explicit.group(1) in {"v1", "pre_merge_verification", "*v1", "*pre_merge_verification"} - or explicit.group(1)[1:] in protected_aliases - ): - reject("legacy V1 ownership", "noncanonical top-level key") - if re.fullmatch(rf"\s*\*{protected}\s*:", line): + if not canonical or indentation == 1 or line.startswith("\t") or explicit: reject("legacy V1 ownership", "noncanonical top-level key") From 8fec1f60a92d4a502bc9885fe179040a32451f54 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:40:46 +0900 Subject: [PATCH 20/23] fix(release-loop): preserve V1 checks across recovery rebase Merge the recovery and V1 ownership contracts on origin/main. Route protected V1 duplicate keys to the V1 validator. Repin conformance digests to the merged reference text. Constraint: origin/main recovery rules and Issue #31 V1 evidence rules must both remain active Rejected: dropping recovery rules or widening the legacy allowlist | would regress existing archive guarantees Confidence: high Scope-risk: narrow Directive: keep source-manifest hashes aligned with the exact heading-section bytes Tested: bash scripts/validate.sh; bash scripts/test-release-loop-conformance.sh static; bash scripts/test-run-artifact-integrity.sh all Not-tested: live GitHub CI and merge result Refs: #31 Assisted-by: GPT-5.6 Luna with T3 Code --- skills/release-loop/references/progress-schema.md | 2 +- skills/release-loop/scripts/run-artifact-integrity.py | 2 ++ tests/conformance/release-loop/baseline-policy.json | 2 +- tests/conformance/release-loop/corpus.json | 2 +- tests/conformance/release-loop/source-manifest.json | 8 ++++---- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/skills/release-loop/references/progress-schema.md b/skills/release-loop/references/progress-schema.md index 12c1fa8..ccc1cfd 100644 --- a/skills/release-loop/references/progress-schema.md +++ b/skills/release-loop/references/progress-schema.md @@ -181,7 +181,7 @@ 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 closed physical-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 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`. diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index 25f1751..c90d1be 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -360,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", "") 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 1c5574e..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,7 +70,7 @@ "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", @@ -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" } ] } From b6dd6c1da03f63c5eccc71b7205e4e6bc4edb4a1 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:05:47 +0900 Subject: [PATCH 21/23] fix(release-loop): scope V1 archive checks to legacy records Scoped records do not own the legacy V1 tree, so completion verification must keep V1 checks on the legacy path. Reuse the shared frontmatter parser to preserve delimiter and missing-input behavior. Constraint: preserve the approved archive lifecycle and fail closed on malformed progress frontmatter Rejected: remove V1 checks globally | legacy archives still require V1 ownership proof Confidence: high Scope-risk: narrow Directive: keep scoped and legacy archive completion contracts explicit Tested: bash scripts/test-run-artifact-integrity.sh legacy_handoff_v1_ownership; bash scripts/test-run-artifact-integrity.sh archive_scoped_run; bash scripts/test-run-artifact-integrity.sh operative_contract_mutation; full artifact-integrity and validate.sh runs completed Not-tested: final remote CI results after this commit Assisted-by: GPT-5.6 Luna with Codex --- scripts/test-run-artifact-integrity.sh | 17 +++++++++++++++-- .../references/resume-and-archive.md | 2 +- .../scripts/run-artifact-integrity.py | 6 +++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index d81d3f7..521e0e0 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -370,7 +370,8 @@ def require_contract(texts: dict[str, str] | None = None, check_invocations: boo (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"], "Verify the terminal record and V1 tree against the exact returned `archive_path`."), + (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"), @@ -6739,7 +6740,8 @@ def run_case(name: str) -> None: 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", "Verify the terminal record and V1 tree against the exact returned `archive_path`.", "Recalculate the terminal archive path."), + ("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."), @@ -6909,6 +6911,17 @@ def run_case(name: str) -> None: "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) diff --git a/skills/release-loop/references/resume-and-archive.md b/skills/release-loop/references/resume-and-archive.md index 525aeb5..77938ca 100644 --- a/skills/release-loop/references/resume-and-archive.md +++ b/skills/release-loop/references/resume-and-archive.md @@ -80,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 and live V1 tree are absent. Verify the terminal record, V1 tree, 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/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index c90d1be..d072be1 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -795,7 +795,11 @@ def validate_v1_top_level_syntax(frontmatter_text: str) -> None: def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: - frontmatter_text = text.split("---", 2)[1] + 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 From 21563e4fbd2ca2dd513f1cbbb0418a81cd1c57bc Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:45:54 +0900 Subject: [PATCH 22/23] chore(ci): retrigger commit identity gate The signed history rewrite did not create a pull-request check run for the current head. This empty commit re-emits the workflow event without changing product files. Constraint: merge requires the commit identity gate to run on this PR head Rejected: bypassing or dismissing the missing check | merge policy requires an explicit successful result Confidence: high Scope-risk: narrow Directive: retain the commit identity workflow as a required merge gate Tested: bash scripts/check-commit-identity.sh against the PR base and current head Not-tested: remote workflow result after this commit Assisted-by: GPT-5.6 Luna with Codex From 3340e5c7b89fbab417cdede66003c06620457bf3 Mon Sep 17 00:00:00 2001 From: Jaehoon You <158752+teslamint@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:17:27 +0900 Subject: [PATCH 23/23] fix(release-loop): close legacy handoff review gaps Allow blank lines in accepted V1 blocks and idempotent complete handoff reruns after base commits. Constraint: preserve malformed indentation rejection and incomplete handoff collision checks Rejected: retaining pre-complete index rejection | blocks committed complete reruns Confidence: high Scope-risk: narrow Directive: keep complete-marker destination comparison authoritative Tested: bash scripts/test-run-artifact-integrity.sh all; bash scripts/validate.sh Refs: #31 Assisted-by: GPT-5.6 Luna with Codex --- scripts/test-run-artifact-integrity.sh | 14 ++++++++++++++ .../release-loop/scripts/run-artifact-integrity.py | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/test-run-artifact-integrity.sh b/scripts/test-run-artifact-integrity.sh index 521e0e0..77eaaad 100755 --- a/scripts/test-run-artifact-integrity.sh +++ b/scripts/test-run-artifact-integrity.sh @@ -6929,6 +6929,18 @@ def run_case(name: str) -> None: 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", @@ -7271,6 +7283,8 @@ def run_case(name: str) -> None: 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", diff --git a/skills/release-loop/scripts/run-artifact-integrity.py b/skills/release-loop/scripts/run-artifact-integrity.py index d072be1..63c63d1 100644 --- a/skills/release-loop/scripts/run-artifact-integrity.py +++ b/skills/release-loop/scripts/run-artifact-integrity.py @@ -708,7 +708,7 @@ def _yaml_key(value: str, anchors: dict[str, str]) -> tuple[str, bool, str | Non scalar = _yaml_scalar(value, start, anchors) if scalar is None: return None - resolved, end = scalar + resolved, _end = scalar anchor = next((token[1][1:] for token in prefixes if token[0] == "&"), None) if not resolved: return None @@ -823,6 +823,8 @@ def structured_progress_blocks(text: str) -> dict[str, dict[str, str] | None]: 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) @@ -1003,14 +1005,14 @@ def legacy_handoff( payload = legacy_read_marker(marker, expected_fields) if payload["manifest_sha256"] != digest: reject("legacy handoff source", "active manifest changed since marker creation") - if legacy_git_active_paths(base_repo): - reject("legacy handoff collision", "base active legacy state is present in the index") destination_children = legacy_scan_children(base_repo, destination, allow_persistent=True) observed_entries = archive_manifest_entries(destination, destination_children) if payload["status"] == "complete": 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: