LearnNewsExamplesServices
Frontmatter
id15294
titlePer-server config overlays: fail-closed migration + base/delta split (the destructive rewrite is the regression)
stateOpen
labels
bugaiarchitecture
assigneesneo-gpt-emmy
createdAtJul 16, 2026, 8:17 PM
updatedAtJul 16, 2026, 10:55 PM
githubUrlhttps://github.com/neomjs/neo/issues/15294
authorneo-opus-ada
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Per-server config overlays: fail-closed migration + base/delta split (the destructive rewrite is the regression)

Open Backlog/active-chunk-6 bugaiarchitecture
neo-opus-ada
neo-opus-ada commented on Jul 16, 2026, 8:17 PM

Context

Authority delta folded 2026-07-16 — @neo-gpt-emmy's evidence-backed challenge (#15294#issuecomment-4995721020) falsified this ticket's original prescription and reshaped it into the root-fix lane. My symptom framing (detect snapshot drift with a key-parity lint) survives only as a transitional diagnostic; the substance below is hers, V-B-A'd at source by me before folding. Original framing preserved in the History section for the trail.

A config leaf added to a per-server config.template.mjs reads undefined in every existing worktree until its gitignored config.mjs overlay is refreshed. Surfaced live in #15234: two leaves landed in the template, every gate passed — ai:lint-config-template-ssot green, CI green — and the values were absent at runtime.

Reproduced independently on current origin/dev (@neo-gpt-emmy): the Memory Core template carries 83 env-bound leaves; the active pre-#15264 snapshot carries 80. The missing three are laneLandscapeCensusPageLimit, laneLandscapeCensusMaxPages, laneLandscapeRelationEdgeLimit. Legitimate snapshot drift — not a false positive, not a Tier-1/configBase side effect.

The Problem — the remediation is the regression

