---
id: 11855
title: 'Sub 14: Centralize env-binding registry — ai/config/env.mjs + dotenv lift'
state: CLOSED
labels:
  - enhancement
  - ai
  - refactoring
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-23T17:58:45Z'
updatedAt: '2026-05-23T21:54:17Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11855'
author: neo-opus-ada
commentsCount: 0
parentIssue: 11831
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy:
  - '[x] 11854 Sub 13: Collapse over-engineered Orchestrator Service-DI (Class C field deletion + historical-context comment strip)'
blocking: []
closedAt: '2026-05-23T21:54:17Z'
---
# Sub 14: Centralize env-binding registry — ai/config/env.mjs + dotenv lift

## Parent Epic

#11831 — Sub 14. Closes the env-binding anti-pattern that survived Sub 1's masterclass refactor.

## Premise

Sub 1 (#11833) committed `ai/daemons/orchestrator/Orchestrator.mjs` to a consumer pattern of:
```javascript
get pollIntervalMs() {
    return Env.parseNumber(process.env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS, 'NEO_ORCHESTRATOR_POLL_INTERVAL_MS')
        ?? AiConfig.orchestrator.intervals.pollMs;
}
```

The env-var name is duplicated at every consumer (13 call-sites in Orchestrator alone; pre-survey will find more across `ai/`). Each consumer also re-implements the lookup-parse-fallback dance. The current `src/util/Env.mjs:12` JSDoc even codifies this pattern as canonical:
> Fallback policy lives at the consumer call-site (e.g., `Env.parseNumber(process.env.X, 'X') ?? AiConfig.X`).

Three failure modes this creates:
- **Rename drift** — change the env-var name in one spot, miss the matching string literal at the consumer; warning messages name a non-existent var.
- **Audit opacity** — no single file enumerates which env vars the system reads. Grep-based discovery only.
- **No .env-file integration** — `dotenv` is already a devDependency, but consumers read `process.env.X` directly; nothing wires `.env` → `process.env`.

## Prescription

Create `ai/config/env.mjs` (new file) with the shape:

```javascript
import 'dotenv/config';                 // .env file → process.env at module load
import Env from '../../src/util/Env.mjs';

// Single declaration per var: name → parser. Adding a new env var means
// one entry here, then `env.<NAME>` everywhere.
const bindings = {
    NEO_ORCHESTRATOR_POLL_INTERVAL_MS:                Env.parseNumber,
    NEO_ORCHESTRATOR_SUMMARY_SWEEP_INTERVAL_MS:       Env.parseNumber,
    NEO_ORCHESTRATOR_KB_SYNC_INTERVAL_MS:             Env.parseNumber,
    NEO_ORCHESTRATOR_BACKUP_INTERVAL_MS:              Env.parseNumber,
    NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_INTERVAL_MS:    Env.parseNumber,
    NEO_ORCHESTRATOR_DREAM_INTERVAL_MS:               Env.parseNumber,
    NEO_ORCHESTRATOR_GOLDEN_PATH_INTERVAL_MS:         Env.parseNumber,
    NEO_ORCHESTRATOR_SWARM_HEARTBEAT_INTERVAL_MS:     Env.parseNumber,
    NEO_ORCHESTRATOR_KB_SYNC_ENABLED:                 Env.parseBool,
    NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_ENABLED:        Env.parseBool,
    NEO_ORCHESTRATOR_BRIDGE_DAEMON_ENABLED:           Env.parseBool,
    NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED:         Env.parseBool,
    NEO_ORCHESTRATOR_GOLDEN_PATH_REPO_ENRICHMENT_ENABLED: Env.parseBool,
    // + all NEO_* vars discovered via pre-survey grep across ai/, buildScripts/, scripts subfolders
};

const env = {};
for (const [name, parse] of Object.entries(bindings)) {
    env[name] = parse(process.env[name], name);  // returns undefined when var absent (preserves ?? fallback chain)
}
export default env;
```

Consumer shape post-migration:
```javascript
import env from '../../config/env.mjs';

get pollIntervalMs() {
    return env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS ?? AiConfig.orchestrator.intervals.pollMs;
}
```

**Pre-survey** (Stage 0 of implementation):
- `git grep -nE "Env\.parse(Bool|Number|Port|Url|String)\(process\.env" --include='*.mjs'` to find every consumer
- `git grep -nE "process\.env\.NEO_" --include='*.mjs'` to find any direct-read consumers that bypass Env.parseX entirely (those may need separate handling or migration)

## Acceptance Criteria

1. `ai/config/env.mjs` created with binding registry covering all NEO_* vars discovered via pre-survey.
2. `dotenv/config` imported at module load — local `.env` files auto-loaded into `process.env`.
3. All consumers migrated from `Env.parseX(process.env.NAME, 'NAME')` → `import env from '...'; env.NAME` pattern.
4. `src/util/Env.mjs:12` JSDoc updated — recommends the registry pattern, not the consumer-side redundant call.
5. Unit test for `ai/config/env.mjs`:
   - Missing var: `env.NAME === undefined`
   - Invalid value: warn fires + `env.NAME === undefined`
   - Valid value: parsed correctly per parser type
   - `.env`-file load: `.env.test` file populates `process.env` (mocked via dotenv programmatic API)
   - Process-env override precedence: explicit `process.env.X` set BEFORE module load wins over `.env.X` (default dotenv behavior — assert it holds)
6. Zero remaining `Env.parseX(process.env.NAME, 'NAME')` call-sites in `ai/` (grep-clean).
7. All existing unit + integration tests pass (no behavior change at consumer surface).

## Avoided Traps

- **Don't lazy-evaluate inside `env`** — eager binding at module load is correct. Env vars don't change at runtime in production; lazy adds complexity without value.
- **Don't make `env` keyed by semantic name** (`env.kbSyncEnabled`) instead of env-var name (`env.NEO_ORCHESTRATOR_KB_SYNC_ENABLED`). The semantic mapping is the consumer's call (different consumers may want different defaults via `??`). Keep `env` as the typed env-var slot, not the policy.
- **Don't extend `Env.applyEnvBindings`** for this. That helper mutates an existing `data` object via dotted paths — different shape. New file uses a simple binding registry, exports own `env` object.
- **Don't add a separate `Env.parseBoolFromEnv(name)` shorthand wrapper** as an interim API. The fix is the registry, not a thin wrapper.
- **Don't move dotenv to a non-dev dependency** — it's already a devDependency; production load is via the deployed environment, dotenv just bridges local dev. Audit ensures no breakage on prod boot when no .env file is present (dotenv.config() silently no-ops).
- **Don't pre-suppose the registry's filesystem location** — `ai/config/env.mjs` is my proposal; @tobiu may prefer `ai/env.mjs`, `config/env.mjs`, or another path. Will confirm via PR body if no operator direction at file-write time.

## Depends on

Sub 13 (collapse Orchestrator over-engineered Service-DI) — Sub 13 lands first to avoid Orchestrator merge conflicts on the 14-call-site rewrite.

## Closes

Epic #11831 (the masterclass refactor that started this) — Sub 14 is the genuine final cleanup; Subs 8-12 were folder-structure derivatives.

**Authored by:** [Claude Opus 4.7] (Claude Code)

## Timeline

- 2026-05-23T17:58:46Z @neo-opus-ada added the `enhancement` label
- 2026-05-23T17:58:46Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-23T17:58:47Z @neo-opus-ada added the `ai` label
- 2026-05-23T17:58:47Z @neo-opus-ada added the `refactoring` label
- 2026-05-23T17:58:47Z @neo-opus-ada added the `architecture` label
- 2026-05-23T17:59:13Z @neo-opus-ada added parent issue #11831
- 2026-05-23T17:59:14Z @neo-opus-ada marked this issue as being blocked by #11854
- 2026-05-23T18:06:46Z @neo-opus-ada cross-referenced by PR #11856
- 2026-05-23T19:30:38Z @neo-opus-ada cross-referenced by #11862
- 2026-05-23T21:42:38Z @neo-opus-ada cross-referenced by PR #11868
- 2026-05-23T21:54:17Z @tobiu closed this issue
- 2026-05-23T21:54:17Z @tobiu referenced in commit `feb0fdd` - "feat(agentos): centralize env-binding registry — ai/config/env.mjs + dotenv lift (#11855) (#11868)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-23T22:56:17Z @neo-opus-ada cross-referenced by #11870
- 2026-05-24T02:38:25Z @neo-opus-ada cross-referenced by PR #11877
- 2026-05-24T02:41:35Z @neo-opus-ada cross-referenced by #11873
- 2026-05-24T09:06:55Z @neo-opus-ada referenced in commit `78c1c71` - "fix(util): single-source-of-name discipline for Env parsers — drop dup-name anti-pattern (#11873)

Operator surfaced the regression on Lane A PR #11876 (2026-05-24):
   `get goldenPathRepoEnrichmentEnabled(){ return Env.parseBool(process.env.NEO_X, 'NEO_X') ?? ... }`
duplicates the env var name at every call site (14 occurrences in
Orchestrator.mjs getters). My pre-Lane-A `env.X ?? AiConfig.X` shape via the
central `ai/config/env.mjs` registry collapsed the name to a single source.
The Lane A cutover (per operator's "FULLY REJECTED" on the unified-config
Discussion #11869) deleted that registry but resurrected the dup-name pattern
instead of refactoring `Env.mjs` to single-source-the-name internally.

V-B-A: my own memory from 12h ago (Sub 14 #11855) explicitly records the
dup-name pattern as the bad shape we replaced — regression detected.

Refactor: change `Env.parseX(rawValue, envVarName, warn)` →
`Env.parseX(envVarName, {env = process.env, warn = console.warn} = {})`.
Parser reads `env[envVarName]` internally. The canonical 2-value chain becomes:

   get pollIntervalMs() { return Env.parseNumber('NEO_X') ?? AiConfig.X; }

Env var name appears ONCE per call site — single source of truth.

Changes:
* `src/util/Env.mjs`: 5 parsers (parseBool/parseNumber/parsePort/parseUrl/parseString)
  + `applyEnvBindings` internal call adapted to the new signature
* `test/playwright/unit/util/Env.spec.mjs`: 22 tests adapted to new shape + added
  `parseString` env-reading coverage (previously identity-passthrough only)
* `ai/daemons/orchestrator/Orchestrator.mjs`: 13 getters collapsed from 4-line
  duplicated shape to single-line `Env.parseX('NEO_X') ?? AiConfig.X` chain;
  JSDoc Class-D description updated to reflect single-source-of-name discipline
* `ai/mcp/server/{memory-core,gitlab-workflow,github-workflow}/config.template.mjs`:
  custom parsers (`parseMemorySharingPolicy`, `parseLogLevel` x2) updated to
  the new `(envVarName, {env, warn})` signature — read env[name] internally
* Gitignored `config.mjs` siblings also updated for runtime correctness

Tests: 22/22 Env spec + 12/12 initServerConfigs spec + 2042/2042 full unit
suite, 0 failures."
- 2026-05-24T09:09:43Z @neo-opus-ada cross-referenced by PR #11876
- 2026-05-24T09:11:49Z @neo-opus-ada referenced in commit `49ac613` - "fix(util): single-source-of-name discipline for Env parsers — drop dup-name anti-pattern (#11873)

Operator surfaced the regression on Lane A PR #11876 (2026-05-24):
   `get goldenPathRepoEnrichmentEnabled(){ return Env.parseBool(process.env.NEO_X, 'NEO_X') ?? ... }`
duplicates the env var name at every call site (14 occurrences in
Orchestrator.mjs getters). My pre-Lane-A `env.X ?? AiConfig.X` shape via the
central `ai/config/env.mjs` registry collapsed the name to a single source.
The Lane A cutover (per operator's "FULLY REJECTED" on the unified-config
Discussion #11869) deleted that registry but resurrected the dup-name pattern
instead of refactoring `Env.mjs` to single-source-the-name internally.

V-B-A: my own memory from 12h ago (Sub 14 #11855) explicitly records the
dup-name pattern as the bad shape we replaced — regression detected.

Refactor: change `Env.parseX(rawValue, envVarName, warn)` →
`Env.parseX(envVarName, {env = process.env, warn = console.warn} = {})`.
Parser reads `env[envVarName]` internally. The canonical 2-value chain becomes:

   get pollIntervalMs() { return Env.parseNumber('NEO_X') ?? AiConfig.X; }

Env var name appears ONCE per call site — single source of truth.

Changes:
* `src/util/Env.mjs`: 5 parsers (parseBool/parseNumber/parsePort/parseUrl/parseString)
  + `applyEnvBindings` internal call adapted to the new signature
* `test/playwright/unit/util/Env.spec.mjs`: 22 tests adapted to new shape + added
  `parseString` env-reading coverage (previously identity-passthrough only)
* `ai/daemons/orchestrator/Orchestrator.mjs`: 13 getters collapsed from 4-line
  duplicated shape to single-line `Env.parseX('NEO_X') ?? AiConfig.X` chain;
  JSDoc Class-D description updated to reflect single-source-of-name discipline
* `ai/mcp/server/{memory-core,gitlab-workflow,github-workflow}/config.template.mjs`:
  custom parsers (`parseMemorySharingPolicy`, `parseLogLevel` x2) updated to
  the new `(envVarName, {env, warn})` signature — read env[name] internally
* Gitignored `config.mjs` siblings also updated for runtime correctness

Tests: 22/22 Env spec + 12/12 initServerConfigs spec + 2042/2042 full unit
suite, 0 failures."
- 2026-05-24T09:52:30Z @neo-opus-ada referenced in commit `265bd8a` - "fix(util): single-source-of-name discipline for Env parsers — drop dup-name anti-pattern (#11873)

Operator surfaced the regression on Lane A PR #11876 (2026-05-24):
   `get goldenPathRepoEnrichmentEnabled(){ return Env.parseBool(process.env.NEO_X, 'NEO_X') ?? ... }`
duplicates the env var name at every call site (14 occurrences in
Orchestrator.mjs getters). My pre-Lane-A `env.X ?? AiConfig.X` shape via the
central `ai/config/env.mjs` registry collapsed the name to a single source.
The Lane A cutover (per operator's "FULLY REJECTED" on the unified-config
Discussion #11869) deleted that registry but resurrected the dup-name pattern
instead of refactoring `Env.mjs` to single-source-the-name internally.

V-B-A: my own memory from 12h ago (Sub 14 #11855) explicitly records the
dup-name pattern as the bad shape we replaced — regression detected.

Refactor: change `Env.parseX(rawValue, envVarName, warn)` →
`Env.parseX(envVarName, {env = process.env, warn = console.warn} = {})`.
Parser reads `env[envVarName]` internally. The canonical 2-value chain becomes:

   get pollIntervalMs() { return Env.parseNumber('NEO_X') ?? AiConfig.X; }

Env var name appears ONCE per call site — single source of truth.

Changes:
* `src/util/Env.mjs`: 5 parsers (parseBool/parseNumber/parsePort/parseUrl/parseString)
  + `applyEnvBindings` internal call adapted to the new signature
* `test/playwright/unit/util/Env.spec.mjs`: 22 tests adapted to new shape + added
  `parseString` env-reading coverage (previously identity-passthrough only)
* `ai/daemons/orchestrator/Orchestrator.mjs`: 13 getters collapsed from 4-line
  duplicated shape to single-line `Env.parseX('NEO_X') ?? AiConfig.X` chain;
  JSDoc Class-D description updated to reflect single-source-of-name discipline
* `ai/mcp/server/{memory-core,gitlab-workflow,github-workflow}/config.template.mjs`:
  custom parsers (`parseMemorySharingPolicy`, `parseLogLevel` x2) updated to
  the new `(envVarName, {env, warn})` signature — read env[name] internally
* Gitignored `config.mjs` siblings also updated for runtime correctness

Tests: 22/22 Env spec + 12/12 initServerConfigs spec + 2042/2042 full unit
suite, 0 failures."
- 2026-05-24T09:58:59Z @tobiu referenced in commit `c090b57` - "feat(agentos): env-primitive deduplication — delete EnvConfig.mjs + migrate MCP configs to Neo.util.Env (#11873) (#11876)

* feat(agentos): env-primitive deduplication — delete EnvConfig.mjs + migrate MCP configs to Neo.util.Env (#11873)

Co-Authored-By: Claude Opus 4.7 (1M context) <neo-opus-ada@neomjs.com>

* fix(test): align initServerConfigs drift assertions to canonical Env.mjs path (#11873)

Lane A cutover replaced `../shared/helpers/EnvConfig.mjs` →
`../../../../src/util/Env.mjs` in fixture import strings but the drift
assertions referenced the wrong intermediate `../shared/helpers/Env.mjs`
path (file never existed). 11 occurrences updated via replace_all on
`../shared/helpers/Env.mjs:` → `../../../../src/util/Env.mjs:`. Test
fixtures and assertions now use the same source path consistently.

Verified locally: `npm run test-unit -- --grep initServerConfigs` →
12/12 pass (2.1s).

* docs(util): clean stale ai/config/env.mjs JSDoc refs post-deletion (#11873)

GPT's PR #11876 review surfaced 2 stale references to deleted `ai/config/env.mjs`
in JSDoc / spec-comment substrate that survived the Lane A cutover:

* src/util/Env.mjs:11 — "Prefer a centralized binding registry (e.g.,
  `ai/config/env.mjs`)..." replaced with the canonical 2-value chain
  description `Env.parseX(process.env.NEO_X, 'NEO_X') ?? AiConfig.X` + the
  bulk MCP-template `Env.applyEnvBindings(...)` shape mention.
* test/playwright/unit/util/Env.spec.mjs:47 — "via the `ai/config/env.mjs`
  binding registry" replaced with same canonical inline chain shape.

Both files now describe the post-#11873 reality (no central registry; per-consumer
inline `Env.parseX` calls). Anchor-and-Echo precision restored for future authors
querying these substrates.

* fix(util): single-source-of-name discipline for Env parsers — drop dup-name anti-pattern (#11873)

Operator surfaced the regression on Lane A PR #11876 (2026-05-24):
   `get goldenPathRepoEnrichmentEnabled(){ return Env.parseBool(process.env.NEO_X, 'NEO_X') ?? ... }`
duplicates the env var name at every call site (14 occurrences in
Orchestrator.mjs getters). My pre-Lane-A `env.X ?? AiConfig.X` shape via the
central `ai/config/env.mjs` registry collapsed the name to a single source.
The Lane A cutover (per operator's "FULLY REJECTED" on the unified-config
Discussion #11869) deleted that registry but resurrected the dup-name pattern
instead of refactoring `Env.mjs` to single-source-the-name internally.

V-B-A: my own memory from 12h ago (Sub 14 #11855) explicitly records the
dup-name pattern as the bad shape we replaced — regression detected.

Refactor: change `Env.parseX(rawValue, envVarName, warn)` →
`Env.parseX(envVarName, {env = process.env, warn = console.warn} = {})`.
Parser reads `env[envVarName]` internally. The canonical 2-value chain becomes:

   get pollIntervalMs() { return Env.parseNumber('NEO_X') ?? AiConfig.X; }

Env var name appears ONCE per call site — single source of truth.

Changes:
* `src/util/Env.mjs`: 5 parsers (parseBool/parseNumber/parsePort/parseUrl/parseString)
  + `applyEnvBindings` internal call adapted to the new signature
* `test/playwright/unit/util/Env.spec.mjs`: 22 tests adapted to new shape + added
  `parseString` env-reading coverage (previously identity-passthrough only)
* `ai/daemons/orchestrator/Orchestrator.mjs`: 13 getters collapsed from 4-line
  duplicated shape to single-line `Env.parseX('NEO_X') ?? AiConfig.X` chain;
  JSDoc Class-D description updated to reflect single-source-of-name discipline
* `ai/mcp/server/{memory-core,gitlab-workflow,github-workflow}/config.template.mjs`:
  custom parsers (`parseMemorySharingPolicy`, `parseLogLevel` x2) updated to
  the new `(envVarName, {env, warn})` signature — read env[name] internally
* Gitignored `config.mjs` siblings also updated for runtime correctness

Tests: 22/22 Env spec + 12/12 initServerConfigs spec + 2042/2042 full unit
suite, 0 failures.

* fix(mcp-config): clean stale Env.parsePort dup-name callsites in legacy applyLegacyEnv (#11873)

GPT V-B-A on PR #11876 head 78c1c71ae surfaced 3 callsites I missed during the
Cycle-2 single-source-of-name refactor — legacy `applyLegacyEnv` deprecation
fallback paths still using the OLD Env.parsePort signature:

- ai/mcp/server/memory-core/config.template.mjs:494 (SSE_PORT legacy fallback)
- ai/mcp/server/memory-core/config.template.mjs:512 (NEO_KB_CHROMA_PORT legacy)
- ai/mcp/server/knowledge-base/config.template.mjs:388 (SSE_PORT legacy)

With Cycle-2's new `Env.parsePort(envVarName, opts)` signature, the OLD
`Env.parsePort(process.env.SSE_PORT, 'SSE_PORT', console.warn)` callsite passes
the VALUE as the name argument — returns undefined silently. GPT's empirical
verification showed `process.env.SSE_PORT='4321'` + old-shape callsite → undefined,
while `Env.parsePort('SSE_PORT')` → 4321. Functional regression in legacy fallback.

Fix: 3 callsites in tracked templates + 3 callsites in gitignored config.mjs siblings
updated to single-source-of-name shape: `Env.parsePort('SSE_PORT')` /
`Env.parsePort('NEO_KB_CHROMA_PORT')`.

Substrate-gold extension: my original survey caught Env.parseX callsites in
Orchestrator getters + applyEnvBindings binding form, but missed inline calls
inside service-method bodies (applyLegacyEnv). Future refactor surveys: grep
for ALL Env.parseX(process.env... patterns, not just the canonical 2-value chain
shape — inline legacy/deprecation paths exist outside the obvious surfaces.

Net: 4 files (2 templates + 2 gitignored siblings); zero remaining dup-name
Env.parseX callsites in tracked tree.

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"

