---
number: 12164
title: 'feat(ai): aiConfig meta-leaf tree + Provider-based BaseConfig (#12103)'
author: neo-opus-ada
state: MERGED
createdAt: '2026-05-29T03:23:34Z'
updatedAt: '2026-05-29T09:35:43Z'
closedAt: '2026-05-29T09:35:38Z'
mergedAt: '2026-05-29T09:35:38Z'
head: claude/serene-elgamal-5add8c
base: dev
url: 'https://github.com/neomjs/neo/pull/12164'
---
Resolves #12103
Resolves #12104
Related: #12101

Authored by Claude Opus 4.8 (Claude Code). Session efd8dc2e-2052-4089-814a-ab22cd8c6a62.

FAIR-band: in-band — operator-directed lane (Epic #12101 execution requested directly by @tobiu).

Stages 1 + 2 of the aiConfig greenfield epic, landed **together** because they are build-coupled: rewriting `BaseConfig` breaks the live `config.template` files until the templates migrate, so shipping them apart would leave an intermediate broken build. `ai/BaseConfig.mjs` now **extends `Neo.state.Provider`** instead of hand-rolling a Proxy over `core.Base`, and the six `config.template.mjs` files (Tier-1 + 5 MCP servers) collapse the parallel `defaultConfig` + `envBindings` trees into a single `metaTree` of `{env, default, parse}` leaves. Consumer-side cleanup (Stages 3–6) is deferred to follow-ups — the new proxy preserves read/write-through and `Neo.util.Env` is untouched, so existing consumers keep working.

Evidence: L2 (new BaseConfig.spec 9/9 + full `ai/` unit suite at dev-baseline parity, zero net-new failures) → L1 required (config-shape contract, unit-covered). Runtime MCP-server restart + operator `config.mjs` regen is post-merge follow-up (below).

## Deltas from ticket
- **S1 + S2 combined into one PR** (the epic decomposes them separately) — they are build-coupled; shipping apart leaves an intermediate broken build. Operator-approved.
- **Design B** chosen at the `internalSetData` choke point (metadata-path-first type-validation, no `Env.mjs` refactor) over **Design A** (extract pure value-coercers from `Env`) — flagged for reviewer; resolves a deferred Stage-1 OQ.
- **Consumer codemod (Stages 3–6) deferred** to follow-up PRs — the new proxy + untouched `Env.mjs` keep existing consumers working, so cleanup is non-blocking.

## What shipped
- **S1 (#12103):** `ai/BaseConfig.mjs extends Neo.state.Provider`. `compileMetaLeaves` (tree → reactive `data` + a **separate** `#leafMetadataRegistry`), `internalSetData` validation override (metadata-path-first), `set` / `setEnvOverride` / path-scoped `observeConfig`, construction-time env-layer, adapted `createConfigProxy` (read-through + set-trap → `setData`, bound to the real instance). New `test/playwright/unit/ai/BaseConfig.spec.mjs` (9 cases).
- **S2 (#12104):** all six `config.template.mjs` → `metaTree`; coupled specs updated to the new API.

## Design decision (flagged for review — resolves a deferred OQ)
Parser-validation at the `internalSetData()` choke point is **Design B: metadata-path-first type-validation** (lenient, warn-on-mismatch). `Neo.util.Env` parsers stay env-readers for the construction-time env-overlay; no `Env.mjs` refactor, keeping Stage 1 scoped. The alternative (**Design A**: extract pure value-coercers from `Env` and coerce at the choke point) is a larger cross-`Env.mjs` change — reviewer's call on upgrade-now vs. defer.

## Body-tier lineage (AC-Epic-4)
Extends, does not reinvent: `Neo.state.Provider` (#6131 base / #6965 + #6973 `createHierarchicalDataProxy` / #6974 Effect reactivity / #6981 set-trap→setData / #6985 deep-merge `data_`). PR-review checkpoint (grep `src/state/` before accepting new machinery): satisfied — no parallel reactivity added.

## MCP config-template change (per mcp-config-template-change-guide)
- **Changed keys:** none added or removed. The `defaultConfig` + `envBindings` parallel trees are reshaped into one `metaTree` per leaf; all values + env-var bindings + parsers are preserved (verified via per-template leaf-count parity + compiled-defaults deep-equality).
- **Local `config.mjs` follow-up (REQUIRED after merge):** each clone's gitignored `config.mjs` overlay is stale (old shape) and must be regenerated from the new templates — `node ai/scripts/setup/initServerConfigs.mjs` (auto-runs via `npm ci` / `prepare`; pass `--migrate-config` when an overlay already exists). Graceful overlay-advancement is Stage 0 (#12102).
- **Harness restart:** REQUIRED — MCP servers load config at boot; the new shape + regenerated `config.mjs` need a restart.
- No `config.mjs` committed (gitignored; CI regenerates from templates via `prepare`).

## Consensus / Signal Ledger (§6.1.1 — Discussion #12100 graduation)
| Family | Identity | Signal |
|---|---|---|
| anthropic | @neo-opus-ada | author |
| openai | @neo-gpt | `[GRADUATION_APPROVED_INHERITS_WITH_STAGE1_REFINEMENT]` — 8+ anchored confirmations v6→v14 |
| google | @neo-gemini-pro | `[GRADUATION_DEFERRED]` converging (OQ8 + OQ9 landed; not quorum-required) |

**Unresolved Dissent:** none. **Unresolved Liveness:** none. Epic #12101 body now carries the full `## Discussion Criteria Mapping` (6 rows) per @neo-gpt's epic-review.

## Test Evidence
- `npm run test-unit -- test/playwright/unit/ai/BaseConfig.spec.mjs` → **9/9** (caught + fixed a real first-cut `observeConfig` subscribe-id bug).
- `npm run test-unit -- test/playwright/unit/ai/` → **2074 passed / 15 failed**; the 15 are pre-existing flaky env/DB specs (checkSunsetted, TextEmbedding, SessionSummarization, backup — need live Ollama/Chroma/populated DB), **identical to the dev baseline** (16–17, same set). **Zero net-new failures** — verified by stashing this branch and running the same subtree on clean dev.
- `node --check` on all 6 templates + `BaseConfig.mjs`; `git diff --check` clean; lint-staged (whitespace + shorthand) passed at commit.
- Full unit suite runs in CI (builds `dist/parse5.mjs` first, which this worktree could not).

## Architectural findings (FYI for reviewer — tracked separately)
- Reactive `Neo.state.Provider` semantics surfaced during spec migration: writing `undefined`/`null` to a parent leaf is a one-way door (reactivity-bubbling breaks on a non-object parent); `delete` on the hierarchical proxy is a no-op (no `deleteProperty` trap); `Config.set(undefined)` is ignored. Specs were adapted to fresh-instance / explicit-`null` patterns.
- `config.template.mjs` and `config.mjs` register the same className → collide in `unitTestMode`. Tests assert the **template** (stable, predictable) via text-read or fresh `Neo.create(BaseConfig, {metaTree})` instances, never the user-mutable `config.mjs`.

## Deferred follow-ups (Epic #12101)
S3 #12105 (orchestrator delegation-getter codemod), S4 #12106 (services / MCP consumer codemod), S5 #12107 (retract `Env.mjs` consumer exports), S6 #12108 (delete legacy `applyEnv()`), S0 #12102 (initServerConfigs overlay-merge).

## Post-Merge Validation
- [ ] Each clone regenerates `config.mjs` (`npm run prepare` / `initServerConfigs.mjs`) + restarts MCP servers.
- [ ] Live MCP servers boot with the new config shape (logger, transport, ports, auth, chroma all resolve).


## Comments

### `@github-actions` commented on 2026-05-29T03:23:42Z

## 🚨 Agent PR Body Lint Violation

@neo-opus-ada — your [PR body on PR #12164](https://github.com/neomjs/neo/pull/12164) does not match the pull-request template structure.

**Required action**: read `.agents/skills/pull-request/SKILL.md` BEFORE editing the PR body. The skill points at:
  - Minimum-viable PR body structure: `.agents/skills/pull-request/references/pull-request-workflow.md §9`
  - Self-Identification mandate: `.agents/skills/pull-request/references/pull-request-workflow.md §5`

Do NOT compose a substitute template or hallucinate section headings. The validator
checks more structural anchors than this comment names. The only reliable path to
passing is reading the actual workflow file and following its structure.

**Diagnostic hint**: visible anchors appear present but the structural template anchors do not.

<details>
<summary>Visible anchors missing (full list)</summary>

(none — visible layer passed; invisible structural layer caught the miss)

</details>

<sub>This is the CI tool-boundary lint companion to PR #11494's MCP `manage_pr_review` validator and PR #11502's `agent-pr-review-body-lint.yml` reviewer-side lint.
Resolves #11501.</sub>

---

### `@neo-opus-ada` commented on 2026-05-29T07:26:31Z

**Input from Claude Opus 4.8 (Claude Code):**

> ❋ ## Review response — core.Base method-shadowing (fixed in ede3e6c43)
> 
> Two real, dangerous gaps confirmed + fixed:
> 
> **1. `set()` shadowed `core.Base#set(values)`** (the canonical batch config setter). Removed — the data-mutation primitive is the inherited `setData(path, value)` (Provider). Option F's "set" sub-point → `setData`.
> 
> **2. `observeConfig()` shadowed `core.Base#observeConfig(publisher, configName, fn)`** — and `Provider#createBinding` calls it internally (`Provider.mjs:270`, `isDestroying` cleanup). My override would have broken Provider's own binding teardown. Renamed to `observeData(path, fn)`. Option F's "observeConfig" sub-point → `observeData`.
> 
> Added a regression-guard spec asserting neither core method is shadowed. **Root cause:** I leaned on research-subagent summaries of Provider's data/reactivity mechanics instead of reading `core.Base.mjs` + `Provider.mjs`'s full inherited public-method surface myself. The original specs passed because they encoded my (wrong) model — they tested the shadows.
> 
> **Known limitations (transparent — your call on in-scope-here vs follow-up):**
> - `createConfigProxy` implements `get`/`set` but not `ownKeys`/`has`/`getOwnPropertyDescriptor`, so `Object.keys(aiConfig)` / `'x' in aiConfig` / spread reflect instance members, not data leaves. (The native `createHierarchicalDataProxy` uses an empty target so it can freely report data keys; matching that on an instance-wrapping proxy runs into proxy-invariant constraints. No current consumer enumerates `aiConfig` — they read `aiConfig.X.Y`.)
> - `Provider#construct` sets `Neo.isUsingStateProviders = true` (inherited) — a UI-coupling side-effect for a Node/MCP config. Harmless in the MCP runtime; inherent to the extend-Provider mandate.
> - `internalSetData` validation (Design B) is lenient type-validation; polymorphic leaves like `keep_alive` (Number|String) emit a benign `expected Number, got String; keeping value` warning. Design A (Env value-coercers) would resolve it.
> - `Provider#toJSON()` returns `data: me.data` (the reactive proxy); `JSON.stringify(aiConfig)` would traverse it. Inherited Provider behavior; out of scope here.
> 
> CI re-running on ede3e6c43.

---

