Skip to main content

Motivation

Documents & OCR closed the file → Markdown seam: a scanned contract or a photographed whiteboard now becomes a searchable, chunked, embedded document instead of a 422. It did not close the seam right after it. OCR output is a guess with a confidence — a driver’s best read of pixels, not a fact anyone vouched for — and until v8.37 that guess retrieved and ranked exactly like content a human had actually reviewed. generation_source (human / auto, ADR 0014) already modelled the distinction for canonical documents; nothing wired it up for the non-canonical case, which is where nearly all OCR content lives — an inbound scan rarely arrives with YAML frontmatter attached. Two defects followed from that gap, both closed in this cycle:
  1. The reranker’s own firewall didn’t apply. human > auto > raw (ADR 0014’s anti-hallucination promise) was implemented as a penalty that short-circuited to zero for every non-canonical chunk, before it ever read generation_source. An unreviewed scan and a human-reviewed sibling scan ranked identically. See the Reranker firewall fix section below — this is fixed at the retrieval layer, independent of whether anyone has reviewed anything yet.
  2. There was no way to review anything. No table recorded which pages of a converted document a human had looked at; no action flipped generation_source from auto to human for a non-canonical document. WikiExplorerService::promote() did the equivalent transition, but only for canonical rows, and unconditionally set canonical_status = 'accepted' — correct for its own callers, and a false canonical fact if reused for an ordinary scan.
Everything on this page is off by default (KB_DIGITIZATION_REVIEW_ENABLED=false, R43). With the flag off, KbReviewService’s mutating methods throw a clean, typed exception (KbReviewDisabledException, HTTP 404) rather than a silent no-op; the one read documentReviewSummary()/pageReviewStatus() exposes is deliberately not gated at the service layer (see Gotchas), but every external surface — HTTP, the CLI’s report display (--report, and the --page=N --report combo), and KbReviewStatusTool — still refuses to serve it while the feature is off. The design rationale is ADR 0031.

Configuration

Theory & background

Two ideas from earlier ADRs meet here, and neither is new — this cycle wires them together for the first time. ADR 0014’s tier is a trust label, not a workflow state. human means “a person is answerable for this text”; auto means “a machine produced it and no person has looked yet.” OCR gives every converted document a starting value of auto — set once, at ingest, by DocumentIngestor — and this cycle’s only new idea is that something can move it to human: a person looking at the page and saying “yes, this is right.” ADR 0003’s boundary — an agent proposes, a person commits — restated for OCR. The promotion pipeline already refuses to let an agent write canonical storage on its own authority; the same boundary applies here, sharper. A provenance_tier: untrusted-external inbound letter (ADR 0028) is, definitionally, text the platform did not author. An agent that can read such a letter must never be the one that marks it “reviewed” — that action is a human vouching for content whose authorship is not the platform’s, and letting an agent perform it would mean the model could clear its own citation for grounding purposes. Concretely: there is no MCP write of review status or approval, on purpose (§8 below).

Design

Per-page review state

kb_document_page_reviews holds exactly one row per (tenant_id, knowledge_document_id, page_number) — the tenant-scoped unique key that makes setPageReviewStatus() an upsert, never an accumulation: re-touching an already-touched page updates that same row (reviewed_by / reviewed_at refreshed, or cleared when reverting to unreviewed) instead of producing a second one. The upsert is a single INSERT ... ON CONFLICT DO UPDATE (Postgres) / ON DUPLICATE KEY UPDATE (MySQL) statement — deliberately not a SELECT followed by an INSERT/UPDATE decided in PHP, because that shape is a real race: two reviewers marking the same page concurrently can both miss the SELECT and both attempt an INSERT, and one of them would hit the unique constraint as an uncaught exception instead of the promised idempotent upsert (R21). The database resolves the race, not two round trips from the application. A page number is meaningless without knowing how many pages the document actually has, so setPageReviewStatus() and pageReviewStatus() both validate against the document’s own recorded metadata.converter.page_count — the same field OcrConverter / PdfConverter already write on every conversion. Two failure modes this closes, both real (found in review, not hypothetical): a page number beyond the document’s real page count no longer silently inserts a “phantom” reviewed page that documentReviewSummary() would then count as legitimate progress; a document that was never converted (no recorded page count at all) is refused outright rather than accepting a page review for content that has no page-level structure to review.

