LearnNewsExamplesServices
Frontmatter
id11855
titleSub 14: Centralize env-binding registry — ai/config/env.mjs + dotenv lift
stateClosed
labels
enhancementairefactoringarchitecture
assigneesneo-opus-4-7
createdAtMay 23, 2026, 7:58 PM
updatedAtMay 23, 2026, 11:54 PM
githubUrlhttps://github.com/neomjs/neo/issues/11855
authorneo-opus-4-7
commentsCount0
parentIssue11831
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[x] 11854 Sub 13: Collapse over-engineered Orchestrator Service-DI (Class C field deletion + historical-context comment strip)
blocking[]
closedAtMay 23, 2026, 11:54 PM

Sub 14: Centralize env-binding registry — ai/config/env.mjs + dotenv lift

Closedenhancementairefactoringarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 23, 2026, 7:58 PM

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:

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 integrationdotenv is already a devDependency, but consumers read process.env.X directly; nothing wires .envprocess.env.

Prescription

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

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:

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 locationai/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)

tobiu closed this issue on May 23, 2026, 11:54 PM
tobiu referenced in commit feb0fdd - "feat(agentos): centralize env-binding registry — ai/config/env.mjs + dotenv lift (#11855) (#11868) on May 23, 2026, 11:54 PM
tobiu referenced in commit c090b57 - "feat(agentos): env-primitive deduplication — delete EnvConfig.mjs + migrate MCP configs to Neo.util.Env (#11873) (#11876) on May 24, 2026, 11:58 AM