diff --git a/docs/superpowers/plans/2026-09-02-consolidation-runtime-environment.md b/docs/superpowers/plans/2026-09-02-consolidation-runtime-environment.md new file mode 100644 index 000000000..153214bdf --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-consolidation-runtime-environment.md @@ -0,0 +1,113 @@ +# Consolidation Runtime Environment 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:** Allow the standalone duplicate-draft consolidation CLI to compose its production adapters without weakening explicit environment validation. + +**Architecture:** Preserve whether `environment` was explicitly supplied to the CLI. Route all three CLI modes through one adapter-option builder that omits an implicit environment, allowing the adapter's existing runtime snapshot to own `process.env`. + +**Tech Stack:** Node.js 24, ESM, `node:test`, pnpm, Biome + +--- + +### Task 1: Preserve the runtime environment boundary + +**Files:** +- Modify: `scripts/release/duplicate-draft-consolidation-cli.mjs` +- Test: `scripts/release/test/duplicate-draft-consolidation-cli.test.mjs` + +- [x] **Step 1: Write the failing three-mode environment regressions** + +Add table-driven CLI cases for `inspect`, `perform`, and `verify`. For each mode: + +1. omit the top-level `environment` option, capture every options object passed + to `createAdapters`, and assert none has an own `environment` property; +2. pass one frozen plain environment explicitly and assert the identical object + reaches every `createAdapters` call; and +3. pass `environment: undefined` explicitly and assert it remains an own field + in the composition options so the adapter's strict explicit-input parser, + rather than the runtime path, owns rejection. + +For `perform`, make the successful dependency invoke `createAdapters()` once +without a budget and once with a frozen request budget. Assert both calls obey +the environment rule and the second preserves the exact budget object. Keep +mode-specific operation dependencies successful so the tests observe only the +composition boundary. + +- [x] **Step 2: Prove the regression is red** + +Run: + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH node --test \ + --test-name-pattern='preserves the environment boundary across every mode' \ + scripts/release/test/duplicate-draft-consolidation-cli.test.mjs +``` + +Expected: FAIL because the current CLI explicitly forwards `process.env`. + +- [x] **Step 3: Add the minimal shared composition helper** + +Keep `environment` absent from normalized CLI options unless it was supplied by +the caller. Add one helper that returns: + +```js +{ + cwd: invocation.cwd, + ...(Object.hasOwn(invocation, "environment") + ? { environment: invocation.environment } + : {}), + dependencies: { now }, + ...(requestBudget === undefined ? {} : { requestBudget }), +} +``` + +Use it for `inspect`, `perform`, and `verify`. Do not change adapter validation. + +- [x] **Step 4: Prove all environment and budget cases are green** + +Run the table-driven regression and require all default, explicit-frozen, and +explicit-undefined cases to pass for all three modes. Require both perform +adapter calls to preserve the exact request-budget behavior. + +- [x] **Step 5: Run focused verification** + +Run the CLI test, the complete duplicate-draft consolidation suite, scoped +repository-configured Biome, `node scripts/check-docs.mjs`, and +`git diff --check`. Expected: all pass. + +- [x] **Step 6: Run full validation and commit** + +Run: + +```bash +PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \ + DAWN_REQUIRE_DOCKER=1 pnpm ci:validate +``` + +Expected: all Definition of Done gates pass. Commit the design, plan, test, and +implementation with a factual message, push a focused PR, and require exact-head +CI before merge. + +### Task 2: Resume live read-only inspection + +**Files:** +- Live private output: `.dawn/release/duplicate-draft-consolidation.proposed.json` + +- [ ] **Step 1: Refresh exact merged-main authority** + +Require clean symbolic `main` and identical local HEAD, `origin/main`, and +GitHub default-branch SHAs. Confirm Release remains disabled and no nonterminal +Release run exists. + +- [ ] **Step 2: Retry the exact production inspect command** + +Run the incident-scoped `pnpm release:consolidate-drafts inspect` command from +the merged main checkout. Expected: a canonical private proposal and bounded +safe summary; zero writer calls. + +- [ ] **Step 3: Independently validate the proposal** + +Confirm mode `0600`, canonical envelope parsing, exact survivor and ordered +duplicates, and the printed record digest before entering the separate live +mutation freeze. diff --git a/docs/superpowers/specs/2026-09-02-consolidation-runtime-environment-design.md b/docs/superpowers/specs/2026-09-02-consolidation-runtime-environment-design.md new file mode 100644 index 000000000..cee8f8426 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-consolidation-runtime-environment-design.md @@ -0,0 +1,52 @@ +# Consolidation Runtime Environment Design + +**Status:** Implemented and locally verified; merge and live retry pending + +**Date:** 2026-09-02 + +**Scope:** Production composition for the duplicate-draft consolidation CLI + +## Problem + +The standalone consolidation CLI defaults `environment` to Node's `process.env` +and then passes that value explicitly to +`createDuplicateDraftConsolidationAdapters`. The adapter treats explicit +environment overrides as untrusted caller data and therefore requires a plain +data object. Node's host-owned `process.env` object is not a plain object, so +production `inspect` stops during adapter composition before any live read. + +The adapter already owns a separate runtime path: when the `environment` option +is absent, it snapshots `process.env` with runtime-specific validation and +allowlisting. Tests that explicitly inject an environment must continue through +the stricter caller-data path. + +## Considered approaches + +1. **Omit an implicit environment at the CLI-to-adapter boundary.** Preserve + whether the caller supplied `environment`; pass it only when explicit. The + adapter then uses its existing runtime snapshot for standalone execution. +2. Copy `process.env` into a plain object in the CLI. This duplicates environment + ownership and allowlisting across layers. +3. Let the adapter's explicit-override parser accept `process.env`. This weakens + the distinction between host-owned runtime state and injected caller data. + +## Decision + +Use approach 1. The CLI will preserve option presence and build one exact +adapter-composition object for `inspect`, `perform`, and `verify`. It includes +`environment` only when the caller explicitly supplied it, and continues to add +the request budget only when present. No new flag, override, fallback, or +compatibility path is introduced. + +## Safety and verification + +- Add a table-driven regression proving the standalone/default CLI omits + `environment` for `inspect`, `perform`, and `verify`. +- For all three modes, prove an explicitly injected frozen plain environment is + still forwarded by identity, while explicit `environment: undefined` remains + explicit and reaches strict adapter rejection. +- For `perform`, cover both initial composition and the request-budget path. +- Run the focused CLI and consolidation suites, repository-configured static + checks, documentation checks, and the full validation lane before merge. +- After merge, retry the exact read-only live `inspect` command. Any later live + authority drift still stops through the existing fail-closed workflow. diff --git a/scripts/release/duplicate-draft-consolidation-cli.mjs b/scripts/release/duplicate-draft-consolidation-cli.mjs index ba2018948..b82aacefc 100644 --- a/scripts/release/duplicate-draft-consolidation-cli.mjs +++ b/scripts/release/duplicate-draft-consolidation-cli.mjs @@ -70,11 +70,7 @@ export async function runDuplicateDraftConsolidationCli(options = {}) { if (input.mode === "inspect") { result = await inspect(input.value, { repositoryRoot: invocation.cwd, - adapters: await createAdapters({ - cwd: invocation.cwd, - environment: invocation.environment, - dependencies: { now }, - }), + adapters: await createAdapters(adapterCompositionOptions(invocation, now)), now, wait, repositoryRootIdentity, @@ -83,24 +79,14 @@ export async function runDuplicateDraftConsolidationCli(options = {}) { result = await perform(input.value, { repositoryRoot: invocation.cwd, createAdapters: (requestBudget) => - createAdapters({ - cwd: invocation.cwd, - environment: invocation.environment, - dependencies: { now }, - ...(requestBudget === undefined ? {} : { requestBudget }), - }), + createAdapters(adapterCompositionOptions(invocation, now, requestBudget)), now, wait, }) } else { result = await verify(input.value, { repositoryRoot: invocation.cwd, - createAdapters: () => - createAdapters({ - cwd: invocation.cwd, - environment: invocation.environment, - dependencies: { now }, - }), + createAdapters: () => createAdapters(adapterCompositionOptions(invocation, now)), }) } const summary = @@ -260,7 +246,7 @@ function normalizeOptions(options) { const result = { argv: values.argv ?? process.argv.slice(2), cwd, - environment: values.environment ?? process.env, + ...(Object.hasOwn(values, "environment") ? { environment: values.environment } : {}), stdout, stderr, dependencies, @@ -274,6 +260,15 @@ function normalizeOptions(options) { return result } +function adapterCompositionOptions(invocation, now, requestBudget) { + return { + cwd: invocation.cwd, + ...(Object.hasOwn(invocation, "environment") ? { environment: invocation.environment } : {}), + dependencies: { now }, + ...(requestBudget === undefined ? {} : { requestBudget }), + } +} + function bindSink(value) { if (value === null || typeof value !== "object" || utilTypes.isProxy(value)) { throw new InvocationError() diff --git a/scripts/release/test/duplicate-draft-consolidation-cli.test.mjs b/scripts/release/test/duplicate-draft-consolidation-cli.test.mjs index ca342c91a..ea1b2393f 100644 --- a/scripts/release/test/duplicate-draft-consolidation-cli.test.mjs +++ b/scripts/release/test/duplicate-draft-consolidation-cli.test.mjs @@ -200,6 +200,67 @@ test("CLI threads each exact convergence request budget into production adapter assert.equal(adapterOptions.requestBudget, requestBudget) }) +test("CLI preserves the environment boundary across every mode", async (t) => { + const explicitEnvironment = Object.freeze({ HOME: "/fixture/home", PATH: "/fixture/bin" }) + const controller = new AbortController() + const requestBudget = Object.freeze({ + operation: "release", + timeoutMs: 12_345, + signal: controller.signal, + }) + const modes = Object.freeze([ + Object.freeze({ name: "inspect", argv: COMMAND }), + Object.freeze({ name: "perform", argv: PERFORM_COMMAND }), + Object.freeze({ name: "verify", argv: VERIFY_COMMAND }), + ]) + const environmentCases = Object.freeze([ + Object.freeze({ name: "omitted", expected: null }), + Object.freeze({ + name: "explicit frozen", + value: explicitEnvironment, + expected: explicitEnvironment, + }), + Object.freeze({ name: "explicit undefined", value: undefined, expected: undefined }), + ]) + + for (const mode of modes) { + for (const environmentCase of environmentCases) { + await t.test(`${mode.name} ${environmentCase.name}`, async () => { + const adapterCalls = [] + const options = { + argv: mode.argv, + cwd: process.cwd(), + stdout: sink(), + stderr: sink(), + dependencies: environmentBoundaryDependencies({ + mode: mode.name, + requestBudget, + adapterCalls, + }), + } + if (environmentCase.name !== "omitted") { + options.environment = environmentCase.value + } + + assert.equal(await runDuplicateDraftConsolidationCli(options), 0) + assert.equal(adapterCalls.length, mode.name === "perform" ? 2 : 1) + for (const adapterOptions of adapterCalls) { + if (environmentCase.name === "omitted") { + assert.equal(Object.hasOwn(adapterOptions, "environment"), false) + } else { + assert.equal(Object.hasOwn(adapterOptions, "environment"), true) + assert.equal(adapterOptions.environment, environmentCase.expected) + } + } + if (mode.name === "perform") { + assert.equal(Object.hasOwn(adapterCalls[0], "requestBudget"), false) + assert.equal(adapterCalls[1].requestBudget, requestBudget) + } + }) + } + } +}) + test("CLI perform rejects digest, confirmation, path, force, survivor, and reordered-ID variants", async () => { for (const argv of [ PERFORM_COMMAND.with(8, CONFIRMATION.replace(PROPOSAL_SHA256, "A".repeat(64))), @@ -580,6 +641,52 @@ function successfulDependencies() { } } +function environmentBoundaryDependencies({ mode, requestBudget, adapterCalls }) { + const dependencies = { + async createAdapters(options) { + adapterCalls.push(options) + return Object.freeze({}) + }, + } + if (mode === "inspect") { + dependencies.inspect = async (input) => + Object.freeze({ + proposalSha256: "a".repeat(64), + version: input.version, + commitSha: input.commitSha, + survivor: input.survivor, + duplicates: Object.freeze([...input.duplicates]), + output: input.output, + }) + } else if (mode === "perform") { + dependencies.perform = async (_input, operations) => { + await operations.createAdapters() + await operations.createAdapters(requestBudget) + return Object.freeze({ + status: "complete", + survivor: "379991871", + deleted: Object.freeze(["379982100", "379986168"]), + receipt: "scripts/release/duplicate-draft-consolidation.json", + receiptSha256: "b".repeat(64), + }) + } + } else { + dependencies.verify = async (_input, operations) => { + await operations.createAdapters() + return Object.freeze({ + status: "verified", + survivor: "379991871", + deleted: Object.freeze(["379982100", "379986168"]), + receipt: "scripts/release/duplicate-draft-consolidation.json", + receiptSha256: "c".repeat(64), + historicalParity: + "Historical duplicate payload parity is supported by embedded pre-delete evidence plus the currently reverified survivor; deleted bytes were not independently re-downloaded.", + }) + } + } + return dependencies +} + function failingWritable(message) { return new Writable({ write(_chunk, _encoding, callback) {