Technical Design Document

Verbatim — citation-grade regulatory change intelligence. How it is built, at the level of files, functions and data shapes.

Present tense, describing the code at this commit. The reasoning behind each choice — the alternative weighed, the trade-off taken — lives in docs/.ai/decisions.html, and is cited here by ADR number rather than argued again.

How to read this. Four rules govern every page.

A number that has not been measured is not printed. Where the eval has not scored something, or a threshold has not been tuned, this document says so. A guess dressed as a result is the failure the product exists to prevent in its own domain.

A dashed, muted box means designed and not built. Nothing here draws intended work in the same ink as shipped work, for the same reason a claim refuses to assert itself on a citation that did not verify: a reviewer who finds one box that overstates stops believing the other twenty.

An open decision is reported open. Where docs/.ai/decisions.html leaves a question unanswered, this document does not answer it with a pick nobody made. Part D carries the list.

And a count about the tree is either absent or machine-checked. Three review rounds found the same defect: a count was true when written, the tree moved, and the sentence went on printing it. So the counts are gone wherever the claim survives without them, and the few that carry the argument are marked with data-count and re-derived by tests/test_tdd_counts.py on every run. The drawings follow the same rule. D2 is the register.

The eleven areas the brief names, and where each is answered

Sections 1 to 11 are the brief's eleven areas, one to one, in its order. Sections 12 to 15 are this document's own additions. The structural material — the module tree, the dependency direction, the database ceiling, the deploy — carries letters rather than numbers, in Part A, so nothing there can be mistaken for a twelfth area. The third column is here because an index that only links goes stale against the sections beneath it: a reader sees the ticks and the concessions in one glance.

The area, as the brief words itAnswered inWhere it stands
Ingestion & passage segmentation§1, drawn in A2Built and tested. Coarser than planned — blank lines only. PDF and HTML extraction not built.
Version diffing§2Built, tested and measured. The restructure cap fires on 8 of the 19 changes in the corpus pair, which is blunt and is stated as blunt; a person can answer the cap and the answer is kept. The cost curve, and one live defect in _similarity that ships unfixed, are in scalability.html.
Evidence-linked extraction§3The model proposer is built and tested against an injected fake, which is the deterministic half. The live transport reached the real endpoint on 2026-08-05, once on purpose and once by accident (transcript), and the suite covers none of it. The claim sentences on screen are assembled deterministically.
Company-context data model§4Obligations are rows. The change-to-obligation edge has a table, a lexical proposer and a confirm step. Nothing derives an edge from meaning. Documents have no table.
Citation verification§5Built, tested and adversarially probed. The depth area. The truncation case is caught by nothing and says so.
Confidence & escalation§6Built. The confidence bar ships at zero, so the third check refuses nothing today; the mechanism is configuration, not a measurement.
Reviewer routing§7The router is built and tested: a named refusal for every way it can fail to name a person, and no default assignee. No screen assigns an item. A screen does start an approval run now; nothing records a decision on a step of one.
Audit history & rollback§8The hash chain is built and tested. One decision can be taken back. Reading and restoring the chain at a moment live in app/state/replay.py; no screen reaches either.
Evals§9Deterministic metrics against an independent oracle, all passing. One hand-built proceeding. Nothing in the real corpus is scored. The model path is scored by a separate harness, app/evals/model.py, which spends money and runs on a person's command. CI runs the suite and the deterministic evals on every push.
Data isolation§10One chokepoint, imported wherever it is needed and copied nowhere. Nothing in the database enforces any of it.
Security§11Login, session handling, a permission vocabulary and an approval gate a permission alone cannot pass. No CSRF token, no second factor, no edge rate limit, no encryption at rest.
Contents
Part A — the system as code
A1. The shape in one sentence
A2. Data flow — raw document to screen
A3. Three properties that do the work
A4. Module layout under app/
A5. Dependency direction, and the edges that run the wrong way
A6. The two corpora
A7. What changes when this outgrows SQLite
A8. Configuration, schema migration, and the deploy
A9. The application surface
A10. Where the product sits in the four agentic patterns
Part B — the eleven areas the brief names
System overview
1. Ingestion & passage segmentation
2. Version diffing
3. Evidence-linked extraction
4. Company-context data model
5. Citation verification
6. Confidence & escalation
7. Reviewer routing
8. Audit history & rollback
9. Evals
10. Data isolation
11. Security
Part C — beyond the eleven
12. Data model
13. Request path — main workflow
14. Failure modes
15. Retrieval over docket text
Part D — what this document does not resolve
D1. Open questions
D2. Every count here is a count that moves

Part A — the system as code

Part A answers the questions no feature-ordered section can: which module may import which, where the database seam is, what the deploy does before it serves a request. It goes first because the eleven areas in Part B each assume a shape the reader has not been given — that ingestion writes and verification reads, that one function is the gate, that the core imports no database. Where a section here touches one of the eleven, it points rather than answering twice.

A1. The shape in one sentence

A pipeline of small, mostly deterministic stages, with exactly one module permitted to call a model on the pipeline path and exactly one function permitted to decide that a stored claim may be asserted. Everything else is either pure computation or a thin read and write against stored state. The seams between stages are the module boundaries under app/.

That is the architectural invariant, and it is narrower than the whole truth. The modules that call a model are app/interpretation/propose.py on the pipeline path, app/chat/agent.py beside it, and app/explain/answerer.py on the page built for a reviewer (A9) — that is the whole list, and grep -rn 'MODEL_ID' app/ finds the pinned ids. One sentence is about the path a claim takes; the other is about the codebase.

A2. Data flow — raw document to screen

Data flow from a raw proceeding version to a screen, and where the gate sits Raw text for one proceeding version enters ingestion, which segments it into offset-addressed passages and writes them to an immutable passage store. Retrieval over that store is an index built from those passages, serving the assistant and no screen. A pure diff compares two versions. app/pipeline.py sequences ingestion, diff and the writes, and calls no model. Claims are written into ordinary tables by rule, in app/seed.py. Interpretation, the one module on this path permitted to call a model, sits to the side of the write path, reached on read by the change screen and not by the pipeline. The gate sits below state, not above it: verification runs on every read, inside app/state/claims.py, which re-reads the source at the cited offsets, normalizes, compares exactly and checks the occurrence. A claim that passes reaches the reader as fact; one that fails is withheld with its reason code and never its statement. The readers are the web screens, the public share page and the assistant's tools, and all three go through the same function. raw proceeding version N ingestion passage segmentation, stable char offsets, SHA frozen passage store offset-addressed, immutable, one row per passage retrieval over docket text FTS5 index, bm25 rank. Candidates, not answers (§15) diff pure function. No model, no I/O. Confidence capped on restructure app/pipeline.py — the write path ingests, diffs, records changes and audit rows calls no model. A second run writes nothing company context obligations, owners, projects interpretation the only model call on this path propose.py pins claude-opus-5 (ADR-037) screens and the clerk call it on read state ordinary tables, plus a hash-chained audit log beside them. Claims are written by rule in app/seed.py. No claim carries a stored verdict every read, not every write verification — the gate (§5) app/state/claims.py re-reads the source at the cited offsets, normalizes, compares exactly and checks the occurrence. No model. No similarity withholds withheld claim the reason code, never the statement. The escalations table holds what the seed raised; resolving one appends an audit row quote matches the readers app/web/ screens, the public /s/ share page, and the assistant's tools — all three through the one gate
deterministic stage may call a model can refuse the refusal path
The gate sits below state, on the read path, and that is the whole design. It is told once, at length, in §5; everywhere else — here, §6, §12, §13 — states the fact and points there. The model box does one job. app/interpretation/propose.py builds its prompt by dispatching on the version's stored status and sends each proposal it can read to the verifier. The change screen calls it on the first view of an unjudged change, and it writes a materiality verdict with the citation that earned it. It does not write claims: the claim sentences on screen come from deterministic assembly in app/seed.py. Check the callers with grep -rn "materiality_for_company(" app/ | grep -v interpretation/propose.py.

A3. Three properties that do the work

The reasoning matters more than the boxes.

  1. The model sees a diff, never a pair of documents. By the time interpretation runs, diff has already decided what changed. The model is never asked "did anything change" — a question it answers unreliably on long documents — only "does this specific change matter," a smaller and checkable question (ADR-004). See §2 for what that costs when a filing is renumbered wholesale.
  2. Nothing leaves state as fact without passing through verification. One function turns a stored claim into something a screen may assert — verified_claims() in app/state/claims.py — and every reader goes through it. The property is "one function, no copies". Every reader goes through it — the change view, the review queue and claim view, the review centre, the project workspace, the actions screen, the public share page through state/sharing.py, state/review.py, the assistant's tools and the seed — and none holds its own copy of the comparison. The list is grep -rn "verified_claims(session" app/, which is the reason this paragraph prints no total: a count here would be a number nothing checks, and one has already gone stale twice. A bug anywhere upstream produces a withheld claim with a reason code, not a wrong fact in front of the analyst. The mechanism is §5.
  3. Every arrow into interpretation and out of it crosses a typed boundaryChange objects in, candidate claims with citations out. Neither side needs to know how the other is implemented. That is what makes each stage swappable on its own, and it is why the gate does not have to know what produced a claim (§3).

A4. Module layout under app/

The packages and the top-level modules beside __init__.py, in full: auth, chat, diff, evals, explain, ingestion, interpretation, jobs, notify, sources, state, text, verification, web, plus config.py, main.py, pipeline.py and seed.py. app/passages/ and app/review/ are not directories; both capabilities ship elsewhere, and they are listed here at the path where the code actually sits. Repeat the layout with find app -name '*.py' | sort. The list is written out rather than counted, because a list a reader can check against the tree beats a total nothing checks.

