LearnNewsExamplesServices
Frontmatter
id11873
titleEnv-primitive deduplication: EnvConfig.mjs deletion + MCP migrations + Orchestrator scoped-binding
stateClosed
labels
enhancementairefactoringarchitecture
assigneesneo-opus-ada
createdAtMay 24, 2026, 3:20 AM
updatedAtJun 7, 2026, 7:15 PM
githubUrlhttps://github.com/neomjs/neo/issues/11873
authorneo-opus-ada
commentsCount0
parentIssue11871
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 24, 2026, 11:58 AM

Env-primitive deduplication: EnvConfig.mjs deletion + MCP migrations + Orchestrator scoped-binding

Closed v13.0.0/archive-v13-0-0-chunk-13 enhancementairefactoringarchitecture
neo-opus-ada
neo-opus-ada commented on May 24, 2026, 3:20 AM

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

Parent Epic: #11871 — Sub 1 of the env-primitive deduplication + shared MCP stderr logger Epic graduated from Discussion #11869 cycle-2.2.

Decision Record impact: none directly; aligned with ADR 0014 cloud deployment topology (preserves the existing per-deployment composability model; no central NEO_* catalog).

Context

Discussion #11869 cycle-2.2 graduated this Sub as the env-primitive consolidation half of Epic #11871. Three coexisting env-binding mechanisms surfaced this session — EnvConfig.mjs (Tier-2 MCP shared, strict parseBool) + Neo.util.Env (Tier-1, permissive parseBool, created via task #22) + ai/config/env.mjs (Sub 14 / PR #11868 introduced as 3rd mechanism). Plus runtime-mutable global state in ai/config/env.mjs (Orchestrator mutates env.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = false on swarm-heartbeat init failure — env-abuse for runtime state). Direct cutover, no shim (v13 unreleased per operator).

The Problem

Per the cycle-2.2 narrow scope:

Drift Impact
EnvConfig.mjs (83 LOC, strict parseBool) vs Neo.util.Env (Tier-1, permissive parseBool) Duplicate code + semantic divergence; "3 partial extractions" in task #22 left EnvConfig.mjs alive
ai/config/env.mjs as 3rd mechanism + runtime-mutable global Compounded drift; env-abuse for runtime auto-disable state
parseBool semantic divergence Same env-var input has different truthiness depending on import path

Consumer ledger (per @neo-gpt cycle-2 STEP_BACK correction):

  • Direct EnvConfig.mjs imports: knowledge-base, memory-core, neural-link, gitlab-workflow config.templates (4 files)
  • Indirect via BaseConfig.mjs: github-workflow (transitive coverage)
  • BaseConfig.mjs itself imports EnvConfig.mjs
  • initServerConfigs.spec.mjs asserts old EnvConfig import strings — must update

The Architectural Reality

Current orchestrator getter chain is already pure 2-layer per operator direction ("env var if set wins, otherwise aiConfig"):

get swarmHeartbeatEnabled() {
    return env.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED ?? resolveDeploymentEnabled('swarmHeartbeatEnabled');
}

After ai/config/env.mjs deletion, getter shorthand must still mean a PARSED env layer (per @neo-gpt cycle-2.2 implementation clarification — raw process.env.X strings would reintroduce the boolean bug OQ1 fixes):

get swarmHeartbeatEnabled() {
    return Env.parseBool(process.env.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED, 'NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED')
        ?? resolveDeploymentEnabled('swarmHeartbeatEnabled');
}

Runtime auto-disable on swarm-heartbeat init failure (currently env.NEO_X = false mutation) refactors to daemon-local instance field on the service (e.g., this.swarmHeartbeatService.initFailed = true), checked at poll() before pulse(). NOT a getter-chain layer. NOT env mutation. NOT AiConfig mutation. Preserves the fail-safe invariant (init failure disables lane regardless of env override) without re-introducing the env-mutation anti-pattern.

dotenv/config placement moves from deleted ai/config/env.mjs to orchestrator entrypoint (ai/scripts/orchestrator-daemon.mjs) deliberately. Cloud deployment doesn't need it (env-only via compose); local dev needs it for .env file load.

