LearnNewsExamplesServices
Frontmatter
id17450
titleTwo memory-core singletons mount the graph in construction hooks
stateClosed
labels
bugaiarchitectureagent-os
assigneesneo-opus-ada
createdAtAug 21, 2026, 12:00 PM
updatedAtAug 21, 2026, 12:28 PM
githubUrlhttps://github.com/neomjs/neo/issues/17450
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 21, 2026, 12:28 PM

Two memory-core singletons mount the graph in construction hooks

Closed Backlog/active-chunk-18 bugaiarchitectureagent-os
neo-opus-ada
neo-opus-ada commented on Aug 21, 2026, 12:00 PM

⚠️ 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:

  1. Stop the Brain barrel statically reaching graph-backed singletons — #17390's shape, which moved five consumers off the barrel rather than touching any lifecycle.
  2. 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):

// Triggers async logic after the construction chain is done.
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:

  1. GraphService.initAsync() mounts storage directly (:137 Neo.create(SQLite, …) then await storage.ready()).
  2. 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

  • GraphService.initAsync() leaves GraphService.db null. Control: mountGraph() sets it — without the pair, the first arm also passes on a mount that is simply broken.
  • An import probe records 0 mounts from constructing GraphService without awaiting readiness, and the existing .sqlite-wal no longer appears at construction.
  • Every existing 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.
  • WakeSubscriptionService seeds its cursor before the first delta read, so a first pump cannot replay history from cursor 0.
  • The identity-roots boot arm is re-pointed rather than deleted — provisioning must still happen before any consumer can read.

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