ADR 0019: AiConfig is the reactive Provider SSOT for Agent OS config
The Brain's configuration (
AiConfig+ its child providers) is a reactive hierarchicalNeo.state.Providertree and the single source of truth. Read resolved leaves at the use site; never re-implement, alias, export, pass-along, mutate, or defend against it. This ADR is the read-gate: the sharp distillation you consult instead of the three primitive files, and the sanctioned-pattern reference a reviewer + the lint check against.
| Attribute | Value |
|---|---|
| Status | Draft (proposed at Discussion #12453 graduation; Accepted on human merge of the implementing PR) |
| Author | @neo-opus-grace (Claude Opus 4.8) drafting; architecture via Discussion #12453 swarm |
| Graduated from | Discussion #12453 — "AiConfig is a reactive Provider SSOT — eliminate the ai/-wide read-then-re-implement antipattern cluster" (cross-family quorum: Claude [AUTHOR_SIGNAL] + GPT [GRADUATION_APPROVED] + GPT §5.2 STEP_BACK) |
| Implementation | Epic #12456, sub #1 (#12457 — this ADR + the turn-loaded AGENTS.md trigger); sub #2 = the fail-build lint |
| Supersedes | the implicit assumption that ai/ config values may be re-derived / aliased / threaded / mutated (the #12420 antipattern cluster) |
| Informs | every PR touching ai/ config; the SSOT lint (sub #2); future config-leaf authoring + review |
| Decision Record relations | depends-on ADR 0005 (ADR-at-graduation); aligned-with ADR 0007 (compaction taxonomy: trigger in the turn-loaded layer, depth here); complements (does NOT replace) the public learn/agentos/AiConfigModel.md configuration-model guide |
| Anti-anchor for | the #12420 failure mode — pattern-matching broken config code without grasping the Neo.state.Provider primitive |
1. Context
We made AiConfig a Neo.state.Provider (ai/ConfigProvider.mjs) to simplify Agent OS config — one reactive hierarchical SSOT instead of scattered env-reads and hand-rolled cascades. Then PR #12420 re-implemented, aliased, threaded, and mutated it across ai/, and a careful reviewer (@neo-opus-grace) approved it twice, catching 0 of the 4 real defects @tobiu then found (cycle-3 over-engineering · cycle-4 wrong-layer · the −90 formulas · a test→live-DB bleed).
The empirical lesson is not "be more careful" — that is falsified (4/4 missed across 2 doc-prepared reviews). It is the broken-window root: a pattern-matcher with no grasp of the sanctioned mechanism has only the broken code to match. This ADR is that mechanism, made readable; the turn-loaded trigger makes reading it un-skippable; the lint (sub #2) makes the mechanical subset un-bypassable.
2. Decision
AiConfig and its child providers are the single source of truth for Agent OS config. READ resolved leaves at the use site. Never re-implement, alias, export, pass-along, mutate, or defend against the SSOT.
2.1 How the primitive works (read this instead of the 3 code files)
ai/ConfigProvider.mjs extends Neo.state.Provider — every config is a reactive state provider; they compose into one tree. (Primitive line-anchors below are for someone changing the primitive itself; consumers do not need them.)
leaf(default, env, type)declares one value (ai/ConfigProvider.mjs:52).ConfigProvider.#applyEnvLayer(:201) already readsprocess.env[env], decodes it bytype(viaNeo.util.Envparsers), and applies it when set — the leaf owns env-override-with-default. A manualhasEnvValue('NEO_X')check re-implements the leaf's internal env-resolution: it is the fingerprint of not understandingleaf().- Hierarchy / realm chain. Tier-1
Neo.ai.Configis the realm root; each per-server config is a child —ai/ConfigProvider.mjsoverridesgetParent()(:268) to return the Tier-1 singleton (the Brain has no component tree).Provider#getOwnerOfDataProperty(src/state/Provider.mjs:376) checks local#dataConfigsfirst, then walks up the parent chain: reads resolve override-else-inherit; writes bubble to the owner. No layer holds a copy of another's data. (The same hierarchy the Body uses for component state — see examples/stateProvider/advanced: read-up, write-to-owner.) formulasare lazyEffects (src/state/Provider.mjs:177→ first run inonConstructed) for genuine reactive computed values — re-run when a dependency accessed via the hierarchical proxy changes. They are NOT a place to re-derive a leaf with env-checks.- The hierarchical data proxy (
src/state/createHierarchicalDataProxy.mjs) resolves nested paths; its get-trap registersEffectManager.getActiveEffect()?.addDependency(config)(:58, automatic dependency tracking) and its set-trap routes assignments to the owning provider'ssetData(:99). Consumers readAiConfig.engines.chroma.dataDir— the Provider resolves; you read. (This routing is why B4 writes are mechanically dangerous — see §4.) data_is amerge: 'deep'descriptor (src/state/Provider.mjs:61). An operator overlay (config.mjs) is a thin child of deltas over the canonical template (config.template.mjs); advancing to a newer template is inheritance, not a source-level merge — never parse/splice config source to reconcile them.- Env precedence is bounded — re-resolved at construct /
setEnvOverride/load/refreshEnv, not live-per-read (a port that silently moves without a coordinated restart is a footgun, not a feature).
3. Antipattern catalog (the sanctioned-pattern reference)
A reviewer checks a config-touching diff against this list; the lint (sub #2) mechanizes the flaggable subset. Each ID is tagged [live-on-dev] (an actual cleanup target) or [#12420-proposed] (existed only on the superseded PR #12420 branch — prevented by not-merging #12420 + the lint, NOT a cleanup target). The tag is V-B-A'd: e.g. hasEnvValue = 0 occurrences on dev.
Group A — re-implementing the Provider's resolution
| ID | Antipattern | Tag | Sanctioned form |
|---|---|---|---|
| A1 | module-level re-derivation (const DB_PATH = process.env.X || path.join(...)) |
[live: daemons :53-54/:36, deploy] |
read AiConfig.X.Y at the use site |
| A2 | imperative cascade hooks (afterApplyEnvLeaf) |
[#12420-proposed] |
a formula (only if genuinely computed) or a plain leaf derivation |
| A3 | over-engineered resolution helpers (resolveAiDataRoot) |
[#12420-proposed] |
the leaf's env-binding already resolves |
| A4 | inline process.env.UNIT_TEST_MODE ? test : prod inside a leaf |
[live: #12451] |
declarative leaf; test-mode resolved by construction |
| A5 | a hasEnvValue(name) helper |
[#12420-proposed] |
delete it — leaf(default, env, type) owns the env-check |
| A6 | leaf+formula duplication (one path defined in both) | [#12420-proposed] |
one definition — the leaf |
| A7 | a formula re-implementing the leaf's env-resolution | [#12420-proposed] |
drop the env knob (likely YAGNI) → path.join(data.root, …) |
| A8 | storagePaths.graph 4-branch tangle (:memory: + two env vars, one read direct from process.env, + derive) |
[#12420-proposed] |
one leaf + one derivation; test-mode by construction |
| A9 | formulas for plain path-joins |
both | a leaf/derivation; formulas = reactive computed values only |
Group B — indirection AROUND the SSOT (even when reading it)
| ID | Antipattern | Tag | Sanctioned form |
|---|---|---|---|
| B1 | exporting config values/subtrees (export const X = AiConfig.Y) |
[live: TaskDefinitions.mjs] |
consumers import AiConfig and read at use site |
| B2 | const X = AiConfig.Y pointers |
review-only | read inline — alias only if used 3+ times in one scope |
| B3 | defensive ?. on AiConfig reads |
[live-on-dev] |
the SSOT guarantees the tree; let it fail loud |
| B4 ⭐ | SAFETY-CRITICAL — runtime writes to AiConfig (see §4) |
[live: ~21 test files; #12435] |
tests isolate by construction (UNIT_TEST_MODE); NEVER mutate the shared singleton |
| B5 | passing AiConfig values into other consumers' configs (Orchestrator → buildTaskDefinitions({chromaPort, …}), 14 threaded args) |
[live: daemons] |
the consumer imports AiConfig and reads it (see §5 for the C1×B5 resolution) |
Group C — boundary / duplication
| ID | Antipattern | Tag | Sanctioned form |
|---|---|---|---|
| C1 ⛔ | NEO imports ONLY in thread-entrypoints (ZERO tolerance — import Neo/_export/AiConfig in a non-entrypoint script can BREAK things) |
[live: TaskDefinitions.mjs] |
genuine non-entrypoint consumers use a pure-defaults module (literals + env-names, no Neo import) — see §5.5 |
| C2 | duplicated primitives (chromaClientPrimitives re-implements the embedding dummy-fn; chromaTestIsolation hidden default DB names) |
[live-on-dev] |
fold into the SSOT leaves |
| C3 | tests import config.mjs (overlay) not config.template.mjs (canonical) |
[live: #11976] |
tests import the canonical template |
V-B-A classification correction (@neo-opus-4-7,
devline-evidence — Discussion #12453DC_kwDODSospM4BBgm5): theai/daemon entrypoints (bridge/daemon.mjs:3-6,orchestrator/daemon.mjs:25-27) legitimatelyimport Neo/_export/AiConfig— they ARE entrypoints, so their path re-derivation is A1 (re-derivation with AiConfig already in scope), NOT a C1 violation; the "can BREAK things" framing applies to non-entrypoints only. The single genuine C1×B5 site isTaskDefinitions.mjs(no Neo import;export const DEFAULT_DB_PATH). The fan-out census MUST tagA1-with-AiConfig-importedvsgenuine-C1so daemons are not over-counted as C1.
Group D/E — why this keeps happening (root, compressed)
D1 premise-gate failure (review checks template-compliance/tests-green, not solution-shape) · D2 reviewing the diff, not the model · D3 correlated same-family blind-spot · E1 broken-window · E2 codify-don't-promise · E3 operating without understanding the primitive. The structural answer to all of D/E is this ADR + the lint — not reviewer diligence (empirically insufficient).
4. The danger (B4, safety-critical)
A test that mutates the shared AiConfig singleton to point at its own DB —
aiConfig.storagePaths.graph = testDbPath;
if (!aiConfig.storagePaths) aiConfig.storagePaths = {}; // B3 defensive subtree-creation
aiConfig.engines.chroma.database = `graph-service-test-${process.pid}-${Date.now()}`;
— is how test data bleeds into live DBs. The proxy set-trap routes that assignment to setData on the owning provider (the shared singleton), so failed cleanup, a shared process, or test-ordering means the next consumer (or a non-mutating path) reads the test DB — or a live read lands on test state. This is the #12335 orphan-incident mechanism (~1,281 orphans purgeTestCollections reclaims), already fixed at great cost by chromaTestIsolation (isolate by construction under UNIT_TEST_MODE) — and bypassed in #12420 by hand-rolled config-mutation. The lesson did not stick because nothing mechanical enforced it.
Rule: a test NEVER mutates the shared AiConfig; isolation is by construction. Lint (sub #2, fail-build): flag any aiConfig.<path> = … runtime assignment, especially in tests — one check prevents the whole orphan-bleeding class. (V-B-A: ~21 test files currently do this; ticket #12435.)
5. Sanctioned patterns (the fix, in one place)
- Read at the use site:
const dir = AiConfig.engines.chroma.dataDir;— no export (B1), no once-used alias (B2), no?.(B3), no threading into other consumers (B5). - Leaves are declarative:
leaf(default, env, type). No inline env-ternaries (A4), nohasEnvValue(A5). - Formulas only for genuine computed values — reactive on real deps. Never to re-derive a leaf (A6/A7) or for a plain path-join (A9); a path-under-root is a derivation, not a formula.
- Tests isolate by construction (
UNIT_TEST_MODE→ the config resolves the test DB). Never mutate the shared singleton (B4). - The C1×B5 sanctioned shape (V-B-A'd against
dev— most "daemon C1" sites are actually A1):- Entrypoints (incl. the
ai/daemons) importAiConfigand read at the use site. The daemons alreadyimport Neo/_export/AiConfigand work — so a daemon re-deriving a path is A1, not C1. Fix: readAiConfig.X.Ydirectly. - Genuine non-entrypoint helpers (e.g.
TaskDefinitions.mjs— imported by the orchestrator entrypoint, with no Neo import of its own): a pure-defaults module — literals + env-var names only, no Neo import — carrying the same defaults the leaves declare. This is the one true C1×B5 locus. - An entrypoint-injected value object is acceptable only at a narrow, explicitly-named bootstrap boundary — not license for generic pass-along plumbing. Do not add a read-only accessor unless it stays pure / no-Neo-import.
- (Folds @neo-opus-4-7's bootstrap-weight reframe: the question was never "can it import?" — the daemons prove it can — but "should a frequent lightweight helper pay full-framework bootstrap weight to read a path?")
- Entrypoints (incl. the
- Overlay = thin child of deltas over the canonical template; never parse/splice config source to reconcile (deep-merge inheritance handles it).
6. V-B-A Pre-Flight for future authors (the read-gate)
Before authoring or reviewing any ai/ config work, you MUST:
- Read this ADR (the AGENTS.md turn-loaded trigger makes this un-skippable).
- If you are changing the primitive itself (not just consuming it), additionally read
src/state/Provider.mjs+src/state/createHierarchicalDataProxy.mjs+ai/ConfigProvider.mjsand cite them. - V-B-A your diff against §3/§5's sanctioned forms — not against the surrounding (possibly broken) code.
7. Codification stack & sequencing
- This ADR (authority) + the one-line AGENTS.md turn-loaded trigger — sub #1 (#12457), merge first (per ADR 0007: high-frequency trigger in the turn-loaded layer, depth here; net loaded-bytes negative — one line replaces a 3-file read).
- The fail-build lint — sub #2, encodes §3's flaggable subset (A1·A4·A5·A6·A7·B1·B3·B4·B5 partial·C1; merges #12451).
- Fan-out inventory (Diamond 1) — AFTER this ADR merges (operator-gated), parallel Claude-family subagents sweep
ai/against this ADR → exhaustive[live-on-dev]instance census. - Cleanup subs (Diamond 2) — scoped from the census, each citing this ADR; folds in #12435, #12438, #11976, #12452.
8. Consequences
Positive: one readable authority replaces a 3-file primitive dig; the read-gate is mechanically enforced (turn-loaded trigger); the lint prevents recurrence — including the safety-critical live-DB bleed — without relying on reviewer diligence empirically shown to fail (4/4 #12420 misses). Negative: substrate cost of one ADR + one turn-loaded line — mitigated: the line replaces a 3-file read (net-negative loaded-bytes for any agent touching ai/ config), and the catalog is dual-use as the lint spec. Risk: the [live-on-dev] vs [#12420-proposed] tags are point-in-time; the fan-out (step 7.3) re-verifies against dev at inventory time.
9. Related
- Discussion #12453 — full archaeology trail + the divergence matrix (incl. @neo-opus-4-7's OQ3 C1=bootstrap-weight V-B-A,
DC_kwDODSospM4BBgm5). - Epic #12456 — workstream coordination; #12457 — this sub.
- #12420 — superseded empirical anchor (do-not-merge). #12335 — orphan incident (the B4 danger).
- Folded subs: #12435 (B4), #12438 (A1), #11976 (C3), #12452 (config primitive rename), #12451 (config-leaf lint → sub #2).
- ADR 0005 (ADR-at-graduation), ADR 0007 (compaction taxonomy).
ai/ConfigProvider.mjs,src/state/Provider.mjs,src/state/createHierarchicalDataProxy.mjs— the primitive.learn/agentos/AiConfigModel.md— the public configuration-model guide (docs-reader audience). This ADR is the maintainer-facing complement (antipattern catalog + safety-critical danger + read-gate); the two cross-reference and neither replaces the other.
Origin Session ID: 3ecb40bf-bfef-40b1-8693-a8aae5afa1b7
Retrieval Hint: query_raw_memories("AiConfig reactive Provider SSOT antipattern read-gate ADR 0019") or commit-range anchor on this ADR's first commit.