Authored by: [Claude Opus 4.7] (Claude Code)
Graduated from: Discussion #11869 (cycle-2.2 §6.2 quorum: anthropic AUTHOR_SIGNAL + openai [GRADUATION_APPROVED] by @neo-gpt @ DC_kwDODSospM4BA_W0 + operator GO 2026-05-24).
Decision Record impact: none (no existing ADR addresses env-primitive ownership or MCP-server logger shape; this Epic establishes the foundational primitives; ADR 0014 Cloud Deployment Topology remains the deployment-target composability authority — preserved, not amended).
Context
V-B-A cascade earlier today surfaced 3 coexisting env-binding mechanisms with semantic divergence (EnvConfig.mjs + Neo.util.Env + ai/config/env.mjs), plus 5 per-server logger reimplementations. Cycle-1 Discussion #11869 framing ("single source of truth across all configs") was REJECTED by operator post-graduation as breaking deployment-target composability. Cycle-2 / cycle-2.2 narrow reframe preserves the existing deployment-subset substrate (docker-compose profiles + TARGET_SERVER build arg + per-container env injection + initServerConfigs.mjs) and addresses only the validated primitive-level drift.
The Problem
| # |
Drift |
Impact |
| 1 |
EnvConfig.mjs (ai/mcp/server/shared/helpers/, 83 LOC, strict parseBool) vs Neo.util.Env (src/util/Env.mjs, lifted via task #22 but EnvConfig never deleted — "3 partial extractions") |
Duplicate primitives + semantic divergence |
| 2 |
ai/config/env.mjs (Sub 14 / PR #11868) introduced 3rd mechanism with runtime-mutable global |
Compounded drift |
| 3 |
parseBool semantic divergence (strict 'true'/'false' vs permissive true/yes/on/1) |
Same env-var input has different truthiness depending on import path |
| 4 |
5 per-server logger reimplementations |
Per-server-N-mechanisms; operator: "NO util.Logger inside ai scope. one stderr mcp logger" |
The Architectural Reality
Cloud deployment topology already implements composability (per ai/deploy/docker-compose.yml + ADR 0014 + Sub B #11720): compose profiles select service subsets; TARGET_SERVER build arg + per-container environment: injection per service. github-workflow / gitlab-workflow / neural-link deliberately excluded from compose. No code-time merge across all servers is needed.
Consumer ledger:
- Direct
EnvConfig.mjs imports: knowledge-base, memory-core, neural-link, gitlab-workflow config.templates (4)
- Indirect via
BaseConfig.mjs: github-workflow
BaseConfig.mjs itself imports EnvConfig
initServerConfigs.spec.mjs asserts old EnvConfig import strings — must update
- 5 per-server
logger.mjs files with divergent sink/gating contracts:
- github/gitlab-workflow: priority-filtered stderr, default
warn
- knowledge-base: daily-rotated file sink + debug-gated stderr
- memory-core: same +
flush() for short scripts
- neural-link: daily-rotated file + tier-gated stderr (info/warn/error always write)
The Fix
Direct cutover, no shim (v13 unreleased per operator). Single Epic, 2 sub-tickets:
Sub 1 — Env-primitive dedup
- 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
- Update
initServerConfigs.spec.mjs assertions
- Reconcile
parseBool to permissive semantic (true/yes/on/1 / false/no/off/0); boolean-binding migration tests prove operator inputs valid under either old strict OR new permissive evaluate identically; semantic-change documented
- Delete
ai/config/env.mjs
- Orchestrator getter chain stays pure 2-layer: each boolean getter becomes
Env.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key) and each interval getter becomes Env.parseNumber(process.env.NEO_X, 'NEO_X') ?? AiConfig.orchestrator.intervals.X (parsed env layer inlined post-registry-deletion; raw process.env.X would reintroduce the boolean bug OQ1 fixes — per @neo-gpt cycle-2.2 implementation clarification)
- Runtime auto-disable on swarm-heartbeat init failure refactors from
env.NEO_X = false mutation to daemon-local instance field on the service (e.g., this.swarmHeartbeatService.initFailed = true), checked at poll() before calling pulse(). NOT a getter-chain layer. NOT env mutation. NOT AiConfig mutation.
dotenv/config placement: moves from deleted ai/config/env.mjs to orchestrator entrypoint (ai/scripts/orchestrator-daemon.mjs) deliberately
Sub 2 — Shared MCP stderr logger primitive
- Create ONE shared stderr-based logger primitive at
ai/mcp/server/shared/Logger.mjs (placement matches sibling BaseConfig.mjs / helpers/EnvConfig.mjs — structural pre-flight gate applies at implementation time per @neo-gpt cycle-2.2 reminder)
- NO use of
Neo.util.Logger inside ai/ scope. Neo.util.Logger (app/browser Neo.core.Base singleton, styled console.log, error() throws) stays for app/browser code; MCP-server side has different safety contracts (protocol-safe stderr-only, error() logs-not-throws)
- Stderr-only (no stdout writes — MCP protocol safety invariant)
- Migrate 5 per-server logger reimplementations to consume shared primitive
- Behavior ledger AC: per-server config-driven sink/gating preserved as CONFIG, not separate primitives. Each server's
config.template.mjs declares its own logger config (file prefix, log path, log level, error serialization, flush availability); shared primitive reads at boot
- Deployment-subset model preserved: each container imports shared primitive + own server's config. NO central log-config catalog (same negative boundary as env per OQ4)
Acceptance Criteria
Sub 1 (env-primitive dedup):
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 true/yes/on/1 / false/no/off/0); migration tests prove dual-semantic compatibility for operator inputs
ai/config/env.mjs 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 the field before pulse()
dotenv/config moved from deleted ai/config/env.mjs to orchestrator entrypoint
initServerConfigs.spec.mjs assertions updated for new import strings
- All existing tests pass; no
process.env.NEO_* direct (unparsed) reads in migrated consumers
Sub 2 (shared MCP stderr logger):
- New
ai/mcp/server/shared/Logger.mjs (or structural-pre-flight-validated alternative placement) — ONE shared primitive
- Stderr-only output;
error() logs (does NOT throw); no stdout writes (MCP protocol safety invariant)
- 5 per-server logger reimplementations deleted; all log call-sites migrated to shared primitive
- Per-server config-driven sink/gating preserved as CONFIG (file prefix, log path, log level, error serialization, flush availability); each server's
config.template.mjs declares its logger config slot
- Per-server divergences captured as config differences:
- github/gitlab-workflow: priority-filtered stderr, default
warn, debug promotes verbosity
- knowledge-base: daily-rotated always-on file sink + debug-gated stderr
- memory-core: same file sink +
logger.flush() for short-lived scripts
- neural-link: daily-rotated file sink + tier-gated stderr (info/warn/error write without debug)
- NO central log-config catalog; each container imports shared primitive + own server's config (deployment-subset preservation; negative boundary parity with OQ4)
- NO use of
Neo.util.Logger inside ai/ scope (grep-clean)
- All existing log call-site behavior preserved; per-server config tests cover the divergences
Out of Scope
(Each merits separate Discussion ONLY if later V-B-A surfaces real drift — do NOT pre-emptively spawn)
- Top-level + per-server config merge contract (cycle-1 rejected; deployment-subset composition is deploy-time, not code-time)
- 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
- Adapting
Neo.util.Logger (src/util/Logger.mjs) for server-side use — explicitly OUT per operator direction
Avoided Traps
- 3rd-mechanism extension (Sub 14's
ai/config/env.mjs as permanent fixture) — resolved by AC 3
- Strict
parseBool retention — resolved by AC 2 permissive cutover
- Unified-everything framing (cycle-1 rejected; broke deployment-target composability)
Neo.util.Logger reuse in ai/ — operator-rejected; app/browser logger has incompatible MCP semantics
- Per-server logger primitives — replaced by shared primitive + per-server config (preserves divergent behavior without reimplementing)
- Central
NEO_* catalog OR central log-config catalog — explicit negative boundary preserves deployment-subset composability
- Backwards-compat shim ceremony — operator-rejected per v13-unreleased
Discussion Criteria Mapping
Maps Discussion #11869 cycle-2.2 OQ resolutions → Epic ACs:
| Discussion OQ |
Resolution |
Maps to Epic AC |
| OQ1 (parseBool) |
[RESOLVED_TO_AC] permissive |
Sub 1 AC 2 |
| OQ2 (EnvConfig deletion strategy) |
[RESOLVED_TO_AC] direct cutover |
Sub 1 AC 1 |
| OQ3 (ai/config/env.mjs disposition + runtime auto-disable) |
[RESOLVED_TO_AC] delete + pure 2-layer chain + daemon-local service field |
Sub 1 AC 3-6 |
| OQ4 (per-server env isolation) |
[RESOLVED_TO_AC] per-server scoping; NO central catalog |
Sub 1 AC 1 + Sub 2 AC 6 (negative boundary parity) |
| OQ5 (bare process.env reads) |
[REJECTED_WITH_RATIONALE] out of scope |
Out of Scope section |
| OQ6 (prototype-pollution guard) |
[WITHDRAWN] per V-B-A commit b9ae97ebf |
N/A — withdrawn |
| OQ7 (per-server logger) |
[RESOLVED_TO_AC] shared MCP stderr primitive + per-server config |
Sub 2 AC 1-7 |
Signal Ledger
§6.2 quorum: ✓ (≥2 active families with signal + 1 non-author family [GRADUATION_APPROVED]).
Unresolved Dissent
(empty — operator's cycle-1 rejection ADDRESSED by cycle-2/cycle-2.2 reframe preserving deployment-target composability)
Unresolved Liveness
| Family |
Identity |
Disposition |
| google |
@neo-gemini-3-1-pro |
Unavailable for ~1 month (operator-confirmed 2026-05-24). Floor-2 quorum reached without. Non-Tier-2 substrate; no revalidationTrigger AC required. |
Authority
Discussion #11869 cycle-2.2 graduation (anthropic AUTHOR_SIGNAL + openai [GRADUATION_APPROVED] by @neo-gpt @ DC_kwDODSospM4BA_W0) + operator GO 2026-05-24.
Authored by: [Claude Opus 4.7] (Claude Code)
Graduated from: Discussion #11869 (cycle-2.2 §6.2 quorum: anthropic AUTHOR_SIGNAL + openai
[GRADUATION_APPROVED]by @neo-gpt @ DC_kwDODSospM4BA_W0 + operator GO 2026-05-24).Decision Record impact: none (no existing ADR addresses env-primitive ownership or MCP-server logger shape; this Epic establishes the foundational primitives; ADR 0014 Cloud Deployment Topology remains the deployment-target composability authority — preserved, not amended).
Context
V-B-A cascade earlier today surfaced 3 coexisting env-binding mechanisms with semantic divergence (
EnvConfig.mjs+Neo.util.Env+ai/config/env.mjs), plus 5 per-server logger reimplementations. Cycle-1 Discussion #11869 framing ("single source of truth across all configs") was REJECTED by operator post-graduation as breaking deployment-target composability. Cycle-2 / cycle-2.2 narrow reframe preserves the existing deployment-subset substrate (docker-compose profiles +TARGET_SERVERbuild arg + per-container env injection +initServerConfigs.mjs) and addresses only the validated primitive-level drift.The Problem
EnvConfig.mjs(ai/mcp/server/shared/helpers/, 83 LOC, strictparseBool) vsNeo.util.Env(src/util/Env.mjs, lifted via task #22 but EnvConfig never deleted — "3 partial extractions")ai/config/env.mjs(Sub 14 / PR #11868) introduced 3rd mechanism with runtime-mutable globalparseBoolsemantic divergence (strict'true'/'false'vs permissivetrue/yes/on/1)The Architectural Reality
Cloud deployment topology already implements composability (per
ai/deploy/docker-compose.yml+ ADR 0014 + Sub B #11720): compose profiles select service subsets;TARGET_SERVERbuild arg + per-containerenvironment:injection per service. github-workflow / gitlab-workflow / neural-link deliberately excluded from compose. No code-time merge across all servers is needed.Consumer ledger:
EnvConfig.mjsimports: knowledge-base, memory-core, neural-link, gitlab-workflow config.templates (4)BaseConfig.mjs: github-workflowBaseConfig.mjsitself imports EnvConfiginitServerConfigs.spec.mjsasserts old EnvConfig import strings — must updatelogger.mjsfiles with divergent sink/gating contracts:warnflush()for short scriptsThe Fix
Direct cutover, no shim (v13 unreleased per operator). Single Epic, 2 sub-tickets:
Sub 1 — Env-primitive dedup
ai/mcp/server/shared/helpers/EnvConfig.mjsBaseConfig.mjstoNeo.util.Env.applyEnvBindingsinitServerConfigs.spec.mjsassertionsparseBoolto permissive semantic (true/yes/on/1/false/no/off/0); boolean-binding migration tests prove operator inputs valid under either old strict OR new permissive evaluate identically; semantic-change documentedai/config/env.mjsEnv.parseBool(process.env.NEO_X, 'NEO_X') ?? resolveDeploymentEnabled(key)and each interval getter becomesEnv.parseNumber(process.env.NEO_X, 'NEO_X') ?? AiConfig.orchestrator.intervals.X(parsed env layer inlined post-registry-deletion; rawprocess.env.Xwould reintroduce the boolean bug OQ1 fixes — per @neo-gpt cycle-2.2 implementation clarification)env.NEO_X = falsemutation to daemon-local instance field on the service (e.g.,this.swarmHeartbeatService.initFailed = true), checked atpoll()before callingpulse(). NOT a getter-chain layer. NOT env mutation. NOT AiConfig mutation.dotenv/configplacement: moves from deletedai/config/env.mjsto orchestrator entrypoint (ai/scripts/orchestrator-daemon.mjs) deliberatelySub 2 — Shared MCP stderr logger primitive
ai/mcp/server/shared/Logger.mjs(placement matches siblingBaseConfig.mjs/helpers/EnvConfig.mjs— structural pre-flight gate applies at implementation time per @neo-gpt cycle-2.2 reminder)Neo.util.Loggerinsideai/scope.Neo.util.Logger(app/browserNeo.core.Basesingleton, styled console.log,error()throws) stays for app/browser code; MCP-server side has different safety contracts (protocol-safe stderr-only,error()logs-not-throws)config.template.mjsdeclares its own logger config (file prefix, log path, log level, error serialization, flush availability); shared primitive reads at bootAcceptance Criteria
Sub 1 (env-primitive dedup):
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 tokenstrue/yes/on/1/false/no/off/0); migration tests prove dual-semantic compatibility for operator inputsai/config/env.mjsdeletedEnv.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 the field beforepulse()dotenv/configmoved from deletedai/config/env.mjsto orchestrator entrypointinitServerConfigs.spec.mjsassertions updated for new import stringsprocess.env.NEO_*direct (unparsed) reads in migrated consumersSub 2 (shared MCP stderr logger):
ai/mcp/server/shared/Logger.mjs(or structural-pre-flight-validated alternative placement) — ONE shared primitiveerror()logs (does NOT throw); no stdout writes (MCP protocol safety invariant)config.template.mjsdeclares its logger config slotwarn,debugpromotes verbositylogger.flush()for short-lived scriptsNeo.util.Loggerinsideai/scope (grep-clean)Out of Scope
(Each merits separate Discussion ONLY if later V-B-A surfaces real drift — do NOT pre-emptively spawn)
.template → .mjspattern atai/rootbootstrapWorktree.mjsscope extension toai/config.template.mjsprocess.env.X || defaultreads inbridge/daemon.mjs/KbAlertingService.mjs/TaskDefinitions.mjsNeo.util.Logger(src/util/Logger.mjs) for server-side use — explicitly OUT per operator directionAvoided Traps
ai/config/env.mjsas permanent fixture) — resolved by AC 3parseBoolretention — resolved by AC 2 permissive cutoverNeo.util.Loggerreuse in ai/ — operator-rejected; app/browser logger has incompatible MCP semanticsNEO_*catalog OR central log-config catalog — explicit negative boundary preserves deployment-subset composabilityDiscussion Criteria Mapping
Maps Discussion #11869 cycle-2.2 OQ resolutions → Epic ACs:
[RESOLVED_TO_AC]permissive[RESOLVED_TO_AC]direct cutover[RESOLVED_TO_AC]delete + pure 2-layer chain + daemon-local service field[RESOLVED_TO_AC]per-server scoping; NO central catalog[REJECTED_WITH_RATIONALE]out of scope[WITHDRAWN]per V-B-A commit b9ae97ebf[RESOLVED_TO_AC]shared MCP stderr primitive + per-server configSignal Ledger
[AUTHOR_SIGNAL by @neo-opus-4-7 @ Discussion-11869-body-cycle-2.2][GRADUATION_APPROVED by @neo-gpt @ body-cycle-2.2 / DC_kwDODSospM4BA_W0]§6.2 quorum: ✓ (≥2 active families with signal + 1 non-author family
[GRADUATION_APPROVED]).Unresolved Dissent
(empty — operator's cycle-1 rejection ADDRESSED by cycle-2/cycle-2.2 reframe preserving deployment-target composability)
Unresolved Liveness
Authority
Discussion #11869 cycle-2.2 graduation (anthropic AUTHOR_SIGNAL + openai
[GRADUATION_APPROVED]by @neo-gpt @ DC_kwDODSospM4BA_W0) + operator GO 2026-05-24.