Skip to content

Commit 695d4db

Browse files
committed
fix(release): preserve runtime environment boundary
1 parent 9985bac commit 695d4db

4 files changed

Lines changed: 285 additions & 18 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Consolidation Runtime Environment Implementation Plan
2+
3+
> **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.
4+
5+
**Goal:** Allow the standalone duplicate-draft consolidation CLI to compose its production adapters without weakening explicit environment validation.
6+
7+
**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`.
8+
9+
**Tech Stack:** Node.js 24, ESM, `node:test`, pnpm, Biome
10+
11+
---
12+
13+
### Task 1: Preserve the runtime environment boundary
14+
15+
**Files:**
16+
- Modify: `scripts/release/duplicate-draft-consolidation-cli.mjs`
17+
- Test: `scripts/release/test/duplicate-draft-consolidation-cli.test.mjs`
18+
19+
- [x] **Step 1: Write the failing three-mode environment regressions**
20+
21+
Add table-driven CLI cases for `inspect`, `perform`, and `verify`. For each mode:
22+
23+
1. omit the top-level `environment` option, capture every options object passed
24+
to `createAdapters`, and assert none has an own `environment` property;
25+
2. pass one frozen plain environment explicitly and assert the identical object
26+
reaches every `createAdapters` call; and
27+
3. pass `environment: undefined` explicitly and assert it remains an own field
28+
in the composition options so the adapter's strict explicit-input parser,
29+
rather than the runtime path, owns rejection.
30+
31+
For `perform`, make the successful dependency invoke `createAdapters()` once
32+
without a budget and once with a frozen request budget. Assert both calls obey
33+
the environment rule and the second preserves the exact budget object. Keep
34+
mode-specific operation dependencies successful so the tests observe only the
35+
composition boundary.
36+
37+
- [x] **Step 2: Prove the regression is red**
38+
39+
Run:
40+
41+
```bash
42+
PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH node --test \
43+
--test-name-pattern='preserves the environment boundary across every mode' \
44+
scripts/release/test/duplicate-draft-consolidation-cli.test.mjs
45+
```
46+
47+
Expected: FAIL because the current CLI explicitly forwards `process.env`.
48+
49+
- [x] **Step 3: Add the minimal shared composition helper**
50+
51+
Keep `environment` absent from normalized CLI options unless it was supplied by
52+
the caller. Add one helper that returns:
53+
54+
```js
55+
{
56+
cwd: invocation.cwd,
57+
...(Object.hasOwn(invocation, "environment")
58+
? { environment: invocation.environment }
59+
: {}),
60+
dependencies: { now },
61+
...(requestBudget === undefined ? {} : { requestBudget }),
62+
}
63+
```
64+
65+
Use it for `inspect`, `perform`, and `verify`. Do not change adapter validation.
66+
67+
- [x] **Step 4: Prove all environment and budget cases are green**
68+
69+
Run the table-driven regression and require all default, explicit-frozen, and
70+
explicit-undefined cases to pass for all three modes. Require both perform
71+
adapter calls to preserve the exact request-budget behavior.
72+
73+
- [x] **Step 5: Run focused verification**
74+
75+
Run the CLI test, the complete duplicate-draft consolidation suite, scoped
76+
repository-configured Biome, `node scripts/check-docs.mjs`, and
77+
`git diff --check`. Expected: all pass.
78+
79+
- [x] **Step 6: Run full validation and commit**
80+
81+
Run:
82+
83+
```bash
84+
PATH=/Users/blove/.nvm/versions/node/v24.19.0/bin:$PATH \
85+
DAWN_REQUIRE_DOCKER=1 pnpm ci:validate
86+
```
87+
88+
Expected: all Definition of Done gates pass. Commit the design, plan, test, and
89+
implementation with a factual message, push a focused PR, and require exact-head
90+
CI before merge.
91+
92+
### Task 2: Resume live read-only inspection
93+
94+
**Files:**
95+
- Live private output: `.dawn/release/duplicate-draft-consolidation.proposed.json`
96+
97+
- [ ] **Step 1: Refresh exact merged-main authority**
98+
99+
Require clean symbolic `main` and identical local HEAD, `origin/main`, and
100+
GitHub default-branch SHAs. Confirm Release remains disabled and no nonterminal
101+
Release run exists.
102+
103+
- [ ] **Step 2: Retry the exact production inspect command**
104+
105+
Run the incident-scoped `pnpm release:consolidate-drafts inspect` command from
106+
the merged main checkout. Expected: a canonical private proposal and bounded
107+
safe summary; zero writer calls.
108+
109+
- [ ] **Step 3: Independently validate the proposal**
110+
111+
Confirm mode `0600`, canonical envelope parsing, exact survivor and ordered
112+
duplicates, and the printed record digest before entering the separate live
113+
mutation freeze.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Consolidation Runtime Environment Design
2+
3+
**Status:** Implemented and locally verified; merge and live retry pending
4+
5+
**Date:** 2026-09-02
6+
7+
**Scope:** Production composition for the duplicate-draft consolidation CLI
8+
9+
## Problem
10+
11+
The standalone consolidation CLI defaults `environment` to Node's `process.env`
12+
and then passes that value explicitly to
13+
`createDuplicateDraftConsolidationAdapters`. The adapter treats explicit
14+
environment overrides as untrusted caller data and therefore requires a plain
15+
data object. Node's host-owned `process.env` object is not a plain object, so
16+
production `inspect` stops during adapter composition before any live read.
17+
18+
The adapter already owns a separate runtime path: when the `environment` option
19+
is absent, it snapshots `process.env` with runtime-specific validation and
20+
allowlisting. Tests that explicitly inject an environment must continue through
21+
the stricter caller-data path.
22+
23+
## Considered approaches
24+
25+
1. **Omit an implicit environment at the CLI-to-adapter boundary.** Preserve
26+
whether the caller supplied `environment`; pass it only when explicit. The
27+
adapter then uses its existing runtime snapshot for standalone execution.
28+
2. Copy `process.env` into a plain object in the CLI. This duplicates environment
29+
ownership and allowlisting across layers.
30+
3. Let the adapter's explicit-override parser accept `process.env`. This weakens
31+
the distinction between host-owned runtime state and injected caller data.
32+
33+
## Decision
34+
35+
Use approach 1. The CLI will preserve option presence and build one exact
36+
adapter-composition object for `inspect`, `perform`, and `verify`. It includes
37+
`environment` only when the caller explicitly supplied it, and continues to add
38+
the request budget only when present. No new flag, override, fallback, or
39+
compatibility path is introduced.
40+
41+
## Safety and verification
42+
43+
- Add a table-driven regression proving the standalone/default CLI omits
44+
`environment` for `inspect`, `perform`, and `verify`.
45+
- For all three modes, prove an explicitly injected frozen plain environment is
46+
still forwarded by identity, while explicit `environment: undefined` remains
47+
explicit and reaches strict adapter rejection.
48+
- For `perform`, cover both initial composition and the request-budget path.
49+
- Run the focused CLI and consolidation suites, repository-configured static
50+
checks, documentation checks, and the full validation lane before merge.
51+
- After merge, retry the exact read-only live `inspect` command. Any later live
52+
authority drift still stops through the existing fail-closed workflow.

scripts/release/duplicate-draft-consolidation-cli.mjs

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,7 @@ export async function runDuplicateDraftConsolidationCli(options = {}) {
7070
if (input.mode === "inspect") {
7171
result = await inspect(input.value, {
7272
repositoryRoot: invocation.cwd,
73-
adapters: await createAdapters({
74-
cwd: invocation.cwd,
75-
environment: invocation.environment,
76-
dependencies: { now },
77-
}),
73+
adapters: await createAdapters(adapterCompositionOptions(invocation, now)),
7874
now,
7975
wait,
8076
repositoryRootIdentity,
@@ -83,24 +79,14 @@ export async function runDuplicateDraftConsolidationCli(options = {}) {
8379
result = await perform(input.value, {
8480
repositoryRoot: invocation.cwd,
8581
createAdapters: (requestBudget) =>
86-
createAdapters({
87-
cwd: invocation.cwd,
88-
environment: invocation.environment,
89-
dependencies: { now },
90-
...(requestBudget === undefined ? {} : { requestBudget }),
91-
}),
82+
createAdapters(adapterCompositionOptions(invocation, now, requestBudget)),
9283
now,
9384
wait,
9485
})
9586
} else {
9687
result = await verify(input.value, {
9788
repositoryRoot: invocation.cwd,
98-
createAdapters: () =>
99-
createAdapters({
100-
cwd: invocation.cwd,
101-
environment: invocation.environment,
102-
dependencies: { now },
103-
}),
89+
createAdapters: () => createAdapters(adapterCompositionOptions(invocation, now)),
10490
})
10591
}
10692
const summary =
@@ -260,7 +246,7 @@ function normalizeOptions(options) {
260246
const result = {
261247
argv: values.argv ?? process.argv.slice(2),
262248
cwd,
263-
environment: values.environment ?? process.env,
249+
...(Object.hasOwn(values, "environment") ? { environment: values.environment } : {}),
264250
stdout,
265251
stderr,
266252
dependencies,
@@ -274,6 +260,15 @@ function normalizeOptions(options) {
274260
return result
275261
}
276262

263+
function adapterCompositionOptions(invocation, now, requestBudget) {
264+
return {
265+
cwd: invocation.cwd,
266+
...(Object.hasOwn(invocation, "environment") ? { environment: invocation.environment } : {}),
267+
dependencies: { now },
268+
...(requestBudget === undefined ? {} : { requestBudget }),
269+
}
270+
}
271+
277272
function bindSink(value) {
278273
if (value === null || typeof value !== "object" || utilTypes.isProxy(value)) {
279274
throw new InvocationError()

scripts/release/test/duplicate-draft-consolidation-cli.test.mjs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,67 @@ test("CLI threads each exact convergence request budget into production adapter
200200
assert.equal(adapterOptions.requestBudget, requestBudget)
201201
})
202202

203+
test("CLI preserves the environment boundary across every mode", async (t) => {
204+
const explicitEnvironment = Object.freeze({ HOME: "/fixture/home", PATH: "/fixture/bin" })
205+
const controller = new AbortController()
206+
const requestBudget = Object.freeze({
207+
operation: "release",
208+
timeoutMs: 12_345,
209+
signal: controller.signal,
210+
})
211+
const modes = Object.freeze([
212+
Object.freeze({ name: "inspect", argv: COMMAND }),
213+
Object.freeze({ name: "perform", argv: PERFORM_COMMAND }),
214+
Object.freeze({ name: "verify", argv: VERIFY_COMMAND }),
215+
])
216+
const environmentCases = Object.freeze([
217+
Object.freeze({ name: "omitted", expected: null }),
218+
Object.freeze({
219+
name: "explicit frozen",
220+
value: explicitEnvironment,
221+
expected: explicitEnvironment,
222+
}),
223+
Object.freeze({ name: "explicit undefined", value: undefined, expected: undefined }),
224+
])
225+
226+
for (const mode of modes) {
227+
for (const environmentCase of environmentCases) {
228+
await t.test(`${mode.name} ${environmentCase.name}`, async () => {
229+
const adapterCalls = []
230+
const options = {
231+
argv: mode.argv,
232+
cwd: process.cwd(),
233+
stdout: sink(),
234+
stderr: sink(),
235+
dependencies: environmentBoundaryDependencies({
236+
mode: mode.name,
237+
requestBudget,
238+
adapterCalls,
239+
}),
240+
}
241+
if (environmentCase.name !== "omitted") {
242+
options.environment = environmentCase.value
243+
}
244+
245+
assert.equal(await runDuplicateDraftConsolidationCli(options), 0)
246+
assert.equal(adapterCalls.length, mode.name === "perform" ? 2 : 1)
247+
for (const adapterOptions of adapterCalls) {
248+
if (environmentCase.name === "omitted") {
249+
assert.equal(Object.hasOwn(adapterOptions, "environment"), false)
250+
} else {
251+
assert.equal(Object.hasOwn(adapterOptions, "environment"), true)
252+
assert.equal(adapterOptions.environment, environmentCase.expected)
253+
}
254+
}
255+
if (mode.name === "perform") {
256+
assert.equal(Object.hasOwn(adapterCalls[0], "requestBudget"), false)
257+
assert.equal(adapterCalls[1].requestBudget, requestBudget)
258+
}
259+
})
260+
}
261+
}
262+
})
263+
203264
test("CLI perform rejects digest, confirmation, path, force, survivor, and reordered-ID variants", async () => {
204265
for (const argv of [
205266
PERFORM_COMMAND.with(8, CONFIRMATION.replace(PROPOSAL_SHA256, "A".repeat(64))),
@@ -580,6 +641,52 @@ function successfulDependencies() {
580641
}
581642
}
582643

644+
function environmentBoundaryDependencies({ mode, requestBudget, adapterCalls }) {
645+
const dependencies = {
646+
async createAdapters(options) {
647+
adapterCalls.push(options)
648+
return Object.freeze({})
649+
},
650+
}
651+
if (mode === "inspect") {
652+
dependencies.inspect = async (input) =>
653+
Object.freeze({
654+
proposalSha256: "a".repeat(64),
655+
version: input.version,
656+
commitSha: input.commitSha,
657+
survivor: input.survivor,
658+
duplicates: Object.freeze([...input.duplicates]),
659+
output: input.output,
660+
})
661+
} else if (mode === "perform") {
662+
dependencies.perform = async (_input, operations) => {
663+
await operations.createAdapters()
664+
await operations.createAdapters(requestBudget)
665+
return Object.freeze({
666+
status: "complete",
667+
survivor: "379991871",
668+
deleted: Object.freeze(["379982100", "379986168"]),
669+
receipt: "scripts/release/duplicate-draft-consolidation.json",
670+
receiptSha256: "b".repeat(64),
671+
})
672+
}
673+
} else {
674+
dependencies.verify = async (_input, operations) => {
675+
await operations.createAdapters()
676+
return Object.freeze({
677+
status: "verified",
678+
survivor: "379991871",
679+
deleted: Object.freeze(["379982100", "379986168"]),
680+
receipt: "scripts/release/duplicate-draft-consolidation.json",
681+
receiptSha256: "c".repeat(64),
682+
historicalParity:
683+
"Historical duplicate payload parity is supported by embedded pre-delete evidence plus the currently reverified survivor; deleted bytes were not independently re-downloaded.",
684+
})
685+
}
686+
}
687+
return dependencies
688+
}
689+
583690
function failingWritable(message) {
584691
return new Writable({
585692
write(_chunk, _encoding, callback) {

0 commit comments

Comments
 (0)