Motivation
Documents & OCR closed the file → Markdown seam: a scanned contract or a photographed whiteboard now becomes a searchable, chunked, embedded document instead of a422. 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:
- 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 readgeneration_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. - 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_sourcefromautotohumanfor a non-canonical document.WikiExplorerService::promote()did the equivalent transition, but only for canonical rows, and unconditionally setcanonical_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 sameauto → humantransition, 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) →
KbReviewServiceperforms the flip directly:generation_source: auto → human, onekb_canonical_auditrow (event_type = 'promoted'). This is implemented here rather than by relaxingpromote()’s own guard, becausepromote()unconditionally setscanonical_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-canonicalautorow (an AutoWiki-enriched raw-markdown document, alsogeneration_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 foris_canonical || ocr_originrows, 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.
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
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 ofapprove() 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
X-Tenant-Id scoped):
?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()andpageReviewStatus()don’t checkkb.review.enabledthemselves (a future internal caller might legitimately want the numbers regardless);KbReviewControllerexplicitly checks the flag before calling either one and returns a clean 404 when off, andkb:review’s own report display (both--reportand the--page=N --reportcombo) 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 waytest_api_summary_returns_404_when_the_feature_is_disabledandtest_report_mode_is_gated_behind_the_disabled_flagdo. - A page can be reverted.
--status=unreviewedis a real, supported operation, not a theoretical inverse of “mark reviewed” — a reviewer who clicks the wrong page needs a way back, and reverting clearsreviewed_by/reviewed_atrather 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_countcannot 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()ANDpageReviewStatus()both refuse it outright rather than accepting (or reporting on) an unbounded page number. The two methods used to disagree here —pageReviewStatus()would happily answerunreviewedforpages/999on 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()recordsversion_hash, butapproveCorrection()re-reads the document family’s LIVE version viaDocumentVersionService::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 thatold_textstill 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_pathon disk. A canonical document’s Markdown carries YAML frontmatter thatDocumentVersionService::contentFor()’s chunk- reconstruction fallback (used when no artifact is retained) explicitly drops — feeding that frontmatter-less body intoreembedFromMarkdown()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 isapprove()’sWikiExplorerService::promote()branch, soapproveCorrection()on a canonical$livereturns{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 throughDocumentIngestor::reembedFromMarkdown($live, $corrected)— the SAME coreReembedDocumentJob’s artifact-fallback path uses — not a manualStorage::put()write tosource_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 freshversion_hash(and therefore a newknowledge_documentsrow — the prior one is archived, not deleted), and chunks + embeds SYNCHRONOUSLY inside its own top-levelDB::transaction().EmbeddingCacheServicemeans 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 — includingreembedFromMarkdown()— 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 newknowledge_documentsrow 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 candidateapplyingright there (the R21 single-use guarantee: a concurrent second approval seesstatus != pendingimmediately, with no lock held across phase 2 —applying, not yet the terminalapplied, since round 5; see the crash-recovery gotcha below). Phase 2 (no ambient transaction, exactly like every otherDocumentIngestorcaller) callsreembedFromMarkdown(); on success, flips the candidate to the terminalappliedstate; 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 thekb_canonical_auditrow. - 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.outcomenames which andmetadata_json.reasonnames why for a denial (bad input,old_text_not_found_or_ambiguous,rate_limited). A per-TENANT+ACTORCache::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, soauth()->user()is genuinelynullon every real MCP call.KbProposeTextCorrectionTooluses a fixed, explicitly-scoped service identity,'mcp:kb-propose-text-correction'(mirroringKbWikiPromoteTool’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 bucketuser:unknown(Copilot PR #496 round 2). The rate-limit budget, idempotency tuple,proposed_byand 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")overchunk_text) whenever a version has no retained conversion artifact — e.g.reference_only/markdown_onlyretention, or a version ingested before artifact retention existed. For a document chunked byPdfPageChunker, the page number lives OUT-OF-BAND on each chunk (metadata['page']), never inlined intochunk_text— so that reconstruction alone has NO## Page Nheaders forlocatePageOccurrence()to anchor on.KbReviewService::pageAwareContentFor()re-synthesizes those headers from chunk metadata before either propose or approve ever callslocatePageOccurrence(), 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 integerpage, 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 → appliedflip, the candidate is left stuckapplying— DELIBERATELY notapplied, 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 matchingkb_canonical_auditrow (metadata_json.candidate_id) proves phase 2 genuinely committed, so it finalizes toapplied; no matching row means phase 2 never got that far, so it reverts topendingfor a reviewer to simply retry. An ordinaryapproveCorrection()call never silently resumes anapplyingrow itself (any non-pendingstatus,applyingincluded, isalready_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 askb: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 throwArtifactPublishFailedException— its own docblock documents this as a failure that happens AFTER its internal transaction already committed the newknowledge_documentsrow 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 postureReembedDocumentJob::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” (mirrorsChatLogManager::log()’s established “never let logging/auditing failure break an already-successful outcome” posture). What round 7 changed: on failure,WriteKbTextCorrectionAuditJobis now dispatched ($tries=4/backoff=[10,30,60], the same durability idiom asIngestDocumentJob/SendExternalNotificationJob) instead of only being logged —kb_canonical_auditis CLAUDE.md’s one IMMUTABLE FORENSIC/COMPLIANCE trail, unlikechat_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 bymetadata_json.candidate_idbefore writing (the sync attempt can fail AFTER its own INSERT actually committed) and only falls back toLog::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
Reviewtab (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 recordedpage_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.