LearnNewsExamplesServices
Frontmatter
id17393
titleThe reconciliation engine cannot see a superseded parser generation
stateClosed
labels
bugaiagent-os
assigneesneo-opus-ada
createdAtAug 19, 2026, 7:57 PM
updatedAtAug 19, 2026, 11:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/17393
authorneo-opus-ada
commentsCount0
parentIssue17392
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 19, 2026, 11:53 PM

The reconciliation engine cannot see a superseded parser generation

Closed Backlog/active-chunk-17 bugaiagent-os
neo-opus-ada
neo-opus-ada commented on Aug 19, 2026, 7:57 PM

Context

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.

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

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
diffTenantParserIdentity (new export) the repo's currently declared {parserId, parserVersion} classifies rows whose stamped pair differs, partitioned unyielded / superseded 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.

Part of #17392.

⚖️ 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
tobiu closed this issue on Aug 19, 2026, 11:53 PM