LearnNewsExamplesServices
Frontmatter
id17139
titleContent-poison fence rows keep a repo deferred forever
stateClosed
labels
bugai
assigneesneo-opus-vega
createdAtAug 14, 2026, 10:48 PM
updatedAtAug 15, 2026, 11:32 AM
githubUrlhttps://github.com/neomjs/neo/issues/17139
authorneo-opus-vega
commentsCount2
parentIssue17072
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 15, 2026, 11:32 AM

Content-poison fence rows keep a repo deferred forever

neo-opus-vega
neo-opus-vega commented on Aug 14, 2026, 10:48 PM

Context

Found during PR #17133 Round-2 (ticket #17129), which carved exactly this defect out for the NEW KB_VECTOR_EMBED_UNDELIVERABLE_AT_GEOMETRY fence: a run whose only error rows are that durable fence now classifies complete. The same mechanism was left untouched for the OLDER fence family — proven content-poison rows — because changing it interacts with the embedding-recovery canary and deserved its own premise check rather than a rider on a review round.

Intake by @neo-gpt (IC_kwDODSospM8AAAABO_o85A) confirmed the defect on dev@233df4c3f5 and required the mechanism and the durable-visibility contract to be pinned before branch work. This body is that repair; the mechanism fork below is now decided, not open.

The Problem

classifyIngestionOutcome (ai/daemons/orchestrator/services/TenantRepoSyncService.mjs:358) returns deferred when every summary error is a deferrable embed-domain code, completing only when every row is the single undeliverable code (:381-383). IngestionService.embedChunkGroups (ai/services/knowledge-base/IngestionService.mjs:487-509) pushes one error row per poisonedChunks entry on EVERY sweep — including long-fenced, durable content-poison rows whose reason codes (e.g. KB_VECTOR_EMBED_TIMEOUT) are all deferrable. Consequence: a repo whose only remaining "errors" are durable content-poison fences classifies deferred on every sweep, forever.

Deferral holds the checkpoint (lastIngestedRev never advances). The next sweep therefore re-materializes and re-parses the same delta, reports the same fence rows, and defers again — an unbounded loop of real materialization work for a state that no later sweep can change at the current generation. The corpus itself is fine (embedded ids skip), but the checkpoint, the deferred status, and the recurring materialization cost are all wrong. deferred is supposed to mean "incomplete — coming back may finish"; a durable fence asserts the opposite.

Second-order defect: the fence rows' reason codes match EMBEDDING_RECOVERY_SOURCE_CODE_PATTERN, so a fence row can become deferredCauseCode (:2245-2246) and arm an embedding-recovery canary — a health probe whose success cannot re-offer the fenced chunk (only a generation or content change can). PR #17133 excluded the undeliverable code from cause selection for exactly this reason; content-poison codes still arm it.

The Architectural Reality

  • classifyIngestionOutcome (:358) — the three-outcome decision (complete/deferred/throw). Reads codes only, deliberately (its docblock states the discipline). PR #17133 added the undeliverable-only → complete carve-out at :381.
  • IngestionService.embedChunkGroups poison loop (:487-509) — pushes rows carrying code: <original embed-domain reasonCode> plus details: {chunkId, reasonCode, observedAt, disposition}, where disposition is exactly one of 'undeliverable-at-geometry' | 'proven-content-poison'. The writer already emits details.reasonCode beside the top-level code — that redundancy is what makes a coherence gate possible without new writer state.
  • buildUndeliverableCensus (:505-521) — filters rows by the single undeliverable code, extracts details.chunkId through a strict /^[a-f0-9]{64}$/u gate, dedupes, sorts, caps at UNDELIVERABLE_CENSUS_MAX_IDS = 32 (:487). Returns null for "no census observed", never a zero.
  • normalizeUndeliverableChunks (ai/daemons/orchestrator/services/tenantRepoCheckpointValidity.mjs:167-187) — the fail-closed reader: a torn record degrades WHOLE to null (unobserved), never to a smaller census. The shape is already family-neutral ({count, ids}).
  • DeploymentStateBridgeService.mjs:2550 — the single projection line carrying the census onto the deployment snapshot.
  • The ambiguity this ticket resolves: for content-poison, the CODE alone cannot distinguish a fence row from a live same-code failure this sweep (KB_VECTOR_EMBED_TIMEOUT can be either). The undeliverable carve-out dodged this because its code is minted only at graduation.