Document-wide summary is derived, never cached

documentReviewSummary() returns {total, reviewed, unreviewed} computed fresh on every call — there is deliberately no cached boolean or counter column to keep in sync when a correction later re-chunks the document into a different page count. Once a document’s metadata.converter.page_count is known, total is anchored on that — the document’s real page count — not on how many kb_document_page_reviews rows happen to exist. This matters from the very first page: reviewing page 1 of a fresh 10-page conversion reports {total: 10, reviewed: 1, unreviewed: 9} immediately, not {total: 1, ...} climbing toward the truth one touched page at a time. A document with no recorded page count at all (never converted through OCR/PDF processing) falls back to counting whatever rows exist — the best available answer for a row shape that predates page-count tracking, not a guess.

Approval — auto → human, branched on canonicity

approve() is the one action that actually changes what the reranker sees (below). It is intentionally not “review every page first” — a reviewer can approve a document at any point in its per-page review progress; per-page tracking and document approval are two separate signals, not a gate on each other. What differs is the destination:
  • Canonical document (has YAML frontmatter, participates in the knowledge graph) → delegates to the existing WikiExplorerService::promote(), which already performs the exact same auto → human transition, audited and transactional, for its own callers (Canonical & Promotion). Reusing it here means one transition, one audit shape, one place that understands “what does approval mean for a canonical row” — not two implementations drifting.
  • Non-canonical document (the ordinary OCR’d-scan case — no frontmatter, not part of the graph) → KbReviewService performs the flip directly: generation_source: auto → human, one kb_canonical_audit row (event_type = 'promoted'). This is implemented here rather than by relaxing promote()’s own guard, because promote() unconditionally sets canonical_status = 'accepted' — correct for a canonical row, and a false canonical fact stamped on a document that was never canonical to begin with. This branch only accepts an OCR-derived non-canonical document (metadata.converter.provenance === 'ocr'); any other non-canonical auto row (an AutoWiki-enriched raw-markdown document, also generation_source = 'auto' by construction) is rejected with {approved: false, reason: 'not_ocr_origin'}. The reason is durability, not policy: the AutoWiki firewall (below) only preserves a human value for is_canonical || ocr_origin rows, so approving a non-OCR row here would write an audit row claiming a durable approval that a later AutoWiki compile pass would silently undo.
Both branches run inside one transaction that lockForUpdate()s the tenant-scoped document row first and re-reads is_canonical / generation_source from that locked row — never from the $document instance the caller passed in, which may be stale by the time the lock is acquired. This serializes concurrent approve() calls on the same document: a second concurrent call blocks on the row lock until the first commits, then sees the now-human row and returns the safe {approved: false, reason: 'not_auto'} no-op instead of racing to write a duplicate audit row (R21). The same transaction also checks the write’s own return value — save() returns false when a model event vetoes the write, and ignoring that would let the audit row get written while generation_source silently stayed auto on disk: an approval that is audited but never actually happened. A vetoed save now throws and rolls back the whole transaction, audit row included. Calling approve() on a document already human is a safe, silent no-op — same posture as promote() — so a double-click or a retried request never produces a second audit row.

The Reranker firewall fix

