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
- Delete
ai/mcp/server/shared/helpers/EnvConfig.mjs
- 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
- Update
initServerConfigs.spec.mjs assertions for new import strings
- 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
- Delete
ai/config/env.mjs + its spec
- 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
- 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()
- Move
dotenv/config import to ai/scripts/orchestrator-daemon.mjs entrypoint
- Update Orchestrator.spec.mjs + MaintenanceBackpressureService.spec.mjs etc. for the new orchestrator import surface
Acceptance Criteria
EnvConfig.mjs deleted; all 4 direct importers + BaseConfig.mjs migrated to Neo.util.Env.applyEnvBindings. Grep-clean: zero EnvConfig.mjs references in ai/
Neo.util.Env.parseBool is the single canonical bool parser (permissive tokens); migration tests cover dual-semantic compatibility for operator inputs
ai/config/env.mjs + its spec deleted
- 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
- 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)
dotenv/config moved to ai/scripts/orchestrator-daemon.mjs entrypoint
initServerConfigs.spec.mjs assertions updated for new import strings
- All existing tests pass; no
process.env.NEO_* direct (unparsed) reads in migrated consumers
- 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/1 ↔ false/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.
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, strictparseBool) +Neo.util.Env(Tier-1, permissiveparseBool, created via task #22) +ai/config/env.mjs(Sub 14 / PR #11868 introduced as 3rd mechanism). Plus runtime-mutable global state inai/config/env.mjs(Orchestrator mutatesenv.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = falseon 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:
Consumer ledger (per @neo-gpt cycle-2 STEP_BACK correction):
BaseConfig.mjsitself imports EnvConfig.mjsinitServerConfigs.spec.mjsasserts old EnvConfig import strings — must updateThe 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.mjsdeletion, getter shorthand must still mean a PARSED env layer (per @neo-gpt cycle-2.2 implementation clarification — rawprocess.env.Xstrings 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 = falsemutation) refactors to daemon-local instance field on the service (e.g.,this.swarmHeartbeatService.initFailed = true), checked atpoll()beforepulse(). 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/configplacement moves from deletedai/config/env.mjsto orchestrator entrypoint (ai/scripts/orchestrator-daemon.mjs) deliberately. Cloud deployment doesn't need it (env-only via compose); local dev needs it for.envfile load.The Fix
ai/mcp/server/shared/helpers/EnvConfig.mjsBaseConfig.mjstoNeo.util.Env.applyEnvBindings. Identical envBindings dotted-path shape; only the helper-module import changesinitServerConfigs.spec.mjsassertions for new import stringsparseBoolto 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 identicallyai/config/env.mjs+ its specEnv.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key)for booleans /Env.parseNumber(...) ?? AiConfig.orchestrator.intervals.Xfor intervals. Pure 2-layer chain; parsed env layer inlinedenv.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = falsetothis.swarmHeartbeatService.initFailed = true(daemon-local instance field),poll()swarm-heartbeat lane checks the field before callingpulse()dotenv/configimport toai/scripts/orchestrator-daemon.mjsentrypointAcceptance Criteria
EnvConfig.mjsdeleted; all 4 direct importers +BaseConfig.mjsmigrated toNeo.util.Env.applyEnvBindings. Grep-clean: zeroEnvConfig.mjsreferences inai/Neo.util.Env.parseBoolis the single canonical bool parser (permissive tokens); migration tests cover dual-semantic compatibility for operator inputsai/config/env.mjs+ its spec deletedEnv.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key); interval getters becomeEnv.parseNumber(process.env.NEO_X, 'NEO_X') ?? AiConfig.orchestrator.intervals.X. Pure 2-layer chain. No runtime mutation of either layerpoll()checks field beforepulse(). Fail-safe invariant preserved (init failure disables lane regardless of operator env override)dotenv/configmoved toai/scripts/orchestrator-daemon.mjsentrypointinitServerConfigs.spec.mjsassertions updated for new import stringsprocess.env.NEO_*direct (unparsed) reads in migrated consumersOut of Scope
(Each merits separate Discussion if later V-B-A surfaces real drift)
.template → .mjspattern atai/rootbootstrapWorktree.mjsscope extension toai/config.template.mjsprocess.env.X || defaultreads inbridge/daemon.mjs/KbAlertingService.mjs/TaskDefinitions.mjsAvoided Traps
ai/config/env.mjsshape — explicitly resolved by AC 3parseBoolretention — resolved by AC 2 permissive cutover (zero docs/skills pin strict per cycle-2 V-B-A)AiConfig.localOnlyfor runtime auto-disable — breaks fail-safe invariant if env override present; resolved by AC 5 daemon-local-field pathprocess.env.Xreads post-deletion — would reintroduce boolean bug OQ1 fixes; resolved by AC 4 parsed-env-layer requirementNEO_*catalog every service imports — negative boundary; resolved by AC 1 per-server scoping preservationContract Ledger
Per
pr-review §5.4audit requirement for PRs touching public/consumed surfaces. Lane A introduces the canonicalNeo.util.Envprimitive consumed by MCP server config templates + the Orchestrator daemon getters. Contract surface enumerated below:Neo.util.Env.parseBool(rawValue, envVarName, warn?)EnvConfig.parseBool(permissivetrue/yes/on/1↔false/no/off/0);PrimaryRepoSyncService.parseEnabledFlag(same tokens)booleanfor known tokens,undefined+ warn for unknownEnv.parseBool;EnvConfig.parseBool+parseEnabledFlagdeletedNeo.util.Env.parseNumber(rawValue, envVarName, warn?)Number(process.env.X)orparseInt(...)inlinedNumberfor valid input,undefined+ warn forNaN/InfinityNeo.util.Env.parsePort(rawValue, envVarName, warn?)EnvConfig.parsePortEnvConfig.parsePortdeletedNeo.util.Env.parseUrl(rawValue, envVarName, warn?)EnvConfig.parseUrl(URL parse + trailing-slash strip)EnvConfig.parseUrldeletedNeo.util.Env.parseString(rawValue)process.env.Xorprocess.env.X ?? defaultswarmHeartbeatIdentitygetter)Neo.util.Env.applyEnvBindings(data, envBindings, env?, warn?)EnvConfig.applyEnvBindings(dotted-path namespace walk + binding map)src/util/Env.mjs;EnvConfig.applyEnvBindingsdeletedimport {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)config.template.mjsfiles + Orchestrator + tests migrated via cutover; gitignoredconfig.mjsfiles refreshed vianpm run prepare -- --migrate-configinitServerConfigsdrift detector<source>:<spec>imports fromEnvConfig.mjssource pathsrc/util/Env.mjssource path17a651bc6; same-source-named-import-drift detection invariant preservedai/config/env.mjscentral binding registryenv.NEO_XEnv.parseX(process.env.NEO_X, 'NEO_X') ?? AiConfig.Xchainenv.NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED = falseenv-registry mutationthis.swarmHeartbeatService.initFailed = truefield check in poll()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(parseEnabledFlagpermissive tokens → unifiedEnv.parseBoolpermissive 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.