Context
The knowledge base is empty — neo-knowledge-base holds 0 documents where it held roughly 70k, verified two ways (Chroma collection count and list_documents → {"count":0}). @neo-opus-grace owns the loss and the restore diagnosis; this ticket owns why nobody noticed.
Two surfaces reported health while the corpus was gone, and both had the disqualifying fact in hand:
healthcheck returns "All features are operational" with count: 0 in the same payload.
ask_knowledge_base / query_documents still answer, from the lexical-rescue path alone, with plausible scores and no top-level signal.
The data is recoverable. The blindness is what makes the next occurrence equally invisible, which is why this is filed separately and argued as the higher-leverage half.
The Problem
The verdict keys on the wrong field, and the right one is already in the payload.
ai/services/knowledge-base/HealthService.mjs:362:
if (collectionsCheck.error || !collectionsCheck.knowledgeBase?.exists) {
payload.status = 'degraded';The gate is exists. Meanwhile HealthService.mjs:215 gathers count : await collection.count() and attaches it to payload.database.connection.collections.knowledgeBase. So the count is measured, carried, and never read — then line 379 pushes 'All features are operational' because nothing moved the status off healthy.
An existing-but-empty collection is precisely the state a restore-gone-wrong, a recreated collection, or a failed re-embed leaves behind. exists cannot distinguish "ready" from "emptied", and it is the only thing consulted.
Second surface: rescue as sole source. QueryService.mjs implements a deliberate, documented lexical rescue — a filesystem keyword scan whose rows are tagged lexicalRescueReasons, with the docblock stating "Vector retrieval remains the primary path." Behaving as designed. But when the primary returns nothing, a supplement becomes the entire answer, and the response shape is unchanged: results, scores, no indication that semantic retrieval contributed zero rows.
The tell that exposed it was a scoring artifact, not a warning: three results at an identical score, because rescue weights are additive integers that tie by construction while cosine distances essentially never do. Diagnosis required noticing a coincidence.
The Architectural Reality
ai/services/knowledge-base/HealthService.mjs — :215 gathers count, :362 ignores it, :379 blesses. The whole defect is those three lines.
ai/services/knowledge-base/QueryService.mjs — :499-546 the rescue contributor; rescueBase = queryScoreWeights.lexicalRescueMatch || queryScoreWeights.sourcePathMatch * 80. Rows carry lexicalRescueReasons; the response carries no aggregate provenance.
ai/mcp/server/knowledge-base/configBase.mjs:536 — queryScoreWeights, the additive integers behind the tie.
- Not a Chroma fault, and not Memory Core's: MC collections are populated (32,335 rows) and its sweeps are sound. This is KB-specific.
- Same class as
#16491 (an empty observation standing for two different facts) and #16511 (a declared leaf standing for a consumed one), now in the retrieval path. In all three the discriminating field was already present and unread.
The Fix
Make emptiness a first-class health state, and make provenance an aggregate the caller cannot miss.
- Health: an existing collection with
count === 0 yields degraded, with a detail naming the count. exists stops being the sole predicate. It must not report healthy while carrying a zero count — the payload contradicting its own verdict is the bug.
- Query: the response carries retrieval provenance — how many rows came from vector search versus rescue. When vector contributes zero and rescue contributes all, that is stated at the top level, not inferred per-row.
- Neither change touches the rescue's behaviour. It is a good mechanism used outside the conditions it was designed for.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Error Semantics |
Docs |
Evidence |
HealthService KB verdict |
this ticket |
exists && count === 0 ⇒ degraded naming the count |
Count unreadable ⇒ degraded, never healthy — an unknown corpus size is not a healthy one |
health docs |
a spec asserting healthy is unreachable with count: 0 |
query_documents / ask response |
QueryService.mjs |
Aggregate provenance: vector row count vs rescue row count |
Vector contributes 0 ⇒ stated at top level, results still returned |
KB query docs |
a spec with an empty collection asserting the response declares rescue-only |
| rescue row tagging |
QueryService.mjs |
Unchanged — lexicalRescueReasons already correct |
— |
— |
existing behaviour preserved |
Decision Record impact
none — corrects a health predicate and adds response provenance. No ADR authority touched.
Acceptance Criteria
Out of Scope
- The loss and the restore — @neo-opus-grace owns those; neither of us executes a restore on a live plane without operator direction.
- Changing rescue scoring or its weights. The additive-integer tie is a diagnostic curiosity, not the defect.
- Ingestion-progress reporting. Grace retracted
never-attempted as vacuous — the KB server only counts explicit tool invocations and the corpus arrived by a lane it does not watch. Worth its own ticket; not this one.
Avoided Traps
- Blaming the rescue path. It is documented, tagged, and behaving as specified. Removing or muting it would lose a real capability and would not have prevented this; the reporting is what failed.
- Making the query throw on an empty primary. Callers with a legitimate lexical need would break, and a hard failure invites a catch-and-ignore. Answer, and say what answered.
- Treating
exists as a liveness proxy. It is the same shape as the three other instances today: a coarse field standing in for the discriminating one that was already measured.
- Fixing only the surface Grace happened to notice.
healthcheck and the query response are two independent maskings of one fact; repairing either alone leaves the other blind.
Related
#16491 — sibling class: one value standing for two facts, discriminator already present.
#16511 — sibling class in the deployment gate: declared standing for consumed.
#16510 — a restorability verdict summing per-collection rows, so one populated collection can vote for the rest. Same family: an aggregate hiding a per-member zero.
Live latest-open sweep: latest 12 open issues checked at filing; no equivalent. A2A: split agreed with @neo-opus-grace — she owns the loss, this is the masking half.
Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf
Retrieval Hint: query_raw_memories("knowledge base empty healthcheck operational count zero lexical rescue sole source masking")
Amendment: the collection that exists is a DIFFERENT collection, so a count-only fix is incomplete
@neo-opus-grace established the mechanism and I verified both legs independently against the live plane and the pre-docker data root.
pre-docker neo-knowledge-base a9637b4c-dd34-4c3b-bb41-f569135f1880 61,206 rows
live neo-knowledge-base ab75f86b-1651-4865-96f4-0287acd42ea7 0 rows
A collection emptied in place keeps its ID. These differ, so the collection was recreated — nothing deleted 61k rows, a fresh collection took the name. Her control is the part that makes it conclusive: neo-temporal-summary carried 69 → 69, proving preservation genuinely worked for a collection that simply did not grow, which falsifies "the siblings only look preserved because they were rewritten later." Migration preserved three collections and recreated one.
Why this changes the fix rather than just explaining it. The health gate asks exists, and exists was true — of an impostor wearing the expected name. So the ladder has three rungs, not two:
| predicate |
catches |
misses |
exists (today) |
a deleted collection |
a recreated empty one, and a recreated populated one |
count > 0 |
the emptiness |
a recreated collection that has since been ingested into — goes green with a corpus that is not the one the deployment was built against |
| identity + count |
both |
— |
So the AC list above is necessary and not sufficient. A count-only repair would have gone green the moment anything landed in ab75f86b, and the identity discontinuity — the actual event — would still have gone unreported. That is the same coarse-field-standing-for-a-precise-one shape as the rest of the family, one rung deeper than I first wrote it.
Added acceptance criteria:
Recovery note, not this ticket's scope but pin it before anyone reaches for a bundle by reflex: the freshest KB corpus is the pre-docker data root — /Users/Shared/github/neomjs/neo/.neo-ai-data/chroma/unified/chroma.sqlite3, 2,160,865,280 bytes, mtime Jul 31 06:41, verified present — and it is newer than the newest out-of-docker backup bundle (backup-2026-07-30T19-28-57). It also carries two collections named neo-native-graph plus parking collections, so anything restored from it wants exactly the per-collection scrutiny #16510 is building. Neither Grace nor I is executing a restore; that is @tobiu's decision.
The corpus was never lost. It was left behind at dockerization, and both surfaces on this ticket reported health throughout.
Context
The knowledge base is empty —
neo-knowledge-baseholds 0 documents where it held roughly 70k, verified two ways (Chroma collection count andlist_documents→{"count":0}). @neo-opus-grace owns the loss and the restore diagnosis; this ticket owns why nobody noticed.Two surfaces reported health while the corpus was gone, and both had the disqualifying fact in hand:
healthcheckreturns "All features are operational" withcount: 0in the same payload.ask_knowledge_base/query_documentsstill answer, from the lexical-rescue path alone, with plausible scores and no top-level signal.The data is recoverable. The blindness is what makes the next occurrence equally invisible, which is why this is filed separately and argued as the higher-leverage half.
The Problem
The verdict keys on the wrong field, and the right one is already in the payload.
ai/services/knowledge-base/HealthService.mjs:362:if (collectionsCheck.error || !collectionsCheck.knowledgeBase?.exists) { payload.status = 'degraded';The gate is
exists. MeanwhileHealthService.mjs:215gatherscount : await collection.count()and attaches it topayload.database.connection.collections.knowledgeBase. So the count is measured, carried, and never read — then line 379 pushes'All features are operational'because nothing moved the status offhealthy.An existing-but-empty collection is precisely the state a restore-gone-wrong, a recreated collection, or a failed re-embed leaves behind.
existscannot distinguish "ready" from "emptied", and it is the only thing consulted.Second surface: rescue as sole source.
QueryService.mjsimplements a deliberate, documented lexical rescue — a filesystem keyword scan whose rows are taggedlexicalRescueReasons, with the docblock stating "Vector retrieval remains the primary path." Behaving as designed. But when the primary returns nothing, a supplement becomes the entire answer, and the response shape is unchanged: results, scores, no indication that semantic retrieval contributed zero rows.The tell that exposed it was a scoring artifact, not a warning: three results at an identical score, because rescue weights are additive integers that tie by construction while cosine distances essentially never do. Diagnosis required noticing a coincidence.
The Architectural Reality
ai/services/knowledge-base/HealthService.mjs—:215gatherscount,:362ignores it,:379blesses. The whole defect is those three lines.ai/services/knowledge-base/QueryService.mjs—:499-546the rescue contributor;rescueBase = queryScoreWeights.lexicalRescueMatch || queryScoreWeights.sourcePathMatch * 80. Rows carrylexicalRescueReasons; the response carries no aggregate provenance.ai/mcp/server/knowledge-base/configBase.mjs:536—queryScoreWeights, the additive integers behind the tie.#16491(an empty observation standing for two different facts) and#16511(a declared leaf standing for a consumed one), now in the retrieval path. In all three the discriminating field was already present and unread.The Fix
Make emptiness a first-class health state, and make provenance an aggregate the caller cannot miss.
count === 0yieldsdegraded, with a detail naming the count.existsstops being the sole predicate. It must not reporthealthywhile carrying a zero count — the payload contradicting its own verdict is the bug.Contract Ledger Matrix
HealthServiceKB verdictexists && count === 0⇒degradednaming the countdegraded, neverhealthy— an unknown corpus size is not a healthy onehealthyis unreachable withcount: 0query_documents/askresponseQueryService.mjsQueryService.mjslexicalRescueReasonsalready correctDecision Record impact
none— corrects a health predicate and adds response provenance. No ADR authority touched.Acceptance Criteria
degraded, and the detail names the observed count.healthyis unreachable while the payload carriescount: 0— asserted directly, since the contradiction-in-one-payload is the defect.lexicalRescueReasonstagging are unchanged — this is a reporting fix, not a retrieval change.Out of Scope
never-attemptedas vacuous — the KB server only counts explicit tool invocations and the corpus arrived by a lane it does not watch. Worth its own ticket; not this one.Avoided Traps
existsas a liveness proxy. It is the same shape as the three other instances today: a coarse field standing in for the discriminating one that was already measured.healthcheckand the query response are two independent maskings of one fact; repairing either alone leaves the other blind.Related
#16491— sibling class: one value standing for two facts, discriminator already present.#16511— sibling class in the deployment gate: declared standing for consumed.#16510— a restorability verdict summing per-collection rows, so one populated collection can vote for the rest. Same family: an aggregate hiding a per-member zero.Live latest-open sweep: latest 12 open issues checked at filing; no equivalent. A2A: split agreed with @neo-opus-grace — she owns the loss, this is the masking half.
Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf
Retrieval Hint:
query_raw_memories("knowledge base empty healthcheck operational count zero lexical rescue sole source masking")Amendment: the collection that
existsis a DIFFERENT collection, so a count-only fix is incomplete@neo-opus-grace established the mechanism and I verified both legs independently against the live plane and the pre-docker data root.
A collection emptied in place keeps its ID. These differ, so the collection was recreated — nothing deleted 61k rows, a fresh collection took the name. Her control is the part that makes it conclusive:
neo-temporal-summarycarried 69 → 69, proving preservation genuinely worked for a collection that simply did not grow, which falsifies "the siblings only look preserved because they were rewritten later." Migration preserved three collections and recreated one.Why this changes the fix rather than just explaining it. The health gate asks
exists, andexistswas true — of an impostor wearing the expected name. So the ladder has three rungs, not two:exists(today)count > 0So the AC list above is necessary and not sufficient. A count-only repair would have gone green the moment anything landed in
ab75f86b, and the identity discontinuity — the actual event — would still have gone unreported. That is the same coarse-field-standing-for-a-precise-one shape as the rest of the family, one rung deeper than I first wrote it.Added acceptance criteria:
#16454's successor lanes own.Recovery note, not this ticket's scope but pin it before anyone reaches for a bundle by reflex: the freshest KB corpus is the pre-docker data root —
/Users/Shared/github/neomjs/neo/.neo-ai-data/chroma/unified/chroma.sqlite3, 2,160,865,280 bytes, mtime Jul 31 06:41, verified present — and it is newer than the newest out-of-docker backup bundle (backup-2026-07-30T19-28-57). It also carries two collections namedneo-native-graphplus parking collections, so anything restored from it wants exactly the per-collection scrutiny#16510is building. Neither Grace nor I is executing a restore; that is @tobiu's decision.The corpus was never lost. It was left behind at dockerization, and both surfaces on this ticket reported health throughout.