Verbatim — citation-grade regulatory change intelligence. How it is built, at the level of files, functions and data shapes.
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.
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 it | Answered in | Where it stands |
|---|---|---|
| Ingestion & passage segmentation | §1, drawn in A2 | Built and tested. Coarser than planned — blank lines only. PDF and HTML extraction not built. |
| Version diffing | §2 | Built, 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 | §3 | The 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 | §4 | Obligations 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 | §5 | Built, tested and adversarially probed. The depth area. The truncation case is caught by nothing and says so. |
| Confidence & escalation | §6 | Built. The confidence bar ships at zero, so the third check refuses nothing today; the mechanism is configuration, not a measurement. |
| Reviewer routing | §7 | The 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 | §8 | The 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 | §9 | Deterministic 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 | §10 | One chokepoint, imported wherever it is needed and copied nowhere. Nothing in the database enforces any of it. |
| Security | §11 | Login, 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. |
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.
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.
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.The reasoning matters more than the boxes.
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.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.interpretation and out of it crosses a typed boundary —
Change 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).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.
| Module | Responsibility | Calls a model? | Swappable without touching the rest |
|---|---|---|---|
app/config.py | Reads .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. | no | Called 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. | no | Parser 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.pyno 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. | no | Making 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. | no | The 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. | yes | The 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 normalization | Runs 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/. | no | Every 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. | no | db.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.pyno 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. | no | Resolution 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. | no | The 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). | yes | The 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. | no | The 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. | no | Deliberately 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 --send | Imports 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 loud | Two 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). | no | A 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). | no | The 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.py | The 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. | no | It imports diff, ingestion, state and audit, and not verification — because the gate is on the read path, not this one. |
app/seed.py | Loads 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. | no | This 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.py | Assembles 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. | no | tests/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. |
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.
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.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.
.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 corpus — 102 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,536
— 127 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.
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.
journal_mode, so a reader waits behind a writer today. The deployment runs one uvicorn worker for
exactly this reason, which is the ADR-028 trade-off arriving in production rather than being papered over. The
fix is Postgres, and SQLAlchemy makes that a connection string, not a rewrite — but transaction and
locking behaviour differ enough that the switch needs its own test pass, not just a config flag.passages_for_company so the swap has one call site (§15). The
semantic half ADR-008 designs — matching a change to an internally-worded obligation — is not
built, so it has no scaling problem yet. The company-context side is small enough to compare
in process before it needs a vector store. data/company_context.json holds a handful of
obligations, projects and documents (§4); a few hundred short sentences compare in
memory in one pass, and a service, an index format and a second corpus to keep in step are all cost bought against a
scale this side does not have. The trigger for buying them has the same shape as ADR-028's: the first customer
whose obligation set does not fit in a request's working set.verify_chain walks every row for a company on every call.
That is linear in the log and fine at demonstration size; at real volume it becomes a walk from the last
verified checkpoint, which needs a published head to anchor to. Compaction is not the answer here —
dropping rows is the one thing that table exists to prevent (§8).pipeline chokepoint, called from a request. At volume this becomes a queued job so a large upload
does not hold a request thread open; the chokepoint's shape does not change, only what calls it. The latency
side of the same problem — a model call inside the request that renders the answer — is a failure
mode rather than a scaling seam, and sits in §14.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..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.
--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 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.
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.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.
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.
/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.
/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.
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.
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.
/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.
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.
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.
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.
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.
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).
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 truth — app/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.
| Option | Rejected because |
|---|---|
| Fixed-size token windows | Even 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 segment | No 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.
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.
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.
| Option | Rejected 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 detection | Finds 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 documents | Untestable 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 fallback | Not 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.
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 affects — change_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 reviewer — action.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.
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.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.
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.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.
| Rule | What it handles |
|---|---|
| Whitespace collapse | Runs of whitespace become one space; leading and trailing space is dropped. Handles PDF reflow and a quote reproduced with different line wrapping. |
| Selective NFKC | Ligatures, 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 dashes | Smart quotes and every dash variant fold to the straight forms, for comparison only. |
| Soft hyphen | Deleted unconditionally. It is a rendering hint and the words either side stay joined. |
| Hyphen against a line break | The 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. |
| Case | Not 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, dates | No 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.
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.
| Reason | What it means |
|---|---|
citation offsets fall outside the source text | The span is not inside the document. No excerpt is shown, because there are no real bytes to show. |
citation span is empty | The span or the quote normalizes to nothing. |
quoted text does not match the source at the cited offsets | The 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 match | The case the diagram above is about. |
the cited version could not be read for this company | Raised 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 ingested | Also 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 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.
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.
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.
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.
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.
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.
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.
VERBATIM_MIN_CONFIDENCE_BP ships at 0 (app/state/claims.py). Every claim in either corpus carries confidence_bp = 10000 — SEED_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.
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.
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.
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.
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.
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.| Append-only log beside the state (built) | Snapshots | |
|---|---|---|
| Extra work | None. 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 explains | Actor, 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 give | Reconstruction. 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.
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.
--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.
| Metric | What it checks | Bar | Result |
|---|---|---|---|
| Citation verification | Every 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 rejection | A 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 completeness | All 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 disambiguation | Each 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 routing | An 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.
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.
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.
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.
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.
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.
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.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.
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.
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.| Cluster | Tables | What it is for |
|---|---|---|
| Project workspace | projects, project_changes, research_threads, research_turns, work_plans, work_plan_steps, scheduled_runs, knowledge_items | The 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 centre | sources, findings, questions, collective_takes, deliverables, steer_directives | Evidence 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 access | users, 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 workflow | obligations, 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 assistant | chat_sessions, chat_messages | One 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). |
| Feedback | feedback, improvement_items | What 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. |
| Sharing | share_links, share_opens | A 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 actions | proposed_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 registry | source_registrations | Which 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. |
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:
create_all. Base.metadata.create_all(engine) creates what is missing and leaves what exists untouched. That is also why the column loop below it has to exist: create_all will not add a column to a table it already sees.ALTER TABLE … ADD COLUMN. The loop walks Base.metadata.sorted_tables, compares each table's declared columns against inspect(engine).get_columns(), and for each missing one executes ALTER TABLE {table} ADD COLUMN {column} {type}. The name and the type come from the model, never from a caller, because a column name cannot be a bind parameter and DDL has to be built as text.CREATE INDEX IF NOT EXISTS. Indexes on a table that already existed are not created by create_all either, so the last block emits SQLAlchemy's CreateIndex(index, if_not_exists=True) for every declared index the database does not report. An index the dialect refuses — a partial index it will not render — is recorded as a refusal rather than swallowed.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:
scripts/migrate.py exits non-zero, so a deploy stops rather than starting against a schema it cannot read.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.
.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.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.One real request, followed through the code: an analyst opens a change and sees which claims the product will make about it.
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 model — app/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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./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.
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.
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.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".
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.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).
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".
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.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:
| Raw | What the index would have done without normalizing first |
|---|---|
file | One token spelled with the ligature, so a query for "file" finds nothing. Normalized it is "file". |
maintain | Splits at the soft hyphen into "main" and "tain", so "maintain" matches neither. Normalized it is "maintain". |
20 MW | Keeps the full-width pair as its own token, so "20 MW" misses. Normalized it is "20 MW". |
cost- | 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.
| Option | Rejected because |
|---|---|
| The substring scan this replaced | It 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 embeddings | A 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 service | Breaks 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 index | Refused 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 analyst | That 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.
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.
claude-opus-5, pinned as a
constant in both places that call one on the product path, deliberately not read from the environment
(ADR-037). The key arrives through app/config.py. Every model path still announces its fallback by
name when there is no key. Kept on the list as closed, because closing a question is worth more than a shorter
list.app/interpretation/propose.py reached the
real API once on purpose, on 2026-08-05: the parameter combination was accepted, the model the module pins is
the model that answered, and the structured output parsed on the first read
(transcript). That is one call on a short prompt, and the same page
records a second call the same day whose output was lost, so it can be neither described nor counted. Nothing
is known about a rate limit, a long input, a retry, a mid-stream failure, or a real answer whose citation does
not verify. tests/test_live_transport_probe.py holds the transcript to whatever MODEL_ID
says, so it cannot go on describing a model the product has stopped calling. §3./explain embeds with text-embedding-3-small, and it has run against the live
endpoint (ADR-104), so the wire format, the auth and the parse are observed rather than guessed. Its refusal
path is unproven: no bad key and no wrong model name has been put to it, and nothing is known about load,
retries or a longer input. §15.record_decision, so a step can be reminded, time out and be bypassed
and no person can sign one. §7.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.
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-count | What it claims, and why the number stays | What the guard derives it from |
|---|---|---|
tables | The schema is wider than a diagram can teach from (§12). | len(app.state.models.Base.metadata.tables) |
restructure-changesrestructure-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-similarityuncapped-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-changescorpus-cautionedcorpus-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-filingsreal-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-beforereal-afterreal-differencereal-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-citationeval-corruptioneval-diffeval-occurrenceeval-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-offsetseval-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.