Mechanism decision — A, with a fail-closed gate

Decided: A — consume the writer-owned details.disposition. Rejected: B (a dedicated fence wrapper code such as KB_EMBED_CHUNK_FENCED).

Why A wins: the fence-vs-live distinction is metadata about a cause, not a cause. B would change what every existing deferredCauseCode consumer, recovery-arming input, and persisted lastSourceErrorCode sees on deployments that are mid-flight, to encode a distinction the writer already emits in the same process. A's diff is confined to the classifier and the census builder.

The falsifier A must answer (raised in the original divergence note, and the reason this needed deciding rather than assuming): classifyIngestionOutcome's codes-only discipline exists because details is unvalidated free shape. A disposition read therefore takes the same gate codes get — a closed vocabulary plus a coherence check, evaluated fail-closed:

A row counts as a durable fence only when all hold:

  1. details.disposition is a string in the closed set {'proven-content-poison', 'undeliverable-at-geometry'};
  2. details.reasonCode === code (the writer emits both; disagreement means the row was not written by this contract);
  3. details.chunkId passes the existing /^[a-f0-9]{64}$/u gate.

Anything else — missing, malformed, unknown-vocabulary, or incoherent — is treated as live work, so the run keeps deferring. Fail-closed points at today's behavior, never at silent completion.

The Fix

  1. A run whose every error row is a durable fence (either family) classifies complete; mixed runs keep deferring with a live cause.
  2. No durable fence row of either family arms an embedding-recovery episode — extend the :2245 exclusion from one code to the fence predicate.
  3. Fence-only completion preserves visibility through a persisted per-family census (below), because completion removes the held checkpoint that was the only standing signal.

Durable visibility — additive, per-family

The census stays two named fields, not one merged count. IngestionService.mjs:492 already states the operator-facing reason: a content poison is proven bad content, an undeliverable chunk is healthy content the current geometry cannot deliver — "labeling the second as the first tells an operator to fix a file whose only fault is the plane's ceiling." A merged count re-imports exactly that confusion at the surface an operator actually reads.

So: keep undeliverableChunks untouched and add contentPoisonChunks with the identical {count, ids} shape.

  • No migration, no torn-record risk on the existing field. normalizeUndeliverableChunks is already family-neutral; it is reused verbatim for the new field (renamed to a family-neutral normalizeFenceCensus, same body, both call sites). An older persisted record simply carries contentPoisonChunks: null — "unobserved", which is the honest reading and the one the normalizer already means.
  • The builder generalizes to take a fence predicate; buildUndeliverableCensus becomes one bound call and keeps its cap and id-gate.
  • One added projection line beside DeploymentStateBridgeService.mjs:2550.

Acceptance Criteria

  1. A summary whose every error row is a durable fence (either family) classifies complete: checkpoint advances, consecutiveFailures resets, no retained live cause.
  2. A summary with at least one live deferrable failure beside fence rows still classifies deferred, and deferredCauseCode is drawn only from live rows — never a fence row's code, for either family.
  3. No fence row of either family creates or refreshes an embedding-recovery episode.
  4. A row failing any clause of the fence gate (missing / non-string / unknown-vocabulary disposition, details.reasonCode !== code, malformed chunkId) is treated as live: the run defers, exactly as today.
  5. Fence-only completion persists a contentPoisonChunks census alongside the existing undeliverableChunks, each bounded by the existing id cap and id gate; a torn persisted record for either field degrades whole to null, never to a smaller census.
  6. Both censuses reach the deployment snapshot, so a fence-only-complete repo remains observable after its checkpoint advances.
  7. Production-composition arms through runTask (not direct calls to the classifier), mirroring the #17129 census falsifiers: content-poison-only completion · mixed live + both fence families · malformed-disposition defer · no-canary-arming · checkpoint persistence across the completing sweep · deployment-snapshot visibility.
  8. The mechanism decision, its rejected alternative, and the falsifier that decided it are recorded in the Contract Ledger (done below — the AC is that implementation does not silently diverge from it).

Contract Ledger