The Fix

  1. Delete ai/mcp/server/shared/helpers/EnvConfig.mjs
  2. Migrate 4 MCP server config.templates (knowledge-base, memory-core, neural-link, gitlab-workflow) + BaseConfig.mjs to Neo.util.Env.applyEnvBindings. Identical envBindings dotted-path shape; only the helper-module import changes
  3. Update initServerConfigs.spec.mjs assertions for new import strings
  4. Reconcile parseBool to permissive semantic (true/yes/on/1 / false/no/off/0); add boolean-binding migration tests proving operator inputs valid under either old strict OR new permissive evaluate identically
  5. Delete ai/config/env.mjs + its spec
  6. Orchestrator getters become Env.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key) for booleans / Env.parseNumber(...) ?? AiConfig.orchestrator.intervals.X for intervals. Pure 2-layer chain; parsed env layer inlined
  7. Orchestrator runtime auto-disable: refactor env.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = false to this.swarmHeartbeatService.initFailed = true (daemon-local instance field), poll() swarm-heartbeat lane checks the field before calling pulse()
  8. Move dotenv/config import to ai/scripts/orchestrator-daemon.mjs entrypoint
  9. Update Orchestrator.spec.mjs + MaintenanceBackpressureService.spec.mjs etc. for the new orchestrator import surface

Acceptance Criteria

  1. EnvConfig.mjs deleted; all 4 direct importers + BaseConfig.mjs migrated to Neo.util.Env.applyEnvBindings. Grep-clean: zero EnvConfig.mjs references in ai/
  2. Neo.util.Env.parseBool is the single canonical bool parser (permissive tokens); migration tests cover dual-semantic compatibility for operator inputs
  3. ai/config/env.mjs + its spec deleted
  4. Orchestrator boolean getters become Env.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key); interval getters become Env.parseNumber(process.env.NEO_X, 'NEO_X') ?? AiConfig.orchestrator.intervals.X. Pure 2-layer chain. No runtime mutation of either layer
  5. Swarm-heartbeat init-failure auto-disable refactored to daemon-local instance field on the service; poll() checks field before pulse(). Fail-safe invariant preserved (init failure disables lane regardless of operator env override)
  6. dotenv/config moved to ai/scripts/orchestrator-daemon.mjs entrypoint
  7. initServerConfigs.spec.mjs assertions updated for new import strings
  8. All existing tests pass; no process.env.NEO_* direct (unparsed) reads in migrated consumers
  9. Orchestrator.spec.mjs + MaintenanceBackpressureService.spec.mjs + Env.spec.mjs all green

Out of Scope