Reranker::canonicalAdjustment() used to compute the auto-tier penalty after an early return for non-canonical chunks — meaning it never ran for them at all. The fix moves that computation ahead of the is_canonical branch, but it does not apply to every non-canonical auto row uniformly — it is scoped to rows that are actually OCR output:
retrieval_priority and the accepted/pending status penalty stay canonical-only — those are properties of the promotion pipeline, and a non-canonical row has no canonical_status to penalize. The auto-tier penalty is not “is this generation_source auto?” for every non-canonical row: generation_source = 'auto' on a non-canonical row is also written by AutoWikiCompiler::apply() when it enriches raw content, a ranking behaviour that predates this ADR (ADR 0014) and that this fix must not silently re-order. So the penalty applies only when the row is canonical (unchanged) or ocr_origin is true — a flag KbSearchService’s chunk-mapping derives from metadata.converter.provenance === 'ocr', not from generation_source alone. Content that never went through OCR (generation_source defaults to human everywhere else, unaffected by this fix at all) sees zero change: the fix only changes ranking for rows where generation_source = 'auto' and ocr_origin is true, which since v8.36/W1 means specifically non-reviewed OCR output — never AutoWiki-enriched raw content.

Data model & contract

The R44 tri-surface, all adapting the same KbReviewService: KbReviewStatusTool answers {disabled: true, flag: 'KB_DIGITIZATION_REVIEW_ENABLED'} rather than a 500 or vanishing from the MCP manifest when the feature is off — tool registration stays flag-independent (the registration test derives the roster from the files in app/Mcp/Tools/, not from config), only the behaviour is gated. KbProposeTextCorrectionTool follows the identical posture: registration is flag-independent, the write itself is gated. The propose/approve split is deliberately asymmetric across surfaces: an agent may only ever propose a correction (MCP, never HTTP — there is no “propose” HTTP endpoint at all), and a human may only ever approve or reject one (HTTP, never MCP). Neither surface can do the other’s half of the transaction — the split itself is the enforcement of ADR 0003’s boundary, not a policy check layered on top of a symmetric capability.

Decision rationale (ADR 0031)

No MCP write — a documented exception

R44 normally requires PHP + HTTP + MCP for every capability. Review status and approval are the deliberate exception, restated from ADR 0003’s boundary: an agent may point at a probable transcription error (KbProposeTextCorrectionTool, ADR 0031 §6-7) but can never be the one that marks anything “reviewed” or “approved” — that is a human vouching for content the platform did not author. KbReviewStatusTool is read-only by construction: it has no path to setPageReviewStatus() or approve() at all, not a gate that could be misconfigured open. KbProposeTextCorrectionTool mirrors this from the other side — it has no path to approveCorrection() or rejectCorrection() either, only to proposeCorrection(), which by itself has zero effect on the corpus.

Why canonicity branches the approval, not a config flag

The two branches of approve() are not two variations on a theme picked by a setting — they are genuinely different transitions with different meanings. A canonical document’s approval is editorial acceptance into the knowledge graph (canonical_status = 'accepted', WikiExplorerService’s existing contract). A non-canonical document’s approval is “a human has read this OCR output and it’s correct” — no graph participation implied, no canonical_status touched. Collapsing them into one code path would force a choice: either stamp a false canonical fact on ordinary scans, or strip the canonical branch of its existing contract. Branching on is_canonical — a fact already on the row — costs nothing on paths that don’t need it and never conflates the two meanings.

Worked example

The equivalent HTTP calls (admin-gated, X-Tenant-Id scoped):
An agent, via MCP, points at a probable transcription error on page 1 — this writes a CANDIDATE only, zero effect on the corpus:
A human reviewer lists the pending queue (bounded — ?limit=/?offset=, capped by kb.review.corrections_page_size, default 50) and approves it — this is the ONLY action that touches the corpus, via DocumentIngestor::reembedFromMarkdown():