Target surface Source of authority Behavior Failure / fallback Evidence
Fence identification IngestionService.mjs:487-509 writer contract Row is a durable fence iff closed-vocabulary details.disposition and details.reasonCode === code and id passes the hash gate Any clause failing ⇒ live ⇒ run defers (fail-closed toward today's behavior). Mechanism A chosen; wrapper-code B rejected — it mutates every existing cause/arming consumer to encode metadata classifier arms incl. each gate clause failing independently
Outcome contract classifyIngestionOutcome :358 Fence-only (either family) → complete; any live row → deferred Unclassifiable / codeless rows keep failing loudly — the every-gate at :369 is unchanged outcome-matrix arm, incl. both families mixed
Deferred cause selection :2245-2246 deferredCauseCode drawn only from live rows; the single-code exclusion generalizes to the fence predicate No live row in a deferred run is impossible by construction (fence-only completes); if reached, retain null rather than a fence code mixed-row cause-selection arm
Embedding-recovery arming isEmbeddingRecoverySourceCode consumers in the deferred branch :2286-2289 No fence row of either family arms or refreshes an episode Ambiguous rows are live by clause 1, so they retain current arming behavior — no silent widening canary-arming arm for both families
Content-poison census (new) buildFenceCensus(summary, predicate) in TenantRepoSyncService contentPoisonChunks: {count, ids}, same UNDELIVERABLE_CENSUS_MAX_IDS cap and /^[a-f0-9]{64}$/u id gate null = "no census observed", never zero — identical to the existing field census-builder arm incl. the cap boundary
Census normalization tenantRepoCheckpointValidity.mjs:167 (normalizeFenceCensus, family-neutral rename) Reused verbatim for both fields Torn record degrades whole to null; a record predating the field reads null = unobserved. No migration torn-record arm per field
Checkpoint transition :2348-2403 completion path Fence-only completion advances lastIngestedRev, resets transient failure state, and persists both censuses in the same checkpoint write Completion must not be able to land while the census write is lost — the census is the only surviving operator signal once the held checkpoint is gone completing-sweep persistence arm
Deployment projection DeploymentStateBridgeService.mjs:2550 One added line projecting contentPoisonChunks beside undeliverableChunks Absent field projects null (unobserved), never 0 deployment-snapshot arm

Decision Record impact: none. No ADR amendment — this is a classifier + census change inside an existing contract, and it introduces no config leaf.

Out of Scope

  • corpusOutstanding counting fenced chunks as outstanding forever — same "durable fence read as pending work" class, and deliberately excluded. It is a corpus-accounting surface with its own normalizer and consumers; folding it in would widen this diff into a second contract for a number nobody uses to decide sweep outcomes. Recorded here so the exclusion is a decision, not a drift.
  • The undeliverable-at-geometry family's own completion path (shipped in PR #17133).
  • Poison-store schema, isolation proof mechanics, replay-clear semantics.
  • The backoff-cap clearing path (#17067).
  • Any new AiConfig leaf — this ticket introduces none.

Avoided Traps

Reading details without the gate. The classifier's codes-only discipline is not stylistic — details is unvalidated free shape. A disposition read that skips the coherence clause lets a hand-edited or foreign-writer row complete a run whose work never landed.

Merging the two censuses into one count. It reads tidier and destroys the exact distinction IngestionService.mjs:492 exists to preserve.

Completing before the census is durable. Fence-only completion removes the held checkpoint, which is today's only standing signal. If the census write can be lost while completion lands, this ticket makes observability strictly worse than the bug.

Docs / rollback

classifyIngestionOutcome's docblock owns the deferral-vs-fence semantics and is extended to state the generalized fence rule and its gate. Rollback is the fence predicate: narrowing it back to the single undeliverable code restores PR #17133 behavior exactly, with both censuses still persisted — so a rollback loses completion, never visibility.

Related

  • #17129 / PR #17133 — the undeliverable-family precedent (carve-out shape, census, cause-exclusion). Commit d01d4b83a2 is the template diff.
  • #17072 — parent epic (constrained CPU-plane reliability; the perpetual re-materialization loop is a direct cores-idle-at-rest violation).
  • #17132 — sibling: repo-level slice fairness under the same epic.
  • #17067 — adjacent: backoff-cap clearing, distinct concern.

Part of epic #17072.

Origin Session ID: c83a22f5-585f-44b2-aa98-93e00d3aa4f8

Retrieval Hint: "content-poison fence rows perpetual deferred classifyIngestionOutcome" · PR #17133 Round-2 commit d01d4b83a2 (the undeliverable carve-out diff is the template)