Context
Surfaced during cycle-1 review of PR #12044 (export-drift guard for TenantRepoSyncService.resolveIngestionService). Any unit test that calls a function which transitively await import('ai/services.mjs') in an operator-overlay context (where ai/config.mjs has already registered Neo.ai.Config extending the template) trips the Neo.ai.Config namespace-collision guard at src/Neo.mjs:820:
Error: Namespace collision in unitTestMode for Neo.ai.Config
at src/Neo.mjs:821
at ai/config.template.mjs:578
PR #12044 worked around this with a per-test Neo.config.unitTestMode = false flip wrapped in try/finally. The flip is correct for the local case (the guard's non-strict branch is exactly designed for "different versions of Neo.mjs" / "different environments in parallel" — the same semantic that operator-overlay + template represents), but the workaround being needed at all is a substrate friction signal.
The Problem
unitTestMode strict behavior treats namespace presence as the collision criterion:
if (ns) {
if (Neo.config.unitTestMode) {
throw new Error('Namespace collision in unitTestMode for ' + proto.constructor.config.className)
}
return ns
}For operator-overlay-extending-template patterns (e.g., Neo.ai.Config registered first by ai/config.mjs, then ai/config.template.mjs:578 re-attempts registration via a transitive import chain), the existing namespace is the same class — strict-mode treats it as a collision, but it's structurally a no-op re-registration.
The current strict-mode guard can't distinguish:
- Real collision: two different classes claiming the same
className (bug — should error).
- Self-equal re-registration: same class registered twice through different import chains (operator-overlay + template; no-op intended).
The Architectural Reality
src/Neo.mjs:811-825 — setupClass namespace-collision guard. Comment at lines 811-818 explicitly names "different versions of Neo.mjs" and "different environments in parallel" as cases the non-strict branch was written for.
ai/config.mjs (gitignored operator overlay, generated from ai/config.template.mjs) — both register Neo.ai.Config.
- Any test that transitively triggers a re-import of either fails-loud under
unitTestMode: true.
The Fix (design discussion needed before mechanical PR)
Three options, listed in increasing intrusiveness:
Option A — Class-identity-aware guard
Change the collision criterion from "namespace exists" to "namespace exists AND points to a different class identity":
if (ns) {
if (Neo.config.unitTestMode && ns !== proto.constructor) {
throw new Error('Namespace collision in unitTestMode for ' + ...);
}
return ns
}Tradeoff: preserves strict-mode's intent (catch real collisions) while allowing self-equal re-registration. Backward-compat for existing strict-mode tests (real collisions still throw). Risk: subtle class-identity comparison semantics under module re-loads (Playwright workers, hot-reload, etc.).
Option B — Test infra: don't auto-load operator overlay in test mode
Have the test runner (or setup.mjs) detect unitTestMode: true and bypass the operator overlay loading entirely — tests always run against canonical template. Pure-template test environment.
Tradeoff: keeps strict-mode semantics intact. But blocks tests that need to exercise the overlay path (e.g., tests verifying operator-specific config behavior). Some tests would need a "load overlay" opt-in.
Option C — Document the per-test flip pattern as canonical
Treat the try/finally flip in PR #12044 as the documented pattern for tests that need to exercise transitive services.mjs imports. Add a helper in setup.mjs (e.g., withoutUnitTestModeStrictness(fn)) so callers don't reinvent the boilerplate.
Tradeoff: lowest blast radius. But "test-mode-bypass-for-testing" is a structural smell — the substrate isn't being fixed, just made livable.
Decision Record impact
challenges the implicit unitTestMode strictness contract — depending on which option lands, may amend or supersede the guard semantics. ADR successor-risk audit needed for substrate-level changes (Option A).
Acceptance Criteria
Out of Scope
- Refactoring all existing strict-mode tests — wait for design decision before bulk-touching.
- Operator-overlay design (whether overlay should extend template OR be a separate file) — orthogonal.
Avoided Traps
- Defaulting to Option C because it's the least disruptive. Option A is the substrate-correct fix; "test-mode-bypass-for-testing" patterns accumulate substrate debt.
- Treating this as cosmetic: the friction surfaced specifically because tests for cloud-deployment substrate (tenant-repo ingestion path) need to exercise the resolver. Future cloud-deployment-related tests will hit the same wall.
Related
- Empirical anchor: @neo-gpt cycle-1 review of PR #12044 — flagged the residual Drift C gap; the workaround was needed to address it.
- Sibling: #12042 (the export-drift guard ticket whose implementation surfaced this).
- Empirical session: first real-world cloud Agent OS deployment, 2026-05-26.
Context
Surfaced during cycle-1 review of PR #12044 (export-drift guard for
TenantRepoSyncService.resolveIngestionService). Any unit test that calls a function which transitivelyawait import('ai/services.mjs')in an operator-overlay context (whereai/config.mjshas already registeredNeo.ai.Configextending the template) trips theNeo.ai.Confignamespace-collision guard atsrc/Neo.mjs:820:PR #12044 worked around this with a per-test
Neo.config.unitTestMode = falseflip wrapped intry/finally. The flip is correct for the local case (the guard's non-strict branch is exactly designed for "different versions of Neo.mjs" / "different environments in parallel" — the same semantic that operator-overlay + template represents), but the workaround being needed at all is a substrate friction signal.The Problem
unitTestModestrict behavior treats namespace presence as the collision criterion:// src/Neo.mjs:819-825 if (ns) { if (Neo.config.unitTestMode) { throw new Error('Namespace collision in unitTestMode for ' + proto.constructor.config.className) } return ns }For operator-overlay-extending-template patterns (e.g.,
Neo.ai.Configregistered first byai/config.mjs, thenai/config.template.mjs:578re-attempts registration via a transitive import chain), the existing namespace is the same class — strict-mode treats it as a collision, but it's structurally a no-op re-registration.The current strict-mode guard can't distinguish:
className(bug — should error).The Architectural Reality
src/Neo.mjs:811-825—setupClassnamespace-collision guard. Comment at lines 811-818 explicitly names "different versions of Neo.mjs" and "different environments in parallel" as cases the non-strict branch was written for.ai/config.mjs(gitignored operator overlay, generated fromai/config.template.mjs) — both registerNeo.ai.Config.unitTestMode: true.The Fix (design discussion needed before mechanical PR)
Three options, listed in increasing intrusiveness:
Option A — Class-identity-aware guard
Change the collision criterion from "namespace exists" to "namespace exists AND points to a different class identity":
if (ns) { if (Neo.config.unitTestMode && ns !== proto.constructor) { // existing namespace points to a DIFFERENT class — real collision throw new Error('Namespace collision in unitTestMode for ' + ...); } return ns }Tradeoff: preserves strict-mode's intent (catch real collisions) while allowing self-equal re-registration. Backward-compat for existing strict-mode tests (real collisions still throw). Risk: subtle class-identity comparison semantics under module re-loads (Playwright workers, hot-reload, etc.).
Option B — Test infra: don't auto-load operator overlay in test mode
Have the test runner (or
setup.mjs) detectunitTestMode: trueand bypass the operator overlay loading entirely — tests always run against canonical template. Pure-template test environment.Tradeoff: keeps strict-mode semantics intact. But blocks tests that need to exercise the overlay path (e.g., tests verifying operator-specific config behavior). Some tests would need a "load overlay" opt-in.
Option C — Document the per-test flip pattern as canonical
Treat the
try/finallyflip in PR #12044 as the documented pattern for tests that need to exercise transitiveservices.mjsimports. Add a helper insetup.mjs(e.g.,withoutUnitTestModeStrictness(fn)) so callers don't reinvent the boilerplate.Tradeoff: lowest blast radius. But "test-mode-bypass-for-testing" is a structural smell — the substrate isn't being fixed, just made livable.
Decision Record impact
challengesthe implicitunitTestModestrictness contract — depending on which option lands, may amend or supersede the guard semantics. ADR successor-risk audit needed for substrate-level changes (Option A).Acceptance Criteria
Out of Scope
Avoided Traps
Related