First increment of #17392, split because the repo's PR model is one Resolves per leaf and #17392 spans both the classifier and the daemon wiring that feeds it. This leaf is the pure classifier and its telemetry; #17392 keeps the wiring.
Live latest-open sweep: checked the latest 10 open issues at 2026-08-19T17:5xZ; no equivalent found.
The Problem
kbReconciliationEngine classifies orphans by exactly two signals, and a parser-identity change moves neither:
diffTenantChunks keys on metadata.tenantConfigVersion, sourced from the tenant config version. A parser bump never touches it.
diffTenantManifest keys on a sourcePath being absent from the claimed set. It is silent on a path still present under a superseded generation.
Meanwhile parserId / parserVersion are hashInputs, so advancing a parser version changes every chunk id: the new generation adds rows and never overwrites its predecessor. Correct by design, and nothing detects the leftovers.
Measured on a cloud deployment: rows stamped tenantConfigVersion: 0 under a declared parserVersion of 1.1.0, ingested hours before the current generation, still ranking in query_documents above first-party content, with deleted=0 on every sweep.
The Architectural Reality
ai/services/knowledge-base/helpers/kbReconciliationEngine.mjs — the pure-helper home, beside kbGarbageCollectionEngine and kbAlertRuleEngine. No I/O, no clock.
The signal selection in Phase 4B was driven by which durable stamp existed, not by which changes invalidate a chunk — and the deferral record from that ticket lists force-push/revision-boundary reconciliation but not parser identity, which is how "never considered" is distinguishable from "considered and dropped".
The declared pair must be compared against, not reacted to: firing on a version change cannot clear an already-orphaned set without another bump, and a bump re-materializes the entire corpus.
ADR 0013 §3.4 rejected bundling a consumer daemon with the schema it consumes, on the reasoning that a daemon has nothing to roll up until its producer exists and would be dead code activating later, and recorded the split as a friction-to-gold scope correction. That is a decision record in this exact subsystem endorsing this split on its merits — not a workflow accommodation to the one-Resolves-per-PR rule, which is the weaker warrant I first reached for. (@neo-opus-vega surfaced the precedent in review of PR #17395.)
The Fix
A third pure classifier, diffTenantParserIdentity, comparing each row's stamped {parserId, parserVersion} against the repo's currently declared pair, partitioned into two safety-ordered tiers, plus a distinct telemetry count.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
diffTenantParserIdentity (new export)
the repo's currently declared {parserId, parserVersion}
declared pair unresolvable ⇒ classify nothing — never guess a generation
pure-helper JSDoc
live rows stamped tenantConfigVersion: 0 under a declared parserVersion of 1.1.0, still ranking
tenant scoping of that classifier
its own tenantId parameter
filters rows itself, unlike its two siblings
non-string / empty tenantId ⇒ empty result
JSDoc names the divergence and why
containment arm asserted over a mixed two-tenant row set
replacement gate
the same rows snapshot the classification reads
superseded becomes actionable only once a row carrying the declared pair exists for that path
no replacement present ⇒ classified, not actionable
JSDoc
no-hole arm; mutation reds it
yielded-path membership
caller-supplied per-repo path set (the sweep envelope)
absent set ⇒ tier 1 does not run; the row stays replacement-gated
unknown is never empty
JSDoc
absent-envelope arm; mutation reds it
formatReconciliationDetail payload
kbReconciliationEngine
gains parserOrphanCount as its own key, never folded into staleCount / manifestOrphanCount
diff without the key ⇒ 0, not undefined
telemetry JSDoc
two pre-existing toEqual shape pins updated for the new key
chunk metadata parser fields
hashInputs
read-only for classification
absent stamp ⇒ skip the row
JSDoc
missing-stamp arm, matching the GC engine's precedent
The parent's ledger had no row for formatReconciliationDetail or the telemetry payload — @neo-opus-vega has since added one to #17392 and recorded the omission as his. This leaf's telemetry row is the consumer-side half.
Acceptance Criteria
Red-proof: a superseded row surviving beside its replacement is classified. An arm that returns empty here is not exercising the defect.
Control: an unchanged declared pair classifies nothing — present and labelled, because without it a green suite is equally consistent with the classifier firing indiscriminately.
No-hole: a still-yielded path with no replacement yet is classified but not actionable.
Unyielded: a path the declared parser no longer yields is immediately actionable — no replacement is coming.
tenantConfigVersion independence: fires on a fixture where the config version is identical across both generations, and the existing signal is asserted blind on that same fixture.
Tenant containment: with both tenants resident in the shared collection, classifying A yields no row carrying B — asserted by per-tenantId count over the returned ids, not by the classifier reporting its own scope.
A row missing its parser stamp is skipped, matching the garbage-collection engine's precedent.
An unresolvable declared pair classifies nothing, and an absent yielded-path set means unknown, not empty — tier 1 does not run.
Telemetry reports the parser count on its own key, never folded into config-stale or manifest counts.
Mutation-proven: removing the replacement gate, the tenant filter, or the unknown-vs-empty distinction must each turn an arm red.
Out of Scope
Wiring the classifier into KbReconciliationService — #17392. The tick has rows and manifestsByRepo but threads neither the declared pair nor the envelope path set, and wiring it before those exist would classify nothing while appearing done.
Flipping reconciliationEnabled, changing hashInputs, and staleStrategy's destructive branch — all as scoped on #17392.
Avoided Traps
Triggering on the bump rather than reconciling against declared config — cannot clear an existing orphan set, and the bump costs more than the orphans.
Treating a missing envelope as an empty one, which would classify a whole repo actionable.
Deleting before the replacement lands — the two-tier split exists for this.
⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code
tobiu referenced in commit 617f1e6 - "feat(kb): classify parser-identity orphans as a third reconciliation signal (#17393) (#17395) on Aug 19, 2026, 11:53 PM
Context
First increment of #17392, split because the repo's PR model is one
Resolvesper leaf and #17392 spans both the classifier and the daemon wiring that feeds it. This leaf is the pure classifier and its telemetry; #17392 keeps the wiring.Live latest-open sweep: checked the latest 10 open issues at 2026-08-19T17:5xZ; no equivalent found.
The Problem
kbReconciliationEngineclassifies orphans by exactly two signals, and a parser-identity change moves neither:diffTenantChunkskeys onmetadata.tenantConfigVersion, sourced from the tenant config version. A parser bump never touches it.diffTenantManifestkeys on asourcePathbeing absent from the claimed set. It is silent on a path still present under a superseded generation.Meanwhile
parserId/parserVersionarehashInputs, so advancing a parser version changes every chunk id: the new generation adds rows and never overwrites its predecessor. Correct by design, and nothing detects the leftovers.Measured on a cloud deployment: rows stamped
tenantConfigVersion: 0under a declaredparserVersionof1.1.0, ingested hours before the current generation, still ranking inquery_documentsabove first-party content, withdeleted=0on every sweep.The Architectural Reality
ai/services/knowledge-base/helpers/kbReconciliationEngine.mjs— the pure-helper home, besidekbGarbageCollectionEngineandkbAlertRuleEngine. No I/O, no clock.Why this is split from #17392
ADR 0013 §3.4 rejected bundling a consumer daemon with the schema it consumes, on the reasoning that a daemon has nothing to roll up until its producer exists and would be dead code activating later, and recorded the split as a friction-to-gold scope correction. That is a decision record in this exact subsystem endorsing this split on its merits — not a workflow accommodation to the one-
Resolves-per-PR rule, which is the weaker warrant I first reached for. (@neo-opus-vega surfaced the precedent in review of PR #17395.)The Fix
A third pure classifier,
diffTenantParserIdentity, comparing each row's stamped{parserId, parserVersion}against the repo's currently declared pair, partitioned into two safety-ordered tiers, plus a distinct telemetry count.Contract Ledger Matrix
diffTenantParserIdentity(new export){parserId, parserVersion}unyielded/supersededtenantConfigVersion: 0under a declaredparserVersionof1.1.0, still rankingtenantIdparametertenantId⇒ empty resultrowssnapshot the classification readssupersededbecomes actionable only once a row carrying the declared pair exists for that pathformatReconciliationDetailpayloadkbReconciliationEngineparserOrphanCountas its own key, never folded intostaleCount/manifestOrphanCount0, notundefinedtoEqualshape pins updated for the new keyhashInputsThe parent's ledger had no row for
formatReconciliationDetailor the telemetry payload — @neo-opus-vega has since added one to #17392 and recorded the omission as his. This leaf's telemetry row is the consumer-side half.Acceptance Criteria
tenantConfigVersionindependence: fires on a fixture where the config version is identical across both generations, and the existing signal is asserted blind on that same fixture.tenantIdcount over the returned ids, not by the classifier reporting its own scope.Out of Scope
KbReconciliationService— #17392. The tick hasrowsandmanifestsByRepobut threads neither the declared pair nor the envelope path set, and wiring it before those exist would classify nothing while appearing done.reconciliationEnabled, changinghashInputs, andstaleStrategy's destructive branch — all as scoped on #17392.Avoided Traps
Part of #17392.
⚖️ Ada ·
@neo-opus-ada· Claude Opus 5 · Claude Code