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
- 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.
- 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.
- 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.
- 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
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
Context
A config leaf added to a per-server
config.template.mjsreadsundefinedin every existing worktree until its gitignoredconfig.mjsoverlay is refreshed. Surfaced live in#15234: two leaves landed in the template, every gate passed —ai:lint-config-template-ssotgreen, 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-#15264snapshot carries 80. The missing three arelaneLandscapeCensusPageLimit,laneLandscapeCensusMaxPages,laneLandscapeRelationEdgeLimit. Legitimate snapshot drift — not a false positive, not a Tier-1/configBaseside 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 narrowisOnlyServerMaterializationDriftcase is safe and preserves operator edits. Every broader per-server drift executesawait fs.writeFile(activePath, activeTemplateSrc)— the whole materialized template overconfig.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-configautomatically 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-43is 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-25states 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#12102closed.The Architectural Reality
ai/mcp/server/<name>/config.mjs— gitignored per-server overlay, currently a full standalone snapshot of itsconfig.template.mjs, differing only in importing../../../config.mjsinstead 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
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.#15229delivered for Tier-1. Freshconfig.mjsoverlays become delta-only subclasses and inherit later leaves by construction — there is no drift left to detect.--writecreates 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.Contract Ledger Matrix
initConfigs({--migrate-config})per-server branchinitServerConfigs.mjs:788-800:284-307,854-912must be inverted — it currently locks the overwritePrimaryRepoSyncService.runConfigMigrate:645-695config.template.mjs#15229Tier-1 precedentAiConfigModel.mdconfig.mjsoverlayAiConfigModel.md:27-43AiConfigModel.mdai:lint-config-template-ssotDecision Record impact
aligned-with ADR-0019. Realizes the thin-child disposition recorded when#12102closed and extends the proven#15229mechanism to the per-server layer. No new config model is introduced.Acceptance Criteria
--migrate-configwrites nothing and returns a typed migration-required outcome; the sync cascade records it without aborting a successful pull.initServerConfigs.spec.mjs:284-307,854-912is inverted: the suite pins no-destructive-overwrite, where it currently pins the overwrite.PrimaryRepoSyncService.runConfigMigratenever rewrites a per-server overlay unattended — pinned by spec, since this is the unattended path.#15229base + thin singleton split; discovery-driven, no memory-core-only case, a fifth server covered on arrival.config.mjsis a delta-only subclass that inherits a newly added template leaf with no migration step.laneLandscapeCensusPageLimit,laneLandscapeCensusMaxPages,laneLandscapeRelationEdgeLimitafter conversion, and a sentinel operator override survives byte-for-byte in declared value.--writewrites a colocated backup; custom leaves survive; non-renderable differences reported; failure leaves the overlay untouched; second run is a no-op.Out of Scope
ai/config.mjssemantics (#15229owns it, merged).bootstrapWorktree.mjscopy behavior.Avoided Traps
--migrate-configas 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.Operational rule until this lands
Do not run
--migrate-configon 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
#15229direction 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,#14674use the root's vocabulary (base split, thin child, overlay).#15229merged 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-25names 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 sharedcodeMaskfor 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 at2026-07-16T18:32Z: no overlapping claim. Semantic sweep returnedQUERY_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 rewriteOrigin Session ID:
ad475320-6bdc-4555-ba3f-b78d51de0b17