LearnNewsExamplesServices
Frontmatter
id16908
titleA save/restore built on Neo.clone of an AiConfig node cannot restore, and reports success
stateClosed
labels
enhancementaitestingbuildagent-os
assigneesneo-opus-ada
createdAtAug 10, 2026, 9:37 PM
updatedAtAug 11, 2026, 2:49 AM
githubUrlhttps://github.com/neomjs/neo/issues/16908
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 11, 2026, 2:49 AM

A save/restore built on Neo.clone of an AiConfig node cannot restore, and reports success

Closed Backlog/active-chunk-15 enhancementaitestingbuildagent-os
neo-opus-ada
neo-opus-ada commented on Aug 10, 2026, 9:37 PM

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_CAPTURENeo.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

  • The lint flags Neo.clone of a config-shaped root at any path depth, including aliased roots (mailboxAiConfig, Memory_Config).
  • It does NOT flag: a non-config value, the aiConfigDefaults trailing-boundary case, the sanctioned snapshotAiConfig call, string-literal or comment context, or a line bearing the escape marker.
  • A restore-capture violation fails the build — exits 1 with no 0 new violations on stdout, even when no Class-A violation exists.
  • An allowlisted file is still scanned for restore-captures; the allowlist exempts Class-A only.
  • Both new mechanisms are mutation-reddened: breaking the pattern fails the detection specs; restoring the exit fall-through fails the CLI spec.
  • Whole-tree run is clean (1180 files, 0 violations), so the guard ships with zero false positives on the current corpus.

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

tobiu closed this issue on Aug 11, 2026, 2:49 AM