(Each merits separate Discussion if later V-B-A surfaces real drift)

  • Top-level + per-server config merge contract (cycle-1 rejected as framed)
  • Two-file .template → .mjs pattern at ai/ root
  • bootstrapWorktree.mjs scope extension to ai/config.template.mjs
  • npx neo-app workspaces JSON config path
  • Bare process.env.X || default reads in bridge/daemon.mjs / KbAlertingService.mjs / TaskDefinitions.mjs
  • SwarmHeartbeatService core.Base contract cleanup (separate ticket — overlapping env-var reads will harmonize naturally during impl)
  • Shared MCP stderr logger primitive (Epic #11871 Sub 2 — separate ticket)

Avoided Traps

  • Re-introducing the 3rd-mechanism ai/config/env.mjs shape — explicitly resolved by AC 3
  • Strict parseBool retention — resolved by AC 2 permissive cutover (zero docs/skills pin strict per cycle-2 V-B-A)
  • Daemon-local field layered into getter chain — operator rejected ("env var if set wins, otherwise aiConfig"); resolved by AC 5 (field is poll-time check, NOT a chain layer)
  • Mutating AiConfig.localOnly for runtime auto-disable — breaks fail-safe invariant if env override present; resolved by AC 5 daemon-local-field path
  • Raw process.env.X reads post-deletion — would reintroduce boolean bug OQ1 fixes; resolved by AC 4 parsed-env-layer requirement
  • Central NEO_* catalog every service imports — negative boundary; resolved by AC 1 per-server scoping preservation

Contract Ledger

Per pr-review §5.4 audit requirement for PRs touching public/consumed surfaces. Lane A introduces the canonical Neo.util.Env primitive consumed by MCP server config templates + the Orchestrator daemon getters. Contract surface enumerated below:

Surface Pre-cutover contract Post-cutover contract Migration
Neo.util.Env.parseBool(rawValue, envVarName, warn?) EnvConfig.parseBool (permissive true/yes/on/1false/no/off/0); PrimaryRepoSyncService.parseEnabledFlag (same tokens) Identical permissive token set; unifies the 2 historical decoders. Returns boolean for known tokens, undefined + warn for unknown All consumers updated to Env.parseBool; EnvConfig.parseBool + parseEnabledFlag deleted
Neo.util.Env.parseNumber(rawValue, envVarName, warn?) No EnvConfig equivalent — bare Number(process.env.X) or parseInt(...) inlined Finite-number decode; returns Number for valid input, undefined + warn for NaN/Infinity New consumer adoption (orchestrator getters); inline parseInt patterns retained ONLY where pre-existing in non-Lane-A scope (out-of-scope per ticket)
Neo.util.Env.parsePort(rawValue, envVarName, warn?) EnvConfig.parsePort Identical integer 1..65535 validation All consumers updated; EnvConfig.parsePort deleted
Neo.util.Env.parseUrl(rawValue, envVarName, warn?) EnvConfig.parseUrl (URL parse + trailing-slash strip) Identical All consumers updated; EnvConfig.parseUrl deleted
Neo.util.Env.parseString(rawValue) No EnvConfig equivalent — bare process.env.X or process.env.X ?? default Identity passthrough; signals intent New consumer adoption (Orchestrator swarmHeartbeatIdentity getter)
Neo.util.Env.applyEnvBindings(data, envBindings, env?, warn?) EnvConfig.applyEnvBindings (dotted-path namespace walk + binding map) Identical shape, lifted verbatim All MCP config templates updated to import from src/util/Env.mjs; EnvConfig.applyEnvBindings deleted
Import path import {parseX} from '<rel>/shared/helpers/EnvConfig.mjs' import Env from '<rel>/src/util/Env.mjs'; Env.parseX(...) (named-import shape DROPPED in favor of namespace-import) All 4 MCP config.template.mjs files + Orchestrator + tests migrated via cutover; gitignored config.mjs files refreshed via npm run prepare -- --migrate-config
initServerConfigs drift detector Projects <source>:<spec> imports from EnvConfig.mjs source path Same projection from src/util/Env.mjs source path Spec fixture+assertion paths updated; AC2 of #11873 fix shipped in 17a651bc6; same-source-named-import-drift detection invariant preserved
ai/config/env.mjs central binding registry Existed (#11855); imported by Orchestrator as env.NEO_X DELETED; per-consumer inline Env.parseX(process.env.NEO_X, 'NEO_X') ?? AiConfig.X chain Operator-rejected central catalog approach (cycle-2 "FULLY REJECTED" framing); per-server scoping is load-bearing negative boundary per AC 1
Runtime auto-disable Was env.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = false env-registry mutation Daemon-local this.swarmHeartbeatService.initFailed = true field check in poll() Preserves fail-safe invariant without env-registry mutation per operator pushback ("what if WE want to disable heartbeat locally? no env var")

No behavioral change at the consumer level: every env var (NEO_ORCHESTRATOR_KB_SYNC_ENABLED, NEO_AGENT_IDENTITY, etc.) still maps to the same decoded value with the same fallback semantics; only the import path + parser invocation shape changed. The semantic-shift on parseBool (parseEnabledFlag permissive tokens → unified Env.parseBool permissive tokens) is internal-only since both decoders accepted identical token sets pre-cutover.

Authority

Discussion #11869 cycle-2.2 graduation (anthropic AUTHOR_SIGNAL + openai GRADUATION_APPROVED by @neo-gpt @ DC_kwDODSospM4BA_W0) + operator GO 2026-05-24 + nightshift lane-claim 2026-05-24.

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
tobiu closed this issue on May 24, 2026, 11:58 AM