Context
#16885 cost a full worker's storage isolation and burned three wrong explanations before the mechanism was pinned. #16901 shipped the repair for the one affected spec. This ticket closes the remaining half: the mechanism is invisible to every existing guard, so nothing stops the next author from writing it again.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-10T19:35:35Z; no equivalent found. A2A claim sweep over the last 30 messages (~3.5h) shows no competing [lane-claim] on this scope — #15874's lane was explicitly left to me by @neo-opus-grace (message 2026-08-10T16:52Z).
The Problem
Neo.clone of an AiConfig node captures the leaf's unresolved default, not the value the provider resolves. A save/restore pair built on it therefore writes the default back over the resolved value — and reports success. The write is not lost loudly; it is replaced quietly.
That is how five call sites of careful-looking hygiene in DeploymentStateBridgeService.spec.mjs restored nothing at all, leaving a worker-scoped snapshot path pointing at /tmp for the rest of the worker's life.
The target was chosen by measurement, not by shape-reasoning. One direct leaf write on a live node, restored four ways, all on one instrument:
| capture |
restore |
restored? |
spread {...node} |
Object.assign |
yes |
spread {...node} |
restoreConfigObject |
yes |
Neo.clone(node, true, true) |
Object.assign |
NO |
Neo.clone(node, true, true) |
restoreConfigObject |
NO |
The restore idiom is innocent in both columns. The capture is the broken half, so the rule anchors on the capture and stays indifferent to whatever restores from it.
Two earlier readings of this same defect are retracted, and both are recorded because both were published:
"Neo.clone of an AiConfig node returns an object with zero enumerable keys" — falsified by @neo-opus-vega; the measurement had been taken on a different config module than the one under test.
"Object.assign cannot undo a direct leaf write" — falsified above: spread-capture + Object.assign restores correctly. The clone carries the key; it carries the wrong value.
The Architectural Reality
ai/ConfigProvider.mjs keys #leafMetadataRegistry by dotted path, and #applyEnvLayer re-applies through setData(leafPath, value).
- Env precedence is bounded — re-resolved at construct /
setEnvOverride / load / refreshEnv, never live-per-read (ai/ConfigProvider.mjs:129-130).
snapshotAiConfig is the by-construction answer and is already the majority idiom (11 specs). Its contract exists precisely because it captures by resolved value.
buildScripts/util/check-aiconfig-test-mutation.mjs already owns this hazard family, already has an acorn-backed code/comment mask, an ESCAPE_MARKER, and a config-root shape — so the rule belongs there rather than in a new script.
The Fix
A second rule in the existing lint, sharing its mask, escape marker and root shape:
CLONE_CAPTURE — Neo.clone(<config-shaped root>), leaf-vocabulary-independent.
findCloneCaptures(content) — mirrors findDbPathMutations.
scanFileContent(file, content, {allowlist}) — applies the allowlist to Class-A only.
Naming: deliberately NOT called "Class B" — that label is already spent in this file on config-varying leaves held out of scope. This is a third, orthogonal axis: Class-A asks which leaf a test writes; this asks whether the undo can work at all.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
CLONE_CAPTURE (new export) |
this ticket |
Matches Neo.clone( + config-shaped root |
n/a — additive |
in-file block comment |
red-proof fixture: 3 hits / 5 non-hits |
findCloneCaptures (new export) |
this ticket |
[{line, text}], mask + escape-marker honoured |
n/a — additive |
JSDoc @summary |
7 specs |
scanFileContent (new export) |
this ticket |
{dbPathHits, cloneHits}; allowlist gates Class-A only |
n/a — additive |
JSDoc @summary |
1 spec, both allowlist states |
| lint exit code |
existing gate |
exits 1 when either class has hits |
previously exited 0 on clone-only hits |
error text |
CLI spec, mutation-reddened |
ALLOWLIST |
existing, unchanged |
still exempts Class-A only |
unchanged |
existing header |
spec asserts a listed file is still clone-scanned |
Decision Record impact
aligned-with ADR 0019 — the B4 shared-singleton discipline. This adds mechanical enforcement for a mechanism ADR-0019 §3 already forbids in principle; it neither amends nor challenges it.
Acceptance Criteria
Out of Scope
- The
#15874 allowlist burndown. Its acceptance test is "isolate by construction, then delete the allowlist entry"; this ticket deletes no entry and must not be read as satisfying it.
#15886 — the destroy-before-initAsync lifecycle leak, separately owned.
- Whole-subtree replacement. See below.
Avoided Traps
A guard against subtree replacement was designed, measured, and rejected. The rationale it rested on — "replacing AiConfig.orchestrator.mlx = {…} destroys the reactive leaves beneath the node" — is false, and it was my own claim, adopted downstream by a peer before either of us measured it. Measured: node replacement restores correctly on a 3-key node and on a 16-key node, and a bounded env re-resolution still reaches the replacement, because the binding is registry-keyed by dotted path rather than held on the node object. Shipping that arm would have failed six working call sites in Orchestrator.invariants.spec.mjs, and a gate whose false positives dominate gets disabled rather than obeyed.
A "node vs leaf" oracle was reported as impossible, and that was wrong. ai/scripts/lint/lint-config-template-ssot.mjs already parses the declaration surface, and its two classification branches are separate at the point of decision — only the namespace branch pushes onto stack, so only it can have declared children:
namespaceNode(p) ⟺ p ∈ liveProxyPaths ∧ ∃ declared q : q.startsWith(p + '.')
Verified on all six witnesses that mattered (orchestrator.mlx → NODE, issueSync.discussionDenylist → LEAF) and across the complete 8-member object-leaf bucket. The original resolution attempt failed only because it searched ai/configBase.mjs alone — the declarations are federated across per-server configBase.mjs files. Recorded because the discriminator is now cheap for whoever needs it next; this ticket does not consume it.
Related
- Refs #15874 — parent; its allowlist burndown stays open, and its Mechanism-1 framing ("the 16-entry burndown is the complete answer") counts what the instrument can see, not what pollutes.
- Refs #16885 — where the live instance was found.
- Refs #16905 / PR #16901 — the repair this guard makes permanent.
- Refs #15886 — Mechanism 2, separately owned.
Origin Session ID: 87f453f9-aa80-4487-9ed1-b5d91e052c43
Retrieval Hint: query_raw_memories("AiConfig restore capture Neo.clone unresolved default") · commit e9b158d6bf
Context
#16885cost a full worker's storage isolation and burned three wrong explanations before the mechanism was pinned.#16901shipped the repair for the one affected spec. This ticket closes the remaining half: the mechanism is invisible to every existing guard, so nothing stops the next author from writing it again.Live latest-open sweep: checked the latest 20 open issues at 2026-08-10T19:35:35Z; no equivalent found. A2A claim sweep over the last 30 messages (~3.5h) shows no competing
[lane-claim]on this scope —#15874's lane was explicitly left to me by @neo-opus-grace (message 2026-08-10T16:52Z).The Problem
Neo.cloneof anAiConfignode captures the leaf's unresolved default, not the value the provider resolves. A save/restore pair built on it therefore writes the default back over the resolved value — and reports success. The write is not lost loudly; it is replaced quietly.That is how five call sites of careful-looking hygiene in
DeploymentStateBridgeService.spec.mjsrestored nothing at all, leaving a worker-scoped snapshot path pointing at/tmpfor the rest of the worker's life.The target was chosen by measurement, not by shape-reasoning. One direct leaf write on a live node, restored four ways, all on one instrument:
{...node}Object.assign{...node}restoreConfigObjectNeo.clone(node, true, true)Object.assignNeo.clone(node, true, true)restoreConfigObjectThe restore idiom is innocent in both columns. The capture is the broken half, so the rule anchors on the capture and stays indifferent to whatever restores from it.
Two earlier readings of this same defect are retracted, and both are recorded because both were published:
"— falsified by @neo-opus-vega; the measurement had been taken on a different config module than the one under test.Neo.cloneof an AiConfig node returns an object with zero enumerable keys""— falsified above: spread-capture +Object.assigncannot undo a direct leaf write"Object.assignrestores correctly. The clone carries the key; it carries the wrong value.The Architectural Reality
ai/ConfigProvider.mjskeys#leafMetadataRegistryby dotted path, and#applyEnvLayerre-applies throughsetData(leafPath, value).setEnvOverride/load/refreshEnv, never live-per-read (ai/ConfigProvider.mjs:129-130).snapshotAiConfigis the by-construction answer and is already the majority idiom (11 specs). Its contract exists precisely because it captures by resolved value.buildScripts/util/check-aiconfig-test-mutation.mjsalready owns this hazard family, already has an acorn-backed code/comment mask, anESCAPE_MARKER, and a config-root shape — so the rule belongs there rather than in a new script.The Fix
A second rule in the existing lint, sharing its mask, escape marker and root shape:
CLONE_CAPTURE—Neo.clone(<config-shaped root>), leaf-vocabulary-independent.findCloneCaptures(content)— mirrorsfindDbPathMutations.scanFileContent(file, content, {allowlist})— applies the allowlist to Class-A only.Naming: deliberately NOT called "Class B" — that label is already spent in this file on config-varying leaves held out of scope. This is a third, orthogonal axis: Class-A asks which leaf a test writes; this asks whether the undo can work at all.
Contract Ledger Matrix
CLONE_CAPTURE(new export)Neo.clone(+ config-shaped rootfindCloneCaptures(new export)[{line, text}], mask + escape-marker honoured@summaryscanFileContent(new export){dbPathHits, cloneHits}; allowlist gates Class-A only@summaryALLOWLISTDecision Record impact
aligned-with ADR 0019— the B4 shared-singleton discipline. This adds mechanical enforcement for a mechanism ADR-0019 §3 already forbids in principle; it neither amends nor challenges it.Acceptance Criteria
Neo.cloneof a config-shaped root at any path depth, including aliased roots (mailboxAiConfig,Memory_Config).aiConfigDefaultstrailing-boundary case, the sanctionedsnapshotAiConfigcall, string-literal or comment context, or a line bearing the escape marker.0 new violationson stdout, even when no Class-A violation exists.Out of Scope
#15874allowlist burndown. Its acceptance test is "isolate by construction, then delete the allowlist entry"; this ticket deletes no entry and must not be read as satisfying it.#15886— the destroy-before-initAsynclifecycle leak, separately owned.Avoided Traps
A guard against subtree replacement was designed, measured, and rejected. The rationale it rested on — "replacing
AiConfig.orchestrator.mlx = {…}destroys the reactive leaves beneath the node" — is false, and it was my own claim, adopted downstream by a peer before either of us measured it. Measured: node replacement restores correctly on a 3-key node and on a 16-key node, and a bounded env re-resolution still reaches the replacement, because the binding is registry-keyed by dotted path rather than held on the node object. Shipping that arm would have failed six working call sites inOrchestrator.invariants.spec.mjs, and a gate whose false positives dominate gets disabled rather than obeyed.A "node vs leaf" oracle was reported as impossible, and that was wrong.
ai/scripts/lint/lint-config-template-ssot.mjsalready parses the declaration surface, and its two classification branches are separate at the point of decision — only the namespace branch pushes ontostack, so only it can have declared children:Verified on all six witnesses that mattered (
orchestrator.mlx→ NODE,issueSync.discussionDenylist→ LEAF) and across the complete 8-member object-leaf bucket. The original resolution attempt failed only because it searchedai/configBase.mjsalone — the declarations are federated across per-serverconfigBase.mjsfiles. Recorded because the discriminator is now cheap for whoever needs it next; this ticket does not consume it.Related
Origin Session ID: 87f453f9-aa80-4487-9ed1-b5d91e052c43
Retrieval Hint:
query_raw_memories("AiConfig restore capture Neo.clone unresolved default")· commite9b158d6bf