From ba2d6de6fc6e8ad7a30713cc6f2e750171243633 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 16:05:59 -0700 Subject: [PATCH 01/43] docs(release): design duplicate draft recovery --- ...v0.8.22-duplicate-draft-recovery-design.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-v0.8.22-duplicate-draft-recovery-design.md diff --git a/docs/superpowers/specs/2026-09-01-v0.8.22-duplicate-draft-recovery-design.md b/docs/superpowers/specs/2026-09-01-v0.8.22-duplicate-draft-recovery-design.md new file mode 100644 index 000000000..1fc1b3864 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-v0.8.22-duplicate-draft-recovery-design.md @@ -0,0 +1,299 @@ +# v0.8.22 Duplicate Draft Recovery Design + +## Problem + +The v0.8.22 release candidate is fully escrowed but cannot advance to npm +publication. GitHub currently contains three mutable draft Releases with the +same canonical Dawn `ESCROWED` marker, candidate commit, and 45-asset base: + +| Role | Release ID | Current temporary `tag_name` | +| --- | ---: | --- | +| Canonical candidate | `379991871` | `untagged-be0ff4bee4ba43b521a9` | +| Duplicate evidence draft | `379982100` | `untagged-a13939767dd2419ade01` | +| Duplicate evidence draft | `379986168` | `untagged-20706099efa3c38335a8` | + +All three identify version `0.8.22` and candidate commit +`2a80deece2ff958fe7fde8fddeb4f99bed70a1c8`. Every one of Dawn's 21 +publishable packages remains at `0.8.21` on npm, and exact version `0.8.22` is +absent for all 21 packages. + +The controller deliberately rejects more than one marker-backed draft before +an exact Release read or any mutation. That invariant is correct for the +normal release path, but it leaves this already-created candidate unable to +resume. The candidate tag is immutable and predates the later draft-identity +fixes on `main`, so a general controller change on `main` cannot replace the +workflow definition stored at `v0.8.22`. + +This recovery must remove only the duplicate live controller identities while +preserving the duplicate draft objects, their original bodies, and every +existing asset as auditable incident evidence. + +## Goals + +- Preserve Release `379991871` byte-for-byte as the canonical v0.8.22 draft. +- Preserve both duplicate draft objects and all existing assets. +- Archive each duplicate's original body byte-for-byte before changing it. +- Make the two duplicates no longer parse as live Dawn release candidates. +- Keep normal duplicate detection unchanged and fail-closed. +- Resume the existing exact-tag release workflow only after a fresh observer + proves one canonical `CANDIDATE_ESCROWED` Release. +- Remove the recovery command after v0.8.22 and the queued v0.8.23 release are + complete. + +## Non-goals + +- A permanent Release-ID override or generic duplicate-selection mechanism. +- Deleting a Release, Release asset, tag, Actions artifact, or npm version. +- Moving or replacing `v0.8.22`. +- Publishing packages, modifying npm configuration, or enabling workflows from + the recovery command. +- Weakening marker, asset, provenance, audit, or immutable-release validation. +- Repairing unrelated release-controller or process-runner behavior. + +## Recovery Command + +Add one temporary executable: + +```text +scripts/release/recover-v0.8.22-duplicate-drafts.mjs +``` + +It exposes only two modes: + +```bash +node scripts/release/recover-v0.8.22-duplicate-drafts.mjs capture \ + --reviewed-commit "$RECOVERY_SHA" \ + --output .dawn/release-recovery/v0.8.22-duplicate-drafts.json + +node scripts/release/recover-v0.8.22-duplicate-drafts.mjs apply \ + --evidence .dawn/release-recovery/v0.8.22-duplicate-drafts.json +``` + +`capture` is read-only. `apply` is the only mutating mode. Unknown commands, +arguments, duplicate flags, symlinks, paths outside the repository, malformed +UTF-8, or noncanonical evidence fail before constructing a writer. + +The candidate version, commit, canonical Release ID, and two duplicate Release +IDs are constants in the executable. Callers cannot provide or override them. +`capture` requires one caller-supplied `--reviewed-commit` because a program +cannot contain its own Git commit hash. The command verifies that SHA through +GitHub: it must be the merge commit of exactly one merged pull request targeting +`main`, the pull request's required `CI / validate` check must have succeeded at +the reviewed head, and the merge commit's tree must equal the reviewed head's +tree. Local `HEAD`, remote `refs/heads/main`, and the supplied merge commit must +then be identical. A later `main` commit, an unmerged branch head, or an +operator-invented SHA is not an execution authority. + +The command is not referenced by `.github/workflows/release.yml`, a package +script, or any other release-owner workflow. It is an explicit operator tool, +not a new publication owner. + +## Capture Preconditions + +Capture reuses the existing bounded owner-preflight readers for repository, +remote-main, workflow-state, and Immutable Releases evidence, plus the existing +GitHub and npm production readers for candidate state. It records only +credential-free facts. It requires all of the following: + +1. Repository identity is exactly `cacheplane/dawnai`. +2. Local `HEAD`, remote `refs/heads/main`, and the reviewed recovery commit are + identical. +3. GitHub Immutable Releases remains enabled. +4. `.github/workflows/release.yml` is `disabled_manually`. +5. There are no nonterminal Release workflow runs. +6. Annotated tag `v0.8.22` peels to the exact candidate commit. +7. No GitHub Release is published at tag `v0.8.22`. +8. Exact version `0.8.22` is absent for all 21 canonical npm packages, with + package-level metadata independently confirming each absence. +9. None of the observed candidate workflow runs has started a `publish-npm` + job. +10. Release `379991871` is present, mutable, not a prerelease, and identifies + the exact candidate through its canonical `ESCROWED` marker and original + 45-member base namespace. +11. Each duplicate has its exact observed opaque temporary `tag_name`, never + `v0.8.22`. The exact expected temporary values are candidate-specific + constants. This matters because the normal controller treats an exact tag + match as managed even when the body has no marker. +12. Each duplicate is in exactly one recognized state: untouched, + body-archived, receipt-archived, or quarantined, as defined below. Its + original 45-member base namespace must remain exact in every state. +13. For an untouched duplicate, its `ESCROWED` marker and original body must + equal the canonical draft. For a body-archived duplicate, those facts plus + the exact original-body asset are required. For a receipt-archived + duplicate, those facts plus both exact evidence assets are required. For a + quarantined duplicate, the exact recovery notice and both evidence assets + are required, and the archived original body must equal the canonical + draft. +14. No fourth marker-backed or exact-tag Release identifies the candidate. + +Any unavailable, ambiguous, malformed, or conflicting observation blocks +capture. The evidence file is created write-once with mode `0600` inside an +ignored private directory. + +## Evidence Model + +The canonical JSON evidence binds: + +- schema version and capture timestamp; +- repository ID, recovery commit, candidate version, and candidate commit; +- the unique merged recovery pull request, reviewed head, successful required + check, merge commit, and equal reviewed/merged tree identity; +- annotated tag object and peeled commit; +- Immutable Releases and Release workflow states; +- complete nonterminal-run and npm-absence observations; +- all three exact Release snapshots; +- SHA-256 of each original body; +- the ordered 45-asset inventory and its canonical SHA-256; +- canonical and duplicate Release roles; and +- the expected archive asset names and post-recovery notice bytes. + +Evidence is valid for at most 15 minutes. `apply` reparses canonical bytes and +recomputes every derived field. Before every individual mutation, it re-reads +the target Release, its complete assets, the canonical Release, workflow/run +state, tag identity, and npm absence, then requires the exact state recorded in +the evidence. Production drift never triggers inference inside `apply`; it +stops and requires a new explicit `capture` invocation. + +`capture` remains available after a partial run. It recognizes body-archived, +receipt-archived, and quarantined duplicates from their exact +candidate-specific evidence, validates the archived original body against the +canonical draft, and issues fresh evidence for only the remaining transitions. +Evidence expiry can therefore never require restoration of a live marker or +strand an exact partial recovery. + +## Duplicate Quarantine + +For each duplicate, in ascending Release-ID order, `apply` performs one +idempotent sequence: + +1. Immediately before the archive upload, re-read all live authorization facts + and require the exact untouched state from the evidence. +2. Upload an asset containing the original body bytes. The asset name includes + the duplicate Release ID and original-body SHA-256. +3. Re-read all authorization facts and the exact archived-body asset before the + receipt upload. +4. Upload a canonical JSON recovery receipt containing the candidate identity, + canonical and duplicate Release IDs, recovery commit, original-body digest, + base-asset inventory digest, and archive asset identity. +5. Re-read all authorization facts and both evidence assets before the body + update. +6. Conditionally replace only the duplicate's live body through a narrow + recovery writer. That writer requires the full expected Release snapshot, + exact asset namespace, and expected body digest immediately before PATCH, + and sends only the new `body` field. It does not reuse the general writer's + title-and-body update or rewrite concurrent metadata. +7. Re-read the Release and require the exact recovery notice, opaque temporary + tag name, mutable draft + state, unchanged metadata, and unchanged original 45 assets. + +The recovery notice is intentionally not a valid Dawn release marker. It names +Release `379991871` as canonical and links its own archive and receipt asset +names and digests. It does not claim that the duplicate was published, +abandoned, or deleted. + +No existing asset is overwritten. An already-present archive or receipt asset +is accepted only when its downloaded bytes are identical. The allowed recovery +asset names and byte limits are exact and candidate-specific. + +## Idempotence and Partial Failure + +Both `capture` and `apply` recognize four states for each duplicate: + +- **untouched**: exact original body and 45 original assets; +- **body-archived**: exact original body plus the exact original-body archive + asset and no recovery-receipt asset; +- **receipt-archived**: exact original body plus exact archive and receipt + assets; +- **quarantined**: exact recovery notice plus the original 45 assets and exact + archive and receipt assets. + +Anything else is a conflict. A rerun resumes only the missing transition for an +exact recognized state, using newly captured evidence when the prior receipt +has expired. If the first duplicate is quarantined and the second fails, the +normal release controller still sees two live candidate markers and remains +blocked. No partial recovery can authorize npm publication. + +The command has no automatic rollback. Reintroducing an archived controller +body could recreate ambiguity. The archived original bytes make a separately +reviewed restoration possible, but restoration is outside this design. + +## Final Authorization Check + +After both duplicates are quarantined, `apply` constructs a new read-only +production observation through the normal controller boundary. Success +requires: + +- Release `379991871` is the only managed Release for v0.8.22; +- it remains an exact mutable `ESCROWED` draft with the original 45 assets; +- npm `0.8.22` remains absent for every package; +- the classified state is `CANDIDATE_ESCROWED`; +- the disposition is `would-transition`; +- the next transition is `publish-npm-packages`; and +- diagnostics and conflicts are empty. + +The final report is written as a second write-once credential-free receipt. +The recovery command does not enable or dispatch the Release workflow. + +## Testing + +Tests must cover: + +- exact CLI argument and contained-file handling; +- authenticated reviewed-commit and merged-PR anchoring, including rejection + of later `main`, mismatched trees, and unsuccessful required checks; +- canonical evidence encoding, expiry, no-clobber writes, and symlink refusal; +- `capture` exposing no writer and performing zero mutations; +- every identity, marker, body, asset, workflow, run, tag, and npm mismatch + blocking before mutation; +- rejection when any candidate `publish-npm` job has started; +- exact archive and receipt bytes; +- exact opaque temporary tag names and rejection of `v0.8.22` on either + duplicate; +- no deletion or replacement of an existing asset; +- a fresh full authorization snapshot before every upload and body update; +- the recovery-only body writer never sending or rewriting title or other + metadata; +- body compare-and-swap and post-write rejection after concurrent drift; +- successful quarantine of both configured duplicates only; +- safe resume from each partial state; +- fresh evidence capture from body-archived, receipt-archived, and quarantined + partial states after the previous evidence expires; +- byte-identical replay becoming a no-op; +- rejection of a fourth candidate draft; +- final observation requiring exactly `CANDIDATE_ESCROWED` and + `publish-npm-packages`; and +- unchanged normal-controller duplicate rejection. + +Focused tests run before the complete release-controller suite. The repository +Definition of Done remains required before merge. The existing host-timing +failure in `process-runner.test.mjs` is tracked as a pre-existing baseline +failure only if it reproduces unchanged on the exact `main` SHA; it is not +silently waived for modified release code. + +## Production Sequence + +1. Merge the reviewed recovery PR while Release remains disabled. +2. Synchronize an isolated checkout to the exact merged recovery commit. +3. Capture fresh evidence and inspect the credential-free report. +4. Run `apply` once against that evidence. +5. Independently verify the two duplicate drafts, their evidence assets, and + the one remaining controller-visible candidate. +6. Enable Release and dispatch `.github/workflows/release.yml` at `v0.8.22` + with the exact version, candidate commit, and `operation=reconcile`. +7. Require all 21 npm publications, provenance checks, five smoke lanes, + independent audit, and final immutable GitHub Release to pass. +8. Approve and merge Version Packages PR #525 only after v0.8.22 is terminal. +9. Verify the fixed-group v0.8.23 publication and updated npm READMEs. +10. Remove the recovery executable, its tests, and candidate-specific asset + allowlist in a cleanup PR. Preserve the production receipts and duplicate + evidence drafts. + +## Success Criteria + +- No Release, tag, existing asset, npm version, or Actions evidence is deleted. +- Only Releases `379982100` and `379986168` receive mutations. +- Release `379991871` remains the sole live v0.8.22 controller candidate. +- The normal exact-tag workflow publishes v0.8.22 without a generic identity + override. +- PR #525 subsequently publishes the README-bearing v0.8.23 fixed group. +- The one-time recovery surface is removed after use. From 92b1b0980b062565179bc37805903addd820ce82 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 16:25:22 -0700 Subject: [PATCH 02/43] docs(release): plan duplicate draft recovery --- ...-09-01-v0.8.22-duplicate-draft-recovery.md | 635 ++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-v0.8.22-duplicate-draft-recovery.md diff --git a/docs/superpowers/plans/2026-09-01-v0.8.22-duplicate-draft-recovery.md b/docs/superpowers/plans/2026-09-01-v0.8.22-duplicate-draft-recovery.md new file mode 100644 index 000000000..170f7b456 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-v0.8.22-duplicate-draft-recovery.md @@ -0,0 +1,635 @@ +# v0.8.22 Duplicate Draft Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Safely quarantine the two duplicate v0.8.22 escrow drafts, resume the canonical exact-tag release, publish the README-bearing v0.8.23 fixed group, and remove the one-time recovery surface. + +**Architecture:** Build a temporary candidate-pinned operator command with a pure evidence/state core, a bounded read-only production adapter, and a narrow recovery-only writer. The command captures short-lived canonical evidence, resumes four exact duplicate states idempotently, archives each original body before removing its live marker, and leaves normal controller duplicate detection unchanged. After production convergence, the existing `release.yml` owns npm publication and the temporary command is removed. + +**Tech Stack:** Node.js 24 ESM, `node:test`, existing Dawn release readers and controller primitives, GitHub REST API, npm registry metadata, canonical JSON/SHA-256 receipts, pnpm 10.33.0. + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `scripts/release/duplicate-draft-recovery.mjs` | Candidate constants, canonical evidence schema, duplicate-state classification, archive/receipt/notice bytes, capture/apply orchestration, and final authorization checks. | +| `scripts/release/duplicate-draft-recovery-adapters.mjs` | Bounded operator reads, reviewed-merge authority, exact production snapshots, candidate-specific evidence upload, and body-only compare-and-swap. | +| `scripts/release/recover-v0.8.22-duplicate-drafts.mjs` | Strict `capture`/`apply` CLI, contained write-once files, stable secret-safe errors. | +| `scripts/release/test/duplicate-draft-recovery.test.mjs` | Pure state/evidence/orchestration tests with an in-memory production model. | +| `scripts/release/test/duplicate-draft-recovery-adapters.test.mjs` | Exact GitHub/npm/git command and HTTP boundary tests. | +| `scripts/release/test/duplicate-draft-recovery-cli.test.mjs` | CLI arguments, file safety, capture/apply wiring, and error-surface tests. | +| `docs/superpowers/runbooks/2026-08-09-release-integrity-cutover.md` | Temporary operator sequence and production receipt fields. | + +Do not modify normal candidate discovery, `isManagedReleaseForTag`, planner behavior, `.github/workflows/release.yml`, or the normal `createGitHubWriter` capability surface. The recovery command must remain unreachable from final release-owner workflows and package scripts. + +### Task 1: Define the candidate policy and four-state classifier + +**Files:** +- Create: `scripts/release/duplicate-draft-recovery.mjs` +- Create: `scripts/release/test/duplicate-draft-recovery.test.mjs` + +- [ ] **Step 1: Write failing policy and state-classification tests** + +Add tests that import the planned exports and assert the exact frozen policy: + +```js +const POLICY = { + repository: "cacheplane/dawnai", + version: "0.8.22", + candidateSha: "2a80deece2ff958fe7fde8fddeb4f99bed70a1c8", + canonicalReleaseId: 379991871, + duplicates: [ + { releaseId: 379982100, tagName: "untagged-a13939767dd2419ade01" }, + { releaseId: 379986168, tagName: "untagged-20706099efa3c38335a8" }, + ], +} +``` + +Build fixtures for these exact states: + +```js +assert.equal(classifyDuplicate(snapshot({ evidenceAssets: [] })), "untouched") +assert.equal(classifyDuplicate(snapshot({ evidenceAssets: ["body"] })), "body-archived") +assert.equal(classifyDuplicate(snapshot({ evidenceAssets: ["body", "receipt"] })), "receipt-archived") +assert.equal(classifyDuplicate(snapshot({ quarantined: true })), "quarantined") +``` + +Reject a wrong ID, exact `v0.8.22` tag name, changed original asset, extra asset, noncanonical marker, malformed notice, receipt without body archive, or unknown combination. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test scripts/release/test/duplicate-draft-recovery.test.mjs +``` + +Expected: FAIL because `duplicate-draft-recovery.mjs` does not exist. + +- [ ] **Step 3: Implement the frozen policy and classifier** + +Export only the small planning surface needed by later tasks: + +```js +export const DUPLICATE_DRAFT_RECOVERY_POLICY = deepFreeze({ ... }) +export function classifyDuplicateDraft(snapshot, expected) { ... } +export function originalBodyAssetName(releaseId, bodySha256) { ... } +export function recoveryReceiptAssetName(releaseId) { ... } +export function canonicalRecoveryReceipt(input) { ... } +export function canonicalRecoveryNotice(input) { ... } +``` + +Use `snapshotJson`, exact-field checks, safe integer validation, lowercase SHA-256 validation, and canonical newline-terminated JSON. Keep asset names candidate-specific, ASCII-only, and bounded. The notice must not contain the Dawn marker delimiter accepted by `parseReleaseMarker`. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the Step 2 command. + +Expected: all policy and four-state tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/duplicate-draft-recovery.mjs \ + scripts/release/test/duplicate-draft-recovery.test.mjs +git commit -m "feat(release): define duplicate draft recovery states" +``` + +### Task 2: Define canonical short-lived recovery evidence + +**Files:** +- Modify: `scripts/release/duplicate-draft-recovery.mjs` +- Modify: `scripts/release/test/duplicate-draft-recovery.test.mjs` + +- [ ] **Step 1: Write failing evidence tests** + +Test an exact schema containing: + +```js +{ + schemaVersion: 1, + capturedAt: "2026-09-01T00:00:00.000Z", + reviewedAuthority: { + mergeCommitSha, + mergeTreeSha, + pullRequestNumber, + reviewedHeadSha, + reviewedTreeSha, + validateRunId, + }, + repository: { id, nameWithOwner, mainSha }, + workflow: { id: 260503756, state: "disabled_manually" }, + immutableReleases: { enabled: true }, + candidate: { version, commitSha, tagObjectSha }, + npm: { packages: [...] }, + releaseRuns: [], + releases: { canonical, duplicates: [...] }, +} +``` + +Require canonical byte stability, deep freezing, a maximum age of 15 minutes, exact current time bounds, exact package order, exact Release IDs, exact state-derived next steps, and rejection of unknown fields/accessors/sparse arrays. Prove an expired partial-state evidence file remains immutable and can be superseded by newly captured canonical evidence at a distinct write-once path. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run the Task 1 test command. + +Expected: FAIL because evidence parsing and verification are absent. + +- [ ] **Step 3: Implement evidence parsing and verification** + +Add: + +```js +export function canonicalDuplicateDraftEvidence(value) { ... } +export function parseDuplicateDraftEvidence(bytes) { ... } +export function verifyDuplicateDraftEvidence({ evidence, current, now }) { ... } +``` + +Derive—not trust—body digests, 45-asset inventory digests, archive names, receipt bytes, notice bytes, duplicate state, and remaining transitions. Never store tokens, URLs containing signed query parameters, response headers, or raw npm/GitHub errors. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Expected: all evidence tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/duplicate-draft-recovery.mjs \ + scripts/release/test/duplicate-draft-recovery.test.mjs +git commit -m "feat(release): seal duplicate draft evidence" +``` + +### Task 3: Build the read-only production capture boundary + +**Files:** +- Create: `scripts/release/duplicate-draft-recovery-adapters.mjs` +- Create: `scripts/release/test/duplicate-draft-recovery-adapters.test.mjs` +- Modify: `scripts/release/duplicate-draft-recovery.mjs` +- Modify: `scripts/release/test/duplicate-draft-recovery.test.mjs` + +- [ ] **Step 1: Write failing adapter contract tests** + +Require an exact frozen reader surface. It may compose `createGitHubReader`, `createNpmReader`, `createGitReader`, and narrowly scoped owner-preflight operations, but it must expose only named recovery reads such as: + +```js +{ + readReviewedMergeAuthority, + readRepositoryState, + readCandidateTag, + readWorkflowState, + readImmutableReleases, + readReleaseRuns, + readCandidatePublishJobs, + readNpmAbsence, + readReleaseSnapshot, + listCandidateReleases, +} +``` + +Test exact GitHub API routes for the reviewed commit, associated pull request, pull request head tree, merge tree, required `CI / validate` run, repository identity, immutable setting, workflow state, tag, Releases, assets, and jobs. Reject pagination drift, another repository, multiple associated PRs, a non-merged PR, wrong base, unequal trees, missing validate success, later `main`, auth ambiguity, malformed schemas, and unsafe URLs. + +- [ ] **Step 2: Run the adapter test and verify RED** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test scripts/release/test/duplicate-draft-recovery-adapters.test.mjs +``` + +Expected: FAIL because the adapter module does not exist. + +- [ ] **Step 3: Implement the bounded read adapter** + +Use argument arrays, the trusted `https://api.github.com` origin, the repository ID already verified by the release readers, existing timeout/byte/page limits, and safe normalized envelopes. Do not shell-interpolate tokens. The reviewed authority must prove: + +```text +supplied SHA == local HEAD == remote refs/heads/main +one associated merged PR targets main +PR merge_commit_sha == supplied SHA +merge tree == reviewed head tree +CI / validate succeeded at reviewed head +``` + +For npm absence, reuse the package metadata plus exact-version E404 confirmation used by the production observer; do not trust an error string alone. + +- [ ] **Step 4: Write failing capture-orchestration tests** + +Inject the reader into a new `captureDuplicateDraftRecoveryEvidence` function. Assert that it reads all 21 packages in canonical order, discovers exactly the configured three Releases, recognizes all four partial states, and refuses a fourth marker-backed or exact-tag Release. Feed it candidate workflow runs and their jobs, and assert it rejects if any observed run has started a job named `publish-npm`, regardless of that job's terminal state. Assert the dependency object contains no writer. + +- [ ] **Step 5: Implement capture orchestration** + +Add: + +```js +export async function captureDuplicateDraftRecoveryEvidence({ reviewedCommit, reader, now }) { + // collect, normalize, classify, derive, and return canonical evidence +} +``` + +Every absence or conflict must produce a stable error code without remote body text. +The capture must consume `readCandidatePublishJobs` for every observed candidate +run and fail closed unless no `publish-npm` job has started. + +- [ ] **Step 6: Run both focused suites** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test \ + scripts/release/test/duplicate-draft-recovery.test.mjs \ + scripts/release/test/duplicate-draft-recovery-adapters.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/release/duplicate-draft-recovery.mjs \ + scripts/release/duplicate-draft-recovery-adapters.mjs \ + scripts/release/test/duplicate-draft-recovery.test.mjs \ + scripts/release/test/duplicate-draft-recovery-adapters.test.mjs +git commit -m "feat(release): capture duplicate draft recovery evidence" +``` + +### Task 4: Add the candidate-specific recovery writer + +**Files:** +- Modify: `scripts/release/duplicate-draft-recovery-adapters.mjs` +- Modify: `scripts/release/test/duplicate-draft-recovery-adapters.test.mjs` + +- [ ] **Step 1: Write failing writer boundary tests** + +Define exactly two mutation methods: + +```js +uploadEvidenceAssetIfAbsentAndEqual(input) +quarantineDuplicateBodyIfCurrent(input) +``` + +Test that the writer: + +- accepts only the two duplicate Release IDs and their exact opaque tag names; +- accepts only derived candidate-specific archive/receipt names; +- verifies annotated `v0.8.22` before and after mutation; +- requires a fresh full Release and asset snapshot immediately before each POST/PATCH; +- uploads only absent bytes and accepts existing assets only after download equality; +- PATCHes `{ body: expectedNotice }` and never sends `name`, `tag_name`, `draft`, or other metadata; +- rejects stale body digests, unexpected titles, targets, prerelease state, assets, or concurrent drift; +- never issues DELETE, npm, tag-write, workflow-enable, or workflow-dispatch requests; and +- bounds request time, response bytes, asset bytes, redirects, and content types. + +- [ ] **Step 2: Run adapter tests and verify RED** + +Run the Task 3 adapter test command. + +Expected: FAIL because the recovery writer is absent. + +- [ ] **Step 3: Implement the narrow writer** + +Keep it separate from `createGitHubWriter`. Construct it lazily only after evidence verification. Use the same trusted API/upload origins and token validation as the production writer, but hard-code the candidate policy and exact allowed asset classes. Snapshot inputs before reading any field. + +- [ ] **Step 4: Run adapter tests and verify GREEN** + +Expected: all read and write boundary tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/duplicate-draft-recovery-adapters.mjs \ + scripts/release/test/duplicate-draft-recovery-adapters.test.mjs +git commit -m "feat(release): add duplicate draft quarantine writer" +``` + +### Task 5: Implement idempotent apply orchestration + +**Files:** +- Modify: `scripts/release/duplicate-draft-recovery.mjs` +- Modify: `scripts/release/test/duplicate-draft-recovery.test.mjs` + +- [ ] **Step 1: Write failing transition tests** + +Build an in-memory boundary that records each read and write. Cover: + +```text +untouched -> body-archived -> receipt-archived -> quarantined +body-archived -> receipt-archived -> quarantined +receipt-archived -> quarantined +quarantined -> no-op +``` + +For every transition assert a fresh full authorization capture occurs immediately before the one mutation. Inject failures after each mutation, expire the evidence, recapture from the resulting partial state, and prove the next run resumes without restoring a marker. Assert the second duplicate never starts before the first is exactly quarantined. + +- [ ] **Step 2: Run core tests and verify RED** + +Run the Task 1 test command. + +Expected: FAIL because apply orchestration is absent. + +- [ ] **Step 3: Implement apply orchestration** + +Add: + +```js +export async function applyDuplicateDraftRecovery({ evidence, reader, createWriter, now }) { + // verify evidence, converge duplicate IDs in ascending order, + // recapture before each mutation, and return final credential-free receipt +} +``` + +Writer construction must occur only after the first complete live evidence comparison. After both duplicates converge, invoke the normal read-only production observer for the exact candidate and require: + +```js +{ + state: "CANDIDATE_ESCROWED", + disposition: "would-transition", + nextTransition: "publish-npm-packages", + conflicts: [], + diagnostics: [], + releaseId: 379991871, +} +``` + +Do not emulate that result from recovery-local state. + +- [ ] **Step 4: Test adversarial and concurrent cases** + +Add cases for an expired receipt, changed main, enabled workflow, new run, npm appearance, moved tag, fourth draft, canonical-body drift, service-digest drift, body-only PATCH followed by title drift, and a writer that returns a malformed receipt. Every case must stop without the next mutation. + +- [ ] **Step 5: Run focused suites and verify GREEN** + +Run the Task 3 combined test command. + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/release/duplicate-draft-recovery.mjs \ + scripts/release/test/duplicate-draft-recovery.test.mjs +git commit -m "feat(release): converge duplicate escrow drafts" +``` + +### Task 6: Add the strict operator CLI and write-once receipts + +**Files:** +- Create: `scripts/release/recover-v0.8.22-duplicate-drafts.mjs` +- Create: `scripts/release/test/duplicate-draft-recovery-cli.test.mjs` + +- [ ] **Step 1: Write failing CLI tests** + +Test exact accepted invocations: + +```text +capture --reviewed-commit <40-lowercase-hex> --output +apply --evidence --output +``` + +The explicit final receipt output avoids stdout being treated as durable evidence. Require every `--output` and `--evidence` path to be a relative descendant of the repository's ignored `.dawn/release-recovery/` directory; reject the directory itself, absolute paths, traversal, alternate `.dawn` subdirectories, and paths that are not ignored by the repository. Also reject missing, duplicate, unknown, joined, reordered ambiguities, NUL/control characters, symlinks, hard links where applicable, existing conflicting files, and files above the evidence byte bound. Assert errors are stable and never contain tokens, remote bodies, signed URLs, or stack traces. + +- [ ] **Step 2: Run CLI tests and verify RED** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test scripts/release/test/duplicate-draft-recovery-cli.test.mjs +``` + +Expected: FAIL because the executable does not exist. + +- [ ] **Step 3: Implement CLI parsing and safe file writes** + +Follow `preflight-owner-cli.mjs` and `workflow-handoff.mjs` patterns: resolve the repository root, enforce the exact `.dawn/release-recovery/` containment boundary before every read or write, require that boundary to remain gitignored, use bounded no-follow reads, write to a mode-`0600` temporary file, fsync, no-clobber link/rename, cleanup on failure, and emit one concise success line. Construct `GITHUB_TOKEN` dependencies only for production capture/apply; never serialize the token. + +- [ ] **Step 4: Run CLI tests and verify GREEN** + +Expected: PASS. + +- [ ] **Step 5: Run all three focused suites** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test \ + scripts/release/test/duplicate-draft-recovery.test.mjs \ + scripts/release/test/duplicate-draft-recovery-adapters.test.mjs \ + scripts/release/test/duplicate-draft-recovery-cli.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/release/recover-v0.8.22-duplicate-drafts.mjs \ + scripts/release/test/duplicate-draft-recovery-cli.test.mjs +git commit -m "feat(release): add v0.8.22 recovery command" +``` + +### Task 7: Document the temporary operator sequence + +**Files:** +- Modify: `docs/superpowers/runbooks/2026-08-09-release-integrity-cutover.md` + +- [ ] **Step 1: Add a candidate-specific recovery section** + +Document exact prerequisites, capture/apply syntax, the two duplicate IDs, expected four-state recovery behavior, evidence inspection, independent post-apply reads, Release enablement, exact-tag dispatch, and stop conditions. State plainly that the command does not delete drafts or publish npm. + +- [ ] **Step 2: Add live receipt fields** + +Add pending fields for recovery PR/head/merge SHA, evidence SHA-256, duplicate archive/receipt asset IDs and digests, post-quarantine body digests, final observer receipt, release run, npm/provenance conclusions, immutable Release ID, v0.8.23 run, and cleanup PR. + +- [ ] **Step 3: Verify docs and workflow reachability** + +```bash +node scripts/check-docs.mjs +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +pnpm test:release-controller +``` + +Expected: docs pass; release-controller passes except no new failure may be attributed to the pre-existing 100 ms process-start timing test. If that test fails, rerun it on the exact unmodified `origin/main` SHA and retain both outputs before proceeding. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/runbooks/2026-08-09-release-integrity-cutover.md +git commit -m "docs(release): add duplicate draft recovery runbook" +``` + +### Task 8: Verify, review, and merge the recovery implementation + +**Files:** +- Verify all files changed in Tasks 1–7. + +- [ ] **Step 1: Run focused verification** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +node --test \ + scripts/release/test/duplicate-draft-recovery.test.mjs \ + scripts/release/test/duplicate-draft-recovery-adapters.test.mjs \ + scripts/release/test/duplicate-draft-recovery-cli.test.mjs +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH pnpm lint +git diff --check origin/main...HEAD +``` + +Expected: all pass. + +- [ ] **Step 2: Run the repository Definition of Done** + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ +DAWN_REQUIRE_DOCKER=1 pnpm ci:validate +``` + +Expected: pass. Diagnose any failure from first principles. A reproduced unchanged host-timing failure may be documented, but modified release code receives no waiver. + +- [ ] **Step 3: Request code review** + +Invoke `superpowers:requesting-code-review`. Require a reviewer to verify that normal duplicate detection and release workflow bytes are unchanged, every mutation is candidate-pinned, partial states are resumable, and no destructive HTTP method exists. + +- [ ] **Step 4: Push and open the PR** + +```bash +git push -u origin blove/recover-0.8.22-duplicate-drafts +gh pr create --repo cacheplane/dawnai --base main \ + --head blove/recover-0.8.22-duplicate-drafts \ + --title "fix(release): recover duplicate v0.8.22 drafts" \ + --body-file +``` + +- [ ] **Step 5: Require exact-head checks and review** + +Record the PR head SHA. Approve any bot-authored `action_required` workflow runs when GitHub requires owner approval. Require `CI / validate`, CodeQL, release-boundary jobs, Vercel, CopilotKit, and substantive infrastructure lanes for that exact head. Treat only the known no-credit reviewer lane as non-blocking. + +- [ ] **Step 6: Merge only the reviewed head while Release remains disabled** + +Use the repository's merge-commit convention and an exact-head guard. Immediately before merge, re-read `.github/workflows/release.yml` and require `disabled_manually`, re-read the PR head and require the reviewed SHA, fetch current `main`, and compute the prospective merge tree. Merge only if the prospective merge tree equals the reviewed head tree; if `main` advanced, update and re-review the branch instead of accepting a different tree. After merge, fetch `main`, require the actual merge tree equals the reviewed head tree, and record the resulting merge SHA/tree for `--reviewed-commit`. Stop before production recovery if any equality or workflow-state check changes. + +### Task 9: Quarantine the duplicates and release v0.8.22 + +**Files:** +- Production evidence only under ignored `.dawn/release-recovery/`. + +- [ ] **Step 1: Create an exact merged checkout** + +Use `superpowers:using-git-worktrees`. Create a detached worktree at the exact recovery merge SHA, install with the repository-pinned Node/pnpm versions, and require remote `main` to remain that SHA. + +- [ ] **Step 2: Reconfirm non-mutating prerequisites** + +Independently verify Release is disabled, Immutable Releases is enabled, no release run is active, all 21 npm versions remain absent, the annotated tag is exact, and the three draft IDs/tag names/45-asset inventories still match the policy. + +- [ ] **Step 3: Capture fresh evidence** + +```bash +install -d -m 0700 .dawn/release-recovery +node scripts/release/recover-v0.8.22-duplicate-drafts.mjs capture \ + --reviewed-commit "$RECOVERY_SHA" \ + --output .dawn/release-recovery/v0.8.22-capture-01.json +``` + +Hash the file, inspect only credential-free facts, and require both duplicates to report an exact recognized state. Every recapture uses the next unused sequence number (`capture-02.json`, `capture-03.json`, and so on); evidence files are never replaced. + +- [ ] **Step 4: Apply once** + +```bash +node scripts/release/recover-v0.8.22-duplicate-drafts.mjs apply \ + --evidence .dawn/release-recovery/v0.8.22-capture-01.json \ + --output .dawn/release-recovery/v0.8.22-apply-01.json +``` + +Do not retry blindly. On failure, preserve both files, inspect the exact partial state, capture fresh evidence to the next unused `capture-NN.json` if and only if the state is recognized, and invoke `apply` with that exact new evidence path plus a matching unused `apply-NN.json` output path. + +- [ ] **Step 5: Verify quarantine independently** + +Read all three Releases and assets directly. Require the canonical body/assets unchanged, both duplicate bodies non-marker recovery notices, both original bodies downloadable byte-for-byte, both receipts canonical, no exact `v0.8.22` duplicate tag names, and final observation `CANDIDATE_ESCROWED -> publish-npm-packages`. + +- [ ] **Step 6: Enable and dispatch Release** + +Enable only `.github/workflows/release.yml`, re-read its state as `active`, then dispatch: + +```bash +gh workflow run release.yml --repo cacheplane/dawnai \ + --ref v0.8.22 \ + -f version=0.8.22 \ + -f commitSha=2a80deece2ff958fe7fde8fddeb4f99bed70a1c8 \ + -f operation=reconcile +``` + +Record the exact direct dispatch/run identity. Do not cancel or generically rerun a failed transition. + +- [ ] **Step 7: Observe every irreversible boundary** + +Require serial trusted publication of all 21 packages, exact npm provenance, npm reconciliation, five smoke lanes, independent audit, and immutable GitHub publication. Stop at the first failed transition and preserve all evidence. + +- [ ] **Step 8: Verify v0.8.22 independently** + +Confirm each exact npm version/integrity/provenance/latest tag, final Release `379991871` at `v0.8.22`, exact candidate SHA, immutable state, audited marker, exact asset set, and production smoke receipts. + +### Task 10: Publish the README-bearing v0.8.23 release + +**Files:** +- Existing Version Packages PR #525 and production evidence only. + +- [ ] **Step 1: Refresh PR #525 state** + +Require its fixed-group version is `0.8.23`, it consumes the README changeset, and it contains no unexpected source changes. If the Changesets bot updates the head, review the new exact diff and SHA. + +- [ ] **Step 2: Approve and run exact-head CI** + +Approve bot-authored Actions runs when required. Require `CI / validate`, changesets, CodeQL, Vercel, CopilotKit, release-boundary, and substantive infrastructure checks on the exact current head. + +- [ ] **Step 3: Merge on green** + +Merge PR #525 using the repository's merge convention only after exact-head checks are green. Record the merge SHA. + +- [ ] **Step 4: Observe v0.8.23 release** + +Watch the controller through tag creation, immutable payload, npm trusted publication, smoke/audit, and final Release. Do not leapfrog or manually publish. + +- [ ] **Step 5: Verify npm README rendering** + +For representative packages in each README tier and `create-dawn-ai-app`, verify npm latest is `0.8.23`, tarball README bytes match the sealed manifest, and npm displays the updated awareness content and video-linked poster assets. + +### Task 11: Remove the one-time recovery surface + +**Files:** +- Delete: `scripts/release/duplicate-draft-recovery.mjs` +- Delete: `scripts/release/duplicate-draft-recovery-adapters.mjs` +- Delete: `scripts/release/recover-v0.8.22-duplicate-drafts.mjs` +- Delete: `scripts/release/test/duplicate-draft-recovery.test.mjs` +- Delete: `scripts/release/test/duplicate-draft-recovery-adapters.test.mjs` +- Delete: `scripts/release/test/duplicate-draft-recovery-cli.test.mjs` +- Modify: `docs/superpowers/runbooks/2026-08-09-release-integrity-cutover.md` + +- [ ] **Step 1: Create a cleanup branch from terminal v0.8.23 main** + +Remove the temporary executable, support modules, tests, and candidate-specific operational instructions. Preserve the design, implementation plan, credential-free live receipt, and duplicate GitHub evidence drafts. + +- [ ] **Step 2: Verify no reachability or stale references remain** + +```bash +rg -n "recover-v0\.8\.22-duplicate-drafts|duplicate-draft-recovery" \ + .github package.json scripts docs/superpowers/runbooks +pnpm test:release-controller +node scripts/check-docs.mjs +pnpm lint +git diff --check +``` + +Expected: only historical design/plan/live-receipt references remain where intentionally retained; all checks pass. + +- [ ] **Step 3: Open and merge the cleanup PR** + +Require exact-head CI and review, then merge on green. Confirm Release remains active, npm latest remains `0.8.23`, and no scheduled reconciliation mutates the terminal immutable releases. + +## Completion Receipt + +Record in the final handoff: + +- recovery spec and plan commits; +- recovery PR number, reviewed head, merge SHA, and equal-tree proof; +- capture and final authorization evidence digests; +- exact duplicate archive/receipt asset IDs and body digests; +- v0.8.22 release run, npm/provenance conclusions, and immutable Release URL; +- PR #525 exact head and merge SHA; +- v0.8.23 release run and representative npm README verification; +- cleanup PR and merge SHA; and +- any separately tracked pre-existing test timing failure with exact untouched-main reproduction. From 68a82ae26de267acd65c07432e1733a9d52fa534 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 20:05:42 -0700 Subject: [PATCH 03/43] feat(release): define duplicate draft recovery states --- scripts/release/duplicate-draft-recovery.mjs | 411 ++++++++++++++++++ .../test/duplicate-draft-recovery.test.mjs | 224 ++++++++++ 2 files changed, 635 insertions(+) create mode 100644 scripts/release/duplicate-draft-recovery.mjs create mode 100644 scripts/release/test/duplicate-draft-recovery.test.mjs diff --git a/scripts/release/duplicate-draft-recovery.mjs b/scripts/release/duplicate-draft-recovery.mjs new file mode 100644 index 000000000..d7cabe164 --- /dev/null +++ b/scripts/release/duplicate-draft-recovery.mjs @@ -0,0 +1,411 @@ +import { createHash } from "node:crypto" + +import { snapshotJson } from "./adapter-normalize.mjs" + +const SHA256_PATTERN = /^[0-9a-f]{64}$/u +const GIT_SHA_PATTERN = /^[0-9a-f]{40}$/u +const ASSET_NAME_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._+-]{0,254}$/u +const MARKER_DELIMITER = "DAWN_RELEASE_CONTROLLER_MARKER" +const MAX_NOTICE_BYTES = 16 * 1024 +const MAX_RECEIPT_BYTES = 64 * 1024 + +export const DUPLICATE_DRAFT_RECOVERY_POLICY = deepFreeze({ + repository: "cacheplane/dawnai", + version: "0.8.22", + candidateSha: "2a80deece2ff958fe7fde8fddeb4f99bed70a1c8", + canonicalReleaseId: 379991871, + duplicates: [ + { releaseId: 379982100, tagName: "untagged-a13939767dd2419ade01" }, + { releaseId: 379986168, tagName: "untagged-20706099efa3c38335a8" }, + ], +}) + +export function classifyDuplicateDraft(value, expected) { + const snapshot = exactObject( + value, + ["releaseId", "tagName", "body", "marker", "assets", "evidenceAssets"], + "duplicate Release snapshot", + ) + const requirements = exactObject( + expected, + [ + "releaseId", + "tagName", + "canonicalBody", + "canonicalMarker", + "originalBodySha256", + "originalAssets", + "recoveryNotice", + ], + "duplicate Release expectations", + ) + + assertDuplicateIdentity(snapshot.releaseId, snapshot.tagName, requirements) + assertBodyDigest(requirements.canonicalBody, requirements.originalBodySha256) + if (!Array.isArray(requirements.originalAssets) || requirements.originalAssets.length !== 45) { + throw new TypeError("Duplicate Release expectations require exactly 45 original assets") + } + const originalAssets = normalizeAssets(requirements.originalAssets, "original Release assets") + const assets = normalizeAssets(snapshot.assets, "duplicate Release assets") + const evidenceKinds = normalizeEvidenceKinds(snapshot.evidenceAssets) + const bodyAssetName = originalBodyAssetName( + requirements.releaseId, + requirements.originalBodySha256, + ) + const receiptAssetName = recoveryReceiptAssetName(requirements.releaseId) + const expectedAssetNames = new Set(originalAssets.map((asset) => asset.name)) + for (const asset of assets) { + if ( + !expectedAssetNames.has(asset.name) && + asset.name !== bodyAssetName && + asset.name !== receiptAssetName + ) { + throw new Error("Duplicate Release has an unexpected asset") + } + } + if (assets.length !== originalAssets.length + evidenceKinds.length) { + throw new Error("Duplicate Release asset namespace is not exact") + } + const originalActual = assets.slice(0, originalAssets.length) + if (!sameJson(originalActual, originalAssets)) { + throw new Error("Duplicate Release original asset namespace changed") + } + const evidence = assets.slice(originalAssets.length) + const notice = + evidenceKinds.length === 2 && snapshot.marker === null + ? parseCanonicalNotice(snapshot.body) + : null + for (const [index, asset] of evidence.entries()) { + const kind = evidenceKinds[index] + if (kind === "body") { + if (asset.name !== bodyAssetName || asset.sha256 !== requirements.originalBodySha256) { + throw new Error("Duplicate Release original-body archive is not exact") + } + } else if ( + asset.name !== receiptAssetName || + !SHA256_PATTERN.test(asset.sha256) || + (notice !== null && asset.sha256 !== notice.receiptSha256) + ) { + throw new Error("Duplicate Release recovery receipt asset is not exact") + } + } + + const canonicalMarker = requirements.canonicalMarker + if (!isRecord(canonicalMarker) || !sameJson(snapshot.marker, canonicalMarker)) { + if (evidenceKinds.length === 2 && snapshot.marker === null) { + // The quarantine state intentionally has no live Dawn marker. + } else { + throw new Error("Duplicate Release marker is not canonical") + } + } + if (evidenceKinds.length === 2 && snapshot.marker === null) { + if (snapshot.body !== requirements.recoveryNotice || !isCanonicalNotice(snapshot.body)) { + throw new Error("Duplicate Release recovery notice is malformed") + } + return "quarantined" + } + if (snapshot.body !== requirements.canonicalBody) { + throw new Error("Duplicate Release original body changed") + } + if (evidenceKinds.length === 0) return "untouched" + if (evidenceKinds.length === 1 && evidenceKinds[0] === "body") return "body-archived" + if (evidenceKinds.length === 2 && evidenceKinds[0] === "body" && evidenceKinds[1] === "receipt") { + return "receipt-archived" + } + throw new Error("Duplicate Release state is unknown") +} + +export function originalBodyAssetName(releaseId, bodySha256) { + assertDuplicateReleaseId(releaseId) + assertSha256(bodySha256, "Original body SHA-256") + return `dawn-v${DUPLICATE_DRAFT_RECOVERY_POLICY.version}-duplicate-${releaseId}-original-body-${bodySha256}.txt` +} + +export function recoveryReceiptAssetName(releaseId) { + assertDuplicateReleaseId(releaseId) + return `dawn-v${DUPLICATE_DRAFT_RECOVERY_POLICY.version}-duplicate-${releaseId}-recovery-receipt.json` +} + +export function canonicalRecoveryReceipt(input) { + const source = exactObject( + input, + [ + "repository", + "version", + "candidateSha", + "recoveryCommit", + "canonicalReleaseId", + "duplicateReleaseId", + "originalBodySha256", + "baseAssetSetSha256", + "archiveAsset", + ], + "recovery receipt", + ) + assertPolicyIdentity(source) + assertGitSha(source.recoveryCommit, "Recovery commit") + assertReleaseId(source.canonicalReleaseId, "Canonical Release ID") + if (source.canonicalReleaseId !== DUPLICATE_DRAFT_RECOVERY_POLICY.canonicalReleaseId) { + throw new Error("Recovery receipt canonical Release ID is not approved") + } + assertDuplicateReleaseId(source.duplicateReleaseId) + assertSha256(source.originalBodySha256, "Original body SHA-256") + assertSha256(source.baseAssetSetSha256, "Base asset set SHA-256") + const archiveAsset = exactObject(source.archiveAsset, ["name", "sha256"], "archive asset") + if ( + archiveAsset.name !== + originalBodyAssetName(source.duplicateReleaseId, source.originalBodySha256) || + archiveAsset.sha256 !== source.originalBodySha256 + ) { + throw new Error("Recovery receipt archive asset is not derived from the candidate") + } + const record = { + schemaVersion: 1, + repository: source.repository, + version: source.version, + candidateSha: source.candidateSha, + recoveryCommit: source.recoveryCommit, + canonicalReleaseId: source.canonicalReleaseId, + duplicateReleaseId: source.duplicateReleaseId, + originalBodySha256: source.originalBodySha256, + baseAssetSetSha256: source.baseAssetSetSha256, + archiveAsset, + } + const bytes = Buffer.from(`${JSON.stringify(canonicalize(record))}\n`, "utf8") + if (bytes.byteLength > MAX_RECEIPT_BYTES) + throw new Error("Recovery receipt exceeds its byte limit") + return bytes +} + +export function canonicalRecoveryNotice(input) { + const source = exactObject( + input, + [ + "repository", + "version", + "canonicalReleaseId", + "duplicateReleaseId", + "originalBodySha256", + "archiveAssetName", + "receiptAssetName", + "receiptSha256", + ], + "recovery notice", + ) + if (source.repository !== DUPLICATE_DRAFT_RECOVERY_POLICY.repository) { + throw new Error("Recovery notice repository is not approved") + } + if (source.version !== DUPLICATE_DRAFT_RECOVERY_POLICY.version) { + throw new Error("Recovery notice version is not approved") + } + assertReleaseId(source.canonicalReleaseId, "Canonical Release ID") + if (source.canonicalReleaseId !== DUPLICATE_DRAFT_RECOVERY_POLICY.canonicalReleaseId) { + throw new Error("Recovery notice canonical Release ID is not approved") + } + assertDuplicateReleaseId(source.duplicateReleaseId) + assertSha256(source.originalBodySha256, "Original body SHA-256") + if ( + source.archiveAssetName !== + originalBodyAssetName(source.duplicateReleaseId, source.originalBodySha256) + ) { + throw new Error("Recovery notice archive asset is not derived from the candidate") + } + if (source.receiptAssetName !== recoveryReceiptAssetName(source.duplicateReleaseId)) { + throw new Error("Recovery notice receipt asset is not derived from the candidate") + } + assertSha256(source.receiptSha256, "Recovery receipt SHA-256") + const notice = { + schemaVersion: 1, + type: "DAWN_DUPLICATE_DRAFT_RECOVERY", + repository: source.repository, + version: source.version, + candidateSha: DUPLICATE_DRAFT_RECOVERY_POLICY.candidateSha, + canonicalReleaseId: source.canonicalReleaseId, + duplicateReleaseId: source.duplicateReleaseId, + originalBodySha256: source.originalBodySha256, + archiveAssetName: source.archiveAssetName, + receiptAssetName: source.receiptAssetName, + receiptSha256: source.receiptSha256, + } + const text = `${JSON.stringify(canonicalize(notice))}\n` + if (text.includes(MARKER_DELIMITER)) throw new Error("Recovery notice contains a Dawn marker") + if (Buffer.byteLength(text, "utf8") > MAX_NOTICE_BYTES) { + throw new Error("Recovery notice exceeds its byte limit") + } + return text +} + +function assertPolicyIdentity(source) { + if ( + source.repository !== DUPLICATE_DRAFT_RECOVERY_POLICY.repository || + source.version !== DUPLICATE_DRAFT_RECOVERY_POLICY.version || + source.candidateSha !== DUPLICATE_DRAFT_RECOVERY_POLICY.candidateSha + ) { + throw new Error("Recovery receipt candidate identity is not approved") + } +} + +function assertDuplicateIdentity(releaseId, tagName, expected) { + assertDuplicateReleaseId(releaseId) + if (releaseId !== expected.releaseId || tagName !== expected.tagName) { + throw new Error("Duplicate Release identity is not exact") + } + if (tagName === `v${DUPLICATE_DRAFT_RECOVERY_POLICY.version}`) { + throw new Error("Duplicate Release must retain its opaque temporary tag") + } +} + +function assertDuplicateReleaseId(value) { + assertReleaseId(value, "Duplicate Release ID") + if (!DUPLICATE_DRAFT_RECOVERY_POLICY.duplicates.some((item) => item.releaseId === value)) { + throw new Error("Release ID is not an approved duplicate") + } +} + +function assertReleaseId(value, label) { + if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${label} is invalid`) +} + +function assertGitSha(value, label) { + if (typeof value !== "string" || !GIT_SHA_PATTERN.test(value)) { + throw new TypeError(`${label} must be a lowercase Git SHA-1`) + } +} + +function assertSha256(value, label) { + if (typeof value !== "string" || !SHA256_PATTERN.test(value)) { + throw new TypeError(`${label} must be a lowercase SHA-256 digest`) + } +} + +function assertBodyDigest(body, expectedDigest) { + if (typeof body !== "string") throw new TypeError("Canonical duplicate body is invalid") + assertSha256(expectedDigest, "Original body SHA-256") + if (sha256(body) !== expectedDigest) + throw new Error("Canonical duplicate body digest is not exact") +} + +function normalizeEvidenceKinds(value) { + if (!Array.isArray(value) || value.length > 2) + throw new TypeError("Duplicate evidence asset list is invalid") + const result = value.map((kind) => { + if (kind !== "body" && kind !== "receipt") throw new Error("Unknown duplicate evidence asset") + return kind + }) + if (new Set(result).size !== result.length) + throw new Error("Duplicate evidence asset list contains duplicates") + if (result.includes("receipt") && !result.includes("body")) { + throw new Error("Recovery receipt cannot exist without the original-body archive") + } + return result +} + +function normalizeAssets(value, label) { + if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`) + return value.map((asset, index) => { + const normalized = exactObject(asset, ["id", "name", "sha256"], `${label}[${index}]`) + assertReleaseId(normalized.id, `${label}[${index}] id`) + if (typeof normalized.name !== "string" || !ASSET_NAME_PATTERN.test(normalized.name)) { + throw new TypeError(`${label}[${index}] name is invalid`) + } + assertSha256(normalized.sha256, `${label}[${index}] SHA-256`) + return normalized + }) +} + +function exactObject(value, fields, label) { + const source = snapshotJson(value) + if (!isRecord(source)) throw new TypeError(`${label} must be an object`) + const actual = Object.keys(source).sort() + const expected = [...fields].sort() + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`${label} contains unexpected or missing fields`) + } + return source +} + +function isCanonicalNotice(value) { + if (typeof value !== "string" || !value.endsWith("\n") || value.includes(MARKER_DELIMITER)) + return false + try { + const parsed = JSON.parse(value) + return `${JSON.stringify(canonicalize(parsed))}\n` === value + } catch { + return false + } +} + +function parseCanonicalNotice(value) { + if (!isCanonicalNotice(value)) throw new Error("Duplicate Release recovery notice is malformed") + const notice = snapshotJson(JSON.parse(value)) + if ( + !isRecord(notice) || + Object.keys(notice).sort().join(",") !== + [ + "archiveAssetName", + "candidateSha", + "canonicalReleaseId", + "duplicateReleaseId", + "originalBodySha256", + "receiptAssetName", + "receiptSha256", + "repository", + "schemaVersion", + "type", + "version", + ] + .sort() + .join(",") || + notice.repository !== DUPLICATE_DRAFT_RECOVERY_POLICY.repository || + notice.version !== DUPLICATE_DRAFT_RECOVERY_POLICY.version || + notice.candidateSha !== DUPLICATE_DRAFT_RECOVERY_POLICY.candidateSha || + notice.canonicalReleaseId !== DUPLICATE_DRAFT_RECOVERY_POLICY.canonicalReleaseId || + !DUPLICATE_DRAFT_RECOVERY_POLICY.duplicates.some( + (duplicate) => duplicate.releaseId === notice.duplicateReleaseId, + ) || + notice.archiveAssetName !== + originalBodyAssetName(notice.duplicateReleaseId, notice.originalBodySha256) || + notice.receiptAssetName !== recoveryReceiptAssetName(notice.duplicateReleaseId) + ) { + throw new Error("Duplicate Release recovery notice identity is not exact") + } + assertSha256(notice.originalBodySha256, "Recovery notice original body SHA-256") + assertSha256(notice.receiptSha256, "Recovery notice receipt SHA-256") + return notice +} + +function sameJson(left, right) { + return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)) +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize) + if (isRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, canonicalize(value[key])]), + ) + } + return value +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function compareText(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex") +} + +function deepFreeze(value) { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child) + Object.freeze(value) + } + return value +} diff --git a/scripts/release/test/duplicate-draft-recovery.test.mjs b/scripts/release/test/duplicate-draft-recovery.test.mjs new file mode 100644 index 000000000..a23b46c03 --- /dev/null +++ b/scripts/release/test/duplicate-draft-recovery.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import test from "node:test" + +import { + canonicalRecoveryNotice, + canonicalRecoveryReceipt, + classifyDuplicateDraft, + DUPLICATE_DRAFT_RECOVERY_POLICY, + originalBodyAssetName, + recoveryReceiptAssetName, +} from "../duplicate-draft-recovery.mjs" + +const POLICY = { + repository: "cacheplane/dawnai", + version: "0.8.22", + candidateSha: "2a80deece2ff958fe7fde8fddeb4f99bed70a1c8", + canonicalReleaseId: 379991871, + duplicates: [ + { releaseId: 379982100, tagName: "untagged-a13939767dd2419ade01" }, + { releaseId: 379986168, tagName: "untagged-20706099efa3c38335a8" }, + ], +} + +const BODY_SHA256 = createHash("sha256").update("canonical body\n", "utf8").digest("hex") +const RECEIPT_SHA256 = "b".repeat(64) +const ORIGINAL_BODY = "canonical body\n" +const ORIGINAL_MARKER = { + schemaVersion: 1, + phase: "ESCROWED", + version: POLICY.version, + commitSha: POLICY.candidateSha, + tag: `v${POLICY.version}`, +} +const ORIGINAL_ASSETS = Array.from({ length: 45 }, (_, index) => ({ + id: 101 + index, + name: `asset-${String(index + 1).padStart(2, "0")}.json`, + sha256: "0123456789abcdef"[index % 16].repeat(64), +})) + +function expectedFor(releaseId = POLICY.duplicates[0].releaseId) { + const duplicate = POLICY.duplicates.find((item) => item.releaseId === releaseId) + assert.ok(duplicate) + return { + releaseId, + tagName: duplicate.tagName, + canonicalBody: ORIGINAL_BODY, + canonicalMarker: ORIGINAL_MARKER, + originalBodySha256: BODY_SHA256, + originalAssets: ORIGINAL_ASSETS, + recoveryNotice: canonicalRecoveryNotice({ + repository: POLICY.repository, + version: POLICY.version, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: releaseId, + originalBodySha256: BODY_SHA256, + archiveAssetName: originalBodyAssetName(releaseId, BODY_SHA256), + receiptAssetName: recoveryReceiptAssetName(releaseId), + receiptSha256: RECEIPT_SHA256, + }), + } +} + +function snapshot(overrides = {}) { + const expected = expectedFor() + const evidenceAssets = overrides.evidenceAssets ?? [] + const evidence = evidenceAssets.map((kind) => ({ + id: kind === "body" ? 201 : 202, + name: + kind === "body" + ? originalBodyAssetName(expected.releaseId, expected.originalBodySha256) + : recoveryReceiptAssetName(expected.releaseId), + sha256: kind === "body" ? expected.originalBodySha256 : RECEIPT_SHA256, + })) + return { + releaseId: expected.releaseId, + tagName: expected.tagName, + body: overrides.quarantined ? expected.recoveryNotice : expected.canonicalBody, + marker: overrides.quarantined ? null : expected.canonicalMarker, + assets: [...expected.originalAssets, ...evidence], + evidenceAssets, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== "quarantined")), + } +} + +test("exports the exact frozen duplicate draft recovery policy", () => { + assert.deepEqual(DUPLICATE_DRAFT_RECOVERY_POLICY, POLICY) + assert.equal(Object.isFrozen(DUPLICATE_DRAFT_RECOVERY_POLICY), true) + assert.equal(Object.isFrozen(DUPLICATE_DRAFT_RECOVERY_POLICY.duplicates), true) + assert.equal(Object.isFrozen(DUPLICATE_DRAFT_RECOVERY_POLICY.duplicates[0]), true) + assert.throws(() => { + DUPLICATE_DRAFT_RECOVERY_POLICY.version = "0.8.23" + }, TypeError) +}) + +test("classifies each exact resumable duplicate state", () => { + const expected = expectedFor() + assert.equal(classifyDuplicateDraft(snapshot({ evidenceAssets: [] }), expected), "untouched") + assert.equal( + classifyDuplicateDraft(snapshot({ evidenceAssets: ["body"] }), expected), + "body-archived", + ) + assert.equal( + classifyDuplicateDraft(snapshot({ evidenceAssets: ["body", "receipt"] }), expected), + "receipt-archived", + ) + assert.equal( + classifyDuplicateDraft( + snapshot({ quarantined: true, evidenceAssets: ["body", "receipt"] }), + expected, + ), + "quarantined", + ) +}) + +test("rejects identity, marker, body, asset, and evidence conflicts", () => { + const expected = expectedFor() + const cases = [ + ["wrong Release ID", { releaseId: POLICY.canonicalReleaseId }], + ["exact candidate tag", { tagName: `v${POLICY.version}` }], + ["changed original asset", { assets: [{ ...ORIGINAL_ASSETS[0], sha256: "e".repeat(64) }] }], + [ + "extra asset", + { assets: [...snapshot().assets, { id: 999, name: "extra.txt", sha256: "f".repeat(64) }] }, + ], + ["noncanonical marker", { marker: { ...ORIGINAL_MARKER, phase: "ATTACHING" } }], + ["malformed notice", { quarantined: true, body: "recovery\n" }], + ["receipt without body archive", { evidenceAssets: ["receipt"] }], + ["unknown evidence combination", { evidenceAssets: ["body", "body"] }], + ] + for (const [name, changes] of cases) { + assert.throws(() => classifyDuplicateDraft(snapshot(changes), expected), undefined, name) + } +}) + +test("derives bounded candidate-specific evidence asset names", () => { + const bodyName = originalBodyAssetName(POLICY.duplicates[0].releaseId, BODY_SHA256) + const receiptName = recoveryReceiptAssetName(POLICY.duplicates[0].releaseId) + assert.match(bodyName, /^dawn-v0\.8\.22-duplicate-379982100-original-body-[0-9a-f]{64}\.txt$/u) + assert.equal(receiptName, "dawn-v0.8.22-duplicate-379982100-recovery-receipt.json") + assert.ok(Buffer.byteLength(bodyName, "ascii") <= 255) + assert.ok(Buffer.byteLength(receiptName, "ascii") <= 255) + assert.throws(() => originalBodyAssetName(POLICY.canonicalReleaseId, BODY_SHA256)) + assert.throws(() => originalBodyAssetName(POLICY.duplicates[0].releaseId, "A".repeat(64))) +}) + +test("creates canonical newline-terminated receipt and notice bytes", () => { + const releaseId = POLICY.duplicates[0].releaseId + const archiveAssetName = originalBodyAssetName(releaseId, BODY_SHA256) + const receiptAssetName = recoveryReceiptAssetName(releaseId) + const receipt = canonicalRecoveryReceipt({ + repository: POLICY.repository, + version: POLICY.version, + candidateSha: POLICY.candidateSha, + recoveryCommit: POLICY.candidateSha, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: releaseId, + originalBodySha256: BODY_SHA256, + baseAssetSetSha256: "1".repeat(64), + archiveAsset: { name: archiveAssetName, sha256: BODY_SHA256 }, + }) + assert.ok(Buffer.isBuffer(receipt)) + assert.equal(receipt.toString("utf8").endsWith("\n"), true) + assert.deepEqual(JSON.parse(receipt), { + schemaVersion: 1, + repository: POLICY.repository, + version: POLICY.version, + candidateSha: POLICY.candidateSha, + recoveryCommit: POLICY.candidateSha, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: releaseId, + originalBodySha256: BODY_SHA256, + baseAssetSetSha256: "1".repeat(64), + archiveAsset: { name: archiveAssetName, sha256: BODY_SHA256 }, + }) + + const notice = canonicalRecoveryNotice({ + repository: POLICY.repository, + version: POLICY.version, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: releaseId, + originalBodySha256: BODY_SHA256, + archiveAssetName, + receiptAssetName, + receiptSha256: RECEIPT_SHA256, + }) + assert.equal(typeof notice, "string") + assert.equal(notice.endsWith("\n"), true) + assert.equal(notice.includes("DAWN_RELEASE_CONTROLLER_MARKER"), false) + assert.match(notice, /379991871/u) + assert.match(notice, /379982100/u) +}) + +test("rejects malformed canonical receipt and notice inputs", () => { + assert.throws(() => canonicalRecoveryReceipt({}), undefined) + assert.throws( + () => + canonicalRecoveryNotice({ + repository: POLICY.repository, + version: POLICY.version, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: POLICY.duplicates[0].releaseId, + originalBodySha256: BODY_SHA256, + archiveAssetName: "bad asset name", + receiptAssetName: recoveryReceiptAssetName(POLICY.duplicates[0].releaseId), + receiptSha256: RECEIPT_SHA256, + }), + undefined, + ) + assert.throws( + () => + canonicalRecoveryNotice({ + repository: POLICY.repository, + version: POLICY.version, + canonicalReleaseId: POLICY.canonicalReleaseId, + duplicateReleaseId: POLICY.duplicates[0].releaseId, + originalBodySha256: BODY_SHA256, + archiveAssetName: `${originalBodyAssetName(POLICY.duplicates[0].releaseId, BODY_SHA256)}\n