ModuleResponsibilityCalls a model?Swappable without touching the rest
app/config.pyReads .env into os.environ once, at startup, and returns the names it set — never the values. The real environment always wins over the file. Standard library rather than a dependency (ADR-014): load_env is one short function, mostly docstring and comment. The two rules are §11; the bug that made it necessary is §14.noCalled at the top of app/main.py and of some but not all of scripts/*.py, before app.state or app.web is imported, because app/auth/policy.py resolves its approval mode at import time. One of the scripts that does not call it matters: backup.py reads VERBATIM_DATABASE_URL straight out of os.environ and falls back to the repository's own verbatim.db, so a deployment that sets the database URL only in .env would have it copy the wrong file and report success. grep -L load_env scripts/*.py names the ones that do not.
app/ingestion/Raw text (proceeding version, or company-context document) → DocumentVersion + Passage rows with stable char offsets. Hashes and freezes the source, so "this is the document the citation points at" is checkable independently of anything the database says. Design and limits: §1.noParser per input format (plain text now; PDF/HTML extraction is additive, not a rewrite) as long as it still emits offset-addressed passages. The module's own docstring makes the same point about its boundaries: where a passage ends is a retrieval convenience, not the unit of truth.
the passage store — state/models.py, state/queries.py
no app/passages/ directory
Schema, offset addressing, and the scoped reads: models.Passage holds the rows and queries.passages_for_company is the way into them. Retrieval over those rows is §15, and its index read lives in the same file for the same reason.noMaking a directory of it is a move, not a redesign.
app/diff/Pure function: two passage sequences in, typed Change[] out (added / removed / modified), each carrying passage references on both sides. §2. structure.py sits in this package and nothing in app/ imports it: a document-tree parser, wired in, measured, and taken back out because the only confidence it moved was moved by the parser failing to read a document (the measurement). Its cost is measured too — scalability.html — and section 7 there records a live defect in _similarity that ships unfixed on stated grounds.noThe alignment algorithm (currently sequence-based) is fully replaceable — a structural, section-aware aligner later — because callers only depend on the Change contract, never the algorithm.
app/interpretation/The only module on the pipeline path allowed to call a model. action.py is a short lookup table and imports nothing: draft-versus-final is read from the version's status field and dispatches to two separate branches (ADR-005), never inferred. propose.py holds the model call and sends each proposal it can read to the verifier. The change screen, the project screen and the clerk's tools reach it through materiality_for_company and the read-side shown_materiality_for_company. Its model path is the one scored by app/evals/model.py. §3.yesThe provider sits behind one transport interface, imported inside the factory function so the module loads with no SDK present. The model id is pinned as a constant, claude-opus-5, and deliberately not read from the environment (ADR-037).
app/verification/The comparison. Given a citation and a source text, re-reads at (char_start, char_end), normalizes both sides, compares for equality and checks the occurrence. It applies no threshold. §5.no — string equality after normalizationRuns and is unit-tested with no model, no network and no database; it imports only app/text/normalize.py. It is the module a reviewer can audit without trusting anything about the AI.
app/text/normalize.py: the one normalization, plus the projection that maps a normalized offset back to the source character that produced it. Imports nothing from app/.noEvery comparison in the product folds through it — the diff, the verifier, the proposer, the index in state/search.py, the document chunker — which is the point. Changing the folding here changes all of them together, on purpose. Ingestion is deliberately not one of them (§1). grep -rl "text.normalize" app/ is the list.
app/state/The tracked modules are git ls-files 'app/state/*.py'. models.py (the schema, §12), db.py (the engine seam), queries.py (the tenant chokepoint, §10), claims.py (the read-time gate and the confidence floor), audit.py (the hash chain, §8), migrate.py (additive schema migration, §12), search.py (the index, §15), alignment.py (the pairing verdict, §2), and the feature stores: projects, review, workflow, routing, mapping, actions, rollback, replay, sharing, invites, identity, permissions, feedback, retention, sources.nodb.py is the seam where SQLite becomes Postgres (ADR-028) — see A7. State is ordinary tables, so the storage engine is load-bearing for correctness in a way an event fold would not be; the audit chain is what carries the evidence.
the escalation queue — state/claims.py, web/views/review.py
no app/review/ directory
models.Escalation is a real table. escalations_for_company() reads it; /escalations shows it; resolving one stamps resolved_at and resolved_by on the row and appends an audit event naming the actor. app/state/review.py is a different thing — the review centre's findings, questions and takes.noResolution is reversible through app/state/rollback.py, which writes a new audit row naming the one it takes back rather than editing either.
app/auth/sessions.py: login, logout, session resolution by token hash. policy.py: the permission vocabulary, role grants, and the segregation-of-duties check on approvals. §11.noThe guard is installed once in app/main.py as middleware rather than decorated onto each route, so a screen added next month is protected by existing.
app/chat/Clarke, the records clerk (ADR-050). persona.py holds the prompt and a deterministic pre-screen; agent.py the turn loop, the pinned model and a named degradation for every way a turn can fail; tools.py the tools, behind an import-time-checked allowlist; pills.py the suggested actions; engine.py the seam the chat view resolves by name. budget.py is the context rule and nothing calls it, on purpose (A9).yesThe model never supplies an identity: company_id, actor, user_id and session are refused as tool arguments rather than stripped, and the turn halts (ADR-051).
app/notify/messages.py composes an invitation mail; transport.py sends it over SMTP. With no SMTP settings it declines, returns MAIL_PATH_OFF_NOT_CONFIGURED and tells the admin the link is real and nobody was emailed.noThe token goes in the link and nowhere else — not the subject, not a header, not a log, not the audit chain.
app/web/FastAPI routes and server-rendered templates. One router per screen, each owning its own absolute paths — no prefixes, so a URL lives in one file. Reads state; for the citation viewer, passages through the scoped read; imports app/diff/ and app/verification/ for the constants and the occurrence helpers.noDeliberately thin, with one conceded exception: private scoped reads sit in web/views/admin.py that belong in state/queries.py, and the file says so.
app/evals/The offline harness: corpus.py, metrics.py, report.py, run.py. Scores diff and verification against the synthetic corpus, whose expected answers come from data/manifest.json rather than from the code under test. §9. obligations.py and model.py are separate entry points with separate exit codes, because make eval opens no socket and must keep not opening one: a metric in that harness that reached the API would turn its own caveat into a lie the first time it ran.model.py can, and only with --sendImports diff, ingestion, interpretation.action and verification. It imports neither state nor web, so the harness cannot accidentally measure the database — checked in a fresh process, because the test's own imports are noisy.
app/explain/The "how it works" page (ADR-095) and the assistant on it. knowledge.py holds the verification path as nodes in the code's own words; documents.py chunks the PRD, MRD and TDD by heading; docindex.py stores those chunks with their vectors and holds the transaction that makes an index whole rather than half-built; embedding.py is urllib against OpenAI's embeddings endpoint, behind the same injected seam propose.py uses; answerer.py assembles the answer. Every citation an answer carries is re-read against the file on disk before the answer is shown. §15.yes, and it degrades out loudTwo independent off-switches, each announced in the reply rather than in a log: no ANTHROPIC_API_KEY and it answers from a keyword index over the same knowledge; no OPENAI_API_KEY and the same chunks are ranked by bm25. The embedder has now run against the live endpoint once (ADR-104), which is why the constants here are observed rather than read out of documentation. Its refusal path is still unproven — no bad key and no wrong model name have been put to it — and nothing is known about load, retries or a longer input. Changing the model or its width is a change of scheme, and docindex.py::VECTOR_SCHEME is built from the model id and the width so it cannot lag them.
app/jobs/The only thing here that acts with nobody present. runner.py sweeps overdue approvals and purges past their retention window. It does not fetch: the source fetch is named and refused in the module's own docstring — asking for it raises with a sentence saying so, rather than registering a job that quietly does nothing. deploy/entrypoint.sh starts the loop as a separate process, and only when VERBATIM_JOBS_ENABLED is true — which is not the default (ADR-066).noA separate process rather than a thread, because a sweep and a request would otherwise compete for the same SQLite write lock. Retention runs dry unless VERBATIM_JOBS_RETENTION_DELETE is exactly true, and purge_all re-checks that flag itself rather than trusting this caller.
app/sources/The only code here that opens a socket, at an address an administrator supplies. fetch.py retrieves a public docket, hashes it, and stores it only if the hash moved; an unchanged fetch writes nothing and says so (ADR-067).noThe SSRF guard is the module, not a check in front of it: scheme before any name is resolved, then every address the name resolves to and every redirect hop. Hands the changed bytes to app/pipeline.py rather than diffing them itself.
app/pipeline.pyThe write chokepoint. Sequences ingestion → diff → record for a new version, so one place owns the order of operations. Idempotent: the same version twice writes nothing the second time. Refuses a corpus whose bytes moved under it rather than rebuilding half of it. §13.noIt imports diff, ingestion, state and audit, and not verification — because the gate is on the read path, not this one.
app/seed.pyLoads the synthetic corpus and writes the demonstration rows: proceedings, versions, claims assembled by rule from data/manifest.json, escalations, accounts. One claim's quote is deliberately altered at real offsets so a citation fails on screen.noThis is where the claims a reviewer sees come from. It is a loader, not a model, and the product's arithmetic is honest only because it says so.
app/main.pyAssembles the application: loads .env, mounts the static files, includes every router in the order an analyst meets them, installs the session guard and the security headers, serves /healthz.notests/test_app_wiring.py derives the expected set from app.routes and fails the moment a router under app/web/views/ is not on the list. That test is how two whole screens were found mounted nowhere.

A5. Dependency direction, and the edges that run the wrong way

Dependency direction runs one way for the core. ingestion knows nothing about diff; diff knows nothing about interpretation; interpretation knows nothing about the gate's pass or fail logic, only that it must emit a citation for anything it wants to survive. verification knows nothing about models, databases or the web. This is what makes "fix the class, not the line" tractable — a bug in one stage cannot silently depend on behaviour in another, because the only thing that crosses a boundary is a typed object. Above the core the direction is looser, and the diagram draws that honestly, including the edges that run back up.

Which package under app/ may import which Solid arrows point from a package to a package it imports when the module loads. Dashed arrows mark an import written inside a function body, which binds at call time. Everything points downward except the dashed ones. app/text/normalize.py imports nothing from app/ at all, and diff, verification, interpretation and the index each import it. Interpretation also imports the verifier, so the proposer puts its own citations through the same module app/state/claims.py uses. Several modules outside app/state reach into app/state/queries.py at call time rather than at import: diff.passage_refs(), verification.verify_citation_for_version(), the proposer's scoped entry points and the explain answerer's engine lookup. One edge runs the wrong way in the top band and is drawn rather than hidden: app/chat/tools.py imports names from app/web/views/. On the right sit two capabilities the table above files under directories that do not exist. The escalation queue ships, in state and web. The passage store ships, in state, and retrieval over it ships beside it in state/search.py. app/evals/ imports the four core packages and neither state nor web. app/main.py app/web/ app/chat/ app/seed.py main.py calls app/config.py first, then mounts every router web imports auth, state, diff, verification and interpretation chat imports state, auth, pipeline — and names back out of web app/pipeline.py app/auth/ app/notify/ pipeline imports ingestion, diff and state — never verification auth and notify import state only app/state/ app/ingestion/ queries.py holds the scoped reads; claims.py calls the verifier ingestion writes rows; the one edge into the core is drawn below app/diff/ imports app/text pure — no I/O see dashed arrow app/verification/ imports app/text no model, no network runs with no API key app/interpretation/ action.py — a lookup, no imports propose.py — the one model call it sends proposals to the gate screens and the clerk call it on read app/text/normalize.py imports nothing from app/ claims.py imports the verifier call-time call-time call-time the escalation queue — it ships no app/review/ directory; the table above names one. The capability is in models.Escalation, claims.py, and web/views/review.py at /escalations the passage store — it ships no app/passages/ directory either. models.Passage holds the rows and queries.passages_for_company is the scoped read of them retrieval over the passages state/search.py — FTS5 and bm25, read through queries.py (§15) app/evals/ imports diff, verification, interpretation and ingestion never state, never web
code that ships may call a model the gate solid arrow: imports at module load dashed arrow: imported inside a function, at call time
The core is testable without a database because it imports none. That is the return on the layering, and the dashed arrows are what it costs. diff.passage_refs() imports app/state/queries.py inside the function body, so the module stays clean at import time and the dependency is real at call time. verification.verify_citation_for_version() does the same, and nothing in app/ calls it; so do the proposer's scoped entry points and the explain answerer's engine lookup. The check has to say "from outside" — the index added call-time imports inside app/state/ itself, where the direction is sideways rather than upward, and a grep over every indented from app.state line cannot tell those apart. The one that stays checkable: grep -rn '^\s\+from app\.state' app/ | grep -v '^app/state/'. Where this picture disagrees with the table above. app/passages/ and app/review/ are not directories, but both capabilities ship, so they are drawn where the code sits. The table's order implies dependency runs top to bottom of it; ingestion imports state/models.py, which runs the other way. And one edge points up out of the top band. app/chat/tools.py imports from app/web/views/admin.py and web/views/workflow.py, private helpers included, so the clerk can answer "where is this in the route" without a second walker that could disagree with the screen. The file concedes the inversion in a comment rather than hiding it. The fix is to move those functions down into app/state/workflow.py; it has not been done. One label on this figure is narrower than it looks. Only app/interpretation/ is drawn as a model caller, because it is the only one on the pipeline path. app/chat/agent.py and app/explain/answerer.py call a model as well. grep -rn 'MODEL_ID' app/ finds the two pinned ids.

A6. The two corpora — one invented and measured against, one real and demonstrated on

There are two, both labelled, and conflating them would be the easiest dishonesty available to this product.

The synthetic corpus — three versions of a fictional proceeding under data/, with a manifest — carries traps built on purpose: one sentence repeated three times per version so an unqualified citation is ambiguous, and a wholesale restructure whose renamed sections read almost identically on raw text similarity while sitting under different numbers. It stays, and it stays the eval corpus (ADR-040). The expected answers live in data/manifest.json rather than in the code under test, so the oracle is independent of the thing it scores. Real filings will not reproduce those traps on demand, so replacing the fixture would quietly gut the harness. The synthetic corpus must never be given a source URL: no commission published it, and a plausible-looking docket link would be the product inventing provenance, which is the one failure everything else here exists to prevent.

Reproduce the restructure numbers in one command. Diffing v2 against v3 reports 19 changes, 8 of them capped at exactly 0.5000, and the pair the §2 figure draws — 6.3 Return of Collateral becoming 5.4.3 Return of Collateral — scores 0.9904 raw.
.venv/bin/python -c "
from app.ingestion.ingest import _segment
from app.diff.engine import diff, PassageRef, _similarity
import pathlib
def refs(v, p):
    return [PassageRef(v, s, e, t) for s, e, t in _segment(pathlib.Path(p).read_text())]
ch = diff(refs('v2','data/v2_revised_proposed_rule.txt'),
          refs('v3','data/v3_final_order.txt'))
mod = [c for c in ch if c.change_type == 'modified']
print(len(ch), 'changes;', sum(abs(c.alignment_confidence-0.5) < 1e-9 for c in mod), 'capped')
for c in mod:
    print(round(c.alignment_confidence,4), round(_similarity(c.before.text, c.after.text),4), c.before.text[:28])
"

A figure this document used to print, still loose elsewhere. The restructure pair scored 0.944 in an older corpus, and that reading is not reproducible from data/ today. This document no longer carries it; other documents here still do, and correcting them is outside this one's remit. grep -rn '0\.944' docs/ finds them, and unlike a list of filenames that line cannot itself go stale.

The real corpus102 public filings under data/real/, from 8 jurisdictions. Every filing has a provenance record beside it carrying the source URL, the docket, the filing date, the filer, how it was obtained, the retrieval time and a SHA-256 of the saved bytes. The record shapes are not uniform, and where a field is absent the record says nothing rather than guessing. One pair is loaded into the demonstration by scripts/ingest_real.py: Kentucky PSC 2025-00113, a witness's direct testimony filed and then corrected. 1,024,409 characters against 1,024,536127 characters of difference across a million, and the pipeline finds 144 changes. Those five are marked, because here the number is the argument: it states the problem as arithmetic on a document somebody really filed, with a link back to the commission's own copy so a reader can leave and check. The filer also published a marked-up version saying what they changed, which is ground truth nobody on this side wrote. The timing curve for pairs that size is in scalability.html.

One claim in the demonstration comes from that real pair, and it was written by hand, not extracted — the script says so. Nothing in this product extracts claims yet (§3). Nothing in the real corpus is scored either, and §9 carries that limit under the heading a reviewer looks for it.

A7. What changes when this outgrows SQLite

ADR-007 picks SQLite so the submission starts with one command on a reviewer's machine. ADR-028 revisits it and names the trigger for moving: a second tenant, or any real customer data, whichever comes first — not a date and not a row count. SQLite does not scale as built. This section says where it breaks and what each fix is.

The seam at app/state/db.py, and which guarantees sit on which side of it One connection string in app/state/db.py is the seam to Postgres. SQLite enforces primary keys, NOT NULL, column types and the unique constraints declared across the schema — among them one account per email per company, one live session per token hash, one live grant of a permission to a person, and one active approval workflow per company. The last two are partial indexes, business rules held in SQL rather than in Python. SQLite has no users, no roles and no GRANT, so no control here can be a database privilege. Tenant scope, the append-only audit rule, the hash chain and citation verification are enforced by Python. The foreign keys declared in models.py enforce nothing today: PRAGMA foreign_keys is off and no line under app/ turns it on. Postgres removes the concurrency limits and makes those foreign keys bite; it moves no guarantee out of Python by itself. app/state/db.py create_engine(VERBATIM_DATABASE_URL) sqlite:///verbatim.db by default — the seam what SQLite enforces primary keys, NOT NULL, column types unique constraints across the schema. The PARTIAL ones are business rules held in SQL: one active approval workflow per company, WHERE status = 'active' one live grant of a code to a person, WHERE revoked_at IS NULL then the token hashes and the address: sessions, invitations, share links, users (company_id, email) and the rest stop duplicate rows: audit seq, roles, permissions, the join tables, chat ordinal no users, no roles, no GRANT, no row-level security, so no control here can be a database privilege what application code enforces tenant scope — a WHERE clause in Python append-only audit — a SQLAlchemy before_flush listener, so it binds this process and nothing else the hash chain — hashlib, walked on demand citation verification — string equality after normalization, no threshold every ForeignKey in models.py enforces nothing: SQLite leaves PRAGMA foreign_keys off and this code never turns it on. On Postgres they bite. WAL is not enabled either — nothing sets journal_mode, so a reader waits behind a writer today Postgres changes this the connection string, plus a test pass for locking and transaction behaviour — not a config flag concurrent writers stop queueing behind one file two web instances can share one store Postgres changes none of this nothing above app/state/ — the core reads no SQL the Python-enforced rules stay Python-enforced a GRANT-based control on the audit table becomes possible, not automatic. Somebody has to write it.
the two sides of the seam left: the database holds it right: Python holds it, on any engine
The database holds uniqueness. Python holds the rules that matter. The foreign keys are held by nobody. The partial indexes are the interesting ones — one active workflow per company, one live grant of a permission code to a person — because those are business rules the database is enforcing, not bookkeeping. This caption used to publish a total, and it was wrong twice: it said two when there were already six, and nine a day after that. So it publishes none. Count them instead: build the schema from the models into a scratch database and read PRAGMA index_list for every table, skipping the primary-key autoindexes. What the move to Postgres does and does not do. It buys concurrency, and it turns the foreign keys on. It does not move tenant scope, the append-only rule, the hash chain or citation verification out of Python. The trap on this page is that declaring a ForeignKey in SQLAlchemy reads like a database constraint and is not one until PRAGMA foreign_keys is on, and no line in this repository turns it on.

A8. Configuration, schema migration, and the deploy

What continuous integration runs. .github/workflows/ci.yml runs make test and make eval on every push and pull request. It runs the documented commands rather than a cleverer pipeline, and that is the whole design: a hand-written pip install -r requirements.txt and pytest -q would stay green while make run — the command a reviewer actually types — was broken, and the two commands this submission promises are the two that must never break. make eval depends on seed, so the job also walks the whole ingest path — every real filing and both index rebuilds — which is the part a reviewer hits first.

The third step is the interesting one. It runs the model harness with nothing sent and requires exit code 2 — the code that means the model was not evaluated — rather than leaving the model path out and letting a green build imply a coverage it has not got. Its comment records what that step does not prove: the harness returns 2 from the no---send branch before the transport is consulted, so the step says nothing about the no-key path. That path is covered in-process by the suite one step above. No key appears in either workflow file, and a test fails the build if --send ever does.

Two things CI does not do. Nobody has timed the pair on a GitHub runner, so the thirty-minute timeout is a guess and the file says so. And nothing deploys from CI: the hosted instance is still pushed by hand.

Two defects were found by deploying, and neither could have been found by a test. The first was configuration that never arrived: several modules read their settings from os.environ and each fell back safely when it found nothing, but nothing put .env into the environment, so all three announced the fallback always. app/config.py is the fix, its two rules are in §11, and the general lesson — a legitimate fallback state makes a wiring bug invisible — is §14. The second was a deploy that ran no migration: deploy/entrypoint.sh seeded only when there was no database, which is right, but when the file existed it ran nothing, not even create_all, so the first deploy after a column was added would have served no such column: document_versions.source_url on every screen. The migration mechanics and the three refusals are §12. What belongs here is the topology: what runs, in what order, before anything serves a request.

The request path and the start-up path, side by side On the left, a request to the hosted site reaches nginx, which matches a named list of path prefixes and proxies those to the application container on port 8000. Everything else is served as a static file from the marketing site. The application container carries no Traefik labels and publishes no port, so nginx decides what reaches it. On the right, the entrypoint runs the migration first, on every start, before anything reads a table and before the seed. The migration derives what is missing from the SQLAlchemy metadata and adds it; a NOT NULL column with no default is refused and the process exits non-zero, so the deploy stops. Below both, the seed runs only when there is no database file, the demonstration content scripts run on every start behind one switch, and then uvicorn starts with one worker. a request arrives the container starts verbatim-site — nginx:alpine, holds the Traefik labels deploy/nginx.conf — a named list, not a catch-all login logout projects proceedings changes review escalations workflow admin permissions actions chat explain feedback users invite s static healthz — that list, and no catch-all deploy/site/ the marketing pages and one same-origin stylesheet verbatim-app:8000 no Traefik labels no published port deploy/entrypoint.sh — every start, set -eu python scripts/migrate.py — always, and first before anything reads a table, and before the seed app/state/migrate.py — derived, not listed asks the models what they declare, the database what it has adds the difference: tables, columns, indexes (§12) never drops, renames, retypes or backfills refuses exit 1 — the deploy stops a NOT NULL column with no default. Add it nullable, backfill deliberately, then tighten then, and only then seed only when there is no database file — a redeploy must never lay demo rows over an audit chain then uvicorn, one worker, because SQLite takes a write lock across the file (ADR-028)
a container, a file, or a step decides, and can refuse the refusal path
The named list is the seam, and it is written down. A catch-all proxy fails quietly in both directions: a page added to the marketing site would silently reach the application, and a router added to the application would silently 404 while every test still passed. So nginx matches a named list and the site keeps the root, because a stranger arriving at the domain should meet the argument before the product. The cost is a list somebody has to remember to extend; tests/test_app_wiring.py::test_every_mounted_path_prefix_is_proxied_by_nginx is what makes forgetting go red rather than 404. Two things this figure asserts that a test cannot. The application container publishes no port and carries no Traefik labels, so nginx is the only way in. And the migration runs before the seed rather than beside it, because the deploy that adds a column is exactly the deploy where the old table is still there. One rule in the entrypoint is bent on purpose and says so in the file. The demonstration content — obligations, the approval route, the real filings, both indexes — is laid down on every start rather than only on a fresh database, because that is the only thing that repairs a workspace seeded before those scripts existed, which is the state the live one was in. The scripts run in a fixed order tests/test_seed_chain.py holds to the Makefile's; none drops anything, each is guarded, and the whole block is gated on VERBATIM_DEMO_ACCOUNTS: one switch meaning "this workspace is a demonstration". A failing script is reported by name and does not stop the site.

A9. The application surface

The pipeline above is the reason the product can be trusted. It is not what a person uses. The capabilities that sit on top of it have a heading each below, and each is described by what it does and by what it does not.

Clarke — the assistant

app/chat/ is one of the three places in the codebase that call a model. The persona is a commission's records clerk (ADR-050): it finds a project, opens a change, says what moved, and offers no opinion on the merits. Its tools are mostly reads; the ones that write are exactly the ones gated on a permission. The model never holds an identity. company_id, actor, user_id and session are supplied by the caller, and if the model puts any of them in a tool call the turn is refused rather than the argument stripped, with an audit row to say it happened (ADR-051). The tool registry checks itself at import: a tool whose signature disagrees with its allowlist, or that names a permission code the product does not define, fails the import rather than the request.

Two limits are worth stating plainly. The claims Clarke reads are gated — change_detail and search_claims go through verified_claims(), and a withheld claim reaches the model as a reason code with no statement and no quote. The prose Clarke writes is not gated. Nothing re-checks the reply text against the source. The controls are the persona prompt, the narrowed payload, and a check that a turn which withheld something says so with the count; app/chat/tools.py concedes it in one line — "a tool result is text a model will summarise, and nothing in this file can stop it paraphrasing badly." Second: history is stored, ordered by an ordinal with a unique index rather than by a timestamp, and the engine discards it before the model sees it. Each turn stands alone. The rule for the day that changes is written and enforced before the behaviour exists: app/chat/budget.py holds one priority ordering in which the question, the citation spans and the diff offsets are never evictable and conversational turns go oldest-first, and it refuses the turn outright when the floor alone will not fit rather than shaving a citation to make room. tests/test_history_needs_a_budget.py walks the syntax tree of app/ and fails when a value derived from the conversation reaches a model call without coming through it. Costs are measured and announced in characters, because no offline tokenizer is a dependency of this project and a token figure here would be a ratio nobody measured. Planning is minimal on purpose: MAX_TOOL_STEPS = 4 and no plan step, because a planner that can spend an unbounded number of calls on a question about a docket is a cost with no ceiling. What search_claims hands the model beside the claims is a ranked list of candidate passages, every one marked as a candidate rather than as evidence — §15.

“How it works” — the page a reviewer reads, not the analyst

/explain draws the path a pair of filings takes, in three tabs, and answers questions about it (ADR-095). A model is called here too, and it is the one capability built for the person reading the submission rather than for the analyst in ADR-001 — worth conceding in that order, because the rule this project holds itself to is that every feature maps to a named user pain, and this one maps to a reviewer's. It answers from this project's own PRD, MRD and TDD plus the nodes of app/explain/knowledge.py, and nothing else: answer from that or refuse, because the alternative is a model reconstructing this codebase from training data and inventing a filename that sounds right. A confident wrong filename is the worst answer available here, which is the argument verifier.py makes about a quote. It keeps no history — one question, one answer — because a conversation here would need a session, a store, a retention window and a tenant scope, four things to get right for a feature nobody asked for. The retrieval, the citation re-read, the announced fallbacks and the thing nobody has tested are §15.

Share links

/s/{token} is the only unauthenticated route in the product, and the token is the whole credential. The row stores a SHA-256 of it and never the token itself; the plaintext exists once, on the object returned to the person who minted it. Expiry is mandatory, seven days by default and thirty at most, and a request for zero or for a year is refused rather than clamped. Expired, revoked, unknown, and "this tenant switched sharing off" all render one identical 404, so nobody can probe which tokens are real. A share re-verifies its claim at open time, on every open, through the same function every screen uses — so a shared claim whose source later changed shows as withheld to the recipient without anybody revoking anything. The defect that made this route public, and the exact shape of the fix, is §11.

Invitations, and no signup

There is no registration route anywhere in app/. An account is provisioned by an administrator holding user.manage, or handed off by somebody holding user.invite when routing finds an obligation owner with no login. Two clocks: seven days for a handoff, twenty-four hours for a provision. A resend is a new invitation with a new token and the old row marked superseded, never a longer life on the same one. app/state/invites.py sends no mail — it returns the token once, to its caller — and the token goes into the link and nowhere else.

The grant ceiling is a door, not a rule, and the difference is the interesting part. On the provisioning path an inviter may never grant more than they hold: without that ceiling it would be the shortest route in the product from user.manage to any authority at all. On the handoff path the invitation grants strictly more than its inviter holds and says so every time — accepting a handoff grants obligation_owner, and admin, the only stock role carrying user.invite, holds neither action.approve nor action.reject. That is the feature rather than a leak in it: this module exists to create the approver for a duty that has none. The keepable claim is narrower and is enforced: _grant_within_ceiling is the only function in the module that grants a role, a test reads the module and fails if that stops being true, and a waiver writes invite.ceiling_waived into the chain naming the codes and the person.

And the escalation that opens, with the right actor named. app/state/invites.py names it in its own docstring, and stopping at the reassuring half would be the dishonest edit. The actor is an administrator, not an analyst: the gate is user.invite or user.manage, and user.invite sits on admin and nowhere else. An administrator invites an address they control at their own domain, accepts it, and now holds action.approve. Two things stand against that and neither is strong: the self-invite check compares under a plus-tag rule, which catches a plus-tag and not a second mailbox on a domain the administrator owns; and separation of duties is enforced where the approval happens, not here. It is the same hole app/state/identity.py names beside user.invite, and the one ADR-064 concedes for grant_role. Closing it needs a second approver on a privilege change, and this build has no such thing.

At this commit /invite/ is not on the public-path list: PUBLIC_PATHS holds /login, /login/demo and /healthz, PUBLIC_PREFIXES holds the static path and the share path, and nothing else. So an invitee is sent to a login page they have no account for. The admin screen renders the warning itself, and it asks is_public_path() rather than assuming, so the warning will disappear on its own the day the prefix is added.

Integrations

/admin/sources is a registry with one working fetcher. The fetchable-kinds tuple is ('public_docket',); every other kind is born not_implemented. ADR-067 landed app/sources/ and ADR-066 landed the job loop that can call it, off by default. What is real either way: rows with a kind, a status, an enable flag, provenance counts, and an audited history of every registration, edit and removal. Credentials are stored as the name of an environment variable, allow-listed by prefix and shape, and the screen shows whether that name is set in this process — never the value, never its length. There is no "test connection" button, and the page explains why: a button that fetches an administrator-typed URL is a request-forgery surface. What exists instead is a text-only preflight that refuses private ranges and internal-looking names, opens no socket and resolves no name, and says so on every verdict.

The first-login tour

A short walk ending on a withheld claim and then the assistant. The stops are data in the template; the script holds behaviour and no words. State is a cookie and nothing else — no route, no table, no column — because a preference is not a credential, and it therefore may not share a name, a lifetime or a reader with the session cookie. A stop whose target is not on the screen is skipped, and if no stop resolves, nothing is drawn and nothing is remembered.

A10. Where the product sits in the four agentic patterns

Andrew Ng's four patterns — reflection, tool use, planning, multi-agent collaboration — are a useful way to say what this product does and, more usefully, what it does not.

Tool use is real: app/chat/tools.py holds the registry, and the tools that write are exactly the ones gated on a permission. Planning is minimal on purpose — MAX_TOOL_STEPS = 4 and no plan step. Multi-agent collaboration is absent from the product entirely. It is how this repository was built, not something the product does; claiming it as a product capability would be the defect this whole document is written against.

One thing that method produced belongs here, because it is the same defect the counting rule exists to stop. Verifiers in that build were told to refute rather than to check, and five drafted blog articles were refused on one finding: every quotation in them was correct byte for byte, and the numbers around the quotations were wrong. That is exactly what three review rounds then found in the counts on this page. The lesson did not transfer on its own, which is why the rule is now a test rather than an intention.

Reflection is present and is deliberately not the usual shape, and this is the part worth arguing about. Reflection usually means asking the model to inspect and revise its own work. That asks the weights that just erred to catch the error, and what comes back is a second opinion with the same blind spot and more confidence. Verbatim's reflection step calls no model: verify_citation re-reads the stored source at the offsets the claim named, normalises both sides, compares exactly and checks the occurrence. It gates the model's own materiality judgement as well as stored claims (§3, ADR-078), so a reviewer can watch the same gate refuse a model verdict and a seeded claim on one screen, centimetres apart. A deterministic reflector cannot share a blind spot with the thing it is checking, which is the whole reason to pay for one.

Part B — the eleven areas the brief names

Sections 1 to 11 answer the eleven areas the brief tables, in its order, one to one. They do not move, renumber, merge or split: cross-references run through this document, through code comments and through anchor links in other documents, and renumbering breaks all of them silently. The System overview is the seam between Part A's structure and the numbered sections.

System overview

A proceeding — an external regulatory docket — arrives as successive document versions, each tagged draft or final. A company context — obligations, projects, documents, written in the company's own words rather than the docket's — sits beside it. The pipeline turns a new version into: what changed (deterministic), and what the product is prepared to say about it (a claim, with a citation that must re-read cleanly before it can be shown as fact).

The system is a single FastAPI service over SQLite (ADR-007), server-rendered. No model call sits on the path a claim takes to the screen. app/interpretation/propose.py asks Claude whether a change is material (ADR-037); app/chat/agent.py answers questions about the record through a fixed set of tools (ADR-050); app/explain/answerer.py answers a reviewer's questions about this design (ADR-095). Everything between reading a version's bytes and rendering a claim — ingestion, segmentation, the diff, normalization, verification, the audit chain — is deterministic and takes no model call. That ordering is the whole architecture: a claim survives its citation whatever produced it, so the gate in §5 does not need to know.

Every model path is off unless ANTHROPIC_API_KEY reaches the process, each says so when it is off rather than answering from something canned, and the suite covers the deterministic half of every one of them and no part of the live one — every test drives a deterministic fake through an injected transport. The live half has been reached, and the honest form of that sentence is not "once". app/interpretation/propose.py's transport sent one deliberate request to the real endpoint on 2026-08-05 against claude-opus-5, and the probe transcript records the request, the response and the token counts. That same page also records a second call the same day: an agent ran the probe with --send believing the key was absent, load_env() put it back, and the output was piped away and lost, so nothing about that call can be reported at all. The embedder behind /explain has since run against OpenAI's endpoint for real, on 2026-08-11 (ADR-104). app/chat/agent.py's transport has still never run against an endpoint. Retrieval over docket text exists — an FTS5 index with a bm25 ranking, serving the assistant and no screen (§15) — and a ranked hit is a candidate that still has to pass the gate before anything may be said about it. What does not exist is the half ADR-008 designs for the company side: nothing embeds anything, and the stages that would need it — materiality judgement grounded in the company's own wording, and automatic change-to-obligation mapping — are designed and not built. This document draws those dashed rather than describing them in the present tense.

1. Ingestion & passage segmentation

Design

A document version is ingested once and stored as immutable raw text plus a SHA-256 of that text (document_versions.source_sha256). Versions are never edited in place — a correction is a new version, never a mutation, because every downstream offset assumes the text under it cannot move. app/pipeline.py enforces this: a version id already stored with different bytes raises CorpusChanged rather than being repaired, since every passage, change span and citation derived from it addresses text that no longer exists.

Ingestion then segments the raw text into passages: the unit diffing and citation both work over. Each passage is stamped with (version_id, ordinal, char_start, char_end, text, section), where the offsets index directly into the stored raw text — not into a re-flowed or re-normalized copy — computed in a single pass so a passage's offsets can never drift from a later re-parse.

The SHA-256 is there so that this is the document the citation points at is checkable independently of anything the database says. If the stored source ever diverges from what was ingested, the hash catches it — which is a different guarantee from the one the offsets give, and the two are kept apart deliberately (§5).

Why offsets are the unit

Every claim in the product — a diff segment, a citation, a piece of obligation evidence — resolves in the end to (document_version_id, char_start, char_end) against an immutable, hashed source. Offsets are the unit rather than passage ids, sentence ids or a similarity score, for one reason: an offset is checkable by exact substring lookup. No embedding maths, no fuzzy threshold, no model in the loop — read the bytes between two integers and compare them to what was claimed. That is what makes verification a pure function instead of another place where the product has to trust something.

Two consequences follow and both are load-bearing. Where a passage ends is a retrieval convenience, not the unit of truthapp/ingestion/ingest.py says so in its own words, which is why a crude segmentation is survivable and a wrong offset is not. And offsets are addressed against the frozen version, never the current document, so a citation minted against version 3 stays valid after version 4 exists. Versions are never edited in place, only appended. §5 carries the rule that follows: an offset is never projected across versions through the alignment computed in §2.

What ingestion actually does to one version Ingestion hashes the exact bytes, splits the text on blank lines only, and writes one passage row per chunk carrying character offsets into the frozen text. Section labels are detected by a leading-number pattern and carried forward. PDF and HTML extraction do not exist; both corpora are stored as text, the real one extracted outside this product. raw version text read as bytes, decoded ingest_version() sha256 of the exact bytes, stored as source_sha256 never trims, re-encodes or rewrites the text PDF and HTML extraction DESIGNED, NOT BUILT. Both corpora arrive as text, so no header, hyphen or column pass runs _segment() one regex, blank lines only. A clause marker like (a) does not start a passage _section_of() 4.4 or SECTION 4. becomes the label, and it is carried forward to the passages beneath it one row per passage, in document order passage, ordinal n−1 char_start, char_end section carried from the last heading passage, ordinal n 4665 – 5063 (v1) section 4.4 — its last 133 chars repeat (§5) passage, ordinal n+1 char_start, char_end same section until a new heading The offsets index this version's stored text and nothing else. A citation is a pair of them, and verification is a slice (§5). Passage carries no company_id. Tenancy sits on the version that owns it, and the join in queries.py enforces it (§10). There is no text_hash column and no structural_ref column.
deterministic stage designed, not built
Ingestion splits on blank lines, and the crudeness is survivable. Section numbers are detected, but only to label a passage, never to end one — so a long clause containing three lettered sub-paragraphs is one passage and one citation window. That is a real loss of precision and it is bounded: correctness rests on the offsets, and an offset into a coarse passage is still an offset that re-reads exactly. The one thing that would not survive is a wrong offset, which is why nothing between reading the bytes and writing the row is allowed to alter the text.

Alternatives considered

OptionRejected because
Fixed-size token windowsEven chunking is easy to implement but ignores document structure — a citation span routinely cuts a sentence in half, and a diff routinely straddles two unrelated clauses.
Sentence-level segmentation (NLP splitter)Finer citation precision, but multiplies passage count 5–8x, which multiplies the alignment decisions diffing must make (§2) and the number of low-confidence edge cases that can each independently escalate.
Whole document as one segmentNo segmentation cost, but every diff and every citation becomes page-scale — useless for judging materiality and useless for a reviewer trying to see what changed.

Trade-off accepted. Paragraph-level segmentation is coarse when a long paragraph contains one small change: the passage offsets bracket the whole paragraph. The plan was to compensate with a character-level diff inside each aligned pair. That is not built. A Change row carries whole-passage offsets on both sides, and the change screen shows the two paragraphs side by side and lets the reader find the difference. Narrowing the span to the changed characters is a diff/-internal improvement, not a redesign, because nothing downstream depends on how wide the span is — only that it is exact.

PDF extraction, stated honestly

The plan was to treat PDF extraction as its own tested stage — page headers and footers, hyphenation across line breaks, column reflow all resolved once, before any offset exists. No such stage exists. grep -rn "pdfminer\|pypdf\|pdfplumber" app/ returns nothing, and ingest_version is handed decoded text in every path.

The real corpus widens what is untested rather than closing it. data/real/ holds 102 real public filings retrieved from 8 jurisdictions, each beside a .provenance.json naming its source URL, docket, filer, filing date, retrieval time and a SHA-256 of the bytes (ADR-058, ADR-059). That text was extracted from the commissions' PDFs outside this product and stored as text, so the extractor is still somebody else's, still untested here, and the risk is unaddressed rather than mitigated. What the real corpus does buy is input the normalizer had never met: line-numbered testimony, certificates of service, words hyphenated across line breaks, soft hyphens and ligatures, in documents nobody on this side shaped to suit the code.

One piece of PDF reality is handled. Soft hyphens and hyphen-against-line-break are folded by app/text/normalize.py, which runs on both sides of every comparison at verification time (§5) — not at ingestion, before offsets exist. Normalization that ran at ingestion would rewrite the text the offsets index, and verification must never fix text out from under an offset it did not compute. tests/test_pdf_text.py exercises the occurrence check over text shaped the way an extractor emits it — soft hyphens, ligatures, wrapped lines — which is a test of the normalizer, not of an extractor the product does not have.

2. Version diffing (ADR-004)

Design

Diffing is deterministic code and the model is not part of it. diff(before, after) normalizes both passage sequences, runs difflib.SequenceMatcher over them, and turns each opcode into typed changes — added, removed, modified. Inside a replace opcode the two runs are paired positionally and any surplus on either side is reported as a pure addition or removal. Given the same two sequences it returns the same list every time, which is what lets the corpus's deliberate edits be asserted exactly rather than approximately.

Every modified pair carries an alignment confidence, and that number is where the design does its real work. Text similarity alone is the wrong measure, and wrong in the dangerous direction: when a section is renumbered and relocated, the words barely move, so similarity runs high precisely when the structural identity has changed. So a disagreement in section label caps the score at RESTRUCTURE_CONFIDENCE_CEILING = 0.5, below any plausible escalation threshold. The passages may well correspond. The alignment is now an inference rather than an observation, and it may not present itself as settled.

A reviewer can answer the inference, and the answer is kept. 16 of the 27 changes in the demonstration corpus carry the caution — more than half of it — so printing the doubt and offering no control over it would be asking for judgement and dropping every answer. app/state/alignment.py records one of three verdicts against the change: the pairing is right, the pairing is wrong, or cannot tell, with optional reasoning. It is written into the hash chain as change.alignment_confirmed, change.alignment_disputed or change.alignment_uncertain, filed under the change, carrying the actor, the account and the moment. POST /changes/{id}/alignment is the route and it is gated on action.propose. The computed number is never overwritten: it stays on Change.alignment_confidence and is quoted into the audit reason as it stood when the person answered, so what the machine thought survives the person disagreeing with it. An unanswered pairing keeps its caution, and so does one answered "cannot tell" — an alignment nobody could resolve is exactly as uncertain as one nobody has read. A confirmed or disputed pairing loses it, because the caution's own words are "read the pairing as a guess, not a fact" and a named person has just made it something else.

One predicate, two surfaces, and that is the load-bearing part. pairing_caution_stands holds the rule for the screen and the clerk together, and tests/test_alignment_decision.py::test_the_screen_and_the_clerk_agree_on_every_answered_state walks all four states through the page and the tool. The test for which pairings are in doubt lives in one function, pairing_is_uncertain: the screen and the clerk each held their own copy of alignment_confidence <= 0.5, and 8 of the cautioned changes sit at exactly that value, so a one-character difference between the two copies would have decided half the cautioned corpus in one surface and not the other. The clerk carries the verdict beside the number on both the change list and the change detail, because a tool that reports the machine's doubt and drops the person's answer can tell somebody a pairing is unsettled after it was settled.

The section label is a proxy and it was measured rather than assumed. _section_label takes the leading integer of a passage, and among the labels it hands back across data/real are 2025, 207 and 480. Those are years and testimony line numbers, and on the real version pairs a large share of the modified changes escalated for no reason better than a printed gutter number shifting by one. app/diff/structure.py was written to fix that: it reads a filing's own numbering into a tree where it can and refuses with a stated reason where it cannot. It is not wired in, and no confidence in this product moves because of it. Measured on the real pairs, the two branches it was built for — a hierarchy contradicting agreeing labels, a hierarchy confirming one node — never fired, and every confidence that moved moved because the parser had failed to read both documents and that failure was being used to withhold the cap. A parse refusal raising a score is absence licensing certainty. So the parser, the measurement and the refusals were kept and the wiring was taken out; the alarms the proxy raises on line-numbered pages are still there and still worth fixing, on the proxy's own terms. Every figure is in structure-measurement.html, which is re-derived from the corpus by one command and guarded by a test.

The restructure case: why alignment confidence is capped, and how widely it fires Diffing v2 against v3, the real pair the product runs. Two passages that read almost identically on raw text similarity, but their leading section labels disagree because the final order renumbered the section, so the confidence is capped at 0.5000 and the pairing cannot present itself as settled. A passage whose own number held keeps its measured score. Across this pair the cap fires on a contiguous run of the reported changes; the figures are in the caption below, where a test can re-derive them. v2 passages normalized text, in document order v3 passages normalized text, in document order diff() — a pure function difflib.SequenceMatcher over the normalized passage texts. No model, clock or I/O. Pairs are positional inside a replace opcode. _alignment_confidence(before, after) text similarity first, then compare the leading section label on each side the labels disagree — the cap fires before 6.3 Return of Collateral after 5.4.3 Return of Collateral The words barely moved: raw similarity 0.9904. min(the similarity, 0.50) = 0.5000 the labels agree — the score stands before 4.5 … required under Section 6. after 4.5 … required under Section 5.4. Its own number held, so no cap: 0.9952. The one edit is a cross-reference chasing the renumbering. Change.alignment_confidence = 0.50 app/web/views/changes.py imports the same ceiling and prints: read the pairing as a guess, not a fact. an escalation row for a low alignment DESIGNED, NOT BUILT. Nothing writes one. Only a failed citation raises an escalation (§6). The blast radius on this pair is in the caption, marked so a test re-derives it. On a wholesale renumbering the cap does not discriminate. RESTRUCTURE_CONFIDENCE_CEILING = 0.5 lives in app/diff/engine.py, and app/web/views/changes.py imports it, so the cap and the caution are one number. Reproduce: diff v2's passages against v3's and read alignment_confidence on each modified pair. Both branches above are rows from that run.
deterministic stage designed, not built the path taken when a pairing cannot be proved
The cap is the whole idea, and on this corpus it is a blunt instrument. The run shows 19 reported changes, 8 capped at exactly 0.5000 — a contiguous run from section 6.1 through 7.4, not the one relocated block the manifest names. That is the price of refusing to be confident, and it is a high price: a caution raised on a run of pairings that were right teaches a reviewer to skim it. Two further gaps. The cap is keyed to a passage's own label, so it misses §4.5, whose only edit is a cross-reference the renumbering forced: 0.9952, no caution, and it is arguably the change most worth reading. And a capped alignment produces a caution on the change screen and nothing else — no escalation, no queue entry, nobody obliged to clear it. The only thing that raises an escalation today is a citation that fails to verify.

Alternatives considered

OptionRejected because
Whole-document diff (Myers over the full text)One pass, but the output does not map to the structural units — passages — the rest of the system reasons about, and a single reflow anywhere produces an unreadable diff everywhere.
Embedding-similarity change detectionFinds drift, not edits (ADR-004/ADR-008): it can say a passage moved semantically without saying which words changed, which is exactly the information a citation needs.
Ask the model to diff two full documentsUntestable against a known answer, and silently drops changes in long documents — the stated reason ADR-004 splits detection from interpretation in the first place.
Structural-anchor alignment with a bounded lexical fallbackNot rejected — not built. Exact hash match first, structural anchor second, token-level Jaccard fallback bounded to the same or an adjacent section, ties broken by nearest structural reference. None of that is in app/diff/engine.py. What is there is sequence matching plus the section-label cap, which is cruder and catches the failure that matters.

Trade-off accepted (ADR-004). Sequence alignment assumes the document's order is stable enough to follow. Wholesale restructuring breaks that assumption for many passages at once, not one — and in the corpus it does exactly that. Renumbering section 6 to 5.4 accounts for some of the capped pairings; the section-7-to-6 ripple that no manifest change names accounts for the rest, which nothing in the manifest predicted. The cap is not a scalpel here: the capped pairings run contiguously from section 6.1 through 7.4, and a reviewer handed a caution on every one of them has been handed a list of everything. This is not a rare edge case in regulatory drafting; a final order routinely renumbers a draft's sections. See §14.

3. Evidence-linked extraction

Design, and what of it exists

Extraction is the stage where a model reads source text and produces a structured claim, downstream of a diff that has already enumerated what changed. The package is two files and only one of them calls a model: action.py, the status-to-vocabulary table with no model in it (§7), and propose.py, which asks claude-opus-5 whether a change is material and hands every answer to the verifier (ADR-037).

Interpretation was designed to do four jobs. Three are answered somewhere other than here, and the fourth is answered nowhere. One, judge materiality — does this change matter. propose.py does it, on the change screen's first view of an unjudged change, and Change.materiality stays NULL until something judges it rather than carrying a default that would read like a judgement. Two, extract obligations with evidence spans — read a duty out of the filing and bind it to the characters that state it. This one is not built at all and has no half anywhere. The obligations table holds the company's own duties, loaded from data/company_context.json by scripts/seed_demo_gaps.py; no code reads an obligation out of a docket, so no obligation in this build carries an evidence span. Three, map a change to the company-context obligations, projects and documents it affectschange_obligations holds that edge, app/state/mapping.py proposes candidates from shared words and a person confirms one, and nothing derives an edge from meaning; documents have no table (§4). Four, recommend an action and a revieweraction.py supplies the vocabulary by dispatching on the version's status, and app/state/routing.py picks the reviewer off obligations.owner_user_id (§7).

The shape of that module is the part worth reading. Every proposal must name a (version_id, char_start, char_end, quoted_text), and judge_materiality re-reads the stored source at those offsets through app/verification/verifier.py before anything survives. Nothing is repaired. A proposal whose offsets sit two characters off the quote is withheld, not nudged into place — snapping an offset to the nearest matching span would make the gate a formality, because every citation would be adjusted until it passed and the verifier would end up checking the repair. A malformed proposal is dropped one at a time with its reason recorded, so one bad entry does not discard the good ones beside it. The model is never asked whether a version is draft or final: that is read from the field and dispatched on in Python (ADR-005), and the permitted actions go to the model as a closed list.

Three limits. The module writes no claim — nothing in it creates a Claim row, because there is no action code for "a model proposed this" and inventing a second spelling of one is how the two that already drifted got that way. It does write a verdict: _record_verdict writes the verdict, the reason and the citation that earned it onto the Change row and appends ACTION_MATERIALITY_SET to the chain under ACTOR_MODEL. And its request has been sent for real once deliberately, and once more by accident — every test drives a deterministic fake through an injected transport, so what the suite proves is the gate and not the request. The deliberate request went to the endpoint on 2026-08-05 through scripts/probe_live_transport.py: the parameters were accepted, the structured output came back in the shape _SCHEMA asks for, and the transcript sets out what that settles and what it leaves open — including the second call the same day, whose output was piped away and lost, so nothing at all can be said about it. The claims a reviewer actually sees are assembled deterministically: app/seed.py writes the synthetic ones by rule from data/manifest.json, and scripts/ingest_real.py carries exactly one hand-written statement about the real Kentucky filing, cited at the offsets where it sits.

That leaves this section a contract as well as a description, and the contract is the useful part. Whatever produces a claim — the model, the seed, a person typing one into a script — emits the same typed object, and that object is the only thing that crosses into verification. Nothing else about the producer is visible downstream, which is what makes the model swappable and, more importantly, what makes the gate in §5 independent of it.

Where claims come from today, and the object that crosses the boundary The model proposer is built and the change screen calls it: it writes the verdict onto the change row and an audit row beside it. The claims a reviewer sees are assembled deterministically in the seed from the manifest, plus one hand-written claim about the real filing. Every producer emits the same typed claim carrying a statement, a citation and a confidence, and that object is the only thing that reaches the verification gate. propose.py — the model proposer BUILT AND CALLED. Asks claude-opus-5, binds every proposal to a citation, repairs nothing, and writes the verdict it earns. app/seed.py — deterministic assembly The sentence is written here; the facts inside it are substituted from data/manifest.json. This is what fills the claims table today. the one object that crosses this boundary statement citation_version_id citation_start citation_end citation_quote cited_occurrence confidence_bp a candidate claim, never a fact verification — the gate (§5) Nothing reaches the screen as fact without passing here, whichever of the two producers above wrote it. The change screen asks propose.py to judge materiality and stores the verdict; unjudged stays NULL, not a default. There is no cited-versus-inferred field split. A Claim carries exactly one citation, and it verifies or the claim is withheld. Every seeded claim carries confidence_bp = 10000, which is a constant and not a measurement. The proposer reports no confidence at all.
deterministic stage may call a model can refuse
The absence is visible in the data, not hidden in a default. Change.materiality is NULL until something judges it — app/pipeline.py passes materiality=None at ingest and only propose.py ever fills the column — because a default like "pending" or "cosmetic" would read as a judgement nobody made. app/web/views/projects.py renders an unjudged row as MATERIALITY_UNASSESSED rather than as a grade. The plan also had each claim field marked cited or inferred, so a category label the text does not use verbatim could be shown as inference. That split is not built and the schema has no room for it: one claim, one citation, verified or withheld. The simplification is defensible — a field with no citation is a field the product does not assert — and it is a simplification, not the original design.

4. Company-context data model

Design

Three things carry the company side of the join. An obligation is a regulatory duty the company holds, recorded in the company's own words. A project is an internal initiative that fulfils or is constrained by obligations. A document is one of the company's own artefacts — a tariff, a compliance policy, a procedure — evidentiary rather than regulatory.

The corpus (data/company_context.json) holds the obligations, projects and documents, deliberately worded the way the company would word them rather than the way the docket does. That is not incidental realism. It is the reason a lexical match is not enough. OBL-001 reads Post security for new large-load interconnection work before construction starts. The clause it answers to, section 6.1, reads A Large Load Customer shall post collateral in an amount equal to 100% of the binding cost estimate produced by the Facilities Study, prior to the Utility's commencement of construction of Interconnection Facilities or Network Upgrades. The words common to both are post, large, load, interconnection and construction — and none of them is the one that matters. The company posts security; the docket posts collateral. A lexical match has to bridge that word, and the ones it does share are the generic terms this docket uses throughout.

How much of that is a table. Projects are rows. obligations is a loaded table: scripts/seed_demo_gaps.py reads them out of the JSON and resolves each owner_name to a real account by name, and where no account matches it writes the obligation without an owner rather than with a guessed one — an unowned obligation is visible and unroutable, which is the honest state. change_obligations exists and the change screen writes it: propose_obligations_for_change offers candidates on every render and confirm_obligation_for_change commits the one a person accepts, through map_change_to_obligation. Documents have no table at all. The candidates are proposed from words the two sides share, which is the half of the join that does not need the word that matters.

The company context: what became a table and what did not The company context is a JSON file read by the seed. Projects, users, sources, findings, questions and knowledge items become rows, and obligations do too, with owners resolved to accounts or left empty. The change_obligations table is written from the change screen. Documents have no table. The join between the company's wording and the docket's wording is the product; mapping.py proposes it from shared words and a person confirms it, and nothing derives it from meaning. Both wordings are shown at their full length so neither can be trimmed into agreeing with the other. data/company_context.json obligations, projects and documents, written in the company's own words. One file. Not a database. app/seed.py + seed_demo_gaps.py read the file and turn the parts that have a table into rows. The rest supplies names, owners and mappings. loaded into rows projects users sources findings questions knowledge_items obligations proposed by words, confirmed by a person change_obligations mapped_by_kind records which; only a confirmed edge routes (§7) documents NO TABLE, and none designed. They stay entries in the JSON file and are read out of it by id. Why this join is the product. Both sentences below are quoted whole. The overlapping words are post, large, load, interconnection, construction. The object of the duty does not — the company posts security, the docket posts collateral. the company writes — company_context.json, OBL-001 internal_wording, entire Post security for new large-load interconnection work before construction starts. the docket writes — section 6.1 in v1 and v2, section 5.4.1 in v3, entire A Large Load Customer shall post collateral in an amount equal to 100% of the binding cost estimate produced by the Facilities Study, prior to the Utility's commencement of construction of Interconnection Facilities or Network Upgrades. the join impact mapping — the missing half DESIGNED, NOT BUILT (ADR-008): nothing embeds the company's wording. What IS built is lexical — mapping.py proposes the edge from shared words, a person confirms it.
deterministic stage designed, not built
The gap between the two wordings is the reason the product would be worth buying, and the half that closes it is not built. ADR-008 proposes lexical retrieval with a semantic re-rank over company-context passages, because the words the two sides share are the generic ones and the word that carries the duty is not shared at all. Half of that ADR is built and it is the lexical half. app/state/mapping.py ranks obligations against a change by the words they share and offers the top ones on the change screen; a person confirms or rejects, and only a confirmed edge routes. Nothing embeds the company's own wording, so the ranking cannot bridge security to collateral — the whole point of the two sentences above is that the shared words are the wrong ones. A proposal is not a derivation. The proposer moves the work from typing to confirming, which is real, and it leaves the hard half where it was: the change-to-obligation mappings a reviewer sees seeded in the demonstration are still written by hand in data/manifest.json and read out by the seed. Both wordings are quoted whole, which is the standard §5 holds a claim to.

5. Citation verification (ADR-003)

This is the depth area. It is the hardest technical decision in the build and the one the product makes visible rather than buries (ADR-003; CLAUDE.md). Everything above exists to feed it evidence; everything below (§6–7) exists to handle what it refuses to verify.

Contract

A citation is (version_id, char_start, char_end, quoted_text), plus cited_occurrence where the quote is not unique. verify_citation slices the source at the offsets, runs both the slice and the quoted text through the same normalization, and compares them for equality — never a similarity threshold. It applies no threshold, and the reason is not this document's own: "A paraphrase is exactly what an auditor will not accept, so a threshold would defeat the point of having the gate at all," says the docstring at the top of app/verification/verifier.py. So "close enough" is not a verification outcome this system produces, and a reader who wants to check that the code agrees with the document reads that docstring rather than taking this paragraph's word for it. It then counts how many times the quote occurs in that version, and if it occurs more than once the claim must name which occurrence it relied on or it is refused.

The module calls no model and makes no network request, on purpose twice over: the code that decides whether the product may assert something has to be auditable by a reviewer who trusts nothing about the AI, and it has to run in CI with no API key. It only compares. It cannot itself misquote fluently, because it does not generate anything.

Normalization — stated explicitly, because an unstated normalization rule is a silent one

RuleWhat it handles
Whitespace collapseRuns of whitespace become one space; leading and trailing space is dropped. Handles PDF reflow and a quote reproduced with different line wrapping.
Selective NFKCLigatures, no-break space, full-width and half-width forms, squared unit glyphs, and canonical composition. Selective, not blanket: a character tagged <super>, <sub> or <fraction> is left alone, and so is any character whose folding begins with a decimal digit — unless that character is itself a decimal digit (category Nd). Blanket NFKC folded "20" followed by a superscript two into "202", and the vulgar half into "1⁄2" with a fraction slash — a footnote marker changing a number's value inside the function whose job is to preserve it. The Nd exception is the judgement to argue with: a full-width two and a mathematical bold two are the digit two in another face, so both fold and "20 MW" still matches "20 MW". A bold digit used as a marker beside a plain number would fold wrongly. Nobody here has seen one in a filing, and there is no way to tell the two uses apart from the characters alone.
Quotes and dashesSmart quotes and every dash variant fold to the straight forms, for comparison only.
Soft hyphenDeleted unconditionally. It is a rendering hint and the words either side stay joined.
Hyphen against a line breakThe break is consumed and the hyphen kept, for every line-break character the module lists, including the form feed a PDF extractor writes at a page boundary. "cost-\ncausation" becomes "cost-causation"; "demon-\nstrate" becomes "demon-strate", which will not match "demonstrate" — so the citation goes to review rather than asserting a joined word nobody wrote.
CaseNot normalized. A citation with wrong case is suspicious — it can mean a defined term was altered — and is a genuine mismatch, not noise.
Digits, units, datesNo digit's value is ever changed. "20 MW" and "10 MW" are the whole point. The precise rule is narrower than "never touched": a squared unit glyph does fold — to cm2, to MHz — and a full-width digit folds to its plain form. What may not happen is a fold that makes a number read as a different number, which is what the digit test above defends.

The same function runs on both sides of every comparison. A normalization applied to one side only is not a normalization, it is an edit.

The occurrence problem: text equality is necessary and not sufficient One sentence appears three times in version one, in three sections about three different subject matters. A citation quoting it correctly at correct offsets matches all three on text alone, so the claim must state which occurrence it relies on. A citation that states none, or states the wrong one, is withheld even though the quote and the offsets are both real. the claim's citation version_id v1 start, end 4930, 5063 quoted_text The Utility shall maintain records … cited_occurrence 0 verify_citation() no model, no network slice the source at the offsets; normalize both sides; compare for equality; then count the occurrences. v1 holds the same 133 characters three times, about three subjects: occurrence 0 — §4.4 4930 – 5063 study records occurrence 1 — §6.3 6699 – 6832 collateral occurrence 2 — §7.3 7395 – 7528 recordkeeping char_start 4930 pins occurrence 0 The other two are not cited. Text equality alone cannot tell them apart. no occurrence stated, or the wrong one withheld — CITATION_AMBIGUOUS_OCCURRENCE The quote is real. The offsets are real. The words match exactly after normalization. It is refused anyway, because a claim that cannot say which of the three sentences it read has not identified its evidence. WithheldClaim has no statement field, and slots=True, so none can be added. What this still does not prove: that the source supports the claim. A correctly cited quote that drops an adjacent "unless" passes. There is no adjacency check, no negation guard, no near-miss re-anchoring and no similarity threshold in app/verification/verifier.py. The comparison is equality after normalization, and nothing else. §14 names the truncation gap, which is open.
deterministic stage can refuse the refusal path
This is the diagram the rest of the document exists for. Every other check in the product asks whether the words match. This one asks the question a matching quote cannot answer: which sentence was read. Regulatory drafting repeats boilerplate — the same clause about the same retention period, filed under study records, collateral records and general compliance — so a model that reasons about one occurrence and cites the offsets of another produces a citation that is real, offsets that are real, a quote that matches, and a claim that is wrong. Verification would report green, and green would be the wrong answer. Counting the occurrences is what closes it, and the cost is that a claim over repeated text must carry one more field or it cannot be made at all.

Outcomes

There are two: verified, or not. A refusal carries the reason and the bytes the source really holds at those offsets, so the analyst sees the mismatch itself rather than a status word. Most come from the canonical function; the ones only the scoped entry point can raise say so in their own row.

ReasonWhat it means
citation offsets fall outside the source textThe span is not inside the document. No excerpt is shown, because there are no real bytes to show.
citation span is emptyThe span or the quote normalizes to nothing.
quoted text does not match the source at the cited offsetsThe fabrication case. The offsets are real and the words are not.
quoted text appears more than once and the cited occurrence was not stated or does not matchThe case the diagram above is about.
the cited version could not be read for this companyRaised only by verify_citation_for_version. A version id that does not exist and one belonging to another tenant are deliberately indistinguishable from outside.
the cited version's stored text no longer matches the hash recorded when it was ingestedAlso only verify_citation_for_version. See the note below on which path production takes.

Graded outcomes were designed and none is built. verified_exact, verified_normalized, near_match and unverified, with a stored verification_status column and a bounded re-anchor search that would hunt for the quote a few characters either side of the given offsets. The stored column and the re-anchor were both bad ideas. A stored status is a promise about bytes that may since have changed; no claim carries a stored verdict, and the verdict is recomputed on every read. And a re-anchor window is a tunable that trades a false reject for a false accept, which is the one direction this design cannot fail in. Rejecting an off-by-a-little citation costs a review. Confirming a coincidental identical phrase elsewhere in the document costs the reason the product exists.

The gate is a read, not a write — and this is the one place that is told at length

The function every reader goes through is verified_claims(session, company_id, change_id) in app/state/claims.py. It fetches the change's claims, re-reads each cited source, calls the verifier, and returns two lists: what may be asserted, and what may not. It writes nothing and stores no verdict. Edit a source after a claim was written and that claim flips to withheld on the next page load, with no migration and no background job.

A write gate would be the weaker design. Verification between interpretation and state, with a verified flag going into the database, makes a promise about bytes that may have changed since. The cost of the arrangement that ships is arithmetic — verification runs once per claim per view instead of once per claim ever — and A7 names what to do about that at volume, which is a cache keyed on the source hash and still not a stored boolean.

There is one verified column in the schema: share_opens.verified, not nullable and with no default. It is not a cache of whether a claim is good today — it is the record of what a named recipient was shown at a named moment, and a writer that says nothing about verification has recorded nothing, so the insert fails rather than filing silence as a measurement. The rule's exact form: no claim carries a stored verdict, and there is no verified column on claims, changes or escalations for a stale one to hide in.

Every reader uses this function and none has its own copy. The readers are the change view in app/web/views/changes.py; the escalation queue and the claim view in review.py; the review centre; the project workspace; the actions screen; the public share page through app/state/sharing.py, which re-verifies at open time on every open; app/state/review.py; the assistant's change_detail and search_claims tools; and app/seed.py, which asks the same question when it writes the demonstration rows. Scripts under scripts/ call it too, outside app/. No total is printed here. One was, for three review rounds, and it was wrong in a different direction each time; the list is the claim and grep -rn "verified_claims(session" app/ scripts/ settles it. A withheld claim's wire form is built in one function and carries a reason code, a reason text and where it sits — never the statement, never the quote. Everywhere else in this document that touches the read gate — §6, §12, §13, A2 — states the fact and points back here.

Two entry points, and which one production uses

verify_citation(citation, source_text) is the canonical one. It trusts the caller for one thing it cannot check: that the text it was handed is the text of the version the citation names. That is a real guarantee to have to make — a citation naming v2 handed v1's text verifies whenever the quote sits at the same offsets in both, which repeated boilerplate across versions makes reachable rather than theoretical. On the path a rendered claim takes the pairing is made once, in verified_claims(), against a map keyed on version id, so there is one place to check it rather than one per call site. Off that path, app/interpretation/propose.py and app/evals/metrics.py each build their own version-to-source map and call verify_citation directly, so "once" holds only for the rendered path and this sentence is the qualifier.

verify_citation_for_version(session, citation, company_id) makes the pairing itself and adds the check the other cannot: it hashes the version's stored text and refuses when the digest disagrees with the one ingestion recorded. Nothing in app/ calls it. It is not on the path a rendered claim takes, because it finds its version by reading every version the company owns, and adopting it would repeat that read once per claim. Two consequences follow and neither is hidden: the hash check does not run in production, and closing that needs a single-row scoped read in app/state/queries.py that does not exist yet.

What that costs, precisely. The bytes under a citation are re-read on every render, so an edit landing on a cited span withholds the claim immediately. An edit elsewhere in the same version does not: the quote still matches at its offsets, and the claim still asserts itself against a document that is no longer the one ingested. That is the freshness principle unclosed on the path the product actually uses, and it is stated here rather than left to be found.

The harder case: a true substring that reverses meaning

A citation can pass exact-match verification and still misrepresent the source, if the quote is a real, correctly-offset substring that omits an adjacent qualifier — dropping "shall not" to leave "shall", or quoting a clause without the "unless" three words later that governs it. Verification proves the text exists at that location. It does not prove the text supports the claim. These are different guarantees and this design does not pretend otherwise.

The plan was a cheap adjacency check scanning immediately outside the cited span for negation and qualifier words. It is not built. There is no such scan in app/verification/verifier.py, so the truncation case is caught by nothing today — not the adjacent one, not the distant one. §14 carries it as the open risk it is.

Offsets are scoped to a version, never projected across versions

A citation into version 1 stays a citation into version 1's immutable text. It is never re-projected onto version 2's text through the alignment computed in §2. The alternative — a document-level coordinate space every version maps into, so a citation could follow a passage across versions — was rejected because that mapping is the alignment problem, and alignment has its own confidence and its own failure mode (§14); routing citation correctness through it would let an alignment error silently corrupt a citation that was otherwise exactly right. The accepted cost: when a change says a deadline moved, the interface shows two citations — the old span in the old version, the new span in the new version — not one citation that claims to have moved.

Adversarial test discipline

The verifier is tested against deliberately corrupted citations. A fabricated quote at real offsets — one numeral altered inside a real cost floor — must come back as a quote mismatch. A real repeated quote cited without stating its occurrence must come back as ambiguous. Both are scored in make eval (§9) and both must be rejected for the right reason: a rejection for the wrong reason is not a pass. The passing bar is every probe, not most. There is no partial credit for a citation verifier, because the product's claim to the reader is "if it is shown as fact, it verified," not "it usually verified."

tests/test_verification.py also fuzzes the occurrence scan over randomized adversarial text. It asserts the property — the fast boundary test never clears a span the exact re-read would refuse — and measures that the fuzz pool is strong enough to be worth asserting over: the run fails if fewer than a thousand hits reached the assertion, and fails again if no hit took the slower path, so the fallback cannot become dead code without the suite saying so. The scan re-reads what it refuses and never re-reads what it clears, which is the direction that keeps the guard from switching itself off quietly.

What that scan costs. Hits scale with the source, and re-normalizing each one was the cost. The boundary test — a hit is valid exactly when it does not share a raw offset with the character either side of it — made the check flat in the quote's length. It is still not O(n) in the worst case: the exact re-read survives for the hits the boundary test refuses, so a source made entirely of expanding characters still costs O(n·m). Filing text is not that; a page of accented capitals would be. The milliseconds this paragraph used to print came from one run on one laptop with nothing checking them, which is the class of number this document has stopped publishing.

6. Confidence & escalation (ADR-006)

Design

The verdict on a claim is not stored. verified_claims() re-runs the whole decision against the stored source every time a screen asks for it and returns two lists: what may be asserted and what may not. The argument for that, and the one verified column that does exist and is not a verdict, are in §5.

The order of the checks is deliberate. The citation is tested before the confidence, so a claim that misquotes reports the misquote rather than a number — that is what the analyst needs in order to act.

Three checks in order, and what a refusal is allowed to carry Every claim is checked for a readable source version, then for a citation that verifies against the stored text right now, then against the confidence bar. Any failure produces a withheld claim carrying a reason code and no statement, so a template cannot render an assertion that failed its citation. a claims row statement, citation, confidence_bp 1. is the cited version readable? versions_for_company(), the tenant chokepoint 2. does the citation verify, now? re-read from the source; never a stored flag 3. confidence_bp at or above the bar? VERBATIM_MIN_CONFIDENCE_BP, in basis points VerifiedClaim carries the statement, the citation, and actual_text — the real bytes at those offsets withheld, with the reason CITATION_SOURCE_UNREADABLE CITATION_QUOTE_MISMATCH CITATION_OUT_OF_RANGE CITATION_EMPTY_SPAN CITATION_AMBIGUOUS_OCCURRENCE CONFIDENCE_BELOW_THRESHOLD CITATION_UNVERIFIED The last is the default: a reason this module has not been taught still withholds, never asserts. WithheldClaim no statement field, and slots=True, so none can be added MIN_CONFIDENCE_BP ships at 0, so check 3 refuses nothing today. There is no evidence yet for where the line sits, and a number chosen to look rigorous would be worse than an honest zero (ADR-006). The mechanism is here so that moving the line is configuration rather than a code change. Model self-reported confidence is not used and is not converted to an ordinal. The proposer asks for none, so there is none to convert. The escalation rows a reviewer clears are written from this same withheld list, so the queue and the screen cannot disagree about what was refused.
deterministic stage can refuse the refusal path
The refusal is a different shape, not a greyed-out version of the same shape. A withheld claim carries no statement field, and because the class is slotted one cannot be attached at runtime either. A template cannot render what the object does not have, so no CSS change, no stray reference and no helpful refactor can leak an assertion that failed its citation. A greyed-out assertion is still an assertion; absence is the only treatment the reader's eye cannot complete. The cost of the zero threshold: the third check is wiring, not a control, until the eval gives a number to put in it — and this document says so rather than quoting a default that looks measured.

Confidence is attached at each stage that makes a judgement, and the sources are kept distinct rather than collapsed into one number: alignment confidence from §2, which is rule-derived and capped; the citation outcome from §5, which is a gate rather than a score and always forces refusal on its own; and a model-reported confidence on interpretation, which does not exist because app/interpretation/propose.py does not ask for one and would not accept one. That is a decision rather than an omission — a number a model produces about its own certainty is a number the gate would then have to argue with.

Where a model does eventually report a confidence, it will be treated as an ordinal signal, not a probability. Raw numbers from language models are poorly calibrated, and trusting a value like 0.83 at face value would be fake precision dressed as rigor. The bar would be tuned against the eval set's known answers (§9) — against outcomes, not against the model's own stated certainty.

Open — not yet tuned. VERBATIM_MIN_CONFIDENCE_BP ships at 0 (app/state/claims.py). Every claim in either corpus carries confidence_bp = 10000SEED_CONFIDENCE_BP in app/seed.py, CONFIDENCE_BP in scripts/ingest_real.py — so nothing would be withheld for confidence at any bar below full. What is real is the mechanism: the bar is read from the environment, not compiled in, so setting it is configuration.

Trade-off accepted (ADR-006). A review queue nobody empties is its own failure. That is why the escalation record carries enough context to be actioned in one look — the reason in plain words, the quote that was made, and the bytes the source really holds at those offsets — rather than making the reviewer re-derive it. It is also why the alignment cap in §2 does not raise an escalation: capping is common in a renumbered filing, and a queue that filled with every capped pairing would be a list of everything.

7. Reviewer routing

What routing means here, and how much of it runs

Two halves. The vocabulary half decides what kind of action a change may produce, from the version's explicit status and nothing else: a draft change offers monitor or comment, a final change offers comply and requires an effective date. It is a lookup table with no model in it (app/interpretation/action.py), and an unknown status raises rather than defaulting, because guessing this wrong is the most expensive error available in this domain (ADR-005). That half is built, tested and scored.

The routing half decides who is asked, and it is built. app/state/routing.py walks an escalation to its claim, the claim to its change, the change to the company obligations it bears on, and the obligation to its owner, then writes Escalation.assigned_to_user_id and assigned_at together. Obligations are rows, with an owner column that resolves to a real account.

The design work is in the refusals, not the routing. Every step can fail to name a person and none of them may guess when it does: no obligation maps to the change, the mapping behind it is the proposer's rather than a person's, the obligation has no owner, the owner's account is inactive or belongs to another tenant, two obligations name two different people. Each is a distinct code — ROUTE_NO_OBLIGATION, ROUTE_MAPPING_UNCONFIRMED, ROUTE_OBLIGATION_UNOWNED, ROUTE_OWNER_INACTIVE, ROUTE_OWNERS_DISAGREE and one per remaining way the walk can end without a person, all in grep -n "^ROUTE_" app/state/routing.py — and after each one the escalation is still in the shared queue with nobody's name on it (ADR-036, ADR-056). The temptation this file exists to refuse is the default assignee: the admin, the person who raised it, the last person who touched anything nearby. Each is one line of code and each is worse than the refusal, because a wrong assignment looks handled — it leaves the queue, sits on a desk that will not act on it, and nobody finds out until the deadline has gone. shared_queue() re-derives every verdict on read rather than trusting a stored one, for the same reason verified_claims() does: suspending an owner this morning has to change what the queue says this afternoon with nothing having re-run.

A mapping the pipeline proposed does not route, and that refusal fires only where a name would otherwise have been handed out. resolve_change_owner reads ChangeObligation.mapped_by_kind: where any mapping behind the change is a person's, only the person's mappings are considered and the proposer's are set aside entirely, because resolving over all of them lets a guess overrule a judgement by arithmetic. Where none is, a name that would have been handed out becomes ROUTE_MAPPING_UNCONFIRMED instead. Every other refusal stands: a proposed mapping onto an unowned duty still answers ROUTE_OBLIGATION_UNOWNED, because "give this duty an owner" is true whoever wrote the mapping, and app/state/invites.py branches on that code to decide an invitation is the fix. The cost, exactly. An owner-gap invitation on a proposed mapping completes — the gap is real, the invitation is written, the account is created — and the last step does not happen: the escalation stays in the shared queue until somebody confirms the mapping.

No screen assigns an item. app/web/views/review.py, changes.py, proceedings.py and projects.py all read escalations_for_company() — the flat company list, in id order, that everyone with the screen sees. resolve_change_owner is reached from app/state/invites.py, when an invited owner accepts or an invitation is withdrawn; from the assistant's tools; and from changes.py, which resolves an owner on every change render and assigns nobody. So the queue is routable and is not yet routed, and a reviewer clicking through the demonstration sees the shared list. The callers are grep -rn resolve_change_owner app/.

Beside routing sits the approval gate, which decides whether a given person may sign off on a given claim, and it is the part worth defending. Holding action.approve gets a user past its second check and no further. The fourth asks whether this person already acted on the claim underneath the action, on the change beneath it, or on an escalation raised against it — and refuses if they did, whatever their permissions say. Authorship is read out of the audit chain rather than from an authored_by column, because a second record of who did what, kept beside the first, is free to disagree with it.

Reviewer routing: the queue the screens read, the router that can name an owner, and the engine nothing starts A change produces an action vocabulary from its status. The escalation screens still read one flat company queue with no assignee. Beside it, routing.py can resolve an obligation owner and write the assignment, and refuses with a named code rather than guessing when it cannot; it is reached from the invitation paths, the assistant and the change screen, which reads a resolution without assigning anything. The /actions screen builds its form from the same vocabulary and asks the approval gate at the click; the gate refuses anyone who already acted on the claim. The approval engine walks a route and is tested, and the escalation screen now starts a run on it. What no screen and no job does yet is record a person's decision on a step of that run. action_vocabulary(status) a lookup, and no model is asked DRAFT → monitor, comment FINAL → comply, + effective date what the screens read one flat list per company, from escalations_for_company(unresolved_ only=True). Everyone with the screen sees the same rows, in id order. routing.py — or a named refusal change → obligation → owner, then writes assigned_to_user_id. A named refusal per failure, no default assignee. Assigned from invites.py. No screen does. can_approve() — four gates, asked at the click on /actions 1. the user exists in this company and is active 2. they hold action.approve 3. the id names a claim or an escalation in this company 4. no audit row shows them acting on that claim, the change beneath it, or an escalation raised against it Gate 4 is the control, and it reads authorship out of the audit chain. approved A different person signed off. The caller writes action.approved into the chain. refused, and the refusal is itself audited access.denied names the claim and the audit row that shows this person already acted on it. Denial is the default everywhere. VERBATIM_APPROVAL_MODE=DEMO_SELF_APPROVAL Gate 4 still runs and still reaches its verdict; the reason states what SEGREGATED would have decided, and approval.waived lands in the chain. the configurable approval workflow ENGINE BUILT, RUNS START, NO STEP IS ANSWERED. state/workflow.py saves and validates a route, assigns a step, reminds, escalates and bypasses. POST /escalations/{id}/route calls start_run. Nothing calls record_decision, so a step can time out and be bypassed and no person can sign one. bypass is not approval: a step that ran out of time was answered by nobody. STEP_RUN_OUTCOMES keeps bypassed apart from approved so nothing downstream can fold the two, and approval_summary returns the list rather than a boolean.
deterministic stage can refuse the refusal path
Bypass is why the outcome vocabulary was worth fixing before the engine walked it. A step that timed out and was skipped, and a step a person signed, must never be the same value. OUTCOME_BYPASSED is a separate member of the vocabulary and a NULL outcome means open, not consent — so a run can reach its end with a step nobody answered, and the run status alone cannot be read as approval (ADR-035). The engine keeps three neighbouring branches apart for the same reason: reminding is not acting, so a reminded step stays on the same desk and grows a counter; and an escalation that resolves to nobody leaves the step open on whoever already had it rather than falling through to bypass. That is the branch a tired implementation gets wrong, and getting it wrong turns a routing bug into an unapproved action that reads as an approved one. The start is no longer the missing piece; the answer is. A run is created from POST /escalations/{escalation_id}/route, which the escalation screen posts to, and start_run derives the run id from the escalation id so a second press returns the first and writes nothing. Nothing calls record_decision. So a run can be started, a step can be reminded, time out and be bypassed by the job loop, and no person can sign one — which is exactly the state OUTCOME_BYPASSED exists to keep distinguishable from consent. app/web/views/workflow.py's own module docstring still says nothing creates a WorkflowRun; it is a day out of date and the screen it describes is not.

Three system roles exist as data — analyst, obligation_owner, admin — and PERMISSION_CODES in app/state/models.py holds the permission vocabulary. Beside them, scripts/seed_roles.py carries role templates named for a function rather than a rank — regulatory analyst, regulatory counsel, interconnection, load forecasting, certifying officer, auditor and the rest — every code in every template checked against PERMISSION_CODES so a template cannot grant a permission the product does not define. They are templates, not a vocabulary. An administrator grants any code to any person; a template is copied, never edited, because a role called "analyst" that one company has quietly changed means something different on every screen and every ADR that names it. And no template holds both action.propose and action.approve — not because a four-person team may not have both, but because a default must not hand that over in silence.

The owner sits on the obligation record rather than the project, because one project carries obligations different people answer for, and a final change routes to the analyst and to that named owner. The column is obligations.owner_user_id, nullable, because an owner who leaves takes their account's usefulness and not the duty. What is missing is the surface: there is no screen that renders obligations, so an obligation nobody owns is visible only as the reason an escalation refused to route.

The action half is built, and segregation of duties has a caller. app/web/views/actions.py serves /actions, where an analyst proposes monitor, comment or comply on a claim and somebody else decides it. The kind is checked against action_vocabulary(change.status) at write time, so a draft cannot be complied with. policy.can_approve gates the decision: four checks, and the fourth refuses anybody the audit chain shows already acting on that claim, the change beneath it, or an escalation against it. Approval by whoever wrote the thing is not review.

It gates a claim, not a change, and that shapes the row. ProposedAction points at a claim because can_approve resolves a claim id into the claim, the change beneath it and every escalation against it. A change-only row would leave the gate a claim short of its evidence. The proposal is appended under ACTION_ACTION_PROPOSED, deliberately outside _NOT_AUTHORSHIP — that row is the evidence gate four reads back.

Every outcome is decided inside the transaction and acted on outside it. can_approve writes its access.denied row through the caller's session, so raising an HTTPException inside session_scope() would roll it back: the person is stopped and the record of stopping them is gone. app/web/views/review.py still has the older shape.

What it costs. Approving records a decision and performs nothing — nothing is filed, nobody is told, no project state moves, and the screen says so. Rejecting gates on action.reject rather than on can_approve, so somebody can knock out a colleague's proposal and leave their own standing. The kind is checked against the change's stored status and nothing checks that status against reality. No refusal is seeded: nobody in the seeded grid holds action.approve and has also touched a claim, so reaching gate four takes one role grant at /users first, and tests/test_seeded_refusal.py walks that route so the README cannot drift from it. ADR-091 carries the argument.

8. Audit history & rollback

What the log is, and what it is not

Every decision the system takes appends one row to a hash-linked chain, scoped to a company: ingesting a version, recording a change, withholding a claim, granting a role, refusing an approval, resolving an escalation, composing a synthesis. One log, not two. A separate table for security events would drift from this one, and the log nobody reads is the one that goes wrong first — silently, because nothing compares them.

This is not an event fold. State tables are written directly — the pipeline inserts a Change row and then records an audit event beside it — and the log is a parallel, append-only record of what was written, not the thing that produces it. There is no fold and no projection. The guarantee that costs: with a real event fold a state table cannot silently disagree with its history, and here it can.

Taking one decision back is built, and it is a row rather than an undo. app/state/rollback.py restores the state the decision changed and then appends an event naming the one it undoes, in that order, inside the caller's transaction, and never edits or flags the original — writing anything onto it would be the rewrite the whole table exists to prevent, and a decision somebody later reversed is precisely what an auditor asks to see. The order and the shared transaction are the guarantee, not an implementation detail. revert_event() calls the registered restorer and only afterwards calls record_event(), and it opens no transaction of its own: it must be called inside one the caller aborts on failure, which app.state.db.session_scope does. Called outside one, a failure between the two writes would leave the state put back with nothing in the log to say why — a change nobody can account for, which is the exact condition this table exists to make impossible. The pointer sits inside the digest under scheme 3 (ADR-053), so re-pointing a reversal at a different event turns the chain red rather than quietly changing what the log says was undone. Two rules are load-bearing. A reversal may not make an unverified citation verify — only the source agreeing with the quote does that, and there is a test that reads this module's own source to stop a later edit blurring it. And taking back a reversal reinstates nothing (ADR-054): the subject stays where the reversal left it and waits for a fresh decision, because putting the original back would write a resolver's name onto state nobody re-decided.

The append-only rule binds at the point of writing rather than living in a comment. A SQLAlchemy before_flush listener on the Session class raises AuditTamperError on any UPDATE or DELETE of an audit row, in every session in the process — not only the ones a careful caller remembered to protect. verify_chain(session, company_id) then walks the rows in sequence order and raises on a sequence gap, a broken prev_hash, an altered row, or a digest scheme it cannot compute.

One limit on the reversal. Every entry in RESTORERS is the same transition — closing an escalation, by resolution, approval or rejection. An action with no entry there is refused rather than recorded, because a row saying a decision was taken back while no state moved is a fallback that did not announce itself.

Reading the chain backwards is built, and no screen reaches it. app/state/replay.py offers the capability in two halves kept apart because they carry different risk. state_at() reads: it reconstructs what the chain says was true at a moment, writes nothing ever, and a test reads the module's own source to keep it that way. restore_to() writes: it takes back every decision the chain records after a moment, newest first, one reversal row each through revert_event() — so a restore can do nothing a single undo could not, and every guard in rollback.py holds over every step. It refuses whole or it writes whole: if anything after the moment cannot be put back, it raises with the list and writes nothing at all. It will not re-make a decision, because putting a decision back into force would need a name and a time against a judgement nobody made.

No screen reaches either half, so both are reachable from tests and from nowhere a reviewer can click. And the chain is never rewound: not truncated, not rewritten, not resequenced. A restore only ever appends, and verify_chain() passes over the whole log afterwards including the rows the restore wrote.

What the log itself provides is narrower and true: it cannot be edited or deleted through application code, its rows verify against each other, and a revision anywhere in the product is a new row that points at the one it replaces.

The hash chain, the two guards on it, and how a correction is recorded Each audit row hashes the previous row's hash, so removing or editing one breaks every row after it. A session listener refuses any update or delete, and verify_chain recomputes each row under the digest scheme that row names — one of three, chosen by what the row carries rather than by the date. A later row names the row it undoes and neither row is edited. A correction is a new row pointing at the one it replaces; the original is never edited. Taking one decision back is a row under scheme 3, and reading or restoring the chain at a moment is built in app/state/replay.py and reached from no screen. Rehashing older rows under a newer scheme was rejected. No chain head is published outside. One chain per company. Each row's hash covers the row before it, so removing or editing one breaks every row after it. seq 1 prev_hash (empty) entry_hash a41c… scheme 1: the row itself, actors NULL seq 2 prev_hash a41c… entry_hash 9b30… scheme 1 seq 3 prev_hash 9b30… entry_hash 77ef… scheme 2: adds who acted, and how seq 4 prev_hash 77ef… entry_hash 0d12… scheme 3: adds the reversal pointer reverts_event_id — a later row names the one it undoes; neither row is edited the session refuses an edit A before_flush listener on the Session class raises AuditTamperError on any update or delete of an audit row, in every session in the process — not only the ones a careful caller remembered to protect. verify_chain() recomputes every row Each row under the scheme it names, so a scheme-1 row followed by a scheme-2 row verifies end to end. An unknown scheme raises rather than falling back to a known one. A correction is a new row that points at the one it replaces. The old row is not edited and not deleted. The seed writes this pair. The replacement's id is minted per run — _new_id in app/state/projects.py takes a hex slice of a uuid4 — so it is drawn as a shape, not as a value. the original — still readable KN-LESSON-COST-ALLOCATION superseded_by kn-<12 hex> body never touched. superseded_by is the only field points at the replacement — a new row kn-<12 hex> superseded_by NULL Both rows are written by app/seed.py. supersede_knowledge() refuses a second replacement, which would fork the history. read and restore at a moment — app/state/replay.py state_at() reads and writes nothing. restore_to() takes back every later decision, newest first, one reversal row each. Both reached from tests. NO SCREEN REACHES EITHER. a chain head published outside DESIGNED, NOT BUILT. The chain detects tampering; it cannot prevent it. Anyone able to write the whole database file can recompute every hash from the break forward. The same shape governs synthesis: compose_take() supersedes rather than edits, and refuses a take that hides its exclusions. The seeded take names the findings it rests on and the ones it could not verify, both stored on the row at compose time rather than recomputed when it is read. record_event() checks the pointer before it writes a real event id, the same company, and not one already reverted. rollback.revert_event() supplies it; replay.py supplies it once per decision it takes back. the scheme is chosen by content, not by date CURRENT_DIGEST_VERSION stays at 2. Only a row carrying a reversal is written under 3, so verify_chain can refuse a v1 or v2 row that turns up carrying one — written out of band. rehash the old rows under the newest scheme REJECTED, NOT BUILT. A chain rewritten by the process that verifies it proves nothing: here the derived data is the evidence. Every hash function is kept for ever and dispatched per row, which is the right price.
deterministic stage can refuse a row that undoes another not built — designed, or rejected
Three digest schemes, and an older one can never be rewritten. Scheme 1 hashes the row's own fields; scheme 2 adds who acted, from which session and address; scheme 3 adds the id of the event this row takes back, and is chosen by what the row carries rather than by a global switch — a scheme that cannot cover the pointer refuses to write the row at all (ADR-053). Chosen by content, not by date, and that is the load-bearing half. CURRENT_DIGEST_VERSION stays at 2, so an ordinary row does not carry the extra field and verify_chain can refuse a scheme-1 or scheme-2 row that turns up carrying a reversal — a pointer written out of band onto a row whose scheme does not hash it. The pointer sits inside the digest, so re-pointing a reversal turns the chain red rather than quietly changing what the log says was undone. Read the three payloads in app/state/audit.py_digest, _digest_v2 and _digest_v3 — and what each one covers is on the page rather than taken on trust. Rows written before attribution existed keep their scheme-1 hashes and verify under scheme 1 for ever, and their attribution columns are NULL. migrate_audit_schema added actor_user_id, actor_kind, session_id and ip to those rows empty rather than defaulted: a default of "system" would have every historical row claim a machine acted, which is a statement the record cannot support, and NULL says the scheme of the day did not record it, which is true. Re-hashing them under a later scheme would produce a chain that verifies and proves nothing, because the process that verifies it would be the process that rewrote it. The limit worth conceding: attribution records what the writing process was told. A stolen session writes rows naming its victim, correctly hashed, and nothing in this file can tell.

Why append-only, against the alternative

Append-only log beside the state (built)Snapshots
Extra workNone. The audit-trail requirement already means every transition is recorded; keeping that record hash-linked and unwritable is the same work, kept.A full state copy per transition, duplicating the growing project state repeatedly.
What it explainsActor, reason and the source change behind every transition. This domain needs "why did this status change on this date", not just the before-and-after.What the state was, not why it changed — coarser, and wrong for a compliance trail.
What it does not giveReconstruction. Because the state tables are written directly rather than folded from the log, the log cannot rebuild them, and a direct write that skipped the log would leave no trace to compare against.Reconstruction is exactly what snapshots do give, which is the argument in their favour and the one this build has not needed.

Trade-off accepted. The rule is a convention, not a constraint: nothing outside the intended write path should touch a state table, and nothing in the schema enforces it. A direct update against a row would leave state and history disagreeing with no alarm — the failure mode of a derived value that is wrong and announces nothing. The log is unwritable; the tables it describes are not.

9. Evals

What is measured, on what data

There are three harnesses and they are separate on purpose. make eval (app/evals/run.py) is the deterministic metrics tabled below: no model, no socket, no database, and a test asserts each of those. app/evals/obligations.py scores the extraction task. make model-eval (app/evals/model.py) scores the one model call on the pipeline path, over judge_materiality. The split is not tidiness. A metric inside make eval that reached the API would turn that harness's own caveat into a lie the first time it ran, and the caveat is the reason a reviewer can trust the run on a clean checkout.

What the model harness measures, and why it is two numbers rather than one. Fabrication is an assertion whose citation the harness could not re-read. Threshold zero, and it fails the build. It is reported twice: how often the model produced an unverifiable citation, which is a property of the model, and whether any survived to an assertion, which is a property of the gate. A model that produced ten and a gate that passed none is the design working, and a single averaged score would have reported that as a failure. A miss — a verdict the gate withheld — costs an analyst an afternoon and blocks nothing, so it is counted apart under a different threshold. The third measure, cited correctly and reasoned wrongly, is the one that matters most and rests on a handful of hand-written cases. That is not a sample, and the harness says so above every count it prints.

The exit codes carry the honesty. A run with no --send exits 2, meaning the model was not evaluated, and both make model-eval and .github/workflows/ci.yml require that 2, so neither can report a scorecard over a model nobody called. Sending is a separate target a person starts, because env -u ANTHROPIC_API_KEY is not a safety here — load_env() puts the key back — so the deliberate act has to be the command.

All deterministic, all scored against an oracle the code did not produce. Expected answers come from data/manifest.json, whose offsets were computed by a separate script and read back from the bytes; no metric asks the code under test what the right answer is. Nothing here calls a model or the network, which is a deliberate limit and is printed in the caveat rather than left for a reader to discover. Every figure in the Result column is marked, so this table cannot go on reporting a scorecard the harness has stopped printing.

MetricWhat it checksBarResult
Citation verificationEvery recorded offset still re-reads to the text the manifest says sits there, occurrence stated where the span repeats.Every offset. An offset that stopped verifying means either the corpus moved or the verifier broke, and both make every citation unsafe to show.20 of 20
Deliberate-corruption rejectionA fabricated quote at real offsets, and a real repeated quote cited without saying which occurrence. Each must be refused with the reason that names what was wrong.Both. A rejection for the wrong reason is not a pass.2 of 2
Diff completenessAll five labelled changes found; the restructure capped at or below the ceiling; the two ordinary in-section edits above it.All five, and both directions. If everything escalated the queue would be useless.5 of 5
Occurrence disambiguationEach of nine recorded spans cited three ways: with the right occurrence (must verify), with none stated (must refuse), with the next one along (must refuse).Every probe. Text equality is necessary and not sufficient, which is why this sits beside citation verification rather than inside it.27 of 27
Draft-versus-final routingAn obligation appearing first in the final order produces comply, never monitor-or-comment; and no change landing in a draft version offers comply.Every check. ADR-005 calls this the error with the highest cost in this domain, in both directions.7 of 7

No percentage is printed, and the mechanism is the interesting part. report.rate() raises below a sample of ten. It does not return "n/a" and it does not soften. The sample is derived from the identity of every row of evidence rather than declared by the caller, so a call site cannot inflate it: the citation metric re-reads 20 recorded offsets, most of which quote the same boilerplate sentence or a wording a revision left alone, and they resolve to 6 subjects. The scorecard prints 20 of 20 manifest offsets verify [n = 6 subjects over 20 recorded offsets; no rate, n < 10]. Every one of those offsets verifying is a real result and it is not that many samples — and the gap between them is the argument. Deflation a call site has to remember is deflation a call site will forget, and it will forget it in the direction that flatters. Precision cannot be computed at all: the manifest labels some changes, not every change, so the corpus carries no negative labels, and the run prints the shortfall — how many changes were reported across both pairs and how few of them carry a label. Every verdict is bound to the corpus digests printed at the head of the run: change the corpus and the verdicts expire.

Honest limitation. The eval corpus is one hand-built proceeding — three versions, a handful of labelled changes, one repeated sentence and 6 subjects in all — authored by the same person who wrote the extractor. That is a regression gate, and a strict one. It is not evidence of accuracy on real filings, it supports no comparison with another system, and it carries no recall figure. Two further metrics were designed and neither exists: obligation extraction precision and recall over a hand-labelled obligation set, and escalation correctness over injected ambiguity. Obligations are not scored at all, because nothing extracts them.

There are two corpora, which changes what this limitation means. data/real/ holds 102 real public filings across 8 jurisdictions, and one pair of them runs in the demonstration. The synthetic corpus stays the eval corpus and must (ADR-040): its traps are built on purpose — one sentence repeated three times per version, a restructure whose renamed sections still read almost identically — and a real filing will not reproduce them on demand, so replacing the fixture would quietly gut the harness. Nothing in the real corpus is scored. The numbers above describe a corpus built to be measurable, not a corpus that resembles the world.

On coverage. There is no line-coverage target and this document does not carry one as a goal (ADR-038). What ADR-038 asks for is branch coverage on the four deterministic load-bearing modules, mutation testing on those same modules, and property tests for the invariants that matter. The property tests exist. Mutation testing exists on one module, and it is not one of the four. .venv/bin/python scripts/mutate_context_budget.py breaks app/chat/budget.py and the tripwire that guards it one clause at a time, restoring each in a finally, and reports that no mutation survived. It is a hand-written list, not a tool — there is still no mutation tool in requirements.txt. On the four modules ADR-038 names, this half of the standard is stated and unmet.

This paragraph used to print a coverage percentage, a statement count, a missed-line count and a test count, and every one of them was stale within a day. None is here now, because line coverage measures execution rather than verification: verify_citation could be driven to full coverage by tests that call it with every argument shape and assert nothing, and the line that rejects a fabricated quote would be green while the product's central claim went untested. Nobody should be asked to defend that number as a standard, so it is not published; the command is .venv/bin/python -m pytest tests/ -q --cov=app and it is the part that does not go stale. One caveat that does hold. There is no coverage target in the Makefile, so nothing fails when coverage falls. pytest-cov is in requirements.txt, under a comment saying it is there so the figure can be reproduced — this document said the opposite for a week and drew a conclusion from it, which is a small lie in the flattering direction and is corrected here rather than quietly dropped.

10. Data isolation

Design

Every company-scoped table carries company_id from the start, even though the build has exactly one synthetic company. Reads go through one chokepoint — app/state/queries.py — which refuses a call with no scope rather than answering it. A query that returns everything when the caller meant nothing is how tenant isolation fails in practice, and it fails silently. The guard is imported by every module that needs it, never copied: two copies of a tenant check drift, and the copy nobody audited is the one that leaks.

Building the query layer as if a second tenant existed, while only one does, is cheap now and expensive later. Retrofitting isolation after the schema exists is the mistake this convention is written against.

Two tables carry no company_id on purpose: permissions and role_permissions, which are vocabulary rather than tenant data — the codes the product defines and the grid mapping them onto role names. proceedings are company-scoped: Proceeding.company_id exists and every read filters on it. And passages carries no company_id either, deliberately: tenancy sits on the version that owns it, and the join to document_versions applies it, because adding the column would give two writers a chance to disagree about who owns a passage.

The tenant chokepoint, what it covers, and what sits outside it app/state/queries.py holds one guard and the scoped reads. The callers that read passages are app/web/views/changes.py, app/pipeline.py, app/state/mapping.py and diff.passage_refs(); a sibling read, all_passages_for_company, serves the index. Versions are read from the web layer, the chat layer, sharing and more. Each hands a company_id, which is the second positional parameter and has no default. The guard refuses a company_id that is empty, None, not a string, padded with whitespace or carrying a LIKE wildcard, and the passage read joins to document_versions to apply the scope, because a passages row carries no company_id of its own. A refused call raises ValueError. Another tenant's version id returns an empty list, so a forbidden version and an absent one look the same. Below that, the read that once went straight to the passages table with no join is closed, and tests/test_passage_isolation.py guards it. Three things sit outside the file. Modules across the codebase import the guard and write their own scoped query, including private reads that two files say belong in queries.py. A few reads are scoped by a token hash rather than a company, because there is no signed-in tenant when they run. And routing.ensure_obligation, which fetched a row by primary key without comparing its company, has been closed. the two scoped reads passages_for_company — changes.py, pipeline.py, mapping.py, passage_refs() versions_for_company — called from web, chat, sharing, seed and the evals company_id app/state/queries.py _require_scope(company_id) refuses empty, None, non-string, padded, and "%" or "_" JOIN document_versions — one place to audit one tenant's rows document_versions company_id lives here passages no company_id column version_id refuses ValueError the scope is unusable, and it says which way refused, never answered with everything empty list another tenant's version id returns none absent and forbidden look the same session.query(Passage) no company scope, no join a version id was enough closed — no such call in app/ found by review, not by a failing test tests/test_passage_isolation.py guards it outside the file, still inside the rule the guard travels; the query stays put modules import _require_scope rather than copying it, then write their own company_id filter. Private scoped reads sit in web/views/admin.py and in chat/tools.py — both files say so, and say they belong in queries.py scoped by a token, not by a company sharing.open_share, auth.sessions.resolve_session and invite_accept._peek look a row up by its token hash with no company filter. Nobody is signed in yet, so the row supplies the company and every read after it is scoped to that CLOSED — routing.ensure_obligation It fetched an Obligation by primary key and returned it, so another company loading OBL-001 first got their title, owner and project back. The fetch and the check are now one call — queries.row_for_company — and the sites that did it by hand use it.
code, or a table the chokepoint — can refuse the refusal path dashed outline: one tenant's rows
Nothing in the database enforces any of this. SQLite has no row-level security, so the scope is a WHERE clause in Python and the boundary holds only because there is one function to write it in. That is the argument for the chokepoint rather than for more tests: the closed bypass in the third row was reachable, not theoretical — version ids are short and guessable — and it was found by reading the code, not by anything going red. The isolation tests all passed for a day while passage_refs read another tenant's source text with no scope at all, because no test had asked that question. A guard that has to be repeated at every call site is a guard that will be forgotten at one of them. The property the chokepoint actually has is the weaker one. company_id is not keyword-only in queries.py: it is the second positional parameter with no default, so it cannot be omitted, but it can be passed in the wrong slot — and in passages_for_company(session, company_id, version_id) it sits between two other positionals. Keyword-only holds one level up, at diff.passage_refs(session, version_id, *, company_id), and tests/test_passage_isolation.py pins that with a TypeError. Demonstrated rather than asserted: tests/test_isolation.py, tests/test_passage_isolation.py, tests/test_screens.py::test_every_route_refuses_another_tenant and tests/test_app_wiring.py::test_the_whole_app_answers_to_the_company_of_whoever_signed_in — and each asserts the refusal itself rather than accepting an empty answer as proof of one, which would pass with the guard deleted. That last name was written down wrong in an earlier draft, so the command a reviewer ran collected nothing and reported it as a pass; the names here are the ones pytest answers to.

The rule the chokepoint states is narrower than "every tenant read goes through one file", and stating it accurately is what makes it enforceable. It is: there is one definition of what an unscoped read is, and no module writes a second one. Every module that needs it imports _require_scope; none copies it. That is checkable in one line — grep -rn "_require_scope" app/ | grep -v "def _require_scope" — and a copy would show up as a second def. The property is "one definition", not "thirty-something importers", so the count that used to sit here — wrong in three review rounds running — is gone.

The honest exception is the private scoped reads that never moved into queries.py, and both files carrying them say so in a comment rather than in a commit message: app/web/views/admin.py and app/chat/tools.py. They import the guard, and their signatures already match the ones queries.py uses, so the move is a handoff and not a rewrite. It is still owed, and the debt has grown rather than shrunk since it was first written down.

A read that resolves to another company's row returns None rather than raising, and the web layer answers 404 — the same answer an id that was never issued gets. Telling the two apart tells a caller which ids exist. Where a read joins to a parent, both company columns are filtered rather than one: a write bug that stamped the wrong company on a child row would otherwise hand one tenant another's rows, and the column meant to prevent that is the column that let it through.

11. Security

What runs, and what is a named gap

Authentication is built: scrypt password hashing with the cost parameters stored per user so the cost can be raised without locking anybody out; session tokens held in the database as a SHA-256 of the bearer token, never the token itself; a middleware that refuses an anonymous request to everything except the login pages, the health check, the stylesheet and a share link; three system roles and the permission vocabulary beside them; and an approval gate that a permission alone cannot pass (§7). Every login, every failure, every denial and every waived approval lands in the same audit chain as the state changes.

Refusing at the middleware rather than per route is the load-bearing choice: a route added next week is protected because it exists, not because somebody remembered a decorator. A login refusal says one thing whatever went wrong — unknown address, wrong password, suspended, locked — because a message that distinguishes them turns the form into an account oracle. And a refusal costs the same as a success: the password check runs against a dummy hash even for an address with no account, so timing does not answer the question the message refused to.

The share prefix is in the public list on purpose, and it got there by way of a defect worth keeping in the document. /s/<token> was not public, so an anonymous open answered 303 to /login?next=%2Fs%2F<token> — which puts a live bearer token into a query string, where it reaches the access log, the Referer header and browser history. A redirect that carries the thing it was protecting is worse than no guard, because it looks like one working. PUBLIC_PREFIXES in app/web/deps.py holds the prefix with its trailing slash; a bare /s and /settings are still guarded, because prefix-matching the rest would make /login-as-somebody-else public by accident.

Where the key comes from, and the bug that made this worth a paragraph

app/config.py reads .env into os.environ once, at startup, in a short function of standard library — no new dependency (ADR-014). Most of what is in the file is docstring and comment rather than code. Two rules govern it. The real environment always wins: a name already set is never overwritten, because a container, a systemd unit or an export on the command line is a deliberate act and a file on disk is a default; read the other way round, a deployment could not override its own checked-out configuration and the surprise would land in production. It never logs a value: it reports which names it set, because the file holds an API key, an OAuth refresh token and a client secret, and a helpful startup line would put all three into every log aggregator the host ships to.

Before it existed, nothing put .env into the process. The key sat in the file the README told a reviewer to put it in and never reached os.environ, so the assistant, the proposer and the mail transport were all permanently on their fallback paths — a deployment holding a perfectly good key behaving exactly like one holding none. Every fallback fired, every message it printed was honest, and the product was still wrong. §14 carries that as a failure mode in its own right, and A8 carries where in the deploy the file is read.

What guards a request, what is stored, and the gaps named out loud A session cookie is resolved once per request by a middleware that sends anonymous traffic to the login page; authorisation is a separate check at the point of action. Passwords are stored as scrypt hashes and session tokens as digests. The gaps beside it are real: no CSRF token, no second factor, no edge rate limit, no encryption at rest, no externally published chain head and no directory outside the product. the running process the browser sends verbatim_session HttpOnly, SameSite=Lax, Secure — off only on plain-http loopback AuthMiddleware resolves the cookie once per request, in a worker thread. Anonymous goes to /login; only /login, /login/demo, /healthz, /static/, /s/ pass. app/auth/policy.py require() for the ordinary codes. can_approve() for the one that matters. codes, three system roles, templates analyst, obligation_owner, admin users scrypt hash and salt, kdf params per row so the cost can rise without locking anybody out. Never a password. login_sessions SHA-256 of the bearer token, never the token. Reading the table wins nothing. audit_events One chain. Failed logins, denials and waived approvals land in it too. There is no second log. Named gaps, not silent ones: not built no CSRF token anywhere. SameSite= Lax is the whole defence. no second factor. no rate limit at the edge. The lockout is per account, so guesses spread across addresses meet nothing here. no encryption at rest. SQLite is a file on one disk. no published chain head, so the log detects tampering and cannot prevent it. no directory outside the product, so one person with two accounts is two people to segregation of duties. no key management. The model API key is read from the environment, which app/config.py fills from .env. No vault, no rotation, no per-tenant key.
deterministic stage can refuse designed, not built
The demonstration downgrade announces itself, which is the only reason it is allowed to exist. VERBATIM_APPROVAL_MODE=DEMO_SELF_APPROVAL lets one operator play both roles for a demonstration, which is what a 48-hour build actually needs. Gate 4 still runs and still reaches its verdict; the reason returned states what the safe mode would have decided, and a row lands in the chain under approval.waived rather than action.approved — so a waived separation of duties can never be read a year later as a clean sign-off. An unrecognised value raises at import: a control that a typo can switch off, in either direction, is not a control. One control this figure does not draw. app/main.py installs install_security_headers after install_auth, and Starlette runs the last-added middleware outermost, so the header stamp wraps the session guard rather than sitting under it. The left column draws AuthMiddleware as the outermost thing in the process, which is one layer wrong. Giving the header stamp a box means re-flowing the whole column, so it is stated here instead of drawn wrong. app/web/headers.py names the attack it stops — clickjacking on the approval route — and tests/test_security_headers.py guards the install order.

Part C — beyond the eleven

Four sections the brief does not ask for and this build needs anyway: the schema, one request followed end to end, the failure modes, and the retrieval layer. None of them is a rubric area, and none renumbers one.

12. Data model

44 tables, marked so a test re-derives it, because it is this section's premise rather than decoration: drawing all of them would teach nothing, so the diagram carries the spine — the seven a reader has to hold in order to follow every other section — with the keys that make each one work. The rest are named by cluster underneath.

The seven tables a reader has to hold, and their keys A proceeding owns document versions, each of which owns passages addressed by character offset. A change references two versions; a claim references a change and cites a version at an offset; an escalation references a claim. Every write on the intended path also appends one audit row; nothing in the schema compels it. No table stores a verification verdict. proceedings id company_id docket commission subject document_versions id company_id docket label status DRAFT|FINAL source_text source_sha256 source_url filer filing_date source_registration_id passages id version_id ordinal char_start char_end text section citation_version_id changes id company_id proceeding_id from_version_id to_version_id change_type section before/after offsets alignment_confidence status DRAFT|FINAL materiality NULL until judged claims id company_id change_id statement citation_version_id citation_start citation_end citation_quote cited_occurrence confidence_bp escalations id company_id claim_id reason_code reason_text detail resolved_at resolved_by assigned_to_user_id assigned_at no stored verdict on any claim: it is recomputed on read (§5) audit_events company_id seq (unique) actor action actor_kind subject_type subject_id reason citation actor_user_id session_id ip prev_hash entry_hash digest_version reverts_event_id Every write on the intended path appends one row here. Nothing in the schema compels it (§8). It records the writes, it does not cause them: state is written directly, not folded from this log. These seven are the spine. The rest fall in clusters: project workspace, review centre, identity and access, approval workflow, the assistant, feedback, sharing, source registry and proposed actions. Each named below.
persisted state
The absence worth noticing is a column that is not there. No claim, change or escalation records whether a citation verified: the verdict is recomputed on every read and there is nowhere for a stale one to hide (§5, which also names the one verified column in the schema and why it is a record rather than a cache). The same instinct explains confidence_bp being an integer in basis points rather than a float: float text differs across platforms and drivers, so a hash taken over the row would differ too, and the audit chain would report tampering where none happened.

The rest, by cluster

ClusterTablesWhat it is for
Project workspaceprojects, project_changes, research_threads, research_turns, work_plans, work_plan_steps, scheduled_runs, knowledge_itemsThe work a person does around a set of changes over months, rather than one change in one moment. knowledge_items is versioned by supersession, never edited (ADR-031).
Review centresources, findings, questions, collective_takes, deliverables, steer_directivesEvidence and synthesis. Every surface that counts or synthesises has to state what it left out, which is why a take carries two counts and not one (ADR-022).
Identity and accessusers, roles, permissions, role_permissions, user_roles, user_permissions, login_sessions, invitations§11. A revoked grant keeps its row and gains a timestamp, because "who could do what, when" is the question asked after an incident and a delete is what makes it unanswerable. user_permissions is a code granted straight to a person rather than through a role; invitations is how an obligation owner who has no account gets one.
Approval workflowobligations, change_obligations, approval_workflows, workflow_steps, workflow_edges, workflow_runs, workflow_step_runs§7. Not schema only: app/state/routing.py and app/state/mapping.py write the first two and app/state/workflow.py the rest. workflow_runs fills from the escalation screen now. workflow_step_runs gains a row per assigned step and no row in it is ever answered by a person, because nothing calls record_decision.
The assistantchat_sessions, chat_messagesOne turn of Clarke, stored so a thumb can attach to a message id. The model never holds an identity: company_id and the actor are injected from the signed-in session, never taken from the model's arguments (ADR-051).
Feedbackfeedback, improvement_itemsWhat a user reported, and what was decided about it. Kept apart on purpose — a complaint is referred, never escalated (ADR-055), because the escalation queue is about refused claims and folding the two would bury both.
Sharingshare_links, share_opensA read-only link to one claim. The token is stored as a hash, like a session token, so reading the table wins nothing; share_opens records that it was opened, from where, and whether the claim verified at that moment.
Proposed actionsproposed_actions§7. One row per action somebody proposed on a claim, with who proposed it, who decided and what the decision was. This is the table can_approve guards: the gate reads authorship out of the audit chain, and the row here is what a second person is being asked to sign.
Source registrysource_registrationsWhich commissions this company watches. It carries a credential_ref rather than a credential, and document_versions.source_registration_id points back at the registration a version came from.

How a schema change reaches a database that already has rows

requirements.txt carries no migrations tool. No ordinary test can catch a missing migration, and the reason is the shape of a suite: tests build their schema from the current models every time, so they never meet yesterday's database. The failure exists only in production, only on the deploy, in front of whoever is looking. A test that guards it has to do what no other test in this repository does — construct yesterday's database on purpose — which is what tests/test_migrate.py does.

app/state/migrate.py is the fix, and it is derived rather than listed. Five ALTER TABLE lines would have closed the instance and left the class open: the next column anybody adds breaks the deploy the same way and they will not think of this file. So it asks SQLAlchemy what the models declare, asks the database what it has, and adds the difference. Stated as the SQL it emits, because "adds the difference" is a shape and a reader deserves the mechanism:

The audit-chain migration runs between the first and the second, and the order is the fix rather than a preference: run migrate_audit_schema after the generic column loop and the loop would add digest_version NULL first, the audit migration would then see the column present and never backfill it to 1, and verify_chain would refuse the whole log. app/state/migrate.py carries that reasoning in a comment beside the call. What the migration will not do is the design:

It is safe to run on every start, and it has to be: an idempotent migration nobody calls is the same as no migration. The second run reports that the schema is already current. A8 draws where in the deploy it runs, and why it runs before the seed rather than beside it.

One thing it deliberately does not migrate. The passage index (§15) is a derived corpus, and derived data half-migrated does not fail — it answers every query, plausibly, and wrongly. So the migration creates the index objects if they are absent and never backfills rows into them; building the index is its own step, and a read that finds the index missing or stale refuses to answer from it and says which path answered instead.

What the migration test covers. Run .venv/bin/python -m pytest tests/test_migrate.py -q --cov=app.state.migrate for the figure. It does not reach every line: one uncovered line reports a rebuilt passage index, and one is unaccounted for and nobody has gone back to it. The risk worth keeping in view is the shape, not the number. The failure this module prevents is invisible to a suite that builds its schema fresh every time, so the tests have to construct yesterday's database on purpose — build the models minus a column, read, fail with no such column, migrate, read again — and a test that stops doing that stops testing anything, while still passing.
A sharp default that ships on purpose. init_db() takes drop_first: bool = True and drops unconditionally against whatever engine it is handed. In this product that is the worst available default: the audit chain is append-only and hash-linked precisely so no row can be removed unnoticed, and drop_all removes every row and leaves nothing behind to notice, so one call on a production path destroys the evidence and looks like a clean install. Every test in the suite that builds a database depends on the drop for isolation — grep -rn "init_db(" tests/ is the extent of it, and none of them passes drop_first — so the default stays and the docstring says what it really does. deploy/entrypoint.sh passes drop_first=False, and only when there is no database file at all.

13. Request path — main workflow

One real request, followed through the code: an analyst opens a change and sees which claims the product will make about it.

One request end to end, with the three places it can refuse The session guard resolves the cookie once and sends an anonymous request to the login page. The tenant comes from the signed-in person. The change, the versions and the passages are read under that scope, and each claim's citation is re-verified against the stored source during this request. The only write on the path is the materiality verdict on the first view of an unjudged change. GET /changes/CHG-v2-v3-004 cookie: verbatim_session AuthMiddleware — session guard, inside the header stamp resolves the cookie once, in a worker thread, into a Principal 303 to /login carrying where it was going current_company() reads the principal the tenant comes from the person, never from anything the client sent change_for_company() joined to proceedings; both company columns filtered 404 the same answer a change that does not exist gets versions_for_company() — the tenant chokepoint one scoped read, reused for every claim below verified_claims() — the verdict is computed here, now re-reads the stored source at the cited offsets and compares. No stored boolean on any claim — see §5. WithheldClaim no statement to render. The reason is shown instead. passages_for_company() + source_window() names the section, and cuts the extract at a line break, never mid-word change.html — two lists, iterated separately The evidence panels ship open; the script only closes them. One write here: the first view of an unjudged change stores its materiality. Nothing else writes, so editing a source flips a claim next render.
deterministic stage can refuse the refusal path
The ordering at the bottom is deliberate and easy to get backwards. Every verified claim's source extract is already in the page when it arrives, with the cited characters wrapped in a mark; the script only toggles visibility and moves focus. The evidence for a claim must not depend on a script running, so the panels ship open and the script closes them, never the reverse. What is not on this path: a lexical-then-semantic retrieval pass, an impact mapping derived from meaning, and an event fold. The one model call a change view can make is the materiality verdict on first view (§3); every other render touches no model and no network.

The load path, which is the other half

Ingesting a version runs through one chokepoint, app/pipeline.py, so exactly one place owns the order of operations. It ingests, diffs against the previous version, writes the Change rows and records an audit entry for each. Three properties are deliberate: it is idempotent, so a second run with the same bytes writes nothing — no second copy of the passages, no second set of changes, no second audit entry; it refuses a corpus that moved under it rather than rebuilding half of it; and it calls no modelapp/pipeline.py passes materiality=None, which is why Change.materiality is NULL rather than defaulted. Change ids are derived from the two version ids and the position in the diff output, so a re-run reuses them; a UUID there would be correct and useless, because the second run would mint new ids for the same changes and double the row count.

The same chokepoint takes the real corpus. scripts/ingest_real.py hands it Kentucky PSC 2025-00113 — one witness's direct testimony, filed and then corrected — as two versions of 1,024,409 and 1,024,536 characters, a 127-character difference across a million. Every version carries the address it was fetched from, so a claim drawn from that docket links back to the Commission's own copy of the PDF, which is the one thing a regulatory reader wants from a citation: not our text, theirs. The URL goes through the same check the templates use, so a provenance file naming a javascript: address is refused at ingest rather than in a template.

14. Failure modes

What this design does not solve. The evaluation asks where modern AI fails and how the system was designed around it — not whether it was designed never to fail. Each item states the failure, the blast radius, and the actual mitigation, including where the mitigation is partial or absent.

Wrong-occurrence citations — the one that is closed

Regulatory documents repeat boilerplate, and a quote that matches at its offsets matches whichever occurrence the writer was reasoning about. A claim built on occurrence B while citing occurrence A has a real citation, real offsets, a matching quote — and is wrong. Verification would report green.

This is closed. verify_citation counts the occurrences of the normalized quote in the source, and where there is more than one the claim must state which it relied on or it is refused. The corpus is built around the trap: one sentence, three sections, three versions, and make eval asks three questions of every span it produces (27 of 27 above). The remaining honesty is about what the check costs: a claim over repeated text cannot be made at all unless the producer knows which occurrence it read, which pushes work back onto whatever writes claims.

The model misquotes, or quotes truthfully and misleads

Fabrication and shifted offsets are caught by the verifier (§5) at a bar of every probe on the corruption suite. The truncation case is caught by nothing. A real, correctly-offset substring that drops a governing qualifier — a clause quoted without the "unless" three words later — passes exact-match verification, and the adjacency scan that would catch it is not built. Citation verification proves a quote exists at a location. It does not prove the interpretation drawn from that quote is correct. Conflating those two guarantees would be the easiest way for this design to overstate what it delivers.

Wholesale restructuring

Sequence alignment assumes document order is stable enough to follow, and a final order that renumbers a draft's sections breaks that for many passages at once. The mitigation is real and partial: the section-label cap (§2) stops a renumbered pairing presenting itself as settled, the change screen says so, and a person can answer the doubt. What it does not do is raise an escalation, so a capped pairing is visible to whoever opens that change and to nobody else.

Retrieval — what can fail now, and what it is still not allowed to decide

Every unavailable state of the index falls back to a complete scan of every passage in scope, saying which path answered and why (§15); a short list with no announcement is the failure this refuses. The load-bearing choice underneath is that "did something change" is complete and deterministic; "what does it touch" is lossy — the diff, not retrieval, enumerates changes, so a retrieval miss degrades explanation and never change coverage. The index can hand the model the wrong passage or miss the right one; neither puts a wrong fact on screen, because a candidate is not evidence. What it can do is make an answer thinner than the record supports, and the thinness is announced by count.

The change-to-obligation edge has a producer, and it is lexical. app/state/mapping.py::propose_obligations_for_change proposes candidates from the words an obligation and a passage share; confirm_obligation_for_change lets a person stand behind one; map_change_to_obligation writes the row either way and mapped_by_kind records which. The gap that remains is the one the two wordings in §4 are about: the lexical rule cannot reach a duty that says "post security" where the docket says "post collateral", so a change whose only link is that word has no mapping at all and routes to nobody. ADR-085 carries it.

A fallback that announces itself correctly, about a state that is not true

This is the failure mode this repository actually produces, and no test goes red for it. Three modules degrade safely with no API key: the assistant says it is unavailable, the proposer proposes nothing and carries FALLBACK_NO_API_KEY, the mail transport declines to send and says so. That is the right design — best-practices §26, a fallback must announce itself. It is also what a wiring bug looks like from outside. With nothing loading .env, a deployment holding a good key behaved exactly like one holding none: every message printed was honest and every one was about a state that was not the state, and nothing failed, which is why nothing found it.

The guard has to be a test that asserts the connection — that the name the caller resolves is the name the callee publishes, that the router is mounted, that the environment a module reads is the environment something fills — not a test that the fallback works. Three such tests exist for the three places this shape has already appeared: app/config.py (§11), the router-mounting check in tests/test_app_wiring.py, and the engine-name seam in app/chat/engine.py.

Tables

A table flattened to text loses its row structure: a cell's meaning depends on the header above it, and a citation landing on a bare cell offset is not gradable without that header. Segmentation compounds it — one inserted row shifts every offset after it, and a diff over the flattened text can present a whole table as changed when one row moved. The intended fix is to segment a table as its own passage type, row tied to header, so a cell's offsets carry its header by construction. Not built. Tables are ordinary text and inherit every problem above.

Footnotes

Segmentation has no passage type for a footnote and no anchor tying one back to the clause it qualifies. A footnote in regulatory drafting often carries the exception that changes the obligation: the clause reads as absolute and the footnote is where "unless" lives. Losing the link means a duty can be read as unqualified when the source qualifies it a few lines down. Not built.

A verified fact with a shelf life

An edit landing on a cited span withholds the claim on the next render. An edit elsewhere in the same version does not, so a claim can go on asserting itself against a document that is no longer the one ingested. The hash check that would catch it exists in verify_citation_for_version and nothing on the run path calls it (§5). Closing it needs a single-row scoped read that does not exist yet, and a decision about what the interface should show for a source that has drifted — which is wider than one module.

At 10x latency, and at 10x scale

Every stage on the path a claim takes to the screen is bounded string work: ingestion, segmentation, diffing and verification call no model and make no network request, so none is latency-sensitive. Two surfaces are. The assistant makes a model call inside the request that renders the answer, with a step cap and nothing else — no timeout of our own, no queue, no background worker — so a slow endpoint is a slow page and a rate-limited one is a failed turn. The change screen's first view of an unjudged change does the same for the materiality verdict. That is acceptable for a chat surface, where a person is waiting anyway; it is less acceptable on the change screen, and the fix when that path carries load is background work, so a request returns "changes detected, interpreting" rather than hanging, and a call that times out leaves its item visibly needing interpretation rather than dropped.

What breaks first under load is SQLite (ADR-028), and A7 works through where it breaks. This section carries only the half that is a failure mode: the deployment runs one uvicorn worker on purpose, because a second buys concurrency the database cannot honour and turns a slow write into a locked one.

When the model is simply wrong

Not a hallucination and not a bad citation: a correct reading of a verified passage, reaching a judgement a domain expert would dispute. This is what confidence and escalation exist for, and the honest limit is that confidence is an imperfect proxy for correctness — a model can be wrong while reporting itself certain. Two parts of the mitigation stack are real today and neither involves a model: draft-versus-final is removed from judgement entirely and read from a field (ADR-005), closing off one whole class of wrong guess; and a person sits between a refusal and its resolution.

What is not solved. There is no mechanism that samples confident, non-escalated output and checks it against ground truth. A review queue only catches what was flagged; it cannot catch what was confidently wrong. At production scale that needs sampled human audit of confirmed output, not just of the queue. It is unresolved because closing it needs exactly the real-user, real-volume evidence a 48-hour build does not have — not because it was overlooked.

15. Retrieval over docket text

What this is, and what it is deliberately not. ADR-002 rejected search as this product's wedge, and the reason still holds word for word: search assumes the analyst already knows what to look for, and the expensive part — the interpretation — is exactly what search skips. docs/future-enhancements.html lists general regulatory search as deliberately excluded. None of that is reversed. There is no search box, no search screen and no nav item, and adding one would be a product decision this section does not make. What is built is an internal capability with one caller — the assistant's tool layer — so that Clarke can find the passage it needs to cite rather than scanning substrings over the claims the product has already written.
There is a second index, over this project's own documents, and it is not the same thing. /explain answers a reviewer's question about how the product works, from docs/prd.html, docs/mrd.html and docs/tdd.html (ADR-095). The two indexes share nothing but a database file: this one is built by scripts/build_doc_index.py and make doc-index, chunked by heading, embedded with text-embedding-3-small where OPENAI_API_KEY is set, and ranked by bm25 over SQLite FTS5 where it is not. How many chunks that comes to is deliberately not written here: the number moves every time one of the three documents is edited, including by this edit, so it is a number that would be stale before the commit landed. .venv/bin/python -c "from app.explain import documents; print(len(documents.chunk_all()))" answers it at the moment you ask.

Three properties are load-bearing and each was a decision. The chunks are embedded ahead of time, not per request, so an answer does not pay a second network round trip and money is not spent again on text that has not changed; the query still has to be embedded live, and the reply says so when it cannot. The index is rebuilt whole and never backfilled: half an embedding index does not fail, it answers every query plausibly and wrongly with no error to notice, so the rebuild is one transaction and a change of model or width is a change of VECTOR_SCHEME that refuses yesterday's vectors rather than comparing against them. Every citation in an answer is re-read against the file on disk before the answer is shown, and one that does not re-read is shown marked unverified rather than dropped — dropping it would leave a claim on screen with no source.

What is known, and it changed on 2026-08-11. This code has now made a real call (ADR-104): the corpus embedded whole against text-embedding-3-small, so the request and response shapes in embedding.py are observed rather than read out of documentation, the width came back 1536 where the previous code named 1024, and a question went through answerer.py end to end on the vector path with every citation surviving its re-read. What is still not known. The refusal path has never been exercised — no bad key and no wrong model name has been put to the endpoint — and nothing is known about a rate limit, a retry or a longer input. Every test drives an injected fake and the suite makes no network call.

Three words a reader should not have to look up

An index is work done once at build time so a lookup is instant instead of linear in the corpus. The cost is that it is a copy of an arrangement of the text, and a copy can fall out of step with the thing it describes — which is the whole of the risk in this section.

FTS5 is that index, compiled into the SQLite this project already runs, so it is a table in the database the product already opens. No service to run, no process to supervise, no package in requirements.txt, nothing for a reviewer to install before make run works. That is ADR-007 paying off rather than a coincidence: choosing SQLite for the clone-and-run property bought a full-text engine for free, where a Postgres or Elasticsearch choice would have made this section a dependency negotiation.

bm25 is the ranking rule, and it is three ideas with no magic in them: a rare word counts for more than a common one, repetition counts with diminishing returns, and length is normalised so a paragraph is not out-ranked by a chapter that happens to contain the same word. SQLite reports it as a number where lower is better, which is its convention and not the one most people expect; the field is named rank rather than score for the same reason the product refuses to call anything a confidence unless it is one.

And the part that matters here: a ranked hit is a candidate, not an answer. bm25 says a passage uses the query's words often and other passages do not. It says nothing whatever about whether the passage supports a claim. Passages carry exact character offsets, so a hit arrives already in citation shape — and the verifier still checks the quote against the source bytes (§5). A top-ranked passage whose quote does not verify is still withheld. Ranking finds it; verification decides whether anything may be said about it. Every candidate handed to the model carries the key is_evidence: false, written out explicitly rather than left absent, because a missing key reads as "not applicable" and a present false reads as "asked and answered no".

Where a ranked passage stops being a rank and has to earn its place A question is folded into terms by the same normalization the verifier uses. Four checks decide whether the index may answer: the query holds a searchable word, the database can hold an FTS5 index, both index objects exist, and the index covers exactly the passages this company owns. Any failure sends the whole answer to a complete scan of every passage in scope, with a named reason. Either path returns passages carrying character offsets. Every candidate goes through the verifier before it reaches the model, and one that does not verify is dropped and counted rather than shown. No candidate is ever marked as evidence. a question, in a chat turn match_terms() folds it with the same normalize() the verifier runs, then keeps the terms holding a letter or a digit four checks, in order a searchable term · SQLite · both index objects present · coverage equals the company's passage count, under this scheme the index answers — bm25, lower is better rare words count more; repetition has diminishing returns; a short passage beats a long one that mentions the word once. A bounded candidate list. the scan answers — every passage in scope INDEX_MISSING · INDEX_STALE · INDEX_UNAVAILABLE A COMPLETE answer, unranked, carrying the reason. rank is None: a fabricated score would read as one. passages, already in citation shape version_id char_start char_end text (the source verbatim) The index is contentless, so there is nothing in it to quote. The text can only come from the passage row. the verifier, again — the same one every claim goes through (§5) Each candidate's own text is checked against the stored source at its own offsets. A passage that fails is dropped from the list and counted, not shown with a caveat. handed over with is_evidence: false dropped, and the count is said out loud
deterministic stage can refuse the refusal path
The reason this is cheap here is a property the product already had. app/state/models.py stores every passage with its version, ordinal, section and character offsets, so a hit is already in citation shape and composes straight into verify_citation. Most retrieval systems chunk text without keeping offsets back into the source, and then cannot cite what they retrieve. tests/test_search.py takes a hit out of the index and runs it through the real verifier for exactly that reason. The two drops are counted separately and neither is folded into the other: a passage held back because it carries a withheld claim's span is the product declining to repeat text beside a refusal, and a passage that fails the verifier is the stored passage disagreeing with the stored source — a defect somebody has to look at. One number would let a bug read as a policy.

The three things that make this riskier than it looks

One — tenancy

An FTS5 virtual table is its own object, and the guard in app/state/queries.py does not reach inside it. Passage carries no company_id at all — tenancy lives on the DocumentVersion that owns it — so an index queried on its own terms hands back every company's rowids. The read therefore lives in app/state/queries.py with every other tenant read, joins passages to document_versions, and filters on the caller's company. Nothing in app/state/search.py queries the index directly. The ledger table beside the index deliberately does not carry a company_id, though it easily could: a denormalised tenant column is a second answer to "who owns this row", and on the day the two disagree there is no way to say which is right (§10).

Two — the index is a derived corpus

Derived data is comparable only within the scheme that produced it, and half-migrated it does not fail — it answers every query, plausibly, and wrongly. So the index is not a migration and migrate.py never backfills into it. It has its own build step, it is written inside a single transaction so it lands whole or not at all, and a read that finds it missing or stale refuses to answer from it: three states, three named reasons, and every one of them degrades to a complete answer from a scan rather than a short one. Returning fewer results with no announcement is the worst available outcome, because the caller cannot tell "nothing matched" from "the index was empty".

What the staleness check does not catch, said plainly. Coverage is counted, and every hit that comes back is checked against the fingerprint recorded when it was indexed. What is not checked is a passage edited in place, indexed under its old words, and never returned — the counts do not move and nothing looks at it. Catching that means fingerprinting the whole corpus on every query, which is the cost the index exists to avoid. It is inert today because nothing in app/ updates a Passage row: ingestion inserts them and no code path edits or deletes one. It stops being inert the day something does, and whoever writes that path owns this paragraph.

Three — the tokenisation has to agree with the verifier

Regulatory text arrives from PDF extraction full of ligatures, soft hyphens, hyphenated line breaks, full-width digits and section marks. If the index folds them differently from the verifier, retrieval and citation stop describing the same document. The rule that makes "agree" precise: the index may be more permissive than the verifier and never less. A permissive index offers extra candidates and the gate rejects them, which costs a little work. A restrictive index cannot find a passage that exists and would have verified — a silent miss, invisible to everyone, and the failure this whole design is arranged against.

So both sides of the index run through normalize(), the same function that runs on both sides of every citation comparison, and FTS5 is left to do nothing but split words on already-folded text. All the folding is ours; the word splitting is symmetric. Measured on this machine against the raw text:

RawWhat the index would have done without normalizing first
fileOne token spelled with the ligature, so a query for "file" finds nothing. Normalized it is "file".
main­tainSplits at the soft hyphen into "main" and "tain", so "maintain" matches neither. Normalized it is "maintain".
20 MWKeeps the full-width pair as its own token, so "20 MW" misses. Normalized it is "20 MW".
cost-
causation
Agrees either way — both spellings split into "cost" and "causation".

Three of the four shapes above are silent misses, all in the restrictive direction, all removed by normalizing first. In the other direction the index keeps what the verifier keeps: a superscript two is a footnote marker, normalize() refuses to fold it, and a query for "202" does not reach "20²". A blanket NFKC index would have joined them (§5). Two differences remain and both are permissive, which is the safe side: FTS5 case-folds where normalize() deliberately does not, and the tokeniser splits on stops and section marks, so § 5.4.1 is searched as a phrase — the tokens 5, 4 and 1 adjacent — rather than as three separate numbers that would match every table reference in the corpus. The scheme is named in SCHEME and recorded on every indexed row, so an index built under an older scheme is refused rather than mixed with a newer one.

Alternatives considered

OptionRejected because
The substring scan this replacedIt is still here, as the fallback, and it was never adequate as the only path: it reads every claim the product has already written, so it can only find text the product has already said. It cannot find the passage nobody has made a claim about, which is the passage a question is usually about.
A vector store and embeddingsA dependency, a service and a second corpus to keep in step, bought to solve paraphrase — which is the company-context matching problem (ADR-008, §4), not this one. Docket text is quoted, not paraphrased: an analyst asking about collateral uses the docket's word. Embeddings here would add cost and a second failure mode to a problem exact terms already solve.
An external search serviceBreaks the clone-and-run property ADR-007 exists to protect. A reviewer would have to start a second process before make run worked, which is a real cost against a benefit this corpus size cannot show.
Storing the text inside the indexRefused on safety, not on size. A stored copy would hold the normalized text — whitespace collapsed, ligatures expanded, soft hyphens gone — and the first person to quote from it would mint a citation whose offsets point into the raw source and whose words came from somewhere else. The index is contentless, so there is nothing in it to quote.
Ranking the results in front of the analystThat is a search product, and ADR-002 rejects it. A ranked list handed to a person puts the ranking in front of the evidence and makes a good rank look like an answer. Retrieval inside the citation path hands the model a candidate that then has to earn its place through the same gate every claim goes through.

Trade-off accepted. The index can be wrong in one direction only — it can offer a passage that does not matter, or fail to offer one that does — and neither can put an unverified sentence on the screen. What it costs is a build step somebody has to run, a scheme string two files have to agree on, and one more piece of derived data to keep honest. What it buys is the thing the citation story needs on a corpus bigger than a page: a way to find the passage worth citing before deciding whether it may be cited.

Where the wiring is. The assistant's search_claims tool calls search_passages, the migration calls ensure_passage_index, and app/state/queries.py holds both index reads beside every other tenant read, and .venv/bin/python -m pytest tests/test_search.py -q is the file that covers it. What is not true and would be easy to write: that ADR-008 is implemented. Its docket-text half is; its company-context half is not.

Part D — what this document does not resolve

Every gap named beside the thing it is a gap in stays where it is: a limit stated next to the design it limits is worth more than a limit in an appendix. This part links back rather than restating, and exists because a document that lists its gaps in a diagram caption and not in the list at the end is hiding them in plain sight.

D1. Open questions

None of these change a module boundary. They are calibration, coverage and unfinished work, which is exactly why the boundaries in Part A were drawn to keep them swappable.

D2. Every count here is either gone or machine-checked

This section used to be a promise and it did not hold. It listed the document's counts beside a command that re-derived each one, and asked a reader to run them. Three review rounds ran them. Every round found the same defect and a different set of wrong numbers: six call sites published as seven, fourteen as fifteen, thirty-five modules as thirty-four, eight columns as seven, and twenty-one tables in fourteen templates published as ten in six. Not one of the errors was in the same direction as the last, so there was no correction to apply — only a practice to stop. A command a reader might run is not a check. A test that runs is.

So every structural count now goes one of two ways, chosen count by count. The number goes and the claim stays — most of them, because most were decoration. "Six modules read passages at seven call sites, all through one function" asserts nothing that every module that reads passages goes through one function does not, and the shorter version cannot rot. Where the list is worth having, this document writes it out or gives the grep that produces it. Or the number stays, marked with data-count and re-derived by a test, in the style docs/scalability.html already uses for its benchmark figures. Only where being wrong by one would mislead. tests/test_tdd_counts.py re-derives every marked figure and fails when this document and the tree disagree; it also fails if a mark has no deriver or a deriver has nothing marked, so the two cannot drift apart in silence.

The whole marked set. Nothing else in this document is a checked number.

data-countWhat it claims, and why the number staysWhat the guard derives it from
tablesThe schema is wider than a diagram can teach from (§12).len(app.state.models.Base.metadata.tables)
restructure-changes
restructure-capped
The blast radius of the section-label cap on the corpus pair. Nearly half is the argument; a tenth would be a different argument (§2, A6).diff() over v2 and v3, and the modified pairs sitting at RESTRUCTURE_CONFIDENCE_CEILING.
restructure-similarity
uncapped-confidence
The two worked rows the §2 figure draws: a renumbered pair whose words barely moved, and a pair whose own number held._similarity on the 6.3 Return of Collateral pair; the highest confidence in the same run the cap did not touch.
corpus-changes
corpus-cautioned
corpus-at-ceiling
"More than half the corpus carries the caution" is the reason a person can answer the pairing at all (§2).Both synthetic pairs, through app.state.alignment.pairing_is_uncertain.
real-filings
real-commissions
The corpus is real and broad enough that its shapes were not chosen to suit the code; one pair of it is exercised, and that gap is the point (A6, §1, §9).The .provenance.json files under data/real/, and the distinct jurisdiction in them.
real-before
real-after
real-difference
real-changes
A hundred and twenty-seven characters moved in a million and the pipeline found them. Here the arithmetic is the claim (A6, §13).The two Kentucky PSC 2025-00113 files and diff() over them.
eval-citation
eval-corruption
eval-diff
eval-occurrence
eval-routing
The Result column of the §9 scorecard. A scorecard that has stopped matching the harness is worse than none.app.evals.run.score(Corpus.load()), a metric's hits and total each.
eval-citation-offsets
eval-citation-subjects
Offsets verifying is work done; subjects is the sample, and the gap is the argument (§9).The citation metric's total and its distinct subject identities.

The counts that went, and where to get one anyway. Each is now a grep or a one-line python -c printed beside the claim it belongs to, in the section that makes it, rather than gathered here where nothing keeps them honest. Two of the lines this table used to print were wrong about their own output and are corrected there: grep -L load_env scripts/*.py names the scripts that do not load .env, not the ones that do; and make model-eval catches the harness's exit 2 and exits 0 itself, so the target's output is the evidence and $? is not.

A re-derivation is only as honest as its scope. Every line in this document reads either the assembled application or the tracked files, and that is deliberate: a find over the working tree counts the git worktrees agents leave under .claude/, and what a reviewer clones is what should be counted.