Cloud pull-mode ingestion can report a healthy scheduler cycle even when the Knowledge Base rejected every vector write. A fresh source audit and executable falsifier on current dev reproduced the mismatch:
The synthetic ingestion result contained ingested: 1, embeddingsGenerated: 0, and errors: [{"code":"KB_VECTOR_EMBED_FAILED"}]. Despite the error-bearing summary, the poller persisted the head revision and supplied it as the next incremental base.
Live latest-open sweep: checked the latest 20 open issues at 2026-07-23T10:10:45Z; no equivalent found. Targeted live searches for tenant-repo ingestion errors and lastIngestedRev found only closed predecessor tickets. A2A all-state sweep: checked the latest 30 messages inside the 60-minute herd window; no overlapping earlier claim found.
The Problem
KnowledgeBaseIngestionService.ingestSourceFiles() is intentionally fail-soft. It accumulates structured errors and returns its summary for both partial failures and caught top-level failures (ai/services/knowledge-base/IngestionService.mjs:211-228). A resolved promise therefore does not mean the ingest succeeded.
TenantRepoSyncService.syncTenantRepos() currently treats every resolved call as success. It invokes ingestSourceFiles(), immediately persists lastIngestedRev = envelope.headRevision, marks the repo active, and increments completedCount without inspecting ingestResult.errors (ai/daemons/orchestrator/services/TenantRepoSyncService.mjs:458-488). The unit-test fake always returns errors: [], so the failure-bearing return path is not covered (test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs:76-87).
This poisons incremental state. Once a failed head is stored, later runs diff from that head and can legitimately produce an empty envelope, so fixing the underlying embedding/config problem does not replay the skipped content.
The existing manual --repo-slug option only selects repositories; it still uses the stored base revision (ai/scripts/maintenance/syncTenantRepos.mjs:29-58,106-111). It is not a full replay mechanism.
The Architectural Reality
KnowledgeBaseIngestionService owns the structured ingestion summary and its errors array.
TenantRepoSyncService owns success/failure classification, retry/backoff state, and revision persistence.
tenant-repo-sync-revisions.json is load-bearing incremental state. The closed #11790 contract says it advances only after successful ingestion.
The existing per-repo catch path already preserves the last good revision, increments consecutiveFailures, and exposes stable outer/source error codes. Error-bearing summaries should enter that same path rather than create a second failure model.
The manual maintenance CLI is the appropriate bounded recovery surface; this ticket does not add remote write authority.
Structure-map ownership: existing implementation stays in ai/daemons/orchestrator/services, ai/services/knowledge-base, and ai/scripts/maintenance; no new directory or service boundary is required.
The Fix
Validate the returned ingestion-summary shape before classifying a repo as successful.
Treat any non-empty ingestResult.errors as a failed repo attempt:
preserve the prior lastIngestedRev;
advance lastRunAttemptAt and increment consecutiveFailures so existing backoff applies;
retain KB_TENANT_REPO_SYNC_SYNC_FAILED as the stable outer code;
preserve the first bounded KB_* summary code as lastSourceErrorCode when present, without copying raw content or arbitrary details.
Treat a missing or non-array errors field as an invalid result and fail closed through the same stable outer error contract.
Add scoped full-replay recovery to syncTenantRepos.mjs, e.g. --full --repo-slug <slug>:
--full requires at least one explicit, repeatable repo selector;
selected repos build the next envelope with lastIngestedRev: null while retaining the persisted prior revision until completion;
a failed replay leaves persisted revision state unchanged;
only an error-free replay advances to the new head and resets failures.
Correct cloud-ingestion docs that currently claim partial ingestion failure leaves the revision unchanged, and document the scoped recovery command.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
KnowledgeBaseIngestionService.ingestSourceFiles() summary consumed by TenantRepoSyncService
Current IngestionService.mjs return contract + closed #11790 revision invariant
Only a summary with an array-valued, empty errors field is eligible for revision advancement
Non-empty, missing, or malformed errors fails the repo through the existing tenant-sync failure path
JSDoc at the consumption boundary
Unit tests for empty, non-empty, missing, and malformed errors
Explicitly selected repos ingest from a null base and advance state only after clean completion
Reject --full without at least one repo selector; failed replay preserves prior state
CLI help + cloud recovery runbook
Parser/dispatch tests and success/failure replay tests
Decision Record impact
Aligned with ADR 0014's existing cloud-deployable tenant-repo-sync lane. No ADR amendment: this restores the shipped revision-success invariant and adds a bounded operator recovery mode within the existing service boundary.
Acceptance Criteria
A returned ingestion summary with one or more errors marks that repo attempt failed/degraded rather than active/completed.
Error-bearing, missing-errors, and malformed-errors summaries never advance lastIngestedRev.
Failure state preserves the prior revision, increments consecutiveFailures, and participates in existing backoff.
The first safe KB_* summary code can surface as bounded lastSourceErrorCode; raw messages, details, source content, repo URLs, and credentials do not.
Mixed-repo cycles preserve current per-repo isolation and partial-success semantics while correctly counting the error-bearing repo as failed.
The next incremental run after a failed ingest receives the last known-good revision, not the failed head.
The manual CLI supports explicit scoped full replay; --full without --repo-slug is rejected.
A failed full replay leaves stored revision state untouched; an error-free full replay advances to the new head and resets failures.
Focused unit coverage exercises the real returned-summary seam, not only thrown exceptions.
Cloud ingestion and troubleshooting docs describe fail-closed revision semantics and scoped recovery accurately.
Context
Cloud pull-mode ingestion can report a healthy scheduler cycle even when the Knowledge Base rejected every vector write. A fresh source audit and executable falsifier on current
devreproduced the mismatch:{ "firstStatus": "completed", "firstRepoStatus": "active", "persistedRevision": "deadbeefcafebabe", "secondBaseRevision": "deadbeefcafebabe", "secondStatus": "completed" }The synthetic ingestion result contained
ingested: 1,embeddingsGenerated: 0, anderrors: [{"code":"KB_VECTOR_EMBED_FAILED"}]. Despite the error-bearing summary, the poller persisted the head revision and supplied it as the next incremental base.Live latest-open sweep: checked the latest 20 open issues at 2026-07-23T10:10:45Z; no equivalent found. Targeted live searches for tenant-repo ingestion errors and
lastIngestedRevfound only closed predecessor tickets. A2A all-state sweep: checked the latest 30 messages inside the 60-minute herd window; no overlapping earlier claim found.The Problem
KnowledgeBaseIngestionService.ingestSourceFiles()is intentionally fail-soft. It accumulates structured errors and returns its summary for both partial failures and caught top-level failures (ai/services/knowledge-base/IngestionService.mjs:211-228). A resolved promise therefore does not mean the ingest succeeded.TenantRepoSyncService.syncTenantRepos()currently treats every resolved call as success. It invokesingestSourceFiles(), immediately persistslastIngestedRev = envelope.headRevision, marks the repoactive, and incrementscompletedCountwithout inspectingingestResult.errors(ai/daemons/orchestrator/services/TenantRepoSyncService.mjs:458-488). The unit-test fake always returnserrors: [], so the failure-bearing return path is not covered (test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs:76-87).This poisons incremental state. Once a failed head is stored, later runs diff from that head and can legitimately produce an empty envelope, so fixing the underlying embedding/config problem does not replay the skipped content.
The existing manual
--repo-slugoption only selects repositories; it still uses the stored base revision (ai/scripts/maintenance/syncTenantRepos.mjs:29-58,106-111). It is not a full replay mechanism.The Architectural Reality
KnowledgeBaseIngestionServiceowns the structured ingestion summary and itserrorsarray.TenantRepoSyncServiceowns success/failure classification, retry/backoff state, and revision persistence.tenant-repo-sync-revisions.jsonis load-bearing incremental state. The closed#11790contract says it advances only after successful ingestion.consecutiveFailures, and exposes stable outer/source error codes. Error-bearing summaries should enter that same path rather than create a second failure model.Structure-map ownership: existing implementation stays in
ai/daemons/orchestrator/services,ai/services/knowledge-base, andai/scripts/maintenance; no new directory or service boundary is required.The Fix
ingestResult.errorsas a failed repo attempt:lastIngestedRev;lastRunAttemptAtand incrementconsecutiveFailuresso existing backoff applies;KB_TENANT_REPO_SYNC_SYNC_FAILEDas the stable outer code;KB_*summary code aslastSourceErrorCodewhen present, without copying raw content or arbitrary details.errorsfield as an invalid result and fail closed through the same stable outer error contract.syncTenantRepos.mjs, e.g.--full --repo-slug <slug>:--fullrequires at least one explicit, repeatable repo selector;lastIngestedRev: nullwhile retaining the persisted prior revision until completion;Contract Ledger Matrix
KnowledgeBaseIngestionService.ingestSourceFiles()summary consumed byTenantRepoSyncServiceIngestionService.mjsreturn contract + closed#11790revision invarianterrorsfield is eligible for revision advancementerrorsfails the repo through the existing tenant-sync failure patherrorstenant-repo-sync-revisions.jsonwritebackTenantRepoSyncService.syncTenantRepos()+ closed#11790lastIngestedRevonly after an error-free summaryTenantIngestionModel.mdlastErrorCode/lastSourceErrorCodecontract from closed#14402KB_*summary code is retained as source provenanceKB_VECTOR_EMBED_FAILED--fullmodesyncTenantRepos.mjs --repo-slugoperator surface--fullwithout at least one repo selector; failed replay preserves prior stateDecision Record impact
Aligned with ADR 0014's existing cloud-deployable
tenant-repo-synclane. No ADR amendment: this restores the shipped revision-success invariant and adds a bounded operator recovery mode within the existing service boundary.Acceptance Criteria
errors, and malformed-errorssummaries never advancelastIngestedRev.consecutiveFailures, and participates in existing backoff.KB_*summary code can surface as boundedlastSourceErrorCode; raw messages, details, source content, repo URLs, and credentials do not.--fullwithout--repo-slugis rejected.Out of Scope
ingest_source_filesresponse semantics.Avoided Traps
ingested > 0, or a non-null head; the structurederrorsarray is load-bearing.Related
Related: #11790
Related: #14396
Related: #14402
Origin Session ID: fc1a49c1-e30a-4e3a-960a-e0596367a4c1
Handoff Retrieval Hint:
tenant-repo-sync error-bearing ingest summary lastIngestedRev poisoned revision scoped full replay