ADR-0025 — HITL Review Queue + Multi-format ingest (F9 + F12)¶
- Status: Accepted
- Date: 2026-06-29
- Sprint: v0.5 (M5 Phase 3)
- Features: F9 (Async HITL review queue) · F12 (Multi-format ingest)
- Supersedes the F9/F12 interface stubs in:
docs/sprints/v0.5-architecture.md§2.2 / §2.4 / §6.2 / §6.4 (this ADR is the detailed design; the stubs stand as the coherence map) - Resolves: AQ-v0.5-6 (
review_items.vault_idis the existingvault_idString, novaultstable;review_itemsis Alembic 0010 — F10 took 0009) - Invariants owned: I7 (HEADLINE — the F9 pre-gen query call is bounded to exactly 1 call/item) · I1 (F12 stays incremental via the hash gate) · I5 (extracted pages are Obsidian-valid; binary never reaches
wiki/) · I6 (pre-gen queries via the resolvedInferenceProvider, no hardcoded backend) · I9 (well-known extractor libs, no reinvention) · I4 (review list virtualized > 50) - Author: solution-architect
1. Context¶
Phase 3 is the broadest M5 phase: it lands two independent features that share only the sprint gate. They are designed together here because both reuse existing seams and both have sharp invariant edges that must be locked before engineers code.
F9 — HITL review queue. Karpathy's K8 principle is "human curates, LLM maintains." After
an ingest writes a wiki/ page, F9 surfaces it (and any AI-proposed follow-up questions) in a
Review nav section so the human can Approve / Skip / Deep-Research. The queue is advisory:
the page is already on disk (AC-F9-2/6); the queue never blocks ingest and never re-triggers it.
The danger is the pre-generated query step — an inference call per item. Left unbounded it is
exactly the I7 failure class. It is bounded here to exactly one InferenceProvider call per
item, with a timeout, degrading to a null query on failure.
F12 — Multi-format ingest. Today only .md/.txt/.markdown enter the vault (the app.upload
allow-list, mirrored by the watcher). F12 accepts PDF/DOCX/PPTX/XLSX by extracting their text to a
companion markdown file and letting the existing ingest pipeline take over. The danger is two
seams colliding (upload → watcher → ingest_file) and producing a double-ingest, or a binary file
reaching the indexer. Both are designed out below.
1.1 Ground truth consumed¶
app.upload(safe_source_name/resolve_under_sources) gatesPOST /ingest/upload. Its_ALLOWED_EXTENSIONS = {".md", ".txt", ".markdown"}is the single source of truth for what the watcher ingests —app.watcher._is_text_fileimports the exact same frozenset. This coupling is the linchpin of the F12 design (§4.3): binary extensions stay OUT of that set, so the watcher ignores the binary and only ingests the companion.extracted.md.POST /ingest/upload(M4-EXT, ADR-0020) writes tovault/raw/sources/and returns 202; the watcher ingests asynchronously. F12 must run extraction synchronously on upload (before 202) so the companion markdown exists when the watcher fires — never inside the watcher.ingest/orchestrator.pyingest_file(path)— the single intake seam (ADR-0003). Extracted text re-enters here unchanged; the mtime/hash gate (ADR-0001) dedups regardless of source type (I1).run_ingest_pipeline()(orchestrator) is where eachWikiPageis written viawrite_wiki_page. F9'senqueue_review()is called fire-and-forget from the orchestrator's post-write step.resolve_provider_config("ingest", vault_id)(provider_config_service.py) resolves the provider row by operation precedence and raisesConfigNotFoundErrorwhen none is configured. F9's query-gen uses the resolved"ingest"provider viaInferenceProvider.chat()— the same backend-neutral text-in/text-out seam F10 uses (no new ABC method, I6).POST /research/start(F10, ADR-0024) — F9's Deep-Research action calls this with the item'spre_generated_query(or the page topic) and stores the returnedrun_idon the review row (AC-F10-5).graph/engine.py/edgestable — the post-ingest consistency scan (CLAUDE.md §7) may read theedgestable to find graph-related pages. It never triggers FA2 (I2). Phase 3 keeps the generator scope minimal (see §3.4): consistency/contradiction flagging is a reserved, bounded extension; the shipped generator produces follow-upsuggested_queryitems only.
2. Decision summary¶
- F9 stores one new table
review_items(Alembic 0010), scoped by the existingvault_idString (AQ-v0.5-6 — novaultstable).page_idis a real nullable FK →pages.id. - Items come from exactly one source in Phase 3: the orchestrator's post-write hook enqueues one
new_pagereview item per generated wiki page, and at most one boundedInferenceProvider.chat()call generates 1–3 follow-upsuggested_querystrings stored on that row. No second call, no loop (I7). - F9 REST:
GET /review/queue(paginated,vault_idfilter),POST /review/queue/{id}/approve,POST /review/queue/{id}/skip,POST /review/queue/{id}/deep-research. The deep-research action delegates toPOST /research/start(F10) and storesrun_id+deep_research_run_idon the row. - F12 extraction lives in one module
ingest/extract.pywith a single dispatchextract_text(path) -> str(no LLM, no loop). Format libs (pypdf,python-docx,python-pptx,openpyxl) are imported only there (static guard, AC-F12-7). - F12 plug-in point:
POST /ingest/uploadwidens its allow-list for binary types; on a binary it (a) writes the original binary toraw/sources/, (b) synchronously extracts → writes<stem>.extracted.mdnext to it, (c) returns 202. The watcher ingests only the.extracted.md(the binary extension is not in_ALLOWED_EXTENSIONS). One ingest, one hash gate (I1), Obsidian-valid (I5). unstructuredis NOT added in M5. Images/AV are explicit placeholder stubs (out of scope, §4.5). This keeps the dependency bundle lean (I9) — decision locked here, narrowing the scope-doc list.
3. F9 — HITL Review Queue¶
3.1 review_items table (Alembic 0010)¶
New SQLAlchemy model ReviewItem in backend/app/models.py. DDL (Postgres; SQLite variant for unit
tests follows the deep_research_runs pattern — UUID(as_uuid=True).with_variant(String(36), "sqlite"),
JSONB().with_variant(JSON(), "sqlite")):
| Column | Type | Null | Notes |
|---|---|---|---|
id |
UUID PK | no | gen_random_uuid() server default |
vault_id |
String | no | The existing vault_id String (AQ-v0.5-6); matches pages/edges/conversations. No FK — there is no vaults table. |
page_id |
UUID FK → pages.id |
yes | The wiki page this item reviews; NULL for page-less items (e.g. a future gap item not tied to one page). |
item_type |
Text (enum-by-convention) | no | new_page | update_page | deep_research_candidate. Enforced at app level by handler Literal types — no DB CHECK constraint (see §3.1 amendment note below). Sub-kinds (suggested_query/gap/contradiction) are payload sub-kinds inside pre_generated_query, not column values — see §3.4. |
status |
Text (enum-by-convention) | no | pending | approved | skipped | deep_researched. Defaults pending (server default). Enforced at app level by handler Literal types — no DB CHECK constraint (see §3.1 amendment note below). |
pre_generated_query |
Text | yes | Newline-separated 1–3 questions from the bounded gen call; NULL when the call failed/timed out or was not configured (AC-F9-4). |
deep_research_run_id |
UUID FK → deep_research_runs.id |
yes | Set when the Deep-Research action fired (AC-F10-5); NULL otherwise. |
created_at |
TIMESTAMP(tz) | no | func.now() |
reviewed_at |
TIMESTAMP(tz) | yes | Set on approve/skip/deep-research; NULL while pending. |
reviewed_by |
Text | yes | Free-text actor (e.g. "web-ui"); NULL while pending. M5 is single-operator, so this is audit-only. |
§3.1 Amendment (docs gate, M5 Phase 3 — 2026-06-29): Migration 0010 implements
item_typeandstatusas plainTextcolumns with no DB CHECK constraint. Validation is enforced application-side via handlerLiteraltypes (FastAPI/Pydantic) and the_set_statushelper inops/review.py. This is the established Synapse convention —ingest_runs.statusanddeep_research_runs.statusare identically structured (Text + app-level Literal, no CHECK). The original ADR text read "CHECK constraint"; that was aspirational and was not implemented. No follow-up migration is required; enum-by-convention (app-level Literal types + mapped Literal on the handlers) is accepted as the house standard, consistent withingest_runsanddeep_research_runs. Architect condition (Phase 3 review, item 1) resolved by this amendment.
Index: ix_review_items_vault_status_created on (vault_id, status, created_at) — the
paginated pending-queue read (WHERE vault_id=? AND status='pending' ORDER BY created_at). No
partial-unique constraint is needed (a page may legitimately appear multiple times across re-ingests;
the queue is an event log, not a per-page singleton).
Naming reconciliation with the scope doc. AC-F9-1 lists
vault_id (uuid FK). AQ-v0.5-6 already overrode this to the Stringvault_idwith novaultstable; this ADR is the authority. The scope-docitem_typeenum (new_page/update_page/deep_research_candidate) is honored verbatim; the §intro mention ofsuggested_query/gap/contradictionin the design brief maps to payload sub-kinds insidepre_generated_query, not extra column values — see §3.4.
ER / D2 impact. make er regenerates docs/er/schema.mmd with the review_items entity and its two
FKs (page_id → pages, deep_research_run_id → deep_research_runs). Zero-drift gate (I8, EC-M5-17).
3.2 Module ops/review.py — interface contract¶
# backend/app/ops/review.py — F9 (ADR-0025). No new ingest logic; advisory queue only.
async def enqueue_review(
*,
vault_id: str,
page_id: uuid.UUID | None,
item_type: Literal["new_page", "update_page", "deep_research_candidate"],
pre_generated_query: str | None = None,
) -> ReviewItem:
"""Insert one pending review_items row. Pure DB write; never calls a provider itself.
Idempotency is NOT required — the queue is an event log (§3.1)."""
async def generate_review_queries(
*, vault_id: str, page_title: str, page_excerpt: str
) -> str | None:
"""Make EXACTLY ONE InferenceProvider.chat() call (operation='ingest', I6) to produce
1–3 follow-up research questions, returned newline-separated. BOUNDED (I7):
- one call, no loop, no refine;
- wrapped in asyncio.wait_for(..., REVIEW_QUERY_TIMEOUT_SECONDS);
- token_budget from the resolved provider row (or REVIEW_QUERY_TOKEN_BUDGET default);
- on ConfigNotFoundError / timeout / any provider error → return None (item still enqueued).
Cost is pushed through the UsageAccumulator and logged like any provider call (I7)."""
# REST handlers (thin; live in main.py or an ops/review router, calling the above):
async def list_queue(vault_id: str, *, limit: int, offset: int) -> ReviewQueuePage
async def approve(item_id: uuid.UUID) -> ReviewItem
async def skip(item_id: uuid.UUID) -> ReviewItem
async def deep_research(item_id: uuid.UUID) -> ReviewItem # → POST /research/start, store run_id
3.3 Where items come from (LOCKED) — the bounded post-ingest generator¶
The only producer in Phase 3 is the orchestrator's existing post-write step. In
run_ingest_pipeline (orchestrator), after the orchestrated branch finishes writing pages
(write_wiki_page + _update_overview), a fire-and-forget hook runs:
for each WikiPage written:
# ONE bounded provider call per page (I7); failure → query=None, item still enqueued
query = await generate_review_queries(vault_id, page.title, excerpt(page.content))
await enqueue_review(vault_id, page_id, item_type="new_page", pre_generated_query=query)
Hard rules (the I7 + AC-F9-2 contract):
- The hook is wrapped in
try/exceptand NEVER fails the ingest (AC-F9-2). The page is already on disk; the queue is advisory. A generator exception logs a WARNING and enqueues the item withpre_generated_query=NULL. - Exactly one
generate_review_queriescall per page. No second call, no loop, no retry. The single call is itself bounded by a timeout + token_budget (§3.2). This is the I7 surface for F9. - The hook is non-blocking to the ingest critical path: it runs after the page write and its cost is
logged but it does not gate
IngestRunResult. (Implementation mayasyncio.create_taskit or run it inline after the run row is written — engineer's choice — but it must not raise into the ingest caller.) - It is attached to the orchestrated branch only. The delegated (CLI) branch writes pages through
the MCP
write_pagetool and the orchestrator does not enumerate them; enqueue for the delegated path is a reserved, out-of-Phase-3 follow-up (recorded in §7 risks). M5 EC-M5-8 ("review queue populated on ingest") is satisfied by the orchestrated path, which is the default dev provider (Ollama/API).
3.4 Consistency/contradiction scan — reserved and bounded¶
CLAUDE.md §7 lists a "consistency/contradiction post-ingest" loop that "scans related pages via the graph
→ flags to review (F9), bounded." Phase 3 reserves this: the shipped generator produces
suggested_query follow-ups only. If a consistency scan is added later it MUST:
- read the edges table for graph-related pages (I2 — never call FA2);
- be bounded by REVIEW_SCAN_MAX_ITEMS (max items enqueued per scan) and the same single-call
token_budget (I7);
- enqueue deep_research_candidate / page-less items with the flagged context in pre_generated_query.
This reservation is explicit so a future PR cannot smuggle an unbounded graph walk in under F9.
3.5 F9 REST surface (D4)¶
| Method + path | Body / params | Success | Notes |
|---|---|---|---|
GET /review/queue |
?vault_id&limit&offset (limit default 50, max 200 — I7 cap on the page) |
200 {items:[…], total, limit, offset} |
Pending items, created_at order. Mirrors /ingest/runs paging. |
POST /review/queue/{id}/approve |
— | 200 ReviewItem |
status=approved, reviewed_at=now(). Human confirmation only; does not re-trigger ingest (AC-F9-6). |
POST /review/queue/{id}/skip |
— | 200 ReviewItem |
status=skipped, reviewed_at=now(). |
POST /review/queue/{id}/deep-research |
— | 202 {review_item_id, run_id} |
status=deep_researched; calls POST /research/start with pre_generated_query (first line) or the page topic when query is NULL; stores deep_research_run_id. 503 if SEARXNG_URL unset (inherits F10's guard). |
| (all) | unknown id |
404 |
ReviewItem JSON projection: {id, vault_id, page_id, page_title?, item_type, status,
pre_generated_query, deep_research_run_id, created_at, reviewed_at}. page_title is a convenience
join from pages for the UI list (AC-F9-5). All four documented in openapi.json (make openapi, I8).
3.6 F9 frontend (AC-F9-5/-7)¶
The Review nav section (its Section union member + SectionRouter branch were retained per
ADR-0021's M5-item-removal pattern) is activated: a list of pending items showing page title / item
type / pre-generated query / Approve·Skip·Deep-Research buttons. The list is virtualized with TanStack
Virtual when > 50 items (I4). F9 is a separate section from the M4 Ingest Activity View — no
Approve/Skip/Deep-Research is added to the Sources/Ingest section (AC-F9-7, the §10 boundary).
4. F12 — Multi-format ingest¶
4.1 Module ingest/extract.py — interface contract¶
# backend/app/ingest/extract.py — F12 (ADR-0025). SOLE home of format libs (static guard, AC-F12-7).
# Extension → extractor dispatch. No LLM, no loop, single pass per file (I7 n/a — stateless).
EXTRACTABLE_BINARY_EXTENSIONS: frozenset[str] = frozenset(
{".pdf", ".docx", ".pptx", ".xlsx"}
)
# Placeholder-only (no transcription/OCR in M5 — §4.5):
PLACEHOLDER_EXTENSIONS: frozenset[str] = frozenset(
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp3", ".mp4", ".wav", ".m4a"}
)
def extract_text(file_path: str | Path) -> str:
"""Dispatch on the lower-cased suffix → plain text.
.pdf → pypdf (page text joined; embedded images skipped with a log warning, AC-F12-1)
.docx → python-docx (paragraph text; basic structure)
.pptx → python-pptx (slide text; one logical doc per presentation)
.xlsx → openpyxl (sheet name + rows rendered as a GFM markdown table)
image/AV (PLACEHOLDER_EXTENSIONS) → a one-line placeholder string (§4.5)
anything else → raise UnsupportedFormatError (caller maps to HTTP 415)
Output is capped at EXTRACT_MAX_CHARS (config, default ~2_000_000) to bound a pathological file."""
class UnsupportedFormatError(ValueError): ...
extract_text is pure (path in, text out) and makes no LLM/provider call (I6/I9, AC-F12-1, the
§10 "extraction only, not new inference" boundary). The static guard (AC-F12-7) asserts no pypdf/docx/
pptx/openpyxl import exists outside this module.
4.2 Where it plugs in — POST /ingest/upload¶
The upload allow-list widens. app.upload.safe_source_name is extended so its extension gate accepts the
binary set in addition to text:
_ALLOWED_EXTENSIONS = {".md", ".txt", ".markdown"} # watcher ingests THESE
_EXTRACTABLE_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx"} # extracted on upload
_PLACEHOLDER_EXTENSIONS = {".png",".jpg",".jpeg",".gif",".webp",
".mp3",".mp4",".wav",".m4a"} # placeholder on upload
_UPLOAD_ACCEPTED = _ALLOWED_EXTENSIONS | _EXTRACTABLE_EXTENSIONS | _PLACEHOLDER_EXTENSIONS
# 415 iff suffix not in _UPLOAD_ACCEPTED (AC-F12-2). _ALLOWED_EXTENSIONS is UNCHANGED so the
# watcher's import of it still means "text companions only".
Upload handler flow for a binary (POST /ingest/upload, still returns 202):
1. safe_source_name(filename) — now allows binary ext; 415 only for truly unknown types.
2. stream body to temp with the MAX_UPLOAD_BYTES cap (413) — unchanged.
3. atomic move → vault/raw/sources/<name>.<ext> (the ORIGINAL binary, preserved; I5/K1)
4. if suffix in _EXTRACTABLE | _PLACEHOLDER:
text = extract_text(dst) # SYNCHRONOUS, on the upload path (NOT in the watcher)
atomic-write text → vault/raw/sources/<stem>.extracted.md (Obsidian-valid; see §4.4)
else (.md/.txt/.markdown): no companion — the file is already watcher-ingestable.
5. return 202 {file_path, status:"queued", overwritten}
The watcher then fires on the new .extracted.md (which IS in _ALLOWED_EXTENSIONS) and runs
ingest_file exactly once. It ignores the binary (.pdf/etc. are not in _is_text_file). Net:
one ingest, one hash gate (I1), one wiki page produced by the unchanged pipeline (I6).
4.3 Why companion-on-upload (not extract-in-watcher)¶
Two seams already exist: upload writes + watcher ingests. The temptation is to teach the watcher to
extract. Rejected — that would put binary-parsing on the ingest hot path and inside the watchdog
thread, and the watcher deliberately knows nothing about formats. Extracting synchronously on the
upload request, before the 202, keeps:
- the watcher unchanged (it only ever sees text it can ingest);
- a single ingest path (ingest_file), no double-ingest, hash gate intact (I1);
- the binary as an immutable raw/ artifact (K1 3-layer rule, AC-F12-4).
Files arriving via the scheduled importer (Feature S) or copied directly into raw/sources/ that are
binary will be silently ignored by the watcher (no companion exists). That is the correct,
conservative behavior for M5: extraction is an upload-time capability. Auto-extraction of binaries
that appear in raw/sources/ by other means is a reserved follow-up (§7) — it must not be smuggled into
the watcher without an ADR.
4.4 Companion .extracted.md is Obsidian-valid (I5)¶
The companion is written with minimal valid YAML frontmatter so the pipeline's frontmatter parser (K6)
and the Obsidian-compat check (test_obsidian_check.py) stay green:
---
type: source
title: <original filename stem>
sources: ["raw/sources/<name>.<ext>"]
---
<extracted text>
sources[] points back at the binary (F3 traceability). wiki/ is written only by the ingest
pipeline (AC-F12-4, EC-M5-11). The companion lives in raw/sources/ alongside the binary — not in
raw/assets/ and not in wiki/.
4.5 Images / AV — placeholder only (out of scope, locked)¶
Per AC-F12-1 and the §10 boundary, images and AV are not transcribed/OCR'd in M5. For a placeholder
extension the companion body is a single line — "Image file: no text extracted (transcription out of
scope in this release)." / "AV file: transcript not available in this release." — so the upload is
accepted (not 415) and produces a traceable stub page, while no transcription machinery is added. This
confirms the scope-doc intent.
4.6 New dependencies (pinned, lean — I9)¶
Added to backend/pyproject.toml, pinned with floors:
| Package | Floor | Why | Bundle note |
|---|---|---|---|
pypdf |
>=4.2,<6 |
PDF text (pure-Python, no native build) | tiny, pure-Python |
python-docx |
>=1.1,<2 |
DOCX paragraphs | small; pulls lxml |
python-pptx |
>=0.6.23,<1.1 |
PPTX slide text | small; shares lxml/Pillow(optional) |
openpyxl |
>=3.1,<4 |
XLSX cells → markdown table | small; pure-Python |
unstructured is deliberately NOT added (the scope doc listed it for image OCR). It is a heavy
dependency tree (pulls model/runtime deps) for a capability M5 explicitly defers (§4.5). Adding it for
placeholder-only image handling violates I9 ("minimize deps / do not over-add"). If real OCR is scoped in
M6, unstructured (or a lighter OCR path) is introduced then, behind the same extract.py dispatch,
under its own ADR. This is the one place this ADR narrows the scope-doc dependency list — recorded as a
decision, not a silent omission.
The Docker image rebuilds cleanly with the four pure-Python/lxml libs (AC-F12-5). All four are pinned.
5. Do-NOT list (rejection triggers)¶
A PR touching F9 or F12 is rejected on review if it does any of the following:
F9
- DO NOT make more than one
InferenceProvidercall per review item, or put the query-gen in any loop / retry. Exactly one bounded call; failure →pre_generated_query=NULL(I7, AC-F9-4). - DO NOT let the post-ingest enqueue hook raise into or block the ingest path. The page is already
written; the queue is advisory (AC-F9-2/6).
try/except, log, continue. - DO NOT add a
vaultstable or makereview_items.vault_ida UUID FK. It is the existingvault_idString (AQ-v0.5-6). - DO NOT hardcode a backend or model for query-gen. Resolve via
resolve_provider_config("ingest"); route by capabilities, never by isinstance/provider_type (I6). - DO NOT add Approve/Skip/Deep-Research actions to the M4 Ingest Activity View (Sources section). F9 is a separate Review section (AC-F9-7, §10).
- DO NOT re-trigger ingest on Approve, or re-scan the vault on any review action. Approve is a status write only (AC-F9-6, I1).
- DO NOT introduce a consistency/contradiction graph walk that calls FA2 or is unbounded. Reserved and bounded only (§3.4, I2/I7).
- DO NOT return an unbounded
GET /review/queuepage —limitis capped (default 50, max 200). - DO NOT skip TanStack virtualization for the review list when it can exceed 50 rows (I4).
F12
- DO NOT import
pypdf/docx/pptx/openpyxlanywhere outsideingest/extract.py(static guard, AC-F12-7). - DO NOT call any LLM/provider during extraction.
extract_textis pure text-in/text-out (AC-F12-1, I6, §10). - DO NOT extract inside the watcher or make the watcher format-aware. Extraction is synchronous on the upload request (§4.3).
- DO NOT add the binary extensions to
app.upload._ALLOWED_EXTENSIONS(the watcher imports it). Binaries go in a separate accepted-but-not-watched set; only.extracted.mdis watcher-ingestable. - DO NOT write extracted output to
vault/wiki/directly, or bypassingest_file. Companion goes toraw/sources/; the pipeline writeswiki/(I1/I5, AC-F12-4). - DO NOT delete or mutate the original binary after extraction. It is an immutable
raw/artifact (K1, AC-F12-4). - DO NOT add
unstructured(or any heavy OCR/model dep) in M5. Images/AV are placeholder-only (§4.5, I9). - DO NOT bypass the
MAX_UPLOAD_BYTEScap or theEXTRACT_MAX_CHARSoutput cap (I7 — bound a pathological file).
6. Invariant compliance statement (Phase 3)¶
| Inv | How F9 + F12 guarantee it |
|---|---|
| I1 | F12 reuses ingest_file's mtime/hash gate; re-uploading the same file is a skipped no-op (AC-F12-3). No review action re-scans the vault; Approve is a status write. |
| I2 | F9 query-gen does not touch the graph. The reserved consistency scan reads edges only — never FA2 (§3.4). |
| I5 | F12 companion has valid YAML frontmatter; binary stays in raw/sources/; wiki/ written only by the pipeline. test_obsidian_check.py 15/15 stays green (EC-M5-14). |
| I6 | F9 query-gen resolves the provider via resolve_provider_config("ingest") and rides InferenceProvider.chat() — no hardcoded backend. F12 extractors make zero inference calls. |
| I7 | F9 query-gen = exactly one bounded call (timeout + token_budget) per item; failure → NULL; no loop. GET /review/queue limit capped. F12 extraction is single-pass with an output cap; no loop. |
| I8 | D2 regenerated (review_items). D4 regenerated (4 review endpoints + widened upload). Zero drift (EC-M5-17). |
| I9 | F12 uses well-known pure-Python extractor libs; unstructured/heavy OCR deliberately deferred. No reinvention. |
| I4 | Review list virtualized > 50 rows (AC-F9-5). |
No invariant is traded for convenience in Phase 3.
7. Flagged tensions & risks¶
- Delegated (CLI) ingest does not enqueue review items in Phase 3. The orchestrator does not
enumerate pages written by the CLI agent through MCP
write_page, so the post-write hook attaches to the orchestrated branch only (§3.3.4). EC-M5-8 is met because the default dev provider is orchestrated (Ollama/API). Enqueue-from-delegated (e.g. the MCPwrite_pagetool enqueuing on write) is a reserved follow-up, not an invariant violation. Recorded so it is a conscious gap, not a silent one. - Query-gen cost on every ingested page. One extra provider call per generated page adds latency and (for API providers) cost. Mitigated by the single-call bound + timeout + the existing $1 anomaly WARNING via the accumulator. The call is fire-and-forget off the critical path (§3.3.3).
- Binaries arriving outside upload are not auto-extracted (Feature S / direct copy). Conservative for
M5 (§4.3): extraction is an upload-time capability. Auto-extraction in
raw/sources/is a reserved follow-up behind a future ADR — it must not be added to the watcher silently. unstructureddeferral narrows the scope doc. AC-F12-1 mentionsunstructuredfor image OCR; this ADR defers it (§4.6) to keep the bundle lean (I9). Images/AV ship as placeholders, satisfying the accept-not-415 behavior without the heavy dep. PM/tech-writer note: the scope doc'sunstructuredline is read as "image handling exists" (placeholder), not "OCR ships in M5."review_itemsis an event log, not a per-page singleton. Re-ingesting a page enqueues another item. Accepted: the queue reflects ingest events; the human disposes of duplicates by skipping. A dedupe/coalesce policy is a post-M5 refinement, not an M5 requirement.
8. Sign-off¶
APPROVED to implement (Phase 3). The review_items table (Alembic 0010, String vault_id,
nullable FKs to pages and deep_research_runs), the exactly-one-bounded-call post-ingest query
generator (I7), the four /review/queue endpoints delegating Deep-Research to F10, the pure
extract_text dispatch in ingest/extract.py (four pure-Python libs, unstructured deferred), and the
companion-.extracted.md-on-upload / watcher-ingests-the-companion plug-in (one ingest, hash gate
intact, binary never reaches wiki/) together satisfy I1, I2, I4, I5, I6, I7, I8, I9. AQ-v0.5-6 is
honored. The Do-NOT list (§5) is the rejection gate.
Conditions on the Phase 3 gate (architect review, EC-M5-19):
1. F9 query-gen makes exactly one provider call per item; a forced timeout/error leaves the item
enqueued with pre_generated_query=NULL and never fails the ingest (test required).
2. The post-ingest hook cannot raise into the ingest caller (try/except proven by test).
3. review_items.vault_id is String; no vaults table; migration is 0010; make er shows the
entity with both FKs, zero drift.
4. Static guard: no pypdf/docx/pptx/openpyxl import outside ingest/extract.py; no unstructured
added; no LLM call in extract.py.
5. Uploading a binary writes the original to raw/sources/, produces one .extracted.md, and the
watcher ingests only the companion (one pages row, one Qdrant point); re-upload is skipped
(I1). _ALLOWED_EXTENSIONS is unchanged.
6. D4 regenerated with the 4 review endpoints + widened upload accept list; zero drift (I8).
Architect condition (item — non-blocking) RESOLVED: DB-level CHECK constraints on
item_typeandstatuswere absent from migration 0010. Resolution: §3.1 amended to document enum-by-convention (app-level Literal types + handler validation) as the accepted approach, consistent withingest_runs.statusanddeep_research_runs.status(same Text + Literal pattern). No migration added. See §3.1 amendment note. Resolved by tech-writer docs gate, M5 Phase 3, 2026-06-29.Handoff: ADR-0025 → tech-writer (formatting + README row). Interface contracts (§3.2, §4.1) → backend-engineer (
ops/review.py,ingest/extract.py, the orchestrator hook, the upload widening, migration 0010) + frontend-engineer (Review section list + actions). C4: no new container/component —review.pyandextract.pyare components inside the existing FastAPI service; no D1 topology change. Phase verdict → orchestrator.