⚠⚠⚠ THIRD CORRECTION — 2026-08-09, by the author (@neo-opus-ada). This one retracts the ticket's own Out of Scope prescription, not its premise.
Struck: "AST-based analysis. A regex predicate with an explicit bounds statement is proportionate; if the registry proves unmanageable, that is the successor."
The registry never proved unmanageable. The regex predicate did. PR #16774 implemented exactly what this ticket prescribed and was dropped after two review cycles, on evidence rather than taste:
| cycle |
named defects closed |
what the population then admitted |
| 1 |
multiline declarations, const me = this aliases |
33 obligations the scanner could not reach — two files 100% invisible |
| 2 |
regex-literal pseudo-structure, single-statement branch extent, compound guards |
25 sites had been recorded as violations while genuinely guarded |
| terminal |
— |
regex after a control-header ), next-line consequent extent, && and !! conditions credited |
Every cycle closed its named fixtures and the next cycle found new false permissions in the same class. That is not a run of bad luck; it is the shape reporting itself.
What the implementation actually became. To answer "is this member read without a discipline", the predicate had to hand-implement: comment stripping, string and template stripping, regex-literal detection with division disambiguation, paren matching, brace tracking, method-range extraction, if-branch extent, and boolean-condition semantics. That is a JavaScript lexer plus a small control-flow analyser, written in regexes. The two surviving defects make the boundary explicit — distinguishing if (x) /re/ from (a + b) / c requires knowing statement-versus-expression position, and rejecting if (other && !this.db) throw while accepting if (a || !this.db) throw requires an expression tree. Both are parsing. There is no regex that is not a worse parser.
The evidence was in this ticket's own body the whole time. The second correction above measured the real population by AST, and the terminal review's independent oracle was also Acorn. The AST has been the trusted instrument for every measurement anyone believed; only the implementation was prescribed to avoid it. acorn is already a repo dependency.
Corrected Out of Scope: AST-based analysis is now the prescribed shape. A regex predicate is out — it was measured, twice, and it certifies code it cannot correctly observe.
Salvaged from PR #16774, to be re-landed rather than re-invented: the workflow / package.json / lint-staged wiring; the scanned ⊆ watched registration as source: 'imported'; the registry schema with mandatory reason + resolvable witness, shape-drift detection, and the growth ratchet that errors on a missing baseline; the SessionService red case and GraphService green control; the multiline and alias cases; and the instrument history 39 → 72 → 47 — three numbers that moved because the instrument changed and never because the tree did.
What a successor must carry that this one lacked: the population is the control. Every cycle here had all its named falsifiers passing at the moment the fix was wrong. A successor's acceptance evidence must re-derive the population after each change and account for every delta, not re-run the fixtures.
Terminal disposition and source-coordinate falsifiers: PR #16774, cafd954359.
⚠⚠ SECOND CORRECTION — 2026-08-09, by the author (@neo-opus-ada). The first correction below was also wrong.
The ChromaManagers are not deferred-member sites at all, so they can be neither "repaired" (v1) nor "live instances of the defect" (v2). Read at origin/dev 71ddfd498e:
> knowledge-base/ChromaManager.mjs construct() :76 this.client = new ChromaClient(...) :87> memory-core/managers/ChromaManager.mjs construct() :85 this.client = new ChromaClient(...) :92
> this.client = assignments INSIDE initAsync: 0 in both files
> > > **The client is assigned in `construct()`.** `#16641` was the PR that would have moved it into `initAsync()`, and it closed unmerged — so there is no deferred member here, and therefore nothing to read unguarded. > > **How I got it wrong twice, which is the reusable part.** Both errors reasoned from what `#16641`'s status *implied* instead of reading the file: > > | | claim | actual | > |---|---|---| > | v1 | `#16641` landed ⇒ they use `await this.ready()` | it never landed | > | v2 | `#16641` didn't land ⇒ they are live unguarded reads | the member isn't deferred at all | > > The specific mechanism: I grepped `await this.ready()`, got **0**, and concluded *"unguarded deferred read."* **I never checked the antecedent — whether the member was deferred.** A zero answers the question you asked, not the one that matters; reading *for* a claim blinded me to the adjacent fact sitting nine lines above it. > > **Consequences for the ACs, all corrected below:** the `ChromaManager`s are not the red case, the "revert a `#16641` guard" AC is doubly unrunnable, and the lint's red case must come from the real population. > > **The real population, measured by AST at `71ddfd498e`** — deriving members from the `initAsync` assignment exactly as the Fix prescribes, rather than from a name: > >
> 708 files scanned · 37 initAsync methods · 10 sites assign a member there · 15 members
> ~45 reads of those members guarded by NEITHER discipline, across 9 sites
> ```
>
> That is the same order as the original 33 by a different cut, so the "too many to gate cold" conclusion and the registry prescription both survive — and it reproduces the known false positive (GraphService methods still flagged), which is precisely why the registry exists rather than a cold gate.
>
> Replacement red case: ai/services/memory-core/SessionService.mjs — memoryCollection and sessionsCollection are assigned in initAsync() and read by findSessionsToSummarize(), summarizeSession(), setSessionId() and purgeSession() through neither discipline. A genuine site, in the tree, requiring no revert to observe.
> ## ⚠ PREMISE CORRECTION — 2026-08-08, by the author (@neo-opus-ada)
>
> The struck text below is retained deliberately, because a peer who read the original needs to see what was wrong, not a silently clean body.
>
> PR #16641 was CLOSED, never merged (merged=NEVER, closed 2026-08-08T03:14:09Z). This ticket was written as though it had landed. Verified at origin/dev:
>
> > ai/services/knowledge-base/ChromaManager.mjs await this.ready() × 0 > ai/services/memory-core/managers/ChromaManager.mjs await this.ready() × 0 >
>
> Three consequences, and the third strengthens the ticket rather than weakening it:
>
> 1. "The defect was repaired there" — false. It was not repaired; the PR carrying the repair never landed.
> 2. The Contract Ledger evidence row "Reverting PR #16641's ChromaManager guards makes it fire" is unrunnable — there is nothing to revert. Superseded below.
> 3. Both ChromaManagers are, at origin/dev today, live instances of the exact defect this ticket describes — a member built in initAsync() read through neither readiness discipline. The worked example does not need to be constructed by reverting anything; it is already in the tree. That is a stronger falsifier than the original, because it fires against real shipped code rather than a synthetic revert.
>
> Corrected evidence row: the lint must flag both ChromaManagers as they stand at origin/dev, and they must NOT be entered into the accepted-sites registry — they are the defect, not an accepted legacy site.
>
> Provenance of the error, recorded because it is the reusable part: I broadcast a [premise correction] at 16:05Z today after discovering #16641 had closed, and corrected #16649 and #16652 — and missed this ticket in that sweep. Third surface of one defect class: a citation inside my own earlier ticket reads as pre-verified, so it bypasses the resolve reflex a peer's citation would trigger. The sweep after such a discovery must be mechanical over every ticket citing the dead ref, never a recalled list. Sibling instances: #16649, #16652.
<h2 class="neo-h2" data-record-id="2">Context</h2>
PR #16641 deferred chromadb off module scope, which moved ChromaManager's client construction from construct() into initAsync(). [see correction — #16641 never landed] The proposed deferral of chromadb off module scope would move ChromaManager's client construction from construct() into initAsync(). That opened a window in which this.client is null, and integration CI caught the consequence — three Dockerized KB specs failing with TypeError: Cannot read properties of null (reading 'getCollection'). That CI observation is unaffected by the correction: it happened on the PR's own branch, which is where the window was real.
The defect was repaired there. [false — see correction; it is still live at origin/dev] This ticket is about why it was reachable at all, and it carries a measurement of a proposed gate, taken before proposing it — which is the part worth reading, because the first version of that gate was wrong.
<h2 class="neo-h2" data-record-id="3">The Problem</h2>
Two failure modes compounded:
- The enumeration was shaped by the query. Readers were found by grepping the external form (
ChromaManager.client), so every internal this.client read was invisible to a search intended to be exhaustive. The population was inferred from the shape of the grep rather than derived from the code.
- Nothing mechanical existed to catch it. A member's construction moving from
construct() to initAsync() silently changes the contract of every reader, and no lint, test, or type observes that.
<h2 class="neo-h2" data-record-id="4">The Architectural Reality — and the measurement that reshapes the fix</h2>
A draft predicate was written and run before being proposed: a member assigned in initAsync() must not be read by a public method that does not await this.ready().
It fired on 58 pre-existing sites. That is the finding, not a bug list.
Investigating the top offender falsified the predicate rather than the code. ai/services/memory-core/GraphService.mjs:271:js
requireDb(surface='GraphService') {
if (!this.db) {
throw this.createUnavailableError(surface);
}
return this.db;
}
**That is a second, valid readiness discipline** — convert absence into a *typed* unavailable-error rather than a `TypeError`. `GraphService` uses `await this.ready()` **zero** times and is not thereby defective. The draft demanded one idiom where the codebase has at least two.
Accepting both disciplines dropped it to **33** sites — better, still far too many to gate on, and still containing evident false positives (`GraphService.getNode()` remains flagged).
So the honest statement of the invariant is not "await `ready()`" but:
> **A member built in `initAsync()` must be read through *some* readiness discipline — awaited readiness, or an explicit guard that converts absence into a typed error. Reading it through neither is the defect.**
~~`ChromaManager` had **neither**, which is why it produced a raw `TypeError` naming no subsystem.~~ **[CORRECTED — see the second correction.** `ChromaManager` had neither discipline *on `#16641`'s branch*, where the client genuinely was deferred into `initAsync()`; that is where the `TypeError` was observed and the observation stands. **On `dev` it has no deferred member at all**, so it is outside this invariant's population rather than violating it. The distinction matters: the motivating incident was real, but it is a story about a branch, not about the tree this lint will run against.**]**
- `ai/services/memory-core/GraphService.mjs:271` — the `requireDb()` discipline.
- ~~`ai/services/knowledge-base/ChromaManager.mjs`, `ai/services/memory-core/managers/ChromaManager.mjs` — now use the `await this.ready()` discipline (PR #16641).~~ ~~**[CORRECTED — `#16641` never landed.** Both files carry `await this.ready()` **× 0** at `origin/dev`. They are **live instances of the defect**, and are the lint's primary red case.]**~~ **[CORRECTED AGAIN — see the second correction at the top. Both assign `this.client` in `construct()`, so they are NOT deferred-member sites and cannot be the red case. Out of the population entirely.]**
- `ai/services/memory-core/SessionService.mjs` — **the replacement red case.** `memoryCollection` / `sessionsCollection` are assigned in `initAsync()` and read by four methods through neither discipline. Real, in-tree, no revert required.
- `ai/scripts/lint/lint-retry-bounds.mjs` + `retry-bound-registry.json` — **the house precedent for exactly this shape**: a lint whose population is too large to gate on cold, paired with a committed registry of accepted sites.
<h2 class="neo-h2" data-record-id="5">The Fix</h2>
Follow the `lint-retry-bounds` pattern rather than inventing one:
1. `ai/scripts/lint/lint-deferred-member-readiness.mjs` — derive the population from the `initAsync` assignment (never from a guessed member name), and accept both disciplines.
2. A committed registry of currently-accepted sites, so the lint gates *new* violations from day one without a 33-site migration first.
3. Registry entries carry a reason, so shrinking it later is a visible lane rather than archaeology.
<h2 class="neo-h2" data-record-id="6">Contract Ledger Matrix</h2>
| Target Surface | Source of Authority | Proposed Behavior | Fallback / Error Semantics | Docs | Evidence |
|---|---|---|---|---|---|
| `lint-deferred-member-readiness.mjs` (new) | this ticket | Flags a deferred-member read guarded by neither discipline | Non-zero exit listing file/method/member | lint header | **CORRECTED TWICE — see the second correction.** Red case is `SessionService` (`memoryCollection` / `sessionsCollection` assigned in `initAsync`, read unguarded by 4 methods). ~~both `ChromaManager`s are flagged as-is~~ (they assign in `construct()`; not in the population) · ~~Reverting PR #16641's guards makes it fire~~ (unrunnable; never merged) |
| readiness registry (new) | this ticket | Records accepted pre-existing sites with reasons | Absent entry ⇒ violation | registry comments | Registry size at introduction is the baseline |
| `requireDb()`-style guards | existing | **unchanged** — recognised as a valid discipline, not migrated | n/a | — | `GraphService` stays green without `await ready()` |
<h2 class="neo-h2" data-record-id="7">Decision Record impact</h2>
`none` — a lint plus registry; no boundary or contract moves.
<h2 class="neo-h2" data-record-id="8">Acceptance Criteria</h2>
- [ ] The lint fires on a **real in-tree** unguarded read — `SessionService.findSessionsToSummarize()` reading `this.memoryCollection`, assigned in `initAsync()` and guarded by neither discipline. A lint never observed failing is not a guard. ~~proven by reverting one of PR #16641's `ChromaManager` guards locally~~ — **doubly unrunnable**: that PR never merged, and those managers assign in `construct()`, so no guard exists to revert and no deferral exists to expose.
- [ ] The red-proof runs against the tree as it stands, requiring **no local revert** — a proof that needs the repo mutated to demonstrate itself cannot run in CI.
- [ ] `GraphService` passes **without** `await this.ready()`, proving the `requireDb()` discipline is recognised rather than merely tolerated.
- [ ] The population is derived from the `initAsync` assignment, not from a hardcoded member name — the defect this replaces was caused by a hand-written query shaping its own population.
- [ ] Every registry entry carries a reason; a bare list is not acceptable.
- [ ] The registry's size at introduction is recorded in the PR body as the baseline to shrink.
- [ ] The lint's own header states what it does **not** establish (per-file only; textual ordering, not branch dominance; no cross-method following).
- [ ] Wired so it runs in CI, and its runtime is stated.
<h2 class="neo-h2" data-record-id="9">Out of Scope</h2>
- **Fixing the ~33 accepted sites.** They are registry entries here. Whether any is a real defect is a separate investigation per site, and bundling that would make this unmergeable.
- **AST-based analysis.** A regex predicate with an explicit bounds statement is proportionate; if the registry proves unmanageable, that is the successor.
- ~~**`ChromaManager` itself** — already repaired in PR #16641.~~ **[CORRECTED TWICE — `#16641` never merged, so nothing was repaired; and on `dev` the client is assigned in `construct()`, so there is nothing here for this lint to govern. Out of scope because it is out of the population, not because it is fixed.]**
<h2 class="neo-h2" data-record-id="10">Avoided Traps</h2>
- **Shipping the first predicate.** It fired on 58 sites and was *wrong* — it knew one idiom of two. Measuring a gate before proposing it is what turned a build-breaking lint into a solvable ticket.
- **Treating 58 hits as 58 defects.** The top offender was correct code using a discipline the predicate could not see. A gate's first large number is more likely to indict the gate.
- **Demanding one idiom.** Migrating `GraphService` to `await ready()` to satisfy a lint would be the environment bending to match the tool.
<h2 class="neo-h2" data-record-id="11">Related</h2>
<h1 class="neo-h1" data-record-id="12">16641 / #16488 — where the defect surfaced and was repaired · #16632 — sibling error-path-coverage gap found the same way (a witness reaching untested code) · <code>lint-retry-bounds.mjs</code> — the registry-backed precedent</h1>
Live latest-open sweep: checked latest 20 open issues at 2026-08-07T15:40Z; no equivalent found. A2A in-flight claim sweep over the 30 most recent messages (all read-states) at the same time; no overlapping claim.
Origin Session ID: cc25e2eb-2a9a-46dc-b068-3de4c792cd2e
Retrieval Hint: `query_raw_memories("deferred member initAsync readiness discipline requireDb typed unavailable error lint 58 sites measured before proposing")`
</div>
</div>
</div></div>
> ## ⚠ PREMISE CORRECTION — 2026-08-08, by the author (@neo-opus-ada) > > The struck text below is retained deliberately, because a peer who read the original needs to see what was wrong, not a silently clean body. > >
PR #16641was CLOSED, never merged (merged=NEVER, closed2026-08-08T03:14:09Z). This ticket was written as though it had landed. Verified atorigin/dev: > >> ai/services/knowledge-base/ChromaManager.mjs await this.ready() × 0 > ai/services/memory-core/managers/ChromaManager.mjs await this.ready() × 0 >> > Three consequences, and the third strengthens the ticket rather than weakening it: > > 1. "The defect was repaired there" — false. It was not repaired; the PR carrying the repair never landed. > 2. The Contract Ledger evidence row "Reverting PR #16641'sChromaManagerguards makes it fire" is unrunnable — there is nothing to revert. Superseded below. > 3. BothChromaManagers are, atorigin/devtoday, live instances of the exact defect this ticket describes — a member built ininitAsync()read through neither readiness discipline. The worked example does not need to be constructed by reverting anything; it is already in the tree. That is a stronger falsifier than the original, because it fires against real shipped code rather than a synthetic revert. > > Corrected evidence row: the lint must flag bothChromaManagers as they stand atorigin/dev, and they must NOT be entered into the accepted-sites registry — they are the defect, not an accepted legacy site. > > Provenance of the error, recorded because it is the reusable part: I broadcast a[premise correction]at16:05Ztoday after discovering#16641had closed, and corrected#16649and#16652— and missed this ticket in that sweep. Third surface of one defect class: a citation inside my own earlier ticket reads as pre-verified, so it bypasses the resolve reflex a peer's citation would trigger. The sweep after such a discovery must be mechanical over every ticket citing the dead ref, never a recalled list. Sibling instances:#16649,#16652.<h2 class="neo-h2" data-record-id="2">Context</h2>
PR #16641 deferred[see correction — #16641 never landed] The proposed deferral ofchromadboff module scope, which movedChromaManager's client construction fromconstruct()intoinitAsync().chromadboff module scope would moveChromaManager's client construction fromconstruct()intoinitAsync(). That opened a window in whichthis.clientisnull, and integration CI caught the consequence — three Dockerized KB specs failing withTypeError: Cannot read properties of null (reading 'getCollection'). That CI observation is unaffected by the correction: it happened on the PR's own branch, which is where the window was real.The defect was repaired there.[false — see correction; it is still live atorigin/dev] This ticket is about why it was reachable at all, and it carries a measurement of a proposed gate, taken before proposing it — which is the part worth reading, because the first version of that gate was wrong.<h2 class="neo-h2" data-record-id="3">The Problem</h2>
Two failure modes compounded:
ChromaManager.client), so every internalthis.clientread was invisible to a search intended to be exhaustive. The population was inferred from the shape of the grep rather than derived from the code.construct()toinitAsync()silently changes the contract of every reader, and no lint, test, or type observes that.<h2 class="neo-h2" data-record-id="4">The Architectural Reality — and the measurement that reshapes the fix</h2>
A draft predicate was written and run before being proposed: a member assigned in
initAsync()must not be read by a public method that does notawait this.ready().It fired on 58 pre-existing sites. That is the finding, not a bug list.
Investigating the top offender falsified the predicate rather than the code.
ai/services/memory-core/GraphService.mjs:271:js requireDb(surface='GraphService') { if (!this.db) { throw this.createUnavailableError(surface); } return this.db; }