⚠️ APPROACH RETRACTED — the lever was wrong, the defect is not
PR #17449 is closed unmerged. @tobiu: "overriding ready() BREAKS neo contracts, keeping an empty initAsync just with a super call is BLOAT — negative ROI, you skipped exploring neo core contracts." Verified, and he is right on all three.
Measured on that branch: isReady: true while db: false. Base.mjs:316 sets isReady = true the moment initAsync() resolves, so an emptied initAsync announces readiness while the graph is unmounted — and afterSetIsReady fires the Observable ready event there too. ready() returns a private #readyPromise the framework resolves; it is not an override point. And Base.mjs:608 states the intent outright: "Once the promise returned by this method is fulfilled, the isReady config will be set to true" — initAsync is the home for async init that readiness depends on.
So the framing below is wrong where it says the defect is I/O in a construction hook. It is not. The defect is that a singleton: true class is constructed by Neo.setupClass during module evaluation, so its perfectly-correct initAsync runs at import. The lever is construction timing, not the lifecycle hook.
Corrected fix candidates:
- Stop the Brain barrel statically reaching graph-backed singletons — #17390's shape, which moved five consumers off the barrel rather than touching any lifecycle.
- Stop
GraphService being an eager singleton; construct on demand.
Both leave initAsync doing exactly what its contract says.
What survives unchanged: the #17383 diagnosis (three triggers, captured stack, host/Brain control), and @neo-gpt-emmy's P1 — seeding the wake cursor at the first consumer samples after the triggering mutation and advances past the wake it was invoked to deliver. The mount-watermark ordering is correct wherever the real fix lands.
My error: I read Base.mjs:963 in isolation and never read the lifecycle around it — :305-316, :348-365, :600-611. Same class as twice earlier today: a mechanism read without its contract.
Context
Split from #17383 once its diagnosis named the callers. That ticket asked why importing the Brain barrel opens the graph; the answer turned out to be three distinct triggers, and two of them are plain defects while the third is a deliberate boot contract. Mixing them in one close-target would either under-deliver or delete a contract by accident, so the defects land here and the architectural fork stays on #17383.
The Problem
core.Base schedules initAsync() from every constructor (src/core/Base.mjs:314-316):
Promise.resolve().then(async () => {
await me.initAsync();
me.isReady = true
})Neo.setupClass instantiates singleton: true classes during module evaluation. Together those mean: on a singleton, any I/O in a construction hook is I/O at import.
Two memory-core singletons do exactly that, so importing them mounts SQLite — a native module load, a mkdir, a database handle and a WAL writer — in every process on the path, whether or not it ever reads a node:
GraphService.initAsync() mounts storage directly (:137 Neo.create(SQLite, …) then await storage.ready()).
WakeSubscriptionService.init() — core.Base's init() hook (Base.mjs:594) — awaits GraphService.ready() to seed its live cursor, which mounts transitively.
Measured with an import probe that wraps SQLite.prototype.initAsync before the barrel loads and records the caller stack: ai/services.mjs → 1 mount from Base.mjs:315; ai/services.host.mjs → 0. Not a test-runtime artifact — the host/Brain asymmetry rules out the playwright config's Brain-tier presence probe, and the probe never loads playwright.
The Architectural Reality
The SQLite.mjs:49 dynamic-import mitigation is correct and was never the defect. A method-scoped dynamic import is inert until the method is called; these hooks call it. Its own comment — "to prevent native Node module evaluation crashes inside browser/test runtimes" — describes a guarantee that construction-time callers silently voided.
The constraint that shapes the fix: ~20 call sites already await GraphService.ready() and treat it as "the graph is usable", and 25+ modules (MailboxService 25 call sites, IssueIngestor 11, MemoryService 10) call graph methods with no readiness await at all, reaching the graph only through gated entry points. So the mount must move, not disappear — deleting it would resolve ready() on a null database.
The Fix
GraphService: initAsync() completes without touching the database; the mount moves to ready(), memoized by the existing _initPromise guard. ready() keeps meaning "the graph is usable", so no caller changes.
WakeSubscriptionService: cursor seeding moves from init() to first pump(), before the delta read. The no-replay-on-boot guarantee is unaffected — nothing can replay before the first pump.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
GraphService.ready() |
~20 existing call sites |
UNCHANGED — resolves only when the graph is usable |
mount failure still recorded in graphInitError |
class JSDoc |
existing readiness specs |
GraphService.initAsync() |
core.Base lifecycle |
no longer mounts; completes clean |
— |
class JSDoc |
new arm asserts db is null after it |
GraphService.mountGraph() |
new |
the single mount entry point, idempotent |
_initPromise memoizes; a failed mount is not retried |
JSDoc |
control arm asserts it mounts |
WakeSubscriptionService cursor seed |
pump() |
seeds once, before the first delta read |
_liveCursorSeeded set before the await, so a concurrent pump cannot rewind it |
JSDoc |
boot-replay behaviour unchanged |
Decision Record impact
none. This uses core.Base's existing lifecycle rather than changing it.
Acceptance Criteria
Out of Scope
SystemLifecycleService.initAsync(), the third trigger. It awaits readiness on four services under its own comment "awaiting ready() is the whole boot contract" — importing the barrel boots memory-core by design. Removing it is an architectural decision, and it stays on #17383 as a named fork. This ticket therefore does not stop the barrel opening the graph; it stops the two paths that do so unintentionally.
- Changing
SQLite.mjs:49. The mitigation is correct.
core.Base's unconditional initAsync scheduling. Shared by every Neo class; the defect is doing I/O inside the hook, not the hook.
Avoided Traps
- Deleting the open instead of moving it. ~20 callers treat readiness as usability; a null database would break the contract rather than the eagerness.
- Trusting a green parse after the first fix. Each fix revealed the next trigger. A PR opened after fixing only
GraphService would have changed nothing observable while claiming to.
- Treating trigger 3 as a third instance. It is a documented contract, not a defect. Same-shaped code, opposite intent.
Related
Split from #17383, which retains the architectural fork and the full diagnosis. Adjacent: #17390 moved five config-only consumers off this barrel — the consumer-side treatment of the same cost.
Retrieval Hint: query_raw_memories("Brain barrel import opens graph singleton initAsync construction hook")
Live latest-open sweep: checked the latest 20 open issues at 2026-08-21T09:59:52Z plus an A2A claim scan; no equivalent ticket and no in-flight claim.
Origin Session ID: 43441f60-7f2a-4734-82da-22b609b115f9
Context
Split from #17383 once its diagnosis named the callers. That ticket asked why importing the Brain barrel opens the graph; the answer turned out to be three distinct triggers, and two of them are plain defects while the third is a deliberate boot contract. Mixing them in one close-target would either under-deliver or delete a contract by accident, so the defects land here and the architectural fork stays on #17383.
The Problem
core.BaseschedulesinitAsync()from every constructor (src/core/Base.mjs:314-316):// Triggers async logic after the construction chain is done. Promise.resolve().then(async () => { await me.initAsync(); me.isReady = true })Neo.setupClassinstantiatessingleton: trueclasses during module evaluation. Together those mean: on a singleton, any I/O in a construction hook is I/O atimport.Two memory-core singletons do exactly that, so importing them mounts SQLite — a native module load, a
mkdir, a database handle and a WAL writer — in every process on the path, whether or not it ever reads a node:GraphService.initAsync()mounts storage directly (:137Neo.create(SQLite, …)thenawait storage.ready()).WakeSubscriptionService.init()—core.Base'sinit()hook (Base.mjs:594) — awaitsGraphService.ready()to seed its live cursor, which mounts transitively.Measured with an import probe that wraps
SQLite.prototype.initAsyncbefore the barrel loads and records the caller stack:ai/services.mjs→ 1 mount fromBase.mjs:315;ai/services.host.mjs→ 0. Not a test-runtime artifact — the host/Brain asymmetry rules out the playwright config's Brain-tier presence probe, and the probe never loads playwright.The Architectural Reality
The
SQLite.mjs:49dynamic-import mitigation is correct and was never the defect. A method-scoped dynamic import is inert until the method is called; these hooks call it. Its own comment — "to prevent native Node module evaluation crashes inside browser/test runtimes" — describes a guarantee that construction-time callers silently voided.The constraint that shapes the fix: ~20 call sites already
await GraphService.ready()and treat it as "the graph is usable", and 25+ modules (MailboxService25 call sites,IssueIngestor11,MemoryService10) call graph methods with no readiness await at all, reaching the graph only through gated entry points. So the mount must move, not disappear — deleting it would resolveready()on a null database.The Fix
GraphService:initAsync()completes without touching the database; the mount moves toready(), memoized by the existing_initPromiseguard.ready()keeps meaning "the graph is usable", so no caller changes.WakeSubscriptionService: cursor seeding moves frominit()to firstpump(), before the delta read. The no-replay-on-boot guarantee is unaffected — nothing can replay before the first pump.Contract Ledger Matrix
GraphService.ready()graphInitErrorGraphService.initAsync()core.Baselifecycledbis null after itGraphService.mountGraph()_initPromisememoizes; a failed mount is not retriedWakeSubscriptionServicecursor seedpump()_liveCursorSeededset before the await, so a concurrent pump cannot rewind itDecision Record impact
none. This usescore.Base's existing lifecycle rather than changing it.Acceptance Criteria
GraphService.initAsync()leavesGraphService.dbnull. Control:mountGraph()sets it — without the pair, the first arm also passes on a mount that is simply broken.GraphServicewithout awaiting readiness, and the existing.sqlite-walno longer appears at construction.await GraphService.ready()caller still observes a mounted graph; the Brain unit suite is the falsifier, and any red names an ungated path rather than being waived.WakeSubscriptionServiceseeds its cursor before the first delta read, so a first pump cannot replay history from cursor 0.Out of Scope
SystemLifecycleService.initAsync(), the third trigger. It awaits readiness on four services under its own comment "awaitingready()is the whole boot contract" — importing the barrel boots memory-core by design. Removing it is an architectural decision, and it stays on #17383 as a named fork. This ticket therefore does not stop the barrel opening the graph; it stops the two paths that do so unintentionally.SQLite.mjs:49. The mitigation is correct.core.Base's unconditionalinitAsyncscheduling. Shared by every Neo class; the defect is doing I/O inside the hook, not the hook.Avoided Traps
GraphServicewould have changed nothing observable while claiming to.Related
Split from #17383, which retains the architectural fork and the full diagnosis. Adjacent: #17390 moved five config-only consumers off this barrel — the consumer-side treatment of the same cost.
Retrieval Hint:
query_raw_memories("Brain barrel import opens graph singleton initAsync construction hook")Live latest-open sweep: checked the latest 20 open issues at 2026-08-21T09:59:52Z plus an A2A claim scan; no equivalent ticket and no in-flight claim.
Origin Session ID: 43441f60-7f2a-4734-82da-22b609b115f9