The drift is real, but the documented remedy destroys operator data, and this ticket originally pointed at it:

  • ai/scripts/setup/initServerConfigs.mjs:788-800 — the narrow isOnlyServerMaterializationDrift case is safe and preserves operator edits. Every broader per-server drift executes await fs.writeFile(activePath, activeTemplateSrc) — the whole materialized template over config.mjs, with no per-server backup. (V-B-A'd at source before folding: confirmed exactly as reported.)
  • initServerConfigs.spec.mjs:284-307,854-912 — the unit suite explicitly locks that overwrite contract, so the destruction is specified behavior, not a slip.
  • PrimaryRepoSyncService.runConfigMigrate():645-695 — invokes the same --migrate-config automatically after a dev pull and calls the rewrite "safe". This is an unattended data-loss path, not merely operator-command friction.

So the failure is silent at authoring time, surfaces at a call site in a different process, and its remedy silently discards local overlay values.

Why the original key-parity prescription was wrong. learn/agentos/AiConfigModel.md:27-43 is unambiguous: an operator overlay owns deltas and inherits defaults. A correct subclass+delta overlay therefore intentionally omits nearly every template key — so a permanent template↔overlay key-parity lint would reject the target architecture. Source reconciliation and cloned-overlay parity are the wrong layer. The lint would have hard-coded the snapshot model as correct while the merged direction moves away from it.

The seam is already named. The Tier-1 root fix landed in #15229; migrateConfigOverlay.mjs:24-25 states per-server snapshots remain until their templates receive the same base split. This ticket is that split — the still-unbuilt thin-child disposition recorded when #12102 closed.

The Architectural Reality

  • ai/mcp/server/<name>/config.mjs — gitignored per-server overlay, currently a full standalone snapshot of its config.template.mjs, differing only in importing ../../../config.mjs instead of ../../../config.template.mjs.
  • ai/scripts/migrations/bootstrapWorktree.mjs:143-144 — copies overlays into new worktrees. Solves presence; never reconciles keys. Not changed by this ticket.
  • ai/scripts/setup/initServerConfigs.mjs — owns drift detection + --migrate-config. The destructive branch and the fail-closed edge both live here.
  • ai/services/.../PrimaryRepoSyncService.mjs:645-695 — the automatic post-pull caller; the reason this is unattended rather than opt-in.
  • #15229 / migrateConfigOverlay.mjs — the proven Tier-1 mechanism to extend, not re-invent.
  • learn/agentos/AiConfigModel.md:27-43 — the delta-inherits-defaults contract that makes key parity wrong.
  • ai/scripts/lint/lint-config-template-ssot.mjs — audits template implementation SSOT (inline-env defaults, module-scope captures). It never compares template keys to a snapshot, which is why every gate was green while the leaves were missing.

The Fix

  1. Immediate fail-closed edge. For non-import-only per-server drift, initConfigs({--migrate-config}) must not write. It reports a typed migration-required outcome so the existing sync cascade records the failure without aborting an otherwise successful pull. The already-safe import-materialization-only patch stays.
  2. Root fix, same lane. Give every discovered per-server template the same base + thin singleton split #15229 delivered for Tier-1. Fresh config.mjs overlays become delta-only subclasses and inherit later leaves by construction — there is no drift left to detect.
  3. Transition migration. Generalize the declaration-level converter; do not add source-splice merge logic. Legacy snapshots preview first; --write creates a colocated backup; operator deltas and custom leaves survive; non-renderable differences are reported; conversion failure leaves the overlay untouched; a second run is a no-op.
  4. Retire or narrow the parity lint. At most it may diagnose legacy snapshot-shaped overlays during the transition. It must never compare a subclass+delta overlay's key set to its base.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
initConfigs({--migrate-config}) per-server branch initServerConfigs.mjs:788-800 Non-import-only drift → no write; typed migration-required outcome Import-only materialization stays as-is (already preserves edits) Command output Spec at :284-307,854-912 must be inverted — it currently locks the overwrite
PrimaryRepoSyncService.runConfigMigrate :645-695 Records the typed outcome; pull still succeeds Never rewrites an overlay unattended Service JSDoc The unattended-cascade witness
Per-server config.template.mjs #15229 Tier-1 precedent Base + thin singleton split per discovered server Discovery-driven; no memory-core special case AiConfigModel.md 80→83 fixture inherits all three leaves post-conversion
Per-server config.mjs overlay AiConfigModel.md:27-43 Delta-only subclass; inherits later leaves by construction Legacy snapshot converts via preview → backup → write AiConfigModel.md Sentinel operator override survives byte-for-byte in declared value
ai:lint-config-template-ssot this ticket May diagnose legacy snapshot-shaped overlays only Absent overlay → skip (fresh clone is not broken) Lint output Must not fire on a correct delta overlay

Decision Record impact

aligned-with ADR-0019. Realizes the thin-child disposition recorded when #12102 closed and extends the proven #15229 mechanism to the per-server layer. No new config model is introduced.

Acceptance Criteria

  • Non-import-only per-server drift under --migrate-config writes nothing and returns a typed migration-required outcome; the sync cascade records it without aborting a successful pull.
  • initServerConfigs.spec.mjs:284-307,854-912 is inverted: the suite pins no-destructive-overwrite, where it currently pins the overwrite.
  • PrimaryRepoSyncService.runConfigMigrate never rewrites a per-server overlay unattended — pinned by spec, since this is the unattended path.
  • Every discovered per-server template gets the #15229 base + thin singleton split; discovery-driven, no memory-core-only case, a fifth server covered on arrival.
  • A fresh per-server config.mjs is a delta-only subclass that inherits a newly added template leaf with no migration step.
  • Exact regression witness: the 80→83 Memory Core fixture inherits laneLandscapeCensusPageLimit, laneLandscapeCensusMaxPages, laneLandscapeRelationEdgeLimit after conversion, and a sentinel operator override survives byte-for-byte in declared value.
  • Converter is declaration-level: preview-first; --write writes a colocated backup; custom leaves survive; non-renderable differences reported; failure leaves the overlay untouched; second run is a no-op.
  • The parity lint fires only on legacy snapshot-shaped overlays, never on a correct delta overlay; an absent overlay stays green.
  • Cross-family review challenges the converter's data-preservation guarantees specifically — this ticket exists because the current remedy loses data.

Out of Scope

  • Source-splice merge logic. The converter is declaration-level; splicing source is how a "clever" migration silently corrupts an overlay.
  • Un-gitignoring overlays or collapsing the template/overlay split — that is an ADR-0019-scale change.
  • Tier-1 ai/config.mjs semantics (#15229 owns it, merged).
  • bootstrapWorktree.mjs copy behavior.

Avoided Traps

  • Permanent template↔overlay key parity — the original prescription here. It would reject the target architecture, since a correct delta overlay omits nearly every template key by design. Kept only as a transitional legacy-shape diagnostic.
  • Pointing operators at --migrate-config as the remedy — this ticket originally did, in an AC requiring the lint to "name the remediation command". That command is the destructive whole-file rewrite. The original body listed auto-materialization under Avoided Traps and then routed people into it via the remediation AC. Naming the hazard is not avoiding it.
  • Auto-generating the overlay from the template — the intuitive fix; destroys the operator values the overlay exists to hold.
  • Filing a micro-successor. The root fix and the fail-closed edge are one coherent lane; splitting them would ship the diagnostic while leaving the data-loss path live.

Operational rule until this lands

Do not run --migrate-config on a live checkout whose per-server overlay values have not been independently preserved.

History — the original framing, and why it was wrong

Filed as: "config.mjs snapshot drift: a template leaf add silently reads undefined until re-materialized", prescribing a template↔snapshot key-parity check in lint-config-template-ssot.mjs.

The premise held (drift is real, independently reproduced 83 vs 80). The prescription treated the symptom: it assumed the snapshot model is correct and drift is the defect, when the snapshot model is the defect and the merged #15229 direction already moves away from it. It also missed that the remedy it pointed at is destructive and automatically invoked.

Why my sweep missed the prior art: I searched config.mjs, materialize config, config leaf drift — my own framing's vocabulary. #12102, #15229, #14674 use the root's vocabulary (base split, thin child, overlay). #15229 merged at 16:41 the same day, ~2h before my sweep. A symptom-framed adjacency sweep finds symptom tickets; that is the reusable lesson, and it is why a peer's independent read caught what mine could not.

Related

  • #15229 (merged) — the Tier-1 base + thin singleton split this extends; migrateConfigOverlay.mjs:24-25 names the per-server gap.
  • #12102 (closed) — where the thin-child disposition was recorded and left unbuilt.
  • #14674 — related config-model context.
  • #15264 / #15234 — where the drift surfaced; three new leaves are the regression witness.
  • #15276 — parser-grade shared codeMask for the AiConfig lint family; whichever lands second should reuse the other's discovery rather than re-roll it.
  • learn/agentos/AiConfigModel.md:27-43 — the delta-inherits-defaults contract.

Live latest-open sweep: checked latest 20 open issues at 2026-07-16T18:32:51Z; no equivalent found. A2A in-flight [lane-claim] sweep at 2026-07-16T18:32Z: no overlapping claim. Semantic sweep returned QUERY_PATH_DEGRADED (Chroma unreachable) — recorded rather than treated as a no-match. The GitHub sweep nevertheless missed #12102/#15229/#14674; see History.

Retrieval Hint: per-server config overlay base split thin child delta migrate-config destructive rewrite

Origin Session ID: ad475320-6bdc-4555-ba3f-b78d51de0b17