Gotchas

  • The summary/page-status reads are ungated at the service layer, on purpose — but every EXTERNAL surface gates them, also on purpose. documentReviewSummary() and pageReviewStatus() don’t check kb.review.enabled themselves (a future internal caller might legitimately want the numbers regardless); KbReviewController explicitly checks the flag before calling either one and returns a clean 404 when off, and kb:review’s own report display (both --report and the --page=N --report combo) checks the same flag and refuses with a friendly message rather than printing a summary the HTTP contract says does not exist. Don’t assume “the service doesn’t gate it” means “no surface does” — verify each surface independently, the way test_api_summary_returns_404_when_the_feature_is_disabled and test_report_mode_is_gated_behind_the_disabled_flag do.
  • A page can be reverted. --status=unreviewed is a real, supported operation, not a theoretical inverse of “mark reviewed” — a reviewer who clicks the wrong page needs a way back, and reverting clears reviewed_by/reviewed_at rather than leaving a stale attribution behind.
  • Approving does not require reviewing every page first. The two are independent signals by design — a reviewer can approve a short, obviously correct document without individually touching each page, and per-page tracking exists to show progress, not to gate the one action that actually changes retrieval.
  • A document with no recorded page_count cannot be reviewed OR page-read at all, not even page 1 — if a document predates page-count tracking or was never converted through OCR/PDF processing, setPageReviewStatus() AND pageReviewStatus() both refuse it outright rather than accepting (or reporting on) an unbounded page number. The two methods used to disagree here — pageReviewStatus() would happily answer unreviewed for pages/999 on such a document — until Copilot PR #494 round 5 closed the gap; they now share the same “no page count, no page number is valid” posture.
  • A correction candidate’s approval re-validates against the CURRENT version, not the one it was proposed against — and LOCKS that exact row. proposeCorrection() records version_hash, but approveCorrection() re-reads the document family’s LIVE version via DocumentVersionService::currentVersionFor($document, lock: true) (never a bare lock on $document — that row may be an OLDER version than the family’s current live one, Copilot PR #496 round 1) and re-checks that old_text still occurs exactly once there. If a later edit changed or removed that text in the meantime, approval REJECTS the now-stale candidate (stale_old_text_not_found_or_ambiguous) rather than silently applying a correction to text that may no longer mean what the proposer thought — ADR 0031 §6’s explicit requirement. proposeCorrection() locks the SAME way, for the SAME reason, before its own old_text validation.
  • Approving a correction refuses a CANONICAL document outright, never mints a NEW version from chunk-reconstructed content, and never mutates source_path on disk. A canonical document’s Markdown carries YAML frontmatter that DocumentVersionService::contentFor()’s chunk- reconstruction fallback (used when no artifact is retained) explicitly drops — feeding that frontmatter-less body into reembedFromMarkdown() would silently demote the document out of canonical status. This feature targets the ordinary OCR/PDF scan (ADR 0031: “non-canonical OCR documents … canonical frontmatter keeps its own say”); a canonical document’s own approval path is approve()’s WikiExplorerService::promote() branch, so approveCorrection() on a canonical $live returns {applied: false, reason: 'canonical_document_not_supported'} (Copilot PR #496 round 2) rather than risk it. For the ordinary non-canonical case, applying the correction goes through DocumentIngestor::reembedFromMarkdown($live, $corrected) — the SAME core ReembedDocumentJob’s artifact-fallback path uses — not a manual Storage::put() write to source_path, which for an OCR/PDF/image-origin document names the ORIGINAL BINARY. reembedFromMarkdown() stages the corrected text as the NEW version’s ARTIFACT, mints a fresh version_hash (and therefore a new knowledge_documents row — the prior one is archived, not deleted), and chunks + embeds SYNCHRONOUSLY inside its own top-level DB::transaction(). EmbeddingCacheService means pages whose text did not change cost no new provider call, but every chunk is re-derived.
  • Approval is a THREE-PHASE flow, not one giant transaction (Copilot PR #496 round 2). Round 1 nested the ENTIRE approveCorrection() flow — including reembedFromMarkdown() — inside one ambient transaction; that call publishes its artifact and dispatches its canonical-graph job right after its OWN inner commit, still inside round 1’s outer one, so a LATER failure rolling that outer transaction back would undo the new knowledge_documents row while the artifact/job side effects had already happened. Phase 1 (own transaction) locks the candidate + the live row, validates, and — on success — ATOMICALLY marks the candidate applying right there (the R21 single-use guarantee: a concurrent second approval sees status != pending immediately, with no lock held across phase 2 — applying, not yet the terminal applied, since round 5; see the crash-recovery gotcha below). Phase 2 (no ambient transaction, exactly like every other DocumentIngestor caller) calls reembedFromMarkdown(); on success, flips the candidate to the terminal applied state; a failure instead reverts the phase-1 claim (REJECTED for a decided staleness, PENDING + re-thrown for any other failure — an infra blip a reviewer can retry) rather than leaving the candidate stranded with no version. Phase 3 (own transaction, best-effort — see below) writes the kb_canonical_audit row.
  • Proposing a correction is itself audited — EVERY denied outcome, not only rate-limiting — and the check-then-act sequence is race-protected on TWO axes (Copilot PR #496 round 2). ADR 0031 §6 requires an audit row for every proposeCorrection() outcome — accepted, denied, and replayed — event_type = 'correction_proposed', metadata_json.outcome names which and metadata_json.reason names why for a denial (bad input, old_text_not_found_or_ambiguous, rate_limited). A per-TENANT+ACTOR Cache::lock() wraps the whole check-then-act sequence: round 1’s lock was keyed on the idempotency key, so it only serialized IDENTICAL proposals — two concurrent DIFFERENT proposals from the SAME actor could both pass the rate-limit check before either recorded a hit, over- spending the actor’s hourly budget; a per-actor lock closes that entirely. Inside it, currentVersionFor($document, lock: true) locks the family’s live row before its content is read, closing the propose-time analogue of approval’s row-locking fix. The accepted path still writes the candidate and its audit row in ONE transaction (an audit-write failure rolls the candidate back too — never a silently unaudited candidate); every DENIED branch returns a result instead of throwing from inside that transaction, so it always commits, and the audit-then- throw happens after, outside it — a denial audited inside a transaction that then rolls back would lose the very audit row it was writing.
  • MCP proposals share ONE identity per tenant, honestly — not a fictitious per-user one. The deployed MCP connection authenticates a TENANT-scoped token (EnforceMcpScope), never a per-user Sanctum session/token, so auth()->user() is genuinely null on every real MCP call. KbProposeTextCorrectionTool uses a fixed, explicitly-scoped service identity, 'mcp:kb-propose-text-correction' (mirroring KbWikiPromoteTool’s 'mcp:kb-wiki-promote'), rather than 'user:'.(auth()->user()?->id ?? 'unknown') — which never actually resolved to a real user and only ever produced the single shared bucket user:unknown (Copilot PR #496 round 2). The rate-limit budget, idempotency tuple, proposed_by and audit actor are therefore shared per TENANT for MCP-originated proposals; the HTTP surface’s future review UI will carry a real per-user identity once it ships.
  • A correction against a document with NO retained artifact still works — page boundaries are re-synthesized from chunk metadata (Copilot PR #496 round 4, H-A). DocumentVersionService::contentFor() falls back to chunk reconstruction (implode("\n\n") over chunk_text) whenever a version has no retained conversion artifact — e.g. reference_only/ markdown_only retention, or a version ingested before artifact retention existed. For a document chunked by PdfPageChunker, the page number lives OUT-OF-BAND on each chunk (metadata['page']), never inlined into chunk_text — so that reconstruction alone has NO ## Page N headers for locatePageOccurrence() to anchor on. KbReviewService::pageAwareContentFor() re-synthesizes those headers from chunk metadata before either propose or approve ever calls locatePageOccurrence(), and degrades to the plain (headerless) content — the exact pre-fix behaviour, still correct for a non-paginated document — the moment any chunk lacks an integer page, since that signals a chunking strategy this method cannot reason about.
  • A crashed approval is recoverable, not silently indistinguishable from success (Copilot PR #496 round 5, H-B). Phase 1 and phase 2 are each individually atomic, but there is no cross-process atomicity BETWEEN them — this whole flow runs synchronously inside one HTTP/MCP request, not a queued retryable job. If the process is killed anywhere between phase 1’s commit and the applying → applied flip, the candidate is left stuck applying — DELIBERATELY not applied, so it is visibly distinguishable from both a genuine success and a genuine in-flight approval. kb:review-reconcile-stuck-corrections (KB_REVIEW_STUCK_APPLYING_MINUTES, default 15) finds candidates stuck past that threshold: a matching kb_canonical_audit row (metadata_json.candidate_id) proves phase 2 genuinely committed, so it finalizes to applied; no matching row means phase 2 never got that far, so it reverts to pending for a reviewer to simply retry. An ordinary approveCorrection() call never silently resumes an applying row itself (any non-pending status, applying included, is already_consumed) — reconciliation is a deliberately separate, lower-frequency, explicitly-triggered operation (R44 — a scheduler-only maintenance sweep with no caller-facing read, so CLI-only, same posture as kb:artifacts-backfill: not auto-scheduled, run on demand or wired into a cron by the operator).
  • A post-commit-only failure (the artifact publish step, not the document/chunks) is treated as a SUCCESS, never reverted (Copilot PR #496 round 4, H-C). reembedFromMarkdown() can throw ArtifactPublishFailedException — its own docblock documents this as a failure that happens AFTER its internal transaction already committed the new knowledge_documents row and its chunks; only the artifact publish afterwards failed. approveCorrection() catches this specifically, before its generic failure handler, logs a warning, and finalizes the candidate as applied — exactly the posture ReembedDocumentJob::logArtifactNotPublished() already takes for the same exception on its own call path. Treating it like any other failure (revert to pending) would mark a correction that DID apply back as unapplied, and a reviewer’s retry would then find the superseded old version and get rejected outright — losing a correction that actually succeeded.
  • A failed audit write can never turn an already-successful approval into a reported failure — and it is durably retried, not just logged (previously-missed MEDIUM round 4, escalated to a must-fix in round 7). By the time phase 3 runs, the correction has genuinely applied (phase 1 + phase 2 already committed), so the failure is still caught rather than propagated — letting it bubble up would tell the caller the approval FAILED when it in fact succeeded, and a client that reacts by retrying would then hit already_consumed (409) on a candidate that isn’t pending anymore, which reads as corruption, not as “it actually worked” (mirrors ChatLogManager::log()’s established “never let logging/auditing failure break an already-successful outcome” posture). What round 7 changed: on failure, WriteKbTextCorrectionAuditJob is now dispatched ($tries=4/backoff=[10,30,60], the same durability idiom as IngestDocumentJob/SendExternalNotificationJob) instead of only being logged — kb_canonical_audit is CLAUDE.md’s one IMMUTABLE FORENSIC/COMPLIANCE trail, unlike chat_logs, so “log a critical line and hope a human reads it” alone was not durable enough for the ORIGINAL scenario this gotcha names (“a momentary DB outage”), which a queued retry with real wall-clock backoff genuinely survives and an inline retry inside the already-returned HTTP/MCP request cannot. The synchronous write stays primary (the >99% common case, zero added latency); the job checks for an existing row by metadata_json.candidate_id before writing (the sync attempt can fail AFTER its own INSERT actually committed) and only falls back to Log::critical() once its OWN retries are exhausted too — a true last resort, not the first one.
  • Review tab: the admin KB document detail pane has a Review tab (ReviewTab) wired to the HTTP surfaces above — per-page reviewed/unreviewed toggle with Prev/Next navigation (degrades to an explanatory message when the document has no recorded page_count), the document-level Approve action, and the pending correction-candidate queue with approve/reject.
  • Not yet shipped: the advanced review UI (original ↔ Markdown side-by-side, confidence heat-map) and the CER/WER quality metrics. These land in later W3 sub-branches before the v8.37 GA tag; this page will be extended, not replaced, when they do.