Every technology and design choice, with the alternatives considered and the trade-off accepted. The panel walks through this; a choice without its reasoning is worth little.
Rule for this file: write the entry when the decision is made, not afterwards. An ADR reconstructed at the end reads like a justification, and the interview is designed to tell the difference.
Every entry answers three questions. What forced the choice. What was rejected, and why it lost. What the decision cost. An entry with no rejected alternatives is a note, not a decision record — a later reader re-proposes the rejected option and has to derive the argument again. An entry claiming no cost has either an unnamed cost or was not a decision; where the cost really is negligible the entry says so and says why, rather than manufacturing a trade-off to fill the slot.
A correction stays visible. When an entry goes false, the original text stays and the correction is added under it, saying what happened and what would still be needed for the original. A record that quietly rewrites itself to match the outcome is not a record, and a stale ADR is worse than a missing one because it reads as a decision that held. The same rule docs/security.html follows. See ADR-08, ADR-09, ADR-10, ADR-11 and ADR-13.
A decision with no code behind it says so, in the entry and in its data attributes. See ADR-08, ADR-32, ADR-33 and ADR-34 to ADR-36.
Where old reasoning could not be recovered, the entry says that too. A reconstructed rationale is a justification wearing a record's clothes, and it is the thing a panel catches you on by reading the history. See ADR-05, ADR-06 and ADR-34.
Decision. The product is built for one person: a regulatory affairs analyst at an investor-owned utility operating in several jurisdictions. Not counsel, not the compliance officer, not the executive. The analyst is the one who reads the docket, works out what changed, and tells the business what it must do.
Alternatives considered. (a) Compliance officer — owns the obligation register but consumes analysis rather than producing it; (b) outside counsel — bills for exactly this work and has the opposite incentive; (c) "regulated enterprises" generically — which is not a user at all.
Trade-off accepted. Narrowing to one persona means the demo will not impress a compliance officer or a lawyer on their own terms. That is the point. The prep guide is explicit: naming more than one type of person means the work is unfinished.
Second role, load-bearing, not a second user. The product also names one load-bearing second role: the obligation owner, who approves the action the analyst recommends. The analyst who interprets a change is never the person who approves the resulting action — that separation is a security control, not an org-chart detail. Where the demo permits self-approval for convenience, that is a disclosed downgrade, not the design.
Open. This persona is a hypothesis until the user interviews (see docs/user-research.html). If two of three interviews say the real reader is counsel, this ADR gets rewritten and the PRD with it.
Decision. The unit of work is a change between two versions of a proceeding, carried through to a recommended action with a named reviewer. Not a document, not a query, not a notification. What forced the choice. The analyst's day is not short of documents or of notifications. It is short of judgement time. Whatever the product's unit is, it has to be the unit the expensive work is measured in, or the product sits beside the work instead of inside it. Alternatives considered — why alerts and search both fail. An alert says something moved; the analyst still has to open both versions, find the delta, judge whether it is material, decide whether it binds yet, and work out which of their obligations it touches. Search is worse: it assumes the analyst already knows what to look for. The expensive part is the interpretation, and neither tool does any of it. Trade-off accepted. A change-centric model is harder to populate — it needs at least two versions of a proceeding before it produces anything. Cold start is real and the PRD must answer it.
Decision. Every claim the system makes carries a citation of (document_version_id, char_start, char_end, quoted_text). Before that claim is shown as fact, a verifier re-reads the stored source at those offsets and confirms the quote matches. A claim whose citation does not verify is never displayed as established — it is routed to expert review, labelled as unverified.
Why this and not something else. "Citation-grade" is the word in the challenge title, and in this domain a confident wrong answer is worse than no answer: an analyst who cannot trust the citation must re-read the source, which is the work the product claims to remove. Language models produce fluent, plausible, subtly wrong quotations, and no amount of prompting fixes it — so the design assumes the model will misquote and makes that survivable.
Alternatives considered. (a) Trust the model's quotation and show it — fastest, and the failure is invisible, which is the worst property a compliance tool can have; (b) semantic similarity between claim and source above a threshold — accepts paraphrase, but a paraphrase is exactly what an auditor will not accept; (c) human review of every claim — trustworthy and useless at volume.
Trade-off accepted. Exact-offset verification is brittle against whitespace, ligatures, and PDF extraction noise, so normalisation has to be defined carefully and will still reject some legitimate quotes. Rejecting a good citation costs a review; accepting a bad one costs the product's reason to exist. We fail toward review.
Visible in the product. Per the prep guide, the hardest decision must be visible rather than buried: the UI shows verified claims with a source span the user can open, and shows unverified ones held back in a review queue with the reason.
Decision. Structural differences between two versions are computed deterministically (segment alignment, then per-segment diff). The model is asked only to interpret the materiality of a change it is shown, never to find the changes. Why. Diffing is a solved, testable problem with exact answers; materiality is a judgement. Asking a model to do both means you cannot test either, and it is precisely the thin-wrapper shape the challenge warns against. Splitting them means the diff has unit tests with known answers, and the model's job is small enough to evaluate. Alternatives considered. (a) Ask the model to compare two documents end to end — cheap to build, impossible to test, and it silently drops changes in long documents; (b) embedding-similarity change detection — finds drift, not edits, and cannot tell you the words that changed. Trade-off accepted. Deterministic alignment struggles when a document is restructured wholesale (sections renumbered, text moved). That is a known failure mode; the design escalates low-confidence alignments rather than guessing.
Decision. Every document version carries an explicit status. A draft change produces monitor-and-comment actions; a final change produces comply actions with an effective date. The two never share a code path.
Why. Conflating them is the error with the highest cost in this domain, in both directions: acting on a draft wastes money on something that may not survive comment, and treating a final order as a draft misses a binding deadline. It is also the one distinction an analyst will check first when deciding whether to trust the tool.
Alternatives considered, added 2026-08-04 — they were not written down at the time. (a) Let the model read the document and say whether it is a draft — the obvious cheap answer, and it puts the domain's highest-cost distinction on the component with the highest error rate; app/interpretation/action.py now says in its own docstring that the model is never asked which branch it is in, and dispatch happens before any model call could be made; (b) one code path with a boolean flag on the action — half the code, and a flag is something a later edit can drop, whereas two branches cannot be merged by accident; (c) infer from the document's language at read time — the same guess as (a) without even a model to blame.
Cost accepted. Status becomes a required input at the door. ingest_version refuses anything that is not DRAFT or FINAL rather than defaulting, so a source that does not state its own status cannot be ingested until a person decides — which is the cost, and it is the right one. There is no third state for "we could not tell", and if a real source needs one, this ADR is what gets reopened.
Decision. Every extraction and every impact mapping carries a confidence. Below a threshold it goes to a review queue instead of into the project state. The threshold is configuration, not a constant buried in code. Why. Carried directly from the peer project's hardest-won lesson: a fallback that returns something plausible hides its own failure. There, an embedding provider silently degraded and ranking ran on noise for months while every check passed. Here the equivalent is an impact mapping the model was unsure about, presented as settled. Alternatives considered, added 2026-08-04 — only one of these was written down at the time. The entry as first written argued the decision and named no rejected option except the one inside the decision sentence: (a) a threshold as a constant in code — rejected in the original text itself, because a number nobody can change without a deploy is a number nobody tunes; (b) no confidence at all, take every extraction — the shape this design exists to refuse; (c) show the low-confidence answer with a hedge attached — the failure best-practices §26 is written about, since a hedge is read as an answer and a queue is not. Options (b) and (c) are reconstructed from the principle this ADR cites rather than from a record of the argument, and that is said here rather than presented as history. Trade-off accepted. A queue nobody empties is its own failure. The review surface has to be fast enough that clearing it is routine, which is a product constraint, not just an engineering one. Still open. Where the threshold actually sits, and on what evidence, is open question 2 at the foot of this file and has not moved.
Decision. Python 3.12 + FastAPI + SQLAlchemy over SQLite, with a server-rendered interface. One command to run, one to test. Why. The submission is executed on a reviewer's machine before a panel is scheduled — so the primary risk is not elegance, it is failing to start. SQLite has no service to install. SQLAlchemy keeps Postgres a configuration change rather than a rewrite. Server-rendered pages remove a build step and a second language from the critical path of a 48-hour build. Alternatives considered. (a) Postgres + pgvector + Docker Compose — what the peer project runs, better at scale, but adds a container runtime to the reviewer's critical path; (b) React SPA — a build step, a second toolchain, and no user-visible benefit at this size; (c) Jupyter notebook — fastest to write, and explicitly not what "the implementation must run" means. Trade-off accepted. SQLite will not carry real multi-tenant volume, and the architecture doc must say what changes at that point rather than pretending it scales. The cost this entry named too lightly, and where it is now named properly. "Will not carry real multi-tenant volume" was the whole of what this ADR said about SQLite, and volume is the least of it. The database question was asked twice afterwards and answered at length; the reasoning, the migration cost and the guarantees that are enforced in application code rather than by the engine are written out in ADR-28, which this entry now defers to. Read them together: this one chose the stack, that one owns the database.
Decision. Passage retrieval starts lexical. Semantic retrieval is added only for the specific step where paraphrase matters — matching a regulatory change to an internally-worded obligation.
Why. Regulatory text is quotation-heavy and citation-bearing; exact terms matter and lexical search is strong, debuggable and free. Company obligations, by contrast, are written in the company's own words and will not lexically match the docket's.
Alternatives considered. (a) Embeddings everywhere — one mechanism, but it obscures why a passage was retrieved, and "why did it pick that passage" has to have an answer a person can read; (b) lexical only — fails the obligation-matching step, which is the product's core join.
Accepted on reasoning, not measurement. This is settled by argument, not by an eval. The synthetic corpus is far too small to show a measured difference between lexical and semantic retrieval on the obligation-matching step, so this ADR is accepted on the reasoning above, not on evidence. That is worth stating plainly rather than letting the "accepted" status imply a measurement that was never taken.
Expected question, and the honest answer. "What happens when retrieval fails?" — a missed passage means a missed change, which is the failure mode that matters most here. Mitigation is that the deterministic diff, not retrieval, is what enumerates changes; retrieval only ranks context. A retrieval miss therefore degrades explanation quality, not change coverage. This separation is deliberate and is the strongest answer this design has.
Implementation: BUILT, 2026-08-04, as ADR-71. This paragraph recorded that it was not, and it is kept because the sequence is the honest record. When it was written there was no index, no ranking, no embeddings and no search anywhere in app/, and the grep it cites returned nothing. It now returns about forty lines: app/state/search.py is FTS5 with bm25 over the passage store, read only by the assistant. This ADR chose lexical before semantic and that choice held — what changed is that somebody built it. This is a decision with no code behind it, and it is marked so in the data attributes as well as in this sentence, because for weeks the ASCII diagram in the architecture document, since absorbed into docs/tdd.html, drew "passage store — lexical BM25 (SQLite FTS5)" as a solid box beside components that really exist. That was corrected on 2026-08-04 by giving diagrams a vocabulary in which unbuilt work is drawn dashed and labelled (ADR-40). A diagram that draws an intention the same as a shipped component is the drawing equivalent of a claim asserted on a citation that did not verify, and this project of all projects should not have shipped one.
What this means for the reasoning above. Nothing in it is retracted — the argument for lexical first still stands and the separation between what enumerates changes and what ranks context is still the design. What is retracted is any impression that it was tested. It has not been run, so it has not been wrong yet either.
Decision. Verbatim is deployed to verbatim.citelocal.ai and that URL is how a reviewer is expected to see the product first. make run and make test continue to work on a clean checkout and are still guaranteed; they move from headline to fallback, not from true to false.
Why. A link a reviewer opens in ten seconds gets looked at. A repository that must be cloned, installed and run gets looked at later, or by someone else, or not at all. The submission competes for attention before it competes on merit.
Alternatives considered. (a) Local run only, per the original plan — safest, and the entire submission then depends on a stranger's Python environment behaving; (b) hosted only, dropping the local contract — removes a whole class of work, and abandons the one thing the prep guide says reviewers do before scheduling a panel; (c) build first, deploy only if hours remain — the disciplined answer, rejected because a deploy done at hour 47 is a deploy that fails at hour 47.
Trade-off accepted, and it cuts against the rubric. The prep guide says the exact run command and test command must work on a reviewer's machine, and that they execute both before scheduling a panel. Leading with a hosted URL is a decision taken against that grain: it adds uptime, TLS and a DNS record to the list of things that can lose the submission, none of which existed before. The mitigation is that the local path stays green and is verified on a fresh clone (task T22), so the hosted instance can fail without taking the submission with it. If only one survives to the deadline, the local one is the one that matters.
Open. Whether the hosted instance carries seeded demo data only, or accepts an uploaded proceeding. Upload is a far larger surface — file parsing, size limits, an untrusted document reaching the ingestion path — and is out of scope until the seeded path works.
Correction, 2026-08-04: the headline is not true yet. What is live at that hostname is a static holding page — deploy/site/index.html, a custom 404 and a /login page that says access is by invitation — served by nginx behind the host's existing proxy. The application is not deployed. That was a deliberate hold, not an oversight: shipping a tree that four agents were still writing to would have meant deploying a moving target and debugging it twice. But it means a reviewer who opens the URL today sees prose about the product and not the product, so until the application is behind that hostname the local run is not the fallback described above — it is the only path that shows anything. The choice this ADR made is unchanged; the claim that it has been carried out is withdrawn until it has.
Consequence for the trade-off. The mitigation named above — the local path stays green so the hosted instance can fail without taking the submission with it — is now carrying the whole load rather than acting as a reserve. That raises the value of the fresh-clone verification, not lowers it.
Decision. A second, cheap DigitalOcean droplet, holding nothing sensitive, destroyable after the panel. Not the host that already serves the author's other projects.
Why. That host is documented in its own runbook as "a single private trust domain" because it stores real family PII and PHI, encrypted at rest. Its firewall, its closed control-plane port and its edge auth gate all follow from that premise. Putting a day-old application on it — one whose URL is about to be handed to reviewers at an investment firm — ends the premise. The containers would isolate the data; the trust domain would no longer be private, and that was the property being protected.
Alternatives considered. (a) Same droplet behind the existing Traefik basic-auth gate — hours faster and genuinely isolated at the container level, rejected because "isolated at the container level" is precisely the assurance the original posture declined to rely on; (b) same droplet, fully public — removes the edge gate protecting everything else on the box, for the sake of saving reviewers a password; (c) a managed host such as Fly or Render — a clean boundary too, rejected only because it introduces an unfamiliar build pipeline on the last night.
Trade-off accepted. Thirty to forty-five minutes that could have gone to the product, plus a few dollars a month, plus a second machine to keep patched. Bought: a hole in Verbatim reaches nothing that matters, and the box can be deleted rather than cleaned.
Correction, 2026-08-04. Everything above stands as the decision that was taken. It is not what happened. No DigitalOcean credential exists anywhere on this machine. doctl is installed and unauthenticated — no ~/.config/doctl, no DIGITALOCEAN_ACCESS_TOKEN in the environment, and no dop_v1_ token in any peer project's .env. Creating a droplet is an account action and there was nothing to take it with. The one deployment credential that does exist is a Coolify API token, and Coolify deploys onto a server it already manages; it cannot provision a new one. So the second droplet was never created, and Verbatim was deployed to citelocal-1 at 143.198.140.28 — an existing, shared droplet, which is the thing the heading of this ADR says not to do.
Why that host, and why this satisfies the reasoning while breaking the letter. The argument above is about exactly one boundary: keep a day-old public application off the machine that holds real family PII and PHI. That machine is concierge-1, and Verbatim is not on it. Three droplets exist, not one. vitalnexa-1 was rejected for the same reason at one remove — health and wearables data is sensitive in the same way the runbook meant. citelocal-1 serves a local-business advisor: commercial data, no health records. The premise this ADR was written to protect is intact. The sentence it wrote to protect it is not.
The residual, stated rather than argued away. Verbatim now shares a kernel and a Docker daemon with citelocal's Stripe keys and customer data. That is a smaller blast radius than the PHI host and it is not zero — and it rests on container-level isolation, which is precisely the assurance alternative (a) was rejected for relying on. So this is a downgrade taken under a missing credential, not a discovery that the rejected alternative was right all along. Whoever reads this at a panel should hear it that way.
What the original decision still needs. One DigitalOcean personal access token with write scope. Then doctl auth init, an s-1vcpu-1gb Ubuntu droplet at a few dollars a month, and the deployment moves. Nothing else blocks it — not time, not design, not money. Until that token exists this entry stands corrected rather than met.
Why the original text is left standing above. The same rule docs/security.html follows, where five false sentences are corrected in place rather than deleted: a record that quietly rewrites itself to match the outcome is not a record. A stale ADR is worse than a missing one, because it reads as a decision that held.
Decision. Two containers on the new droplet: the application, and Caddy as the reverse proxy terminating TLS. Compose brings them up. No platform-as-a-service layer.
Why. Caddy obtains and renews Let's Encrypt certificates from a three-line configuration file with no further setup. Coolify is the tool already in use on the other host and is genuinely better for many applications over time — it is a worse fit for one container on a box that will be destroyed in a week, because installing and onboarding it costs more than it returns at this scale.
Alternatives considered. (a) Coolify, matching the peer project — familiar, and the familiarity is worth real money at 2am, rejected on install and onboarding cost for a single-container deployment; (b) Traefik, matching the other droplet exactly — more configuration than Caddy for the same outcome here; (c) nginx with certbot — more moving parts and a renewal cron to forget about.
Trade-off accepted. Two deployment mechanisms now exist across the author's projects rather than one. That is a real cost to consistency, accepted because this droplet is explicitly temporary and will not survive to become the thing anyone has to maintain.
Correction, 2026-08-04: this decision was reversed by the one above it, and Caddy was never installed. ADR-10's correction moved the deployment onto citelocal-1, and that box already runs Coolify's proxy — traefik:v3.6, with a live acme.json and Let's Encrypt certificates for everything else on the host. Two proxies cannot both hold ports 80 and 443, so bringing Caddy would have meant either taking the host's TLS away from its existing applications or running Caddy behind Traefik for no gain. What shipped instead reads the Traefik labels off a container already on that box and copies the convention exactly: the same entrypoints, the same certresolver, the same redirect and compression middlewares. Alternative (b) above — "Traefik, matching the other droplet exactly" — is what runs, and alternative (a), Coolify, is what issues the certificate.
What that does to the trade-off. It inverts it. This entry accepted a second deployment mechanism as the price of a temporary box. The outcome is the opposite: there is one mechanism across the author's projects, and it is the peer project's. The cost paid instead is that Verbatim's deployment is now coupled to somebody else's proxy configuration — an edit to citelocal's Coolify stack can take Verbatim's certificate with it, which the disposable droplet would have made impossible.
Was the original reasoning wrong? No, and this is worth being precise about rather than generous. The comparison in this entry was Caddy against Coolify on a box that had neither. On a box that already runs one, the install-and-onboarding cost that decided it is already paid. The reasoning did not fail; its premise was replaced.
Decision. The move to a hosted web application does not change the stack. FastAPI with Jinja templates, one stylesheet holding the design tokens, and a small amount of vanilla JavaScript for the citation viewer. No Node, no build step, no framework.
Why this is recorded rather than assumed. The request that prompted it was for a modern designed interface, which usually implies a component framework. The reasoning is that it does not have to: design quality here is typography, hierarchy, spacing, restraint and colour under both themes — judgements that apply identically to a Jinja template. ADR-007 rejected a single-page application on cost, and wanting the product to look considered does not touch that reasoning. Reaffirming a decision under new pressure is worth writing down, because at the panel it will look like a decision that was never revisited unless the record shows otherwise.
Alternatives considered. (a) FastAPI plus a React single-page app — the highest interaction ceiling and the conventional answer, rejected because it adds a Node toolchain to the image, CORS across two origins, and a written reversal to defend, on a night where the citation verifier does not yet exist; the plausible outcome is a polished shell with no product behind it. (b) Templates plus HTMX — no build step and smoother partial updates, held in reserve for the review queue if hours permit, so the dependency is taken only against evidence rather than in anticipation.
Trade-off accepted. Interactions that a framework gives free — optimistic updates, client-side routing, rich state — have to be hand-written or done without. The wager is that this product needs exactly one rich interaction, and that one is small.
Decision. Proceedings list, proceeding detail with the version timeline, change detail, and the review queue. Four. On change detail every claim carries a citation chip; opening it shows the source document beside the claim, scrolled to the cited offsets with the exact span marked.
Why four and not more. Each screen has to earn a named user pain, and an unearned screen is something a panel will challenge. A settings page, a dashboard of counts, an admin view — none of them are anything the analyst asked for, and each would dilute the one path the demo depends on.
Why the chip is the design's centre. The prep guide requires the hardest technical decision to be visible in the product rather than buried in the stack (ADR-003). The chip is that requirement made concrete: the reviewer clicks it, reads the sentence in the original filing, and sees for themselves that the system quoted correctly. The paired demonstration is the point — one change whose citation verifies and renders as fact, and one whose quote was deliberately corrupted, which never renders as a claim at all and instead appears in the review queue with the reason and the mismatch shown.
Constraint this places on the visual design. Verified and unverified must not read as two shades of one treatment. A greyed-out version of an assertion is still an assertion. The unverified state has to differ in structure, not only in colour, so that it plainly declines to say anything — and it has to do so without a legend explaining what the colours mean.
Open, and stated precisely to avoid contradicting the architecture. app/review/ is specified in docs/tdd.html as supporting approve, reject and amend, and that stands — amend is an event appended to the log like any other. What is open is narrower: whether the 48-hour interface exposes amend, since it implies an editing surface on a claim and a way to show what was changed. If hours run short the queue ships with approve and reject only, and the module keeps the third path unused rather than the architecture being rewritten around a temporary UI limit.
Correction, 2026-08-04: it is not four. The application serves nine templates and eleven routes — a project list, a project detail, a new-project form, a proceedings list, a proceeding detail, a change detail, a review centre with a per-project view, an escalation queue, and a login page. Two more screens are specified and unbuilt: a workflow editor and a read-only workflow route (ADR-34 to ADR-36). So the number in the heading is wrong, and it is left in the heading because the discipline behind it is the part worth keeping.
Which of the extras earned a user pain, and which did not. Honest audit, in the terms this entry set. The login page is forced by ADR-15 and ADR-20 — an identity system with no way to sign in is a library, not a control. The project list and detail are the frame the four original screens hang inside; the original four assumed a single project and the corpus has six. The read-only workflow route answers a pain that is written down: an analyst hands an item off and cannot say who has it or what happens if nobody acts, so they chase it by email — which is the workflow this product claims to replace. The new-project form and the split between the escalation queue and the review centre are the two that were not argued from a named pain before they were built; they are the ones to challenge, and the honest answer at a panel is that the review surface grew two doors because two agents built it in parallel, which ADR-23 is the scar from.
The rule survives the count. "Each screen has to earn a named user pain" is still the test, and a settings page, a counts dashboard and an admin view still do not exist. What changed is that four was a guess made before the corpus and the identity model existed, and a number in a heading is not a design principle.
Decision. A single .env, gitignored before it was created, holding five values: one model API key and four Gmail OAuth values used by the user-research outreach. A committed .env.example documents the shape without the secrets.
Why the width matters more than the location. The credentials were available as a complete file from a peer project holding roughly fifty values — production brokerage API keys, a health-data encryption key, a database password, a signing secret, a source-control token. Copying the file wholesale would have been one command and would have widened the blast radius of this repository by every one of them, for no gain: none are needed here.
Why the gitignore entry came first. This repository is published with its history intact and deliberately not squashed (task T24), and reviewers read that history. A secret committed here cannot be withdrawn by deleting it later — the commit remains. Adding the ignore rule before writing the file makes the ordering a property of the repository rather than of anyone remembering.
Alternatives considered, labelled 2026-08-04 — the argument was in the entry, the list was not. (a) Copy the peer project's .env whole — one command, and it drags production brokerage keys, a health-data encryption key, a database password, a signing secret and a source-control token into a repository whose history is about to be published; (b) a shared secret store across both projects — the right answer for a company, and it puts a service between a reviewer and make run, which is the ADR-07 argument again; (c) commit an encrypted secrets file with the key held elsewhere — one fewer thing to hand over, and it puts ciphertext in a permanent history where a leaked key later is a leak now; (d) environment variables set by hand with no file at all — nothing to leak and nothing to reproduce, and the reviewer gets no .env.example telling them what is needed.
Trade-off accepted. Two copies of a model API key now exist across two projects, so rotating it means remembering both. Accepted over the alternative of a shared secret store, which is the right answer for a company and overbuilt for a two-day project.
Decision. Verbatim authenticates people against passwords it stores itself. hashlib.scrypt derives the hash — OpenSSL-backed, memory-hard, a real key derivation function — at n=214, r=8, p=1, dklen=64 over a fresh 16-byte salt per user. The cost parameters are written onto the user row and read back at every verify, never taken from the module constants, so raising the cost later is one edit and every password already stored keeps working. secrets.token_urlsafe makes every token, secrets.compare_digest makes every comparison of a secret, and there is no == on a hash, a token or a password anywhere in app/auth/ or app/state/identity.py. requirements.txt is unchanged.
Why no dependency, when a better one exists. argon2id is the stronger algorithm and argon2-cffi is the obvious way to get it. It is also a pinned package with a compiled wheel that has to install on a stranger's machine. ADR-007 says the primary risk in this submission is not elegance but failing to start, and the prep guide says reviewers run make run and make test before they schedule anything. A password hash that is one notch weaker and always installs beats a better one that sometimes does not.
Alternatives considered. (a) argon2-cffi or passlib — better algorithm, better ergonomics, and a build step on the reviewer's critical path; (b) a hosted identity provider such as Auth0, Clerk or Cognito — the right production answer and the wrong prototype one, because it makes a local run depend on a network service, an account and a set of keys, which is exactly the contract ADR-009 refuses to give up; (c) hashlib.pbkdf2_hmac — standard library too, and not memory-hard, so a GPU buys an attacker far more against it than against scrypt; (d) a bare SHA-256 of the password — fast, which is the whole defect.
Trade-off accepted. We now run our own password store, which is the component an enterprise buyer will ask to remove first, and scrypt at these parameters is weaker than argon2id at a comparable setting. Cost parameters on the row are the mitigation for the second half; the first half is not mitigated, it is a stated limit — docs/security.html says production needs SSO against the enterprise's directory and that nothing here substitutes for it.
What this does not decide. No second factor, no password reset, no password-change path, no reuse check against a breach corpus, and no rotation. Those are absent, not deferred to a design that exists.
Decision. Successful logins, failed logins, sign-outs, expiries, account creation, suspension and reinstatement, role grants and revocations, authorization refusals and waived approvals all go through record_event into the one AuditEvent chain in app/state/audit.py. There is no security-events table and there will not be one.
Why. Two logs drift, and the one nobody reads goes wrong first — silently, because nothing compares them. The questions also interleave: "who approved this obligation" and "who was signed in at the time, and had they just been granted the role" is one question asked in one breath, and answering it across two tables means joining on a timestamp and hoping. One chain, one sequence per company, one verification.
Alternatives considered. (a) A separate security log — the conventional split, easier to ship to a SIEM on its own retention, and it puts the grant in one place and the approval it enabled in another; (b) the application log file — rotates away, is not tenant-scoped, and is not tamper-evident, so it answers nothing under dispute; (c) both, with the security table as the source of truth for auth — two writers on one story, which is the drift this decision exists to avoid.
Trade-off accepted. The chain now grows with authentication traffic rather than only with decisions: someone probing accounts writes a row per attempt, and verify_chain walks every row. That is a real cost and it is bounded deliberately — a replayed dead token records its expiry once and says nothing on later requests, so an unauthenticated caller cannot write a row per request. Verification is an operation for a dispute, not for a request path, and the docstring says so.
The rule that comes with it. Never write a secret into this log. A failed login records the address that was tried and never the password tried against it, not even hashed, because an append-only table cannot be redacted afterwards. An input that does not parse as an address is stored as a marker instead, since a password typed into the email box is common and would otherwise be preserved for ever.
Title corrected 2026-08-10. It read “Two digest schemes” and there are three: DIGEST_V1, V2 and the V3 ADR-53 added for a row carrying a reversal pointer. The body below was right and only the title had gone stale, which is the worse half — a reader scanning titles takes the count from the title and never reaches the body.
Decision. Attribution — actor_user_id, actor_kind, session_id, ip — is hashed inside the digest, not stored beside it. That changed the hashed field set, so every row states which scheme produced it in a digest_version column, defaulting to 1 in the model and in the database. Scheme 1 hashes the original ten fields and is frozen. Scheme 2 hashes those ten plus the four. record_event always writes 2; nothing can append a scheme-1 row from application code. verify_chain recomputes each row under the scheme that row names, so a chain that starts under 1 and continues under 2 verifies end to end. The migration adds the columns and backfills the version; it touches no hashed field and there is no rehash step.
Why the old rows must still verify. Their hashes are the evidence. A log rewritten by the process that verifies it proves nothing — a chain that recomputes cleanly after its verifier edited every row is a chain that says whatever that process last said. And a chain that cannot verify its own history is worse than no chain: it reports tampering on records nobody touched, and after the second false alarm nobody reads it.
Principle 27, pointed the other way. docs/best-practices.html says a derived corpus migrates all at once or not at all, because a half-migrated corpus answers every query plausibly and wrongly. Here the derived data is the evidence itself, so the same principle gives the opposite instruction: not at all. Stating the inversion is the point — the principle is about never leaving two truths in one store, and versioning the row is how you keep one truth when the rule that produced it changed.
Alternatives considered. (a) Re-hash every existing row under scheme 2 — one scheme to maintain, and it destroys the only thing the log was for; (b) keep attribution in columns outside the digest — no migration at all, and then anyone with write access could rename the actor while the chain still verified, which is the same as having no attribution; (c) a second table for scheme-2 rows — leaves two chains and an unprovable join between them; (d) put the scheme number in the hashed payload — unnecessary, because the two field sets already differ, so a row whose version is flipped in either direction recomputes to a different hash and is caught.
Trade-off accepted. Two hash functions to maintain for ever, and the scheme-1 function can never be edited again — not a rename, not a separator. A test holds a frozen expected hash for a fixed input so an edit fails a test rather than silently invalidating the past. An unknown scheme number raises rather than falling back to a known one: absence is denial, and a fallback there would let anyone bypass the chain by writing a version nobody has implemented.
Gap. migrate_audit_schema is written and tested and nothing on the run path calls it. A database created today gets the columns from the model; one written before attribution keeps its old shape until somebody runs the migration by hand.
Decision. app/auth/policy.py::can_approve runs four gates: the user exists in this company and is active, they hold action.approve, the id resolves to a claim or an escalation in this company, and no audit row shows them acting on that claim, on the change beneath it, or on any escalation raised against it. The fourth gate is the control. Authorship is read from the hash chain; there is no authored_by column and there will not be one.
Why a permission alone cannot do this. A permission answers "may this kind of person approve", never "may this person approve this". Separating analyst from obligation owner is necessary and not sufficient: an obligation owner who worked an escalation on a claim is the same person the approval exists to check, and their role still says approve. The seeded corpus makes the point — two people in it hold a title that reads as both, which is why the seed assigns one role each and says so in a comment rather than leaving the demo to ship with its own control switched off.
Why the chain and not a column. A column recording who authored a claim is a second record of who did what, kept beside the first and free to disagree with it — and the one that disagrees is discovered during the dispute it was supposed to settle. The chain already records every decision with an actor, it is hash-linked so an entry cannot be edited to move the blame (ADR-17), and it is the artefact a regulator would be shown. Reads are not audited, only decisions, so any user-attributed row against the claim is somebody having acted on it.
Alternatives considered. (a) Role check only — one query, no chain read, and it permits exactly the self-approval this product's pitch says it prevents; (b) an authored_by column on the claim — cheap and fast, and it is a second truth; (c) a workflow engine with explicit assignment and separation rules — the production answer, weeks of work, and it needs the org chart that tasks.html has already cut; (d) a static "cannot approve what you touched" list maintained by hand — unmaintainable the first time a new action code appears.
Trade-offs accepted, all four. The check sees only rows that name the claim, the change or an escalation on it, so an analyst who shaped a claim through a steer directive, a knowledge item or a conversation leaves nothing this finds and will not be refused. Approvals, rejections, denials and waivers are excluded from what counts as authorship, or an approver would become an author by approving and a refusal would become the evidence that refuses again. Rows written before attribution existed are matched on the actor display string, which is weaker than an identity — it widens the net, and widening is safe in exactly one direction because this function can only refuse, never allow. And require() refuses to gate on action.approve at all rather than leaving a shorter call that looks like it would do, because the shortcut is how the control gets retired by accident.
Visible, and the gap that means it is not visible yet. No view calls can_approve, require or has. The review screen and the review centre still take a reviewer's name typed into a form. The control is enforced in the module and proved in tests/test_policy.py; on the running screens it is not enforced at all, and docs/security.html says so in those words rather than implying the product already carries it.
Decision. VERBATIM_APPROVAL_MODE takes two values. Unset, empty or SEGREGATED is the product. DEMO_SELF_APPROVAL lets one operator approve their own work — and only that: the fourth gate still runs and still reaches its verdict, the reason returned names the downgrade and states what SEGREGATED would have decided, and an approval.waived row goes into the chain. The other three gates are untouched by the mode. An unrecognised value raises at import and stops the process.
Why a downgrade exists at all. A demonstration is one person with seven seeded accounts and about four minutes. Refusing self-approval outright means signing out, signing in as somebody else, and losing the thread — so the honest choice is between a demo that cannot show the approval step and a mode that shows it while saying it is not the real thing.
Why it announces itself, in three places. approval.waived is a different action code from action.approved on purpose: read a year later, a waived separation must not look like a clean approval, which is the one reading a log must never allow. The verdict text carries the refusal that would have happened, so the record holds the counterfactual and not just the outcome. And an unrecognised value is fatal rather than treated as the safe default, because a control that a typo can move — in either direction — is not a control, and the operator has to know which of the two they are running.
Alternatives considered. (a) No downgrade — cleanest, and the demo then needs two browsers and a second pair of hands; (b) a flag that skips the check — one line, and it leaves a log showing an ordinary approval at the moment the control was off, which is worse than no control because it is a false record; (c) seed a second operator and sign in twice during the demo — honest, costs the demo its pace, and it stays the fallback if the waiver reads badly at the panel; (d) waive at the call site instead of in the policy — puts the decision in the code that benefits from it.
Trade-off accepted. The product ships with a documented way to switch off the control its pitch rests on. The mitigations are that the safe value is what you get for doing nothing, a typo stops the process, the waiver is audited under its own code, and the reason states the refusal it replaced. What is not mitigated: nothing rate-limits the check, so a demo that clicks approve twenty times writes twenty waiver rows.
Gap. approval_mode() exists so a banner can say which mode is running, and no template reads it. .env.example does not list the variable either, so today the only place an operator learns the mode exists is this ADR and docs/security.html.
Decision. Signing in writes a LoginSession row and returns a 256-bit random token once. The database stores SHA-256 of the token and never the token. The lifetime is twelve hours, written at creation and never extended. Every request resolves the cookie against that row, and the row can be revoked — by signing out, by an administrator ending every session a person holds, or by the account ceasing to be active. The cookie is HttpOnly, SameSite=Lax and Secure, with the Secure flag coming off by itself only for plain http to this machine, and VERBATIM_COOKIE_SECURE overriding in either direction.
Why not a signed cookie. A JWT or a signed session cookie needs no table and no read per request, which is the whole argument for it. It also cannot be withdrawn before it expires. "Suspend this account now" then means "in up to twelve hours", and that is the one thing an account-suspension control is for. Here suspending the account stops the sessions it already holds at the next request, because resolution reads the user through the same scoped chokepoint and an inactive user resolves to nothing.
Why a plain SHA-256 of the token and not a KDF. The token is 256 bits this process generated at random. There is nothing to guess, so a slow hash would only tax every request. The same function on a password would be a defect, and the two are kept in one module with the reasoning written on both.
Alternatives considered. (a) A signed JWT — stateless, and unrevocable; (b) Starlette's signed session cookie — same revocation problem, plus application state living in the browser; (c) a server-side session with a longer lifetime and a sliding window — friendlier, and it removes the ceiling on a stolen token; (d) binding the session to the caller's address — tempting, and mobile networks move people between addresses far more often than thieves steal cookies, so the address is recorded as evidence and never used as a decision.
Trade-off accepted. One database read per request on SQLite, which is a real cost. It is bounded: last_seen_at moves at most once a minute rather than on every request, the resolution runs in a worker thread rather than on the event loop, and a request with no cookie does no read at all. The Secure-off-on-loopback rule is a genuine downgrade, narrow enough that it needs both plain http and this machine, and the login page prints on the page when it has applied.
Decision. Three system roles — analyst, obligation owner, admin — and a closed vocabulary of thirteen permission codes. SYSTEM_ROLE_PERMISSIONS in app/state/identity.py is the one place the mapping exists, and ensure_system_roles() sets each role's permissions to exactly that grid on every call, so a code dropped from the grid is dropped from the role rather than surviving in a database nobody re-reads. A grant is a row in user_roles, scoped to a company; revoking closes the grant and leaves it readable. Permission and RolePermission carry no company_id because they hold vocabulary, not tenant facts, and every path from that vocabulary to a person's authority runs through Role, UserRole and User, all filtered on the same company on every read. ADR-015 settled how a password is stored; this is the separate question of what a person may then do, and it has different alternatives.
Why a grid, and why this shape. The control docs/security.html rests on is that the analyst who interprets a change never approves the action that follows. Written as prose that is an assertion. Written as a grid it is a set of rows a reviewer can read, and the control is the row that is absent: the analyst has no action.approve and no action.reject. The obligation owner is deliberately narrow — it approves, rejects, and reads the evidence needed to do that, and it does not propose, resolve escalations or issue steers, because an approver who can shape what reaches them is not a second pair of eyes. The admin does not approve anything, so the routine account-management role is not also a way into the decision path.
Alternatives considered. (a) Boolean columns on the user row — no place to record why a person holds a power, and every new power is a migration; (b) free-string role names checked at each call site — a typo becomes a silent grant or a silent denial, and there is no single place to read the control; (c) a policy library such as casbin or oso — the right answer at ten roles and a dependency on the reviewer's critical path at three, which is the ADR-015 argument again; (d) the grid as configuration in the database, editable by an admin screen — then the control can be changed by anyone with write access and the change never appears in a diff.
Trade-off accepted. Entitlements move only by a code change and a deploy, never by an admin at a screen. That is slower and it is the point: a control editable without a code review is a control nobody reviews. And a stated hole, not a mitigated one — an admin holds user.manage, so an admin can grant themselves the obligation owner role and approve their own work. The chain records the grant, which makes it visible afterwards; nothing here prevents it. Preventing it needs a second approver on privilege changes, which is not built.
Decision. Any surface that synthesises, counts or produces something a person carries out of the building — the collective take, the analytics panel, every deliverable — must say what it rests on and what it left out, in the same type at the same size. coverage_for_project() in app/state/review.py computes both from the rows on every call. compose_take() writes findings_included and findings_withheld together, from coverage taken at that moment, and refuses to write a take that would hide its exclusions; the schema makes both columns non-optional so the rule is storage rather than template discipline.
Why this is a decision and not a style note. ADR-003 stops one claim asserting past its evidence. It says nothing about a set of claims, and the set is what gets read. A take that says "rests on nine findings" and a take that says "rests on nine, and excludes four that failed verification" are different documents, and only the second one can be handed to a regulator. Without this rule the product would be honest claim by claim and dishonest in aggregate — which is the worse failure, because the aggregate is the artefact and the individual claim is the audit.
Why equal weight is part of the rule. A withheld count set in small grey type under a headline number is a footnote, and a footnote is a thing nobody reads. The exclusion has to be as easy to see as the inclusion or the rule has been satisfied in the code and defeated on the page.
Alternatives considered. (a) Show the verified set, with exclusions behind a drill-down — the conventional design, and it makes the honest number the one you have to go looking for; (b) store the counts once and render the stored pair everywhere — one query instead of many, and a stored count is a second copy of the truth that nothing recomputes, so on the day it disagrees with the rows it counts, the count is what people believe; (c) a banner when anything was withheld — binary, so it says nothing about how much, and a banner shown every time stops being seen; (d) leave it to whoever writes each template — which is how the rule quietly stops applying to the next surface somebody adds.
Trade-off accepted. Coverage is re-derived on every render, which SQLite pays for on every page. And the product's headline numbers are permanently smaller than a competitor's, because ours subtract what did not verify — a real cost in a demo, and the whole argument for the product. One exception is deliberate: the counts stored on a take are a snapshot of what coverage said when the take was composed, because a take is a statement about that moment and a take whose numbers moved after it was written is a different lie.
Decision. Every router under app/web/views owns a distinct path, assigned rather than chosen: the project list takes the landing screen at /, proceedings moves to /proceedings, the review centre keeps /review, the escalation queue moves to /escalations. Three tests derive their answer from app.routes and from pkgutil over the views package — every router defined must be mounted, no two routers may claim one path, and every nav link the masthead renders must resolve.
Why this is written down at all. It is not a naming preference. It was the cause of an outage: projects.py and proceedings.py both claimed /, and review_centre.py and review.py both claimed /review. Neither pair could be included, so 2,000 lines and nine routes stayed unmounted while base.html linked to /projects from every page — a 404 on the first thing a reviewer clicks, on every screen. Parallel agents each assumed they would own a path, and nothing in the repository disagreed with either of them.
Why the guard is derived. The wiring test that existed asserted five hand-written paths, and it passed throughout — it could not see the two modules nobody had added to its list. A list a person maintains cannot catch a module that person forgot, which is the same defect as the outage one level up. Deriving the expected set from the package and from the mounted application is the only version of this test that fails when the next module is missed.
Alternatives considered. (a) Give the colliding routers a prefix at include time in main.py — one line, and the URL a route answers on then lives in a different file from the route, so reading the module no longer tells you where it is; (b) merge each colliding pair into one module — removes the collision and produces a 2,000-line view file; (c) add the two missing entries to the hand-written list — fixes the instance and leaves the class, and the class had already cost nine routes; (d) a route-registry module every view imports — one place to read, and a second thing to keep in step with the routers themselves.
Trade-off accepted. Paths are now allocated, so a new view module needs a home decided before it is written, which is friction on exactly the work that felt free before. And two URLs moved, so any link taken from an earlier commit is dead. Both are cheap against a nav bar that 404s.
What forced the choice. The requirements this submission is judged against ask the product to support "expert review, annotation, and override — not replace the expert". Override has two readings and they are opposites. One: a person may record that they disagree with a withholding, and that disagreement is part of the record. Two: a person may push a claim into the world whose citation does not verify. The word does not say which, so the product has to, and it has to say it out loud rather than pick quietly and hope the panel reads it the same way.
Decision. The first reading, and only the first. A reviewer opens a withheld item, sees the reason and the mismatch, and can approve the refusal or reject it — both audited, both attributed. Neither publishes anything. Resolving an escalation cannot make an unverified citation verify. There is one thing in this system that turns a withheld claim into an asserted one, and it is the source text agreeing with the quote.
How the code holds the line, in three ways rather than one. There is no function to do it: no override, no publish, no unwithhold anywhere in app/. There is no audit action code for it, and everything that changes state writes to the chain, so a thing the vocabulary has no verb for cannot be recorded and therefore must not happen. And the type refuses to carry it — the withheld dataclass has no statement field at all, so a template cannot render the sentence even by mistake; the test asserts the fabricated text is absent from the response body, not greyed out. Verification also runs at read time and never from a stored column, so there is no cached verified flag for anyone to flip.
Alternatives considered. (a) A force-publish path behind a permission — the literal reading of the requirement, and it hands the product's only guarantee to whoever holds the permission; the first time it is used under deadline pressure the product becomes every other tool; (b) publish with a warning banner — the same thing with a decoration, and a banner shown on a claim is read as a claim; (c) let an approver's rejection of the refusal mark the claim verified — subtle, and it makes a human's opinion an input to a mechanical check, which is exactly the confusion the check exists to remove; (d) say nothing and let the panel read "override" whichever way they like — the cheapest option and the one that fails on contact with a direct question.
Cost accepted, and it is a real one. If the panel means the second reading, this submission does not meet the requirement and the answer is a defence rather than a demonstration. That is the trade taken deliberately. The second cost is nearer: there is no annotation today. A reviewer cannot attach a free-text note to a claim or a passage. Escalation reasons and steer directives are the closest thing and neither is annotation, so of the three words in the requirement the product serves review and refuses override and simply has not built the middle one.
What forced the choice. PDF extraction breaks words across lines in two ways. Some extractors write main-\ntain; others write a soft hyphen and a newline, main<U+00AD>\ntain. Normalization deletes soft hyphens everywhere, so the second form leaves a bare line break between two half-words, and the break collapses to a space: main tain. That is not a word anyone wrote, and the citation carrying it will match nothing.
Decision. Leave it. The soft hyphen is deleted unconditionally, the break becomes a space, the citation fails to verify, and the claim goes to review with the reason. The module's docstring states the case, and a test pins it so nobody rediscovers it as a bug and helpfully fixes it.
Why the wrong text is the right answer here. Consuming the break instead would give maintain, which is very probably what the filing said. Probably. It is still a guess, and it is a guess in the one direction this product cannot afford: it manufactures a match, so a claim asserts itself on text the system invented. The current behaviour also produces wrong text, but it produces wrong text that matches nothing — the error surfaces as a refusal a person looks at, rather than as a confident quotation nobody checks. ADR-03 fails toward review; this is that rule applied to a case where failing toward review costs a real citation.
Alternatives considered. (a) Treat a soft hyphen before a line break the way a real hyphen is treated and consume the break — recovers the word, and guesses, and fails open; (b) join the two fragments only when the result is a word in a dictionary — a dictionary of regulatory and utility terms does not exist, and a lookup that succeeds is still a guess with a reference; (c) fold the case earlier by asking the extractor to keep the hyphen — no control over the extractor, and the corpus arrives as text; (d) narrow the docstring so it stops mentioning the case — the failure this file has already had once, and it is worse than the bug, since it retires the reader's ability to see it.
Cost accepted. Any filing whose extractor emits soft hyphens at line breaks loses every citation that crosses one, and those citations land in a review queue as work. On a corpus of scanned or heavily paginated documents that could be a lot of work, and there is no measurement of how much, because the three filings in data/ are plain ASCII and do not exercise it. This is a decision taken on principle with the volume unknown, and that is stated rather than glossed.
What forced the choice. The normalizer applied NFKC to everything while its docstring promised that digits are never folded. Both cannot be true. NFKC turns 20 followed by a superscript two into 202, and turns the vulgar fraction one-half into 1/2 — a footnote marker and a tariff fraction changing value inside the one function whose whole job is to preserve value.
First repair, and why it was not enough. The first fix skipped folding for characters tagged <super>, <sub> and <fraction>, and the docstring called those "the three tags where folding rewrites a number". That sentence was false by 210 characters. A circled digit is a footnote and enumeration marker in exactly the way a superscript is, and it is tagged <circle>, so 20 beside a circled one still folded to 201.
The fix that was suggested, and was not taken. The brief handed to the agent proposed adding <circle> and <font> to the protected list, leaving <compat> open, and rewriting the universal sentence in the docstring to describe the looser code — on the stated ground that protecting <compat> would cost the trade mark sign and the CJK compatibility forms. The agent checked both premises and both were wrong. The trade mark sign decomposes as <super> and was already protected, so it was never a cost. And the CJK forms only break if <compat> is closed by tag; U+3392 folds to MHz, which starts with a letter, so closing it by value leaves it alone.
Decision. Keep the three tags and add a value test: protect any character whose NFKC folding begins with a decimal digit. The tags cover position against a number; the value test covers everything else by what the folding does rather than by which tag the character happens to wear, so a future Unicode release adding markers is covered without anyone editing a list. Measured over the whole code space: 150 characters newly protected — 99 tagged <compat>, of which U+2488 DIGIT ONE FULL STOP is the plainest, and 51 tagged <circle> — and nothing lost. Strictly additive.
What stays unprotected, and why that is a judgement rather than a proof. A character that is itself a decimal digit (category Nd) still folds. A full-width two and a mathematical bold two are the digit two in another face, and folding keeps the value they already carried, so 20 MW still matches 20 MW. Protecting them would send a styling difference to review and would contradict the full-width folding the module does on purpose, since Unicode calls both Nd — one question would then have two answers, which a panel finds in ten seconds. The residual is real: normalize("20𝟏") with a mathematical bold one still returns 201. If a filing ever uses a bold digit as a marker beside a plain number, it folds wrongly. None has been seen, and there is no way to tell a bold digit used as a marker from one used as a digit by looking at the characters alone. Second residual: the value test guards the left edge only. Twelve squared-unit glyphs fold to something ending in a digit — U+33A0 to cm2 — so a number to their right could close up. Those are the units folded deliberately, and cm2 run against a following number is not text a filing contains, which is a belief and not a measurement.
Alternatives considered. (a) Narrow the docstring to match the looser code — the suggested route, rejected because the honest version of that sentence would have had to say the module rewrites values, in the module that exists to preserve them; (b) extend the tag list to <circle> and <font> only — closes the two visible classes and leaves 99 <compat> characters open, and it keeps the wrong axis, so the next marker Unicode adds is another bug; (c) protect every compatibility form — folds nothing, and ligatures, no-break spaces and full-width text then defeat exact matching, which is what normalization is for; (d) drop NFKC entirely and hand-list the folds — a list a person maintains, which is the defect class ADR-23 is about.
Cost accepted. The predicate costs more per non-ASCII base character than a tag lookup — about 197 nanoseconds against 126 — and is cached to hold that down. And the claim is barely exercised by the corpus, since all three filings in data/ are pure ASCII and take the fast path, so this protection is proved by a sweep of the code space and by tests, not by the demo.
What forced the choice. A citation carried a version_id that nothing checked. The verifier took a quote and some source text handed to it by the caller, and trusted the caller to have paired them. That works until two versions of a proceeding share a sentence — which is the normal case, since filings repeat their boilerplate — and then a quote from version 2 verifies happily against version 1 at the same offsets, and the claim cites a document it was never in.
Decision. verify_citation_for_version makes the pairing itself. It loads the version the citation names, through the tenant chokepoint, and refuses a version this company cannot read rather than answering about it — which covers both a version that does not exist and one belonging to somebody else, without the refusal telling the caller which. It then re-hashes the stored text and compares it against the source_sha256 recorded at ingestion, so a version whose text has been edited since it was ingested is refused rather than verified against whatever the row holds today.
Why the hash and not just the offsets. Offsets are only meaningful against the bytes they were taken from. Edit twenty characters near the top of a version and every offset below it points somewhere else, and each one still lands on real text, so each one still verifies against something. The hash is what turns "these offsets still resolve" into "these offsets still mean what they meant".
The general rule behind it. A verified fact has a shelf life. This came out of the outreach work rather than the code: an address verified honestly against a 2021 certificate of service bounced in 2026, because the firm had changed domain and nothing announced it. Both readings were honest; only one worked. That is the argument for scoping a citation to a proceeding version rather than to a proceeding, and for treating any label of the form "verified" as carrying a date.
Alternatives considered. (a) Keep the caller-supplied pairing and document the rule — a rule a caller must remember is a rule that fails on the first new call site, which is how the unscoped read in the diff engine happened; (b) store a verified boolean on the claim at write time — one read instead of a check, and it is a cached answer to a question whose inputs can change, so it becomes a lie the moment the source moves; (c) compare a fingerprint of the quoted span only, not the whole version — cheaper, and it cannot see an edit above the span that moved every offset; (d) forbid editing a version's text — the right answer, and SQLite offers no way to enforce it, so it would be another application promise rather than a guarantee.
Cost accepted. The convenience entry point finds its version by reading every version the company owns and comparing ids, which is a linear scan that a real corpus will make expensive; the indexed lookup is a change to queries.py that has not been made. And the whole source text is hashed on every verification rather than a cached digest being trusted — deliberately, since a cached digest is the same defect as a cached verdict, but it is work paid on every read.
What forced the choice, and why this is a second entry rather than a line in ADR-07. ADR-07 chose the stack and gave SQLite one sentence of cost: it "will not carry real multi-tenant volume". Volume is the least of it. The question was then asked twice — "should we switch to Postgres?" and, after the deadline moved from about twelve hours to forty, "so why not Postgres?" — with an instruction to log the judgement rather than answer it in conversation. This is that log.
Decision. SQLite stays the default so a clean checkout needs no service. Postgres is supported through VERBATIM_DATABASE_URL when it is needed. The trigger for moving is not a date: a second tenant, or any real customer data. Whichever comes first.
Why not tonight, in the terms that actually decide it. With SQLite the database is a file and Python ships the driver. With Postgres the reviewer needs Postgres running — Docker, or Homebrew, or a cloud instance — and each has a way to fail on a machine nobody has seen: wrong Docker version, port 5432 taken, no image for the architecture, a corporate laptop blocking the daemon. A wrong materiality call is embarrassing and gets explained. make run failing costs the whole submission, because nothing after it gets read. Postgres is the better database; SQLite is more likely to start on a stranger's laptop.
What SQLite genuinely cannot do. These are the two that matter and both are load-bearing. Row-level security. Tenant isolation is a chokepoint in app/state/queries.py enforced by tests, which means it is an application-layer promise: one function reading a table directly is a full breach. That is not hypothetical — it happened, in passage_refs in the diff engine, and 56 passing tests did not see it because no test asked. Postgres RLS makes isolation an engine guarantee a bug in application code cannot route around. Revoking write on the audit table. SQLite has no users, no roles and no GRANT, so "the application's database user holds no UPDATE or DELETE on the audit table" is a sentence that can only be true of a Postgres deployment. It was written in docs/security.html as though it were true here, an agent ran REVOKE against SQLite and got a syntax error, and the sentence is now corrected in place. What stands in for it is a before_flush guard, which stops a mistake, and the hash chain, which detects a rewrite made around the application. Neither is the same thing as not having the permission.
Three more the engine does not give us. Concurrency: nothing serialises writers at the engine level, and double-approval, a time-of-check gap on a revoked permission, and two audit rows claiming one sequence number are all reachable on SQLite with SQLAlchemy sessions. Timezone-aware timestamps: SQLite ignores DateTime(timezone=True), which is why ADR-30 exists. Vocabulary constraints: there are no CHECK constraints on the status and outcome columns, so a misspelt value writes a row that no query for that value will ever return.
Alternatives considered. (a) Move to Postgres now, with RLS, for both local and hosted — the better database, and it puts a service on the reviewer's critical path, which is the one failure that loses everything; (b) Postgres for the hosted instance and SQLite locally, done immediately — roughly four to six hours, and it was scheduled behind the screens because the screens are what gets judged and the engine moves that needle not at all; (c) SQLite with the limits unstated — cheapest, and it is the failure this product exists to prevent, applied to our own documentation; (d) an ORM-agnostic abstraction layer so the choice can be deferred — SQLAlchemy already is that layer, and a second one would be work with no buyer.
Migration cost, so nobody thinks it is free. SQLAlchemy carries the schema across. What does not come free: RLS policies written and tested per table, a session-level tenant setting wired into the connection, a compose service and a connection string for every environment, the partial unique index checked on a dialect nobody here has run it against, and a re-run of the concurrency questions that SQLite made unanswerable. Call it a week-one job, not an evening.
Cost accepted. Every guarantee in the paragraph above stays a promise enforced by application code and tests until the trigger fires. The mitigation is that the promise is written down, has a named reversal condition, and has already been shown to fail once in public rather than being asserted as sound.
What forced the choice. ADR-28 explains why isolation cannot be an engine guarantee here. That leaves the question of where in the application it lives, and there are only two shapes: a WHERE company_id = ? at every query site, or one module every scoped read passes through.
Decision. One module — app/state/queries.py — and a read with no company refuses rather than answering. Child rows do not carry their own company_id: Passage takes its tenancy from the DocumentVersion that owns it through a join, and the workflow graph tables take theirs from the workflow row. Where a function must take the company, it takes it keyword-only with no default, so it cannot be omitted by accident or slid into the wrong positional slot.
Why no second column. A denormalised company_id on a child row is a second copy of a fact, with two writers, free to drift — and the day it drifts, the copy is what the query believes. The join keeps one truth and puts the enforcement in one auditable place. The exception is deliberate and named: the workflow step-run table does carry the company, because "what is waiting on me" is the most frequent read in the product and forcing it through two joins is where a missing scope creeps back in. That is a judgement about read patterns rather than a principle, and a reviewer could fairly call it inconsistent.
Alternatives considered. (a) Scope at every call site — no machinery, and it relies on every future contributor remembering, which is how tenant isolation fails in practice and fails silently; (b) a company_id on every table — one filter everywhere and no joins, at the price of a second truth per table; (c) a SQLAlchemy global filter or session-level event that scopes every query automatically — closer to RLS and genuinely attractive, rejected because a filter that applies invisibly is a filter nobody notices has stopped applying, and because unscoped reads are legitimate in the seed and in migrations; (d) wait for Postgres RLS — the right answer and not available under this stack.
Cost accepted, and it has already been paid once. A chokepoint only works if everything goes through it. passage_refs in the diff engine read the passages table directly and knowing a version id was enough to read another tenant's source text — and version ids are short and guessable, which made that reachable rather than theoretical. It was found by adversarial review, not by a failing test. So the honest statement of this decision is that it is the strongest option available on this engine and that its failure mode is a single function, which is exactly why ADR-28 names its own reversal condition.
What forced the choice. Audit timestamps came back without a timezone. SQLite ignores DateTime(timezone=True) — it accepts the declaration and hands back a naive datetime. A naive timestamp in an audit record cannot be compared across systems, and "what did we know, and when" is the only question that table exists to answer.
Decision. A UtcDateTime type decorator stores every timestamp as ISO-8601 UTC text and returns it aware, so the guarantee lives in the column type rather than in whoever wrote the write. Functions that take a moment take it as now: datetime | None and pass it through _require_aware, which raises on a naive value rather than assuming it meant UTC.
Why raising and not assuming. Assuming a naive datetime is UTC is right most of the time and silent when it is wrong, and the case where it is wrong is a machine in another timezone writing an audit row that will later be read as evidence. An exception at the call site costs a developer a minute. A misdated audit row costs the dispute.
Alternatives considered. (a) Trust DateTime(timezone=True) — what was there, and it is a declaration this engine ignores; (b) store epoch integers — unambiguous, and unreadable in the database file, which matters because the audit table is meant to be inspectable by a person; (c) normalise naive values to UTC on write — no exception, no friction, and it converts a caller's bug into a stored fact; (d) store local time with an offset column — two fields to keep in step and arithmetic at every read.
Cost accepted. Timestamps are text, so date arithmetic in SQL is string comparison — correct for ISO-8601 UTC and it will not stay correct if anyone stores a different format in the same column. And every call site that builds a datetime must remember the timezone or take an exception; that friction is the feature, and it is still friction.
What forced the choice. Company context — what an obligation means here, who owns it, which interpretation the business settled on — changes. If a row is edited in place, the question "what did we believe in March, when we filed that comment" stops having an answer, and that is the question a regulator asks.
Decision. A knowledge item is never updated. A new item is written and the old one is marked superseded, with the chain readable backwards from the live one. Nothing is deleted. The same shape as the audit chain and for the same reason.
Alternatives considered. (a) Edit in place with an updated_at — one row per fact, and it destroys the history that makes the store worth having; (b) edit in place and write the old value into the audit log — the history survives in a place nobody queries for it, and the audit log is then carrying two jobs; (c) full row versioning with a version integer — the same thing with a number, and a number labels an edit rather than preventing one; (d) soft-delete with a flag — the flag becomes a filter everyone must remember, which is the defect ADR-29 is about.
Cost accepted. The table only grows, and every read of "current" is a filter rather than a lookup. Correcting a typo costs a new row, so the history contains noise as well as belief. Accepted because the alternative is a store that answers today's question and forgets yesterday's — and the MRD calls this store the compounding advantage, which it only is if it accumulates rather than overwrites.
What forced the choice. Open question 3 in this file asked whether audit history is an event log or a set of snapshots. The event log won — every state change is an append, and a correction is a new row that supersedes an old one rather than an edit. That needs a way for a row to say which earlier row it reverses, so AuditEvent.reverts_event_id was added.
Decision. The column is added and the constant DIGEST_V3 = 3 is reserved, and nothing writes a version-3 row. CURRENT_DIGEST_VERSION stays 2. Until the version-3 digest function exists, the reversal column is storage the hash chain does not cover, and no product surface may present it as evidence.
Why it cannot ship half-defended. Left outside the digest, anyone with write access could re-point a reversal at a different event and the chain would still verify. A field that looks like part of a tamper-evident record and is not is worse than no field, because the whole value of the chain is that a reader need not ask which columns it covers. ADR-17 already fixed the pattern for this: a new field set means a new scheme number, the old scheme stays frozen, and no row is re-hashed.
Alternatives considered. (a) Ship the column and mention the gap in a docstring — how a column becomes evidence by habit; (b) add the column to scheme 2 — changes what scheme 2 hashes, so every existing row stops verifying, which is the exact failure ADR-17 exists to prevent; (c) hold the column back until the digest is written — cleaner, and it leaves a migration to do later on a table where migrations are expensive; (d) record reversals in a separate table — a second log, which ADR-16 refused for good reasons.
Cost accepted. There is now a column in the audit table that the chain does not defend, and the only thing keeping it honest is this entry and the constant that nothing sets. The work to close it is known and small — a _digest_v3 over scheme 2's fields plus the reversal, the constant moved into audit.py so one scheme number does not have two spellings, and CURRENT_DIGEST_VERSION raised. It is not done.
What forced the choice. Of the five requirement areas this submission is judged against, document intelligence is the weakest — understanding the structure of a filing: exhibits, testimony, cross-references, precedent chains. Nothing serves it today but section-heading parsing in app/ingestion/ingest.py, which recognises SECTION 4. STUDY PROCESS and 4.4 Study Timelines and stops. A regulatory corpus is natively a graph: an order modifies a tariff schedule established by an earlier order, cited in testimony that supersedes an exhibit. The questions an analyst asks are multi-hop and of unknown depth — what else does this change reach, what is this rule's lineage, if this paragraph moves what breaks. SQL joins do depth two well and unknown depth badly.
Decision. Model the relationships as typed, directional edges over documents, versions, orders, tariffs, exhibits and obligations. Five edge types: cites, modifies, supersedes, consolidates, implements. Traverse with recursive common table expressions in SQLite.
The constraint that makes it safe, and it is the load-bearing part of this entry. Every edge carries a citation into the source text and is verified by the same verifier that gates claims. An edge whose citation does not verify is withheld. A traversal that meets a withheld edge must report that the chain stops there, and why, rather than returning a shorter chain that looks complete. That is ADR-03 and absence-is-denial applied to a graph: an unverified edge is a confident guess wearing a schema, and a lineage that quietly drops a hop is worse than no lineage, because it reads as an answer.
Alternatives considered. (a) A graph database such as Neo4j — a dependency with no earned benefit at this scale, and it breaks the clone-and-run property a reviewer depends on, since make run must not need a service; typed edges in SQLite with recursive CTEs cover unknown depth; (b) graphing the relationships that are already relational — change to obligation to owner to project is two joins, and a graph there adds a layer and no capability, which is technology for its own sake; (c) a graph as a retrieval layer — it indexes relationships and verifies nothing, so using it to answer "what does the source say" routes around the gate; (d) storing edges unverified and trusting extraction — rejected for the same reason a claim cannot assert itself on an unchecked citation.
Cost accepted. It is meaningless on the current corpus: three versions of one synthetic proceeding is a graph with three nodes, so this decision is coupled to ingesting a second, structurally different docket carrying real exhibits and testimony, and it does not stand alone. Edge extraction needs the model path, so it inherits the model's failure modes — the verifier is the whole of what makes that acceptable. And withheld edges make traversals incomplete by design, so every consumer — screen, deliverable, synthesis — has to render "the chain stops here, and why" at the same weight as the chain itself, which extends ADR-22 further than it currently reaches.
Status: accepted, not built. No table, no edge type, no traversal exists. Marked the same way as ADR-08, whose implementation is likewise absent. The gap between a recorded decision and shipped code is the thing this file has been told to stop hiding.
What forced the choice. The approval route has deadlines — a step gets a number of hours, and when they run out something happens. Every property worth testing is on the far side of a deadline: does a reminder fire once or twice, does an escalation reassign correctly, does a bypass record itself honestly. A test that has to wait out a real clock either sleeps for hours or tests nothing.
Decision. The engine takes now as a parameter and never calls the clock in its interior. A default at the boundary is allowed; a datetime.now() deep inside a decision is not. This generalises a pattern already load-bearing across app/auth/sessions.py, app/state/projects.py and app/state/identity.py, where every lifetime check already takes now: datetime | None = None and every value passes _require_aware (ADR-30). The spec file pins a frozen constant as its only source of time.
The second time decision that comes with it. A step run's due_at is written when the step is assigned, from the step's hours at that moment, and never recomputed. It is a copy of a derived value and it earns its place: the deadline the person was actually given is the deadline they should be held to. An engine that recomputed it at read time would let an admin move a deadline somebody had already missed, retroactively.
Alternatives considered. Named honestly: the record contains two rejections and no debate about the rest. Rejected in writing were sleeping in tests and calling the clock inside the engine. A time-freezing library such as freezegun, monkeypatching the clock, and a global clock object were never weighed and never rejected — the pattern was inherited from the modules above rather than chosen against alternatives, and that is said here rather than dressed up as an argument that happened. Against freezegun specifically, the argument that would have been made is the ADR-15 one: a dependency on the reviewer's critical path to test something a parameter already solves.
Cost accepted. There is no scheduler in this product. Nothing fires the overdue sweep. The engine exposes a function that advances every overdue run, and something outside — unbuilt — has to call it, which is a real gap named rather than hidden. Because nothing schedules it, idempotency becomes the engine's problem: running the sweep twice must not double-remind or double-bypass, which forces a reminder counter into the row and a test that runs it twice.
Implementation — and this sentence was false when it was written, which is worth more than the correction. It read: "the schema exists in app/state/models.py and eighteen tests pin its shape in tests/test_schema.py". This ADR was written at 295c40d, 09:58. At that commit tests/test_schema.py did not exist and models.py held no workflow class at all; both arrived at 97daf02 just under three hours later. So the decision log asserted a guard that was not there. It is true today — the file exists and carries exactly eighteen tests — and that is precisely what makes it the dangerous kind of wrong: a claim that reads as verified because it happens to have come true. In a decision log for a product whose whole argument is that a claim must not assert itself ahead of its evidence, this is the failure in miniature, and it is left visible rather than tidied away. What is true now: the schema is in app/state/models.py and eighteen tests pin its shape in tests/test_schema.py. app/state/workflow.py does not exist.
What forced the choice. The route allows a step to time out, and one of the three things a timeout may do is let the item move on. In a compliance product, a step that ran out of time and was skipped is a step nobody approved. If that is stored as an approval, or reported as one, the product files an unapproved action as an approved one — which is the failure the whole submission is an argument against, moved from citations to sign-off.
Decision. bypassed is a distinct outcome from approved, in a closed vocabulary of five where exactly one means a person said yes. acted_by_user_id is NULL on a bypassed row, because naming an actor there would be a false attribution — the clock is not a person. A NULL outcome means open, not approved, so nothing counting approvals may read absence as consent. And the run-level status is firewalled from the step-level question: completed means the route reached its end, never that every step was approved. The function that answers "did every step get approved" returns the list of bypassed steps, not a boolean, so a caller cannot write if fully_approved: and forget the else — the names of the skipped steps land in their hands.
Why this is the same decision as the citation gate. Bypassed is to approved what withheld is to asserted. The product already refuses to render a claim whose citation did not verify; this applies the identical rule to an approval nobody gave.
Alternatives considered. (a) Fold bypassed into approved and note the timeout elsewhere — how an unapproved action ends up filed as an approved one; (b) report run status without step detail — a dashboard reading status == "completed" and printing "Approved" is the most likely real failure here, which is why the two questions live on different tables; (c) name the timing process as the actor on a bypassed row — tidy, and it invents a person; (d) return a boolean from the completeness check — one word for the caller and one word is what gets ignored; (e) treat NULL as an outcome — absence is then a value, and the reader chooses which one.
Cost accepted. Every downstream reader does more work: a caller cannot ask one boolean but must enumerate step rows and exclude both bypassed and NULL. Two things are true at once — a run is complete and not fully approved — and every screen, report and deliverable has to render both without softening either. There is also no database constraint behind the vocabulary; the tuples are checked on write, so a misspelt outcome writes a row no query for that outcome will return. That is stated rather than implied, and it is another entry on ADR-28's list.
A product judgement that comes with it. The seed carries one finished run containing a bypassed step, so the rendering is exercised by real data rather than only by a test — and deliberately never on a step whose name implies sign-off. A demo that shows an approval being skipped teaches the wrong lesson about the product.
What forced the choice. The pain this feature exists for: an analyst opens a shared queue of fourteen withheld items with no way to tell which are theirs, and the one that matters waits behind thirteen that do not. Routing is the fix. But routing can fail — no obligation matches the change, the obligation has no owner, the owner is suspended, or two obligations disagree — and what the product does then decides whether routing is trustworthy at all.
Decision. When an owner cannot be resolved, the escalation stays in the shared queue, visibly unrouted, with the reason recorded. It is never assigned to a default person, never to the last person who touched it, never to an admin, and never to whoever raised it. Escalation failing is not permission to skip: a step whose escalation target will not resolve stays pending and must not fall through to bypass.
Why a wrong assignment is worse than none. Because it looks handled. An unassigned item is work the product has failed to route, and it reads as work. An item sitting in the wrong person's queue reads as somebody's problem, and the person it belongs to never learns it exists. This is absence-is-denial applied to routing, which is the same rule as a citation that will not verify.
How the schema carries it. The assignee is nullable, and NULL is the state an escalation is born in and returns to when the person it named leaves. The assignment time is nullable beside it and the pair is written together or not at all, because an assignment time with no assignee describes an event nobody can name. The unassigned pile is a first-class query on the same index as "what is on my desk" — the same query with a NULL. An obligation's owner is nullable for the same reason: a NOT NULL owner column would force a lie, some name in a column a reviewer would read as accountability. And the column holds an account id, not a display name, because the next thing done with it is deciding whether that account may approve — a name typed into an id column would be accepted without complaint and route nowhere.
Alternatives considered. (a) A default assignee or team inbox — the conventional answer, and it converts a routing failure into somebody's silent backlog; (b) assign to an admin — the same, with the added defect that the admin is the one role deliberately kept out of the decision path (ADR-21); (c) assign back to whoever raised it — self-approval by another door; (d) assign to the last person who touched it — plausible and arbitrary; (e) pick one when two obligations disagree — an ambiguity is a routing failure, not a tie to break, which is the same rule the verifier applies to a quote that occurs more than once; (f) delete or hide an obligation whose owner left — the row vanishing takes the duty with it.
Cost accepted. The shared queue — the exact pain this feature was built to reduce — remains the destination for every hard case. The product deliberately declines to shorten the queue where shortening it would mean guessing. The compensating control is visibility, not assignment: the reason is recorded and the interface has to show unrouted work as work rather than leaving the row looking calm. None of that interface exists yet.
Amended the same day, and the amendment is the honest part. This ADR was written saying exactly one module may call a model, and the assistant was built afterwards. Two now do: app/interpretation/propose.py and app/chat/agent.py. The rule the decision was really making survives — a model call happens in a named module, never on the deterministic path, and never where a citation is checked — but the sentence stating it as a count did not, and a count is what a reader checks with one grep. Corrected here rather than left to be found at the panel; the title and the text below now say two.
What forced the choice. "How do you test an AI system" has no answer as asked. It only becomes answerable once the system is split into the part a model touches and the part it cannot, and that split has to be a boundary in the code rather than an intention.
Decision. One module — app/interpretation/ — may call a model. Ingestion, normalization, the diff, citation verification and the audit chain are deterministic, take no model call and are tested against known answers. The model is claude-opus-5, pinned as a constant in propose.py and deliberately not read from the environment: which model wrote a claim is part of the record, and a deployment that silently swapped it would leave no trace. The transport is an injected dependency and the SDK is imported inside the factory rather than at module scope, so importing the module costs nothing and reaches nothing.
What happens when the model is unavailable, and why it is three named states rather than one. With no key, transport_from_environment() returns None, the proposer proposes nothing, and the run carries MODEL_PATH_OFF_NO_API_KEY with an announcement for the reader. An unreadable response and a failed transport carry their own names. It does not quietly fall back to the seeded corpus and present it as model output — that is best-practices §26, and it is the one degradation that would make the whole submission dishonest, since the demo would then be showing deterministic seed data dressed as interpretation.
What the gate does with what the model returns. 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, since every citation would be adjusted until it passed and the verifier would be checking the repair. A malformed proposal is dropped with the reason recorded, one at a time, so one bad entry does not discard the good ones beside it. The model can return nothing: a proposer that always proposes is a proposer that invents.
Alternatives considered. (a) Let the model do more of the pipeline — cheaper to build, and it makes every stage untestable at once, which is the thin-wrapper shape the challenge warns about; (b) read the model id from the environment — flexible, and it means a claim's record cannot say what produced it; (c) fall back to the seeded corpus when the key is missing, so the demo always shows something — the failure §26 exists for, and here it would be a lie about the product's central claim; (d) send everything to the model and rely on the vendor's terms — cheapest, and it puts a company's planning data outside its control by default; (e) a local self-hosted model for anything touching company context — removes the exposure and neither the quality nor the operational burden is provable in this timebox. Per-call minimisation is what shipped, and it fails toward under-informed rather than toward leaked.
Cost accepted, and it is the concession to make unprompted. This path has never run against the real API. Nobody here has held a key, so every test drives a deterministic fake through the injected transport. What is proven offline is the gate. What is not proven is anything only the endpoint can answer: that the model accepts this combination of parameters, that it returns the shape asked for, that the response parses first time, or how it behaves under a rate limit. The first real call will find bugs in the transport and it will not find them in the verifier. Second cost: the module does not write. Nothing here creates a claim row or appends to the audit chain, 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.
Still open. Whether the model runs under a zero-data-retention agreement — no training on inputs, no persistent logging of prompt content, a documented retention window — is not settled and is a procurement question, not a code one.
What forced the choice. The question asked was how to reach 99% test coverage. Answering it as asked would have produced a number and no assurance.
Decision. Refuse the line-coverage target. In its place: 100% branch coverage on the four deterministic load-bearing modules — app/verification/, app/diff/, app/text/normalize.py, app/state/audit.py; mutation testing on those same modules; property-based tests for the invariants that matter (normalize is idempotent, a citation built from the source always verifies, a repeated quote never verifies without an occurrence); and evals rather than coverage for the model path, reported with variance across repeated trials.
Why. Line coverage measures execution, not verification. verify_citation could be driven to 100% by tests that call it with every argument shape and assert nothing about the results — the line that rejects a fabricated quote would be green and the product's central claim would be untested. Mutation testing is the only measure that answers the question anyone actually means: do my tests catch defects. The evidence is already in this repository — 56 passing tests did not catch an unscoped tenant read, because no test asked.
Alternatives considered. (a) The 99% line target as asked — a single number that is easy to report and does not mean what a reader will take it to mean; (b) 100% line coverage on everything — the same defect, at more cost, and it drags seed and template code into the measurement; (c) coverage with a review rule that tests must assert — an unenforceable convention; (d) no target at all — leaves the load-bearing modules with whatever attention they happen to get.
Cost accepted. Mutation testing is slow and there is no mutation tool in requirements.txt, so the standard is stated and not yet met. Nor has the branch coverage been measured — there was no coverage tool at the time this was decided. Saying that is better than guessing a number, and it means this ADR currently describes a policy rather than a result.
Overruled, then withdrawn — the record of both, because only one of them is flattering. The owner overruled this ADR later the same day and asked for 99% on every module, then asked whether both standards could hold at once. Both can, and the honest reading is that this ADR had argued against a number rather than against measuring: refusing a target is only a stronger position if something else is measured instead, and at that point nothing was. So a coverage tool went in and the answer to "what is it now" stopped being a guess. The target itself was cut once its cost was visible against the deadline — three to six hours of tests written to raise a number, in a submission whose weakest dimension is user research rather than test volume. What survives is this ADR as written plus a measured line figure nobody is being asked to defend as a standard.
What forced the choice. The eval corpus is one synthetic proceeding with five labelled changes. Precision and recall over five items have error bars wide enough to swallow the estimate, and "80% precision" from four right answers out of five is exactly the false precision this product refuses everywhere else. Printing it in our own eval output would be the worst possible place to slip.
Decision. Report raw counts with denominators — "5 of 5 changes found", never "100% recall". A rate is printed only when the independent sample is ten or more; below that the harness prints the counts and says why. Every scorecard carries a standing caveat naming the sample size and what it cannot support, and the harness exits non-zero when a release-blocking threshold fails so it is usable in CI.
The repair that made this a real rule rather than a slogan. The harness broke it. It printed "20 of 20 manifest offsets verify [100%, n = 20]" — and only ten of those twenty were distinct strings, nine of them the same boilerplate sentence. So the deflation rule was applied everywhere except the one place applying it would have suppressed a headline number. The fix was taken at the class rather than the call site: the count of independent samples is now derived by de-duplicating on the identity that makes two probes the same evidence, rather than passed in by a caller who can pass the inflated one. Probes are not the sample.
Alternatives considered. (a) Print percentages with a footnote about sample size — the footnote is not what gets quoted; (b) keep the rate and let the caller declare the sample size — the shape that produced the defect above, because the caller is the party with an interest in a big number; (c) print nothing until the corpus is large enough — no regression gate at all, and the gate is what catches a change that breaks verification; (d) widen the corpus first — right, and it is a different job with a different cost (ADR-40).
Cost accepted. The product's own numbers read smaller and rougher than a competitor's, in the document most likely to be skimmed. And the harness itself shipped with no tests at all — about 1,200 lines of code whose job is to be trusted, covered only afterwards. A harness that quietly reports a percentage off six items is worse than no harness, and for a while that is what existed.
What forced the choice. Two pulls in opposite directions. Real filings would make the demo concrete and answer "have you ever seen a real docket". And the synthetic corpus was built with deliberate traps — a fabricated quote at real offsets, a sentence repeated nine times, a wholesale restructure that must escalate rather than assert — which is what the eval harness measures. A real filing will not reproduce those on demand.
Decision. The synthetic corpus stays, fictional jurisdiction and all, and stays the eval corpus. If real filings are added they become a separate demo corpus, labelled as such, with per-document provenance: docket, filing date, source URL, retrieval date, and the hash of the text as fetched. The mapping between the two — which real proceeding archetype each sample project models — goes in the MRD as a table, which costs one table and gives both.
Why the fictional names are load-bearing rather than laziness. The moment real docket numbers appear in the seed, a reviewer can reasonably conclude that real filings were ingested. The synthetic disclaimer is what stops that, and the honesty claim carries the whole submission. Two rules follow if real text is ever added: public versions only, since redactions exist for a reason; and the text must be fetched, never reconstructed. A plausible quote written from memory would mean the verifier was checking against invented text, which would make the demo a lie about itself.
Alternatives considered. (a) Replace the synthetic corpus with real AES filings — the most impressive demo, and the eval harness loses the cases it was designed around, so the numbers stop meaning anything; (b) keep the synthetic structure and rename it with real docket numbers — the cheapest realism and the most dishonest, since it claims ingestion that never happened; (c) real filings only, with the traps hand-inserted — corrupting a real document to make it fail is a worse artefact than a synthetic one built to; (d) no real filings ever — leaves the extraction risk untested, which is the concession below.
Cost accepted, and it is the sharpest limit in the testing story. The synthetic corpus has none of the ligatures, multi-column layout or scanned exhibits that the extraction risk is actually about. All three files are plain ASCII. So the golden-file tests do not cover the risk they appear to cover, and ADR-25 and ADR-26 are proved by sweeps of the Unicode code space rather than by the corpus. The eval numbers describe a corpus built to be measurable, not a corpus that resembles the world.
What forced the choice. The one diagram this repository had drew "passage store — lexical BM25 (SQLite FTS5)" as a solid box beside components that exist. There is no retrieval layer in this codebase at all. A design intention was drawn as though it had shipped, in the documentation of a product whose argument is that a system must not assert what it cannot show. Adding a dozen more diagrams without fixing that would have multiplied it.
Decision. One shared stylesheet, docs/diagrams.css, holds the vocabulary rather than each diagram inventing one: a box that may call a model, a box that can refuse, and — the one that matters — a box that is designed and not built, drawn dashed, muted, and labelled so in its own text. Amber means the system declined to assert something, in the diagrams as in the application. The rule handed to anyone drawing: read the code the box claims to describe before drawing the box.
Why the style is pinned centrally rather than left to each author. Consistency across a dozen diagrams is a design decision, not something several authors should each guess at. And a reviewer who finds one box that overstates stops believing the other twenty, so the cost of an inconsistent vocabulary is not aesthetic.
Mechanics fixed at the same time, and why each is a rule and not a preference. No inline styles, so every shape takes a class and the vocabulary cannot be bypassed. Arrowhead markers defined once per page rather than once per diagram, because ten diagrams each defining the same id is ten elements sharing one id. Every drawing carries a viewBox and no fixed width or height, so it scales. Each carries a title and description for a screen reader. No external font, image, script or network call — a diagram must render with the network off, opened from the filesystem. And a caption that narrates the boxes is a caption to delete.
Alternatives considered. (a) Keep ASCII diagrams — no toolchain and no way to distinguish built from unbuilt except by adding words nobody reads; (b) a diagramming library or a rendering service — a build step and a network dependency, against the no-build-step rule this repository holds elsewhere; (c) images exported from a drawing tool — the source stops being reviewable in a diff, and the diagram drifts from the code with nothing to catch it; (d) let each document style its own — what produced the overstating box.
Cost accepted. Hand-written SVG is slower to author and harder to rearrange than a text diagram, and a stylesheet is a thing that can go stale on its own. There is a validation script for well-formedness and duplicate ids, and nothing checks that a box marked as built still is — that remains a human reading the code.
What forced the choice. Every other document in docs/.ai/ is prose a person wrote, and every one can go stale the moment the code moves. The briefing had four false statements in it at one point, and rehearsing from a stale brief is worse than having none.
Decision. docs/.ai/state.json is generated by scripts/status.py and never hand-edited. The module list comes from the filesystem, the test count from pytest, the decision list from the data attributes on this file, the commit from git. The index says plainly that where prose disagrees with state.json, state.json is right. That is also why every ADR here carries data-adr, data-status, data-date, data-area and its dependency attributes: so a generator selects the entries without parsing prose, and so anything can link to a specific decision.
The failure it had within an hour, which is the reason this entry is honest about its limits. The generated file claimed the model-interpretation capability was built, because the detector asked only whether a folder contained a Python file and never looked for the symbol. The file whose whole purpose is that it cannot go stale was making a false claim within an hour of being written. Generation removes the class of error where prose is not updated. It does not remove the class where the generator asks the wrong question, and nothing in this design catches that but a person reading the output.
Alternatives considered. (a) Keep the status in prose and update it by discipline — the thing that had already failed; (b) generate it and let prose override where an author disagrees — two truths, and the one that flatters wins; (c) generate a full report rather than machine-readable state — pleasant to read and unusable as an assertion; (d) put the derived facts in the README — the README is read by people and would then need regenerating in a file reviewers diff.
Cost accepted. A generator is code with no tests. It is one more thing to run, and a stale state.json is now authoritative and wrong rather than merely old. The mitigation is that it is cheap to regenerate and that the false claim above was caught by reading it — which is not a mitigation anyone should rely on twice.
What forced the choice. The prep guide says reviewers read the commit history, and this build is largely agent-written, which makes the history itself part of what is being judged. A history that has been tidied says something different from one that has not.
Decision. No squash. And the harder half: no single large commit either. A change that swept 5,520 lines from three separate workstreams into one commit labelled as a routing fix was split into six before it landed. A message that describes one thing and contains ten is worse than a squash, because it is a false statement rather than a missing one. The repository was created private, because private to public is one command and the reverse does not un-publish.
Alternatives considered. (a) Squash to a clean narrative — reads better, and it removes the evidence of how the work was actually done, which is the thing being examined; (b) keep the history and let commits be as large as they arrive — no rewriting, and it produces messages that misdescribe their contents; (c) rewrite messages after the fact — the same tidying by another route, and it is harder to spot; (d) create the repository public immediately — one fewer step, and the MRD carries a compiled list of thirteen real people with work addresses, which reads differently from thirteen scattered public filings.
Cost accepted. The history is long, uneven and shows false starts, dead ends and corrections — including several in this file. Splitting commits costs time at exactly the moment there is least of it. And a private repository has to be opened by hand before anyone can read it, which is one more thing to forget.
What forced the choice. Two agents each assumed they would own the application's root path. Both were reasonable and nothing in the repository disagreed with either, so two routers claimed /, two claimed /review, neither pair could be mounted, and 2,000 lines and nine routes stayed dark while every page linked to a 404. ADR-23 fixed the class inside the application. This is the same class one level up, in how the work is dispatched.
Decision. Every agent in a parallel wave owns a disjoint set of files, stated in its brief. Agents do not run git, make or pip, because they share one working tree and those race; the orchestrating process commits. Two waves whose file sets overlap are not run together — one is parked, explicitly, even when both are ready. Contracts that two agents would otherwise have to agree on — a JSON wire shape, a URL, a vocabulary — are pinned by the orchestrator in advance rather than negotiated, because two agents guessing at a shape is the failure above with different symptoms.
Alternatives considered. (a) Let agents coordinate through the files themselves — what happened, and the loser's work disappears silently; (b) give each agent its own branch or worktree and merge — real isolation, and merging several thousand lines of independently written code under a deadline is a second project; (c) run everything sequentially — no collisions and no parallelism, which on this timebox is the whole method; (d) let agents commit their own work — readable per-agent history, and concurrent index operations on one tree corrupt it.
Cost accepted. Parking a ready wave wastes wall-clock time on purpose. File ownership has to be decided before the work is understood, which sometimes means an agent cannot make an obviously correct one-line fix in a neighbouring file and must hand it on instead. And a single committer becomes a bottleneck and the only reader of what nine agents produced — which is how a 5,520-line commit nearly shipped under one label (ADR-43).
What forced the choice. Thirty-nine cold approaches to people who do not know us, on a deadline, where the reply rate decides whether the user-empathy story exists at all. The obvious lever is money — a $50 or $20 card for twenty minutes.
Decision. No card, at any amount. Instead: an offer to send a short write-up of what others in the same job say, made unconditionally — whether or not the recipient can spare the time.
Why, and the reason is different for each third of the list. Eleven of the thirty-nine are public officials — state commission staff and consumer advocates. State employees work under gift rules, and an unsolicited card from a stranger with an interest in dockets they handle is not a gesture; it is something they must decline, possibly report, and remember. The same email already says "I have no business before the Commission", and a gift card contradicts that sentence. The utility side is little better: the large operators run codes of conduct with gift thresholds and disclosure duties, so a card to a regulatory affairs manager from an unknown sender is a compliance ticket rather than an incentive. And the consultants bill by the hour at rates where fifty dollars is a rounding error — offering it misprices the ask.
Why unconditional rather than "if you can spare the time". The moment it is conditional it is a trade: something of value offered in exchange for access, to people who work on dockets we have an interest in. For the eleven officials that is the structure their gift rules exist to catch. Unconditional makes it a courtesy, and courtesies need no declaring. Two practical arguments run the same way: the marginal cost is zero, since the document is written once whether it goes to twelve people or thirty-nine; and it buys an honest second contact — "here is the write-up I promised" is a welcome reason to appear in an inbox again, and cold outreach usually dies for want of one.
Alternatives considered. (a) A $50 card — the strongest conventional lever and the one that backfires hardest with this list; (b) $20 — the same objection at a lower price, since the rules are about the act and not the amount; (c) a donation to a charity of their choice — cleaner and still a thing of value offered for access, which is the shape being avoided; (d) the write-up offered only to those who talk — the trade above; (e) no incentive at all — safe, and it wastes the one thing on offer that costs nothing and is worth something to someone whose job is knowing what peers do.
Cost accepted, and it is a promise not a gesture. Thirty-nine people are now owed a write-up. It has to be written even if only two people reply. What makes it affordable is that it is the same artefact the submission already requires — docs/user-research.html with the internal reasoning trimmed — one page serving both. Two constraints came with it: keep it to a page, and do not promise a date.
What forced the choice. Cold email to a deputy general counsel and to staff at four state commissions needs a reason to be read, and a title is the cheapest one. The first instruction was to present the sender as an engineer in residence working with a named person at a named utility.
Decision, after two reversals. Name and email address only. No title, no company, no third party named. The body says "I am building a platform for regulatory affairs teams at multi-state utilities", which is true and reads as founder either way. For the eleven public officials one sentence stays verbatim through every rewrite: I have no business before the Commission. Every message states that nothing is being sold, because there is nothing to buy.
The reasoning, including the part that was wrong first. Naming a real person at a utility to borrow credibility puts the consequences on her if a recipient mentions it, and she had not agreed to it. Worse: that utility is a party in dockets these recipients work on, so an email presenting itself as affiliated with it, sent to the staff who regulate or oppose it, is a problem for both sides whatever the intent. "Founder, Verbatim" was then proposed as the honest substitute — accurate, checkable, nobody's permission needed. That reasoning did not survive the next fact: Verbatim is the venture, it has a founding corporate partner, and its chief executive seat is open with the sender one of 106 applicants. Signing cold email to industry as "Founder, Verbatim" while that seat is open is a claim that could travel back through exactly the network being emailed. "Engineer in Residence" has the same defect one notch smaller, since the challenge is the pipeline into that role.
Alternatives considered. (a) Engineer in residence, working with a named contact at a named utility — the strongest opener and the one that lands consequences on a third party and creates an apparent affiliation with a docket party; (b) "Founder, Verbatim" — accurate on its face and a title claim against an open seat the sender is competing for; (c) drop the title only for the officials and keep it for industry — two versions of who the sender is, in a small industry where recipients talk; (d) send the correction to the seven already sent with the earlier signature — a follow-up to clarify a signature draws more attention to it than it is worth, so the change was applied forward only, on the reasoning that seven copies of a title in circulation is one thing and forty-six is another.
Cost accepted. The emails lost their strongest opening credential and had to earn attention on the specific filing the recipient wrote, which is more work per message and does not scale. Seven messages went out under the earlier signature and stay that way. And the decision is applied unevenly across the batch by design, which is a thing to own rather than explain away.
Related decisions taken in the same work, recorded so they are not re-argued. Recipients came from public docket service lists rather than a professional network or a contact broker, because filing in the docket proves a person does the job rather than holds the title. Messages were created as drafts and reviewed before any send, because sending is irreversible; the send, when instructed, was paced at random intervals of five to thirteen minutes with one recipient per employer first, so that anything wrong is contained to one contact per organisation. Four colleagues at one office had received drafts sharing roughly sixty per cent of their text, and rewriting the middle paragraphs was chosen over sending anyway or dropping recipients — pacing hides volume and does nothing about two people in one office comparing near-identical letters, which is how a considered approach reads as a mail merge. And no automated access was taken against any professional network: it breaks the terms, and the account at risk is the same account the outreach depends on.
What forced the choice. The stylesheet carried nine media queries, which reads as responsive to anyone counting them. Three were breakpoints and they held four rules between them, none about tables — while ten tables sat across six templates with no scrolling container anywhere. A table wider than a phone pushes the whole page sideways: the masthead scrolls away from the content and nothing lines up again. That is the symptom every reader recognises as not built for a phone, and the media-query count hid it.
Decision. One rule in the stylesheet, and the table itself becomes the scrolling box. display:block gives it a formatting context that can overflow while rows and cells keep their table behaviour, so column alignment survives; overflow-x then has something to act on. Both declarations are load-bearing — overflow-x alone does nothing to a table box, and display:block alone scrolls nothing.
Why in the stylesheet and not at the ten call sites. "Fix the class, not the line", applied to CSS. A table added tomorrow is covered without anybody remembering, which a wrapper in six templates cannot promise. It is the same argument ADR-23 makes about a hand-kept list of routes: a list a person maintains cannot catch the entry that person forgot.
Alternatives considered, named honestly. The record holds one rejection: wrapping each of the ten tables in a scrolling element across six templates, rejected on the class argument above. Narrowing or hiding columns below a breakpoint, and leaving the tables alone, were not weighed and are not claimed here as arguments that happened.
Cost accepted. display:block changes the element's box model. It stops being a table box, so anything that relied on table-box behaviour — filling its container, the way width resolves — has to be checked rather than assumed. And the guards read the stylesheet and the markup: they catch a fixed pixel width wider than a phone, a table with no way to scroll, a deleted viewport tag, an inline style that beats every breakpoint. They cannot lay out a page, and nothing here has been opened on a handset. That is written in the test docstring rather than implied.
What forced the choice. Two questions, one feature. An analyst hands an item off and cannot say who has it, how long they have had it, what happens if nobody acts, or what comes next — so they chase it by email, which is the workflow this product claims to replace. An admin needs to draw the route in the first place. ADR-23 gave every router its own path; it did not say how to split a feature whose read half everybody needs and whose write half is an admin's.
Decision. Two modules and two routers. app/web/views/workflow.py serves GET /workflow to anyone signed in, rendered entirely on the server with no JavaScript at all. app/web/views/admin.py serves the five /admin/workflows paths behind workflow.manage, and is a canvas.
Why the open screen is not the editor with its buttons removed. The two answer different questions and the answers have different shapes. "What shall the route be" is a graph, and a canvas of nodes and arrows is how you draw one. "Where is my thing and what happens next" is a numbered list of steps in the order an item meets them, each written as a sentence. Greying out the editor hands the second audience the first audience's shape and calls it access.
Why two modules rather than one with per-path gates. The open screen would then sit in the same file as the gated ones, which is how a screen ends up gated by accident — and the other way round, which is worse.
Two rules the open screen carries, both inherited rather than invented. No JavaScript, because the one screen every signed-in person depends on must not be breakable by a script failing to load. And the bypass sentence is not softened: "after 48 hours this step is skipped and the item moves on without approval", with a step already bypassed on a live item rendered at equal weight to an approved one and said plainly to have been skipped rather than signed. That is ADR-35 in the interface — a route that renders a bypassed step to look like an approved one is the same defect as a claim asserted on a citation that did not verify.
Alternatives considered. (a) One screen with the editing controls hidden for people without the permission — one template, and it answers the wrong question for the larger audience; (b) one router with a gate on each path — fewer files, and the open path lives beside the gated ones with nothing structural keeping them apart.
Cost accepted. Two templates and two modules describe one graph, so a vocabulary change has to land in both. The translation from column values to sentences — on_timeout: escalate into "after 24 hours with no answer, this goes to anyone holding the Regulatory Affairs role" — lives only in workflow.py and none of it in the template, which bounds that cost without removing it. And the open screen is honest about being empty: nothing in this build creates a WorkflowRun, so the "where has my item reached" half says no run has been started rather than rendering an empty list that reads as "nothing has happened".
What forced the choice. The wire contract says a draft route may be saved while invalid. "Invalid" was doing two jobs. Half-finished is one — an admin who has drawn three nodes and filled in one. Wrong is the other: on_timeout: "ignore" is not a half-finished answer, it is a word the product does not have. There is no CHECK constraint to stop it reaching the column (ADR-28), and once there it routes nowhere while looking like a decision somebody made.
Decision. A save validates types and vocabulary and refuses anything outside them. Activation validates completeness. What is allowed to be missing stays missing: approval_hours, on_timeout, remind_every_hours and escalate_to are written NULL rather than defaulted. Every activation refusal names the step it belongs to so the editor can put the message on the node; the four graph-level checks — one starting point, every step reachable, no loop, every edge naming a step that exists — carry a null step id, which is the only extension made to the contract's error shape.
Why NULL and not a default. A default of 24 hours is an answer nobody gave, sitting in a column a reviewer reads as a decision. The same argument ADR-36 makes about a NOT NULL owner column forcing a name into a field that reads as accountability.
Why an edge to a missing step is refused at save rather than at activation. SQLite accepts it and Postgres refuses it. Accepting it would mean the product behaves differently on the demo database from the one it ships on, which is a defect that only appears after the migration ADR-28 names.
Absence is denial, applied to a form. Activation resolves every assignee_rule and every escalate_to against the company's roles and users and refuses the ones that land nowhere. role:approvers is well-formed and, in a company with no such role, routes into silence — which is the citation rule moved onto routing.
Alternatives considered. (a) Accept anything on a draft and validate only at activation — matches the contract's letter, and lets a value the vocabulary does not contain sit in a column until somebody activates; (b) default the missing hours so a route always activates — a deadline nobody set, presented as one somebody did; (c) one error list at the foot of the canvas rather than a message on each node — a list nobody reads.
Cost accepted. An admin can be refused a save on a route they consider half-drawn, which is friction at the moment they are experimenting. And one rule cannot be checked this way: obligation_owner resolves per escalation, so whether that person exists is a question about the item and not about the route. A route can activate cleanly and still fail to route, which is ADR-36 and is not a defect in this check.
What forced the choice. Chat is where a person asks the loose question they would never type into a form. A general assistant's instinct is to be helpful, and helpful here means answering "is this material", "will the commission approve it", "what should we argue" — the judgements this product refuses to make on evidence it cannot show. An assistant with that instinct would undo the citation discipline on the very first turn, on the softest surface in the product.
Decision. The persona is a commission's records clerk. It never opines on the merits, and it says "nothing on the record for that" without embarrassment. Asked for a view, it says that is the analyst's call and offers what it has: the versions, the diff, the obligation, the owner.
Why a clerk and not some other voice. Every commission has one, and they are the person who actually knows where things are. Two of their habits are worth copying exactly. Ask a clerk whether a tariff is lawful and you get the docket number, not an opinion. And an empty answer honestly given is the whole value of the office — a clerk who invents a filing to be helpful is a clerk who gets struck off. That is ADR-03 in human form, which is the reason the persona is this and not a friendly guide.
Alternatives considered. One is recorded: a friendly guide, rejected in app/chat/persona.py for the reason above. A domain expert that reasons about materiality, and a bare search box with no persona at all, were not weighed, and that is said here rather than dressed up as an argument that happened.
Cost accepted. The assistant declines the question the analyst most wants answered, so beside a general chatbot it will read as less capable. That is deliberate and it is still a cost — a demo where somebody asks "so is this bad for us" and gets a docket reference is a demo that has to be explained. The second cost matters more: this module is prompt text and a regular expression. It shapes what the assistant says and constrains nothing about what it can reach. The reach is the gate in app/chat/tools.py and the permission grid of ADR-21. A persona is not a control, and the file says so in its own docstring so it can never be read as one.
What forced the choice. Every chat tool reads tenant data, so something has to decide which tenant. The model is the one component in the turn whose input includes text written by other people — a project description, a steer directive, the body of a filing.
Decision. Every tool is called as run(session, company_id=..., actor=..., **arguments). The first two come from the signed-in session; only arguments come from the model, and they are filtered against the tool's own declared schema before the call. A tool that declares company_id or actor in its own schema is refused at registration and raises, because a tool that advertises identity is inviting the model to supply what only the session may.
Why the filter is an allowlist and not a blocklist of two names. A blocklist would be correct today and wrong the first time a tool grows an argument somebody forgets to add to it. Passing only what the schema declares means a name the schema does not know never reaches the call, whatever it is called and whoever invents it.
Alternatives considered. (a) Let the model pass a company_id and check it against the session — one comparison, and a model that can hold a company id is a model that a sentence inside a filing can talk into holding a different one, at which point the tool answers; (b) the blocklist above. No other shape was weighed.
Cost accepted, and it is the honest one rather than the flattering one. An identity argument the model emits is dropped, not reported. _safe_arguments filters silently and writes nothing to the chain, so an injection that got past the deterministic screen and made the model emit a company_id leaves no evidence that it tried. The screen audits its own refusals under chat.refused_override and chat.refused_scope; this filter does not. The loud refusal is at registration, and registration happens once at import rather than once per turn. Closing that gap means auditing a dropped argument, which is not built.
What forced the choice. ADR-22 requires every synthesis to state what it left out, at the same weight as what it included. A chat turn is a synthesis composed on the spot from several tool results, and it is the easiest place in the product for a withheld statement to leak out inside a summary.
Decision. Every tool declares whether it can withhold. One that can must return an integer withheld beside its integer found: zero is an answer and a missing key is a defect. The turn does not read the absence as nothing withheld — it announces that the count was unavailable and carries a fallback code for it. found is required of every tool for the same reason, because the turn contract promises the transcript what each tool consulted and a count nobody measured would be invented here. In the prompt the rule is stated as withheld means withheld: never repeat, paraphrase, summarise or hint at the text of a withheld claim, and never soften a refusal into "it appears that". search_claims returns the reason rather than the text, so the sentence is not in the turn to leak.
Why the code and not only the prompt. A rule that lives only in prompt text is renegotiated by every model release. The count is a required key on a return value, so a tool that cannot answer the question fails rather than reassures.
Alternatives considered. One is recorded and it is the important one: default a missing withheld to zero. That is the NULL-read-as-zero that turns "we did not count" into "we withheld nothing" — the same defect the status script had against its own test count (ADR-60). Letting the model report what was withheld was not weighed as a written alternative, though it could not work: the model sees only what the tools returned, which is the set with the withheld items already taken out.
Cost accepted. Every tool author has to answer a question about counting before their tool can be registered, and a tool with nothing to say about withholding must say so rather than stay silent. The turn also grows an announcement that a person may read as a fault when what happened is that a number was missing — which is the right way round, and still noise on the screen. And none of this has run against the real API: what the tests prove is the loop, not the endpoint (ADR-37).
What forced the choice. ADR-32 added reverts_event_id, reserved DIGEST_V3, wrote nothing under it, and said the work to close the gap was "a _digest_v3 over scheme 2's fields plus the reversal ... and CURRENT_DIGEST_VERSION raised". Writing the rollback path made the second half wrong, so the closing decision is recorded here rather than left to look like the plan being carried out.
Decision. _digest_v3 exists and hashes scheme 2's fourteen fields plus the pointer. CURRENT_DIGEST_VERSION stays at 2. A row is hashed under 3 only when it carries a pointer. And _digest_for_row refuses a scheme 1 or scheme 2 row that carries a pointer rather than verifying it.
Why the current version was not raised. Scheme 3 exists to cover one nullable column. An ordinary row has nothing extra to cover and gains nothing from a wider payload, and every row already in the log stays exactly as it is — which is ADR-17's rule that nothing is re-hashed, carried into the choice of what new rows use rather than only into what old rows keep.
Why the refusal is the load-bearing half. Schemes 1 and 2 do not hash the pointer, so a pointer written onto one of their rows out of band would not break its hash. The log would then assert, with a hash that verifies, that a decision had been taken back by a row nobody wrote that way. A field the chain does not defend is not evidence, so under those schemes it is refused rather than read — which is the same instruction ADR-17 gives about an unknown scheme number, pointed at a known one.
Alternatives considered. (a) Raise CURRENT_DIGEST_VERSION to 3, as ADR-32 proposed — every new row then carries a wider payload for a column almost none of them use, and a third scheme goes into general circulation for no gain; (b) leave the column outside every digest, as it was — the defect ADR-32 named; (c) verify a scheme 1 or 2 row carrying a pointer under its own scheme and ignore the pointer — the hash passes and the log says something nobody wrote, which is worse than a red chain.
Cost accepted. Three hash functions to maintain for ever instead of two, and none of them may ever be edited. The scheme a row uses is now a property of its contents rather than of when it was written, which is harder to reason about than a date and has to be read out of the row. And DIGEST_V3 is declared twice — app/state/audit.py holds it beside its siblings, app/state/models.py declares the same number beside the column, because the column landed first while audit.py was being written by somebody else. tests/test_rollback.py pins the two together, which is a test standing in for a deletion nobody has made yet.
What forced the choice. A reviewer closes an escalation — records that a refusal was right, or that a person disputes it — and is wrong. Until now that stood for ever, and the only remedy was a second decision written beside it saying something else. An auditor reading two rows could not tell a correction from a disagreement, which is the question they came to answer.
Decision. A reversal is a new event naming the one it takes back. The original row is not edited, not deleted and not flagged. Reverting restores the state that decision changed and nothing else. Taking back a reversal writes a third row under its own action code — and the decision the reversal undid does not come back into force.
Why a withdrawn reversal reinstates nothing. Putting the decision back would write a resolver's name onto state nobody re-decided: either the person who only withdrew the undo and never made that judgement, or the original reviewer at a moment when the escalation was demonstrably open. Both are false statements, so neither is written. The subject stays where the reversal left it and waits for a fresh decision. decision.reversal_withdrawn is a separate code from decision.reverted for the same reason ADR-35 keeps bypassed apart from approved: a reader scanning for undone decisions must never be handed a row that undid an undo and restored nothing.
The line that does not move. Nothing in this module may turn a withheld claim into an assertion. Undoing the approval of a refusal reopens the escalation and changes nothing whatsoever about what the source says — only the source agreeing with the quote does that (ADR-24). A test reads the module's own source to stop a later edit blurring it, rather than trusting the docstring to hold the line.
What is reversible, and why the set is closed. Only what RESTORERS names — today, closing an escalation. An action with no entry there is refused rather than recorded, because a row saying a decision was taken back when no state moved is a fallback that did not announce itself. Adding one means writing the function that puts the state back and registering it under the exact action and subject pair the writer uses.
Alternatives considered. (a) Edit or flag the original row — the rewrite the whole table exists to prevent; (b) a second decision beside the first with no pointer — what existed, and an auditor cannot tell a correction from a disagreement; (c) reinstate the original decision when a reversal is withdrawn — writes a judgement nobody made; (d) one action code covering both kinds of undo — the reader then chooses which one it meant.
Cost accepted. revert_event restores state and then appends the row, in that order, and must be called inside a transaction the caller aborts on failure. Called outside one, a failure between the two writes leaves state restored with nothing in the log saying why. That rule is written in the docstring and is not enforced by the function. And the reversible set is one action long, so most decisions in this product still cannot be taken back at all.
What forced the choice. A thumbs-down has to land somewhere. The obvious home for "this answer is wrong" is the escalation queue, which already holds everything the product refused and is already the screen a reviewer opens.
Decision. Feedback writes to the feedback table, the improvement backlog and the audit chain, and to nothing else, ever. A complaint that a claim is wrong is marked referred in words on its own row and audited under its own code. It never becomes an Escalation. tests/test_feedback.py reads the module's own source for a write to a claim or an escalation, so the rule survives the next edit rather than living in a paragraph.
Why not the escalation queue, in two steps. First, every escalation row means the citation check ran against the stored source and failed, with a reason code and a claim id. Its whole value to a reviewer is that everything in it was machine-checked; the first unfounded complaint filed there would be indistinguishable from a real refusal, and the reviewer could no longer say what the queue is a list of. Second, escalations resolve. A complaint dropped there could be closed by somebody holding escalation.resolve, and the hash-chained log would then say, permanently and verifiably, that a refusal by the verifier had been reviewed and cleared — when no verifier ever refused anything. A false clean record on an append-only chain is worse than no record.
Why not the product backlog either. A backlog is sorted by product value. "The product asserted something its source does not support" is not a feature request that lost a priority argument.
Alternatives considered. (a) File it as an escalation — the two objections above; (b) file it in the improvement backlog with everything else — the objection above; (c) let feedback change a verdict, or reopen a claim — a store that could act on a complaint is a way to publish a statement by complaining about it loudly enough, which is ADR-24 approached from the user's side rather than the operator's.
Cost accepted. The person who reports the most serious failure this product can have gets a slower path than the one who reports a broken button. A referral is words on a row and an event in the chain; nothing schedules a reader for it and nothing puts a deadline on it. The compensating control is the same one ADR-36 relies on — visibility rather than routing — and it is weaker here, because a referral has no queue of its own.
What forced the choice. ADR-36 decided that an escalation nobody can route stays in the shared queue, visibly unrouted, with the reason recorded. It did not say what a reason is, or whether the queue reads a stored one. Both had to be settled to build it.
Decision. Each failure carries its own code — the obligation has no owner, the owner's account is inactive, two obligations name two different people, and the rest — and shared_queue() re-resolves every item it shows rather than reading a stored verdict.
Why a code and not a sentence. "Could not route" collapses five different fixes into one line nobody can act on. Unowned means give the duty an owner; inactive means reinstate an account or move the duty; disagreement means a person has to choose. Code branches on the code and the analyst reads the text, so one field does not have to serve both.
Why the reason is re-derived rather than stored. A stored verdict is a promise about rows that may have changed since — the argument app/state/claims.py makes about verification, and the argument ADR-27 makes about a cached verified flag. Suspending an owner this morning must change what the queue says this afternoon without anything having re-run. The audit row records what was decided at the moment of routing; the queue says what is true now. Both are wanted and they answer different questions.
A limit stated here rather than discovered by an admin. A role resolves only when exactly one person holds it, because WorkflowStepRun.assigned_to_user_id holds one account. "The obligation owner" in a company with five obligation owners has no single answer, and the module refuses instead of picking. The proper fix is a column letting a step run be held by a role — a queue several people can see and one of them can claim — and it is not built.
Alternatives considered. (a) One reason string — the collapse above; (b) store the verdict when routing runs and read it back — one read instead of a walk, and it is the cached-answer defect ADR-27 refuses for citations; (c) pick one when two obligations disagree — an ambiguity is a routing failure and not a tie to break, which ADR-36 already settled and which this entry only carries out.
Cost accepted. The shared queue walks escalation to claim to change to obligation to owner for every item on every render, which SQLite pays for on every page. That is the same cost ADR-22 accepted for coverage, taken again for the same reason. And a reason code is a closed vocabulary with no database constraint behind it, so a misspelt code writes a row no query for that code will return — another entry on ADR-28's list.
What forced the choice. requirements.txt pinned anthropic==0.39.* while nothing in app/ imported the package at all — a declared dependency no code exercised, which is a claim the repository could not back. Writing the proposer gave it a caller, and the caller sends parameters that pin cannot accept.
Decision. anthropic==0.120.*. The import stays inside the factory function so nothing pays for the SDK at import time, and make test still passes with no key and no network (ADR-37).
Why. 0.39 predates adaptive thinking and structured outputs, so thinking={"type": "adaptive"} and output_config= would both be rejected as unknown arguments before a request was ever built. A pin that cannot run the code beside it is worse than no pin, because it reads as tested. This is the same failure the repository has now corrected several times in prose — a diagram drawing an unbuilt box as built (ADR-41), a status file reporting a count it never measured (ADR-60) — appearing in a dependency file.
Alternatives considered. None was written down. What the record holds is the defect and the fix. Writing the call in 0.39's older shape, and loosening the pin rather than moving it, would each have been available, and neither is claimed here as an argument that was had.
Cost accepted. A far larger dependency now installs on the reviewer's critical path, which ADR-07 names as the single failure that loses the submission — make run not starting. It buys nothing a reviewer can see, because the path behind it has still never run against the real API and every test drives a deterministic fake (ADR-37). So the honest position is that the pin is now consistent with the code and the code is still unproven, and the first real call will find bugs in the transport.
What forced the choice. ADR-40 said real filings, if added, become a second labelled corpus. Retrieving them was handed to agents working state commission portals, each with a target of about ten documents. A target is the exact condition under which something plausible gets written instead of something fetched, and the agents doing the writing are the ones under the target.
Decision. The rule handed to every retrieval agent was absolute. Every character of every file is text actually fetched from a public source: not a paraphrase, not a reconstruction from memory, not a plausible example, not a representative excerpt, not a placeholder for somebody to replace later. If it cannot be fetched, report that and write nothing. "I retrieved three, here is precisely what blocked the rest" is a successful run, and the result schema says in as many words that an empty list is a valid answer. The number is an ambition; the rule is a floor.
Why it is absolute rather than strong. This text becomes the corpus a citation verifier checks claims against. The entire product is the promise that a claim cannot assert itself unless its quote is really in the source. If the source is something an agent composed, the demo is a lie about itself, told to a founding partner about their own industry, and there is no recovering from that at a panel.
What checks it, rather than trusting it. Every haul got a sceptic that re-fetches the source, recomputes the SHA-256 against the provenance file, and reads the text looking for the absence of artefacts — clean, well-paragraphed prose is the signature of something generated rather than extracted, so clean text is the finding and not the reassurance. Anything that cannot be authenticated is deleted along with its provenance file. The run produced 102 documents across eight jurisdictions with provenance beside each, and every hash matched.
Alternatives considered. One is recorded: hit the count and let something composed stand in for what could not be fetched, rejected on the argument above. Lowering the target, or dropping it, was not weighed as a written alternative — the target was kept and named as an ambition rather than a licence, which is the same instrument pointed the other way.
Cost accepted. Coverage is whatever the portals allowed, so the corpus is shaped by what was reachable rather than by what would be most useful. And the artefact test is a judgement about how a real document looks, not a proof: a fabrication carrying convincing line numbers and running heads would pass it, and the hash proves only that the file has not moved since it was saved. The defence that actually holds is the re-fetch, and it holds only for as long as the source URL does.
What forced the choice. ADR-40's sharpest stated cost is that the synthetic corpus has none of the ligatures, multi-column layout or scanned exhibits the extraction risk is actually about — all three files are plain ASCII, so the golden-file tests do not cover the risk they appear to cover. A retrieval run is the chance to close that, and it closes nothing if the text is tidied on the way in or if the documents are unrelated to each other.
Decision, in two parts. What to fetch is ranked, and two versions of the same document sits at the top: an original and its revision, a filing and its errata, direct testimony and its corrected reissue. One document with an errata beats five unrelated PDFs. What to write is the text exactly as retrieved — not cleaned, not reflowed, not de-hyphenated — beside a provenance file carrying the source URL, the retrieval time in UTC, the SHA-256 of the saved bytes, jurisdiction, docket, title, filing date, filer, and a plain sentence saying what was fetched and what was done to it.
Why version pairs and not volume. The product's unit is a change between two versions (ADR-02). A corpus of unrelated documents cannot exercise it however large it grows. The dockets were chosen for that shape: one Virginia proceeding carries a direct, an errata and a rebuttal on a single position; two of the recurring reports file consecutive periods that are literally versions of one document; one Ohio docket holds two competing stipulations settling the same thing differently.
Why dirty. The artefacts are the point. This repository has a normalization module built for soft hyphens, ligatures and hyphenated line breaks, and two ADRs — 25 and 26 — whose decisions are currently proved by sweeps of the Unicode code space because the corpus cannot exercise them. A corpus that has been cleaned tests nothing that a written one would not.
Alternatives considered. (a) Clean or reflow the text so it reads well in a demo — removes exactly the property it was fetched for; (b) maximise the document count — five unrelated PDFs, named above. Storing the original PDF bytes rather than extracted text was not weighed.
Cost accepted. The corpus is ugly, and it will produce refusals that are correct and look like defects — a soft hyphen at a line break costs a citation by design (ADR-25), and this is the first corpus that will exercise it. More honestly: none of it is wired to anything. data/real/ holds 102 text files and 102 provenance files, no code in app/, tests/ or scripts/ refers to the directory, and the labelling, the second-corpus mapping ADR-40 promised the MRD, and the separation from the eval corpus all remain undone. This entry records the decisions the retrieval was run under, not a capability the product has.
What forced the choice. scripts/status.py read pytest's summary with a regular expression for "N passed" and fell back to 0 when it found none. A collection error prints no summary at all — one half-written module importing something not yet created aborts the run before a single test executes — so "I could not measure" became "0 tests pass" in state.json, the file ADR-42 makes authoritative over all prose. That zero is indistinguishable from an empty suite and from a suite where everything failed. Three different facts, one number, and the number was the one a reader would trust most.
Decision. passed is None when the run produced no summary, a measured field says so, and a problem field carries the reason and names the module that broke collection. The script says the same on stderr. A consumer that wants a number has to handle the None, which is the point, because there genuinely was not one. parse_pytest_summary is a pure function so it can be tested, and tests/test_status_script.py pins it.
Why. Absence is denial, applied to our own instruments. This project refuses to let a claim assert itself on a citation that did not verify, and then let its own status file assert a measurement it never took. A fallback that returns something plausible hides its own failure — the peer project's hardest-won lesson (ADR-06), found here in the one file whose whole purpose is that it cannot go stale.
Alternatives considered. One is recorded: fall back to 0, which is what existed. Keeping the last known count, and failing the whole script rather than emitting a partial state file, were not weighed and are not claimed here as arguments that happened.
Cost accepted. Every reader of state.json now has to handle a null where an integer used to be, including anything already written against it. And the script still cannot tell a genuinely empty suite from a broken one except through the reason it prints, which is prose — so the machine-readable file says "not measured" and the explanation is a sentence a person has to read.
What forced the choice. ADR-44 gave every agent in a parallel wave a disjoint set of files. It did not say who writes the record, and in practice nobody did. An agent that would have to pick the next ADR number while another agent might be picking the same one skips writing the ADR at all, and the decision is lost with it. The audit on the morning of 2026-08-04 is the measurement: five recorded ADRs had gone false and roughly two dozen decisions had never been written down.
Decision. The record is a phase of its own. One agent owns docs/.ai/decisions.html and nothing else. It runs after the build and attack phases rather than alongside them, and it is handed both the orchestrator's own decisions and every builder's report, so it can record the judgement calls nobody anticipated. It does not commit and does not stage.
Why serialised. Two writers racing on the next number is why earlier agents wrote none. One owner means the numbering cannot race, which removes the reason the writing was skipped rather than exhorting anybody to be more careful.
Why after the builders and not before. Several of the decisions worth recording are taken inside the files the builders own — a refusal semantic, a vocabulary, a limit discovered halfway through. A stage that ran first could only record what was already known, which is the smaller half.
Alternatives considered. (a) Each agent appends its own entry — the race above, and it is the arrangement that produced no entries at all; (b) the orchestrator writes them all at the end from memory — the reconstruction CLAUDE.md forbids, and it cannot see what a builder decided inside its own file. Reserving a block of numbers per agent in advance was not weighed.
Cost accepted. The record is written by an agent that took almost none of the decisions, so it depends on what the builders chose to report: a decision taken and not mentioned in a report is invisible to it, and the honest mitigation is that the writer also reads the code and the commits rather than only the reports. Serialising the stage also costs wall-clock at the end of a wave, which is when there is least of it — the same cost ADR-44 accepted for parking a ready wave.
What forced the choice. The marketing video needs a browser driver. The project's virtualenv is what make install builds on a reviewer's machine, and ADR-07 makes failing to start the primary risk in this submission.
Decision. Playwright and its browsers go into a throwaway virtualenv outside the repository. requirements.txt is unchanged by it — the only edit that file has taken is the model SDK pin (ADR-57).
The reasoning, which is one sentence and is quoted rather than expanded. "Playwright installs into a scratch venv so the project's own environment stays clean — the video is a build artifact, not a runtime dependency."
What was not recorded. No alternative was written down and no cost was named at the time. Searched: the session transcript for any weighing of installing into .venv, of a second requirements file for development tools, or of a system-wide install; the git history, which contains no commit touching Playwright; and requirements.txt itself. The sentence above is the whole of the record. The argument that would have been made against installing into the project environment is ADR-07's and ADR-15's — a browser driver on the reviewer's critical path is another way for make install to fail on a machine nobody has seen — and it is named here as a reconstruction rather than as history, because it is not in the record.
Cost, named now and not then. The environment that produced the video is not in the repository, so nobody can rebuild it from a checkout. That is the trade the decision took and it was not stated when it was taken.
What forced the choice. Eight messages had gone out and one bounced, confirmed by the mailer daemon. The bounced address carried the label filing-verified — it was taken off a certificate of service — and the label was wrong. A service list records an address that was live when it was filed, not one that is live now. That is the same defect class as everything else in this project: a confidence label that had not earned itself. It is also the observation ADR-27 generalises into a rule, that a verified fact has a shelf life.
Decision. The queue was reordered so the three addresses that are genuine pattern guesses send last, after every address read off a filing.
Why that ordering — not recorded. The decision was taken in one sentence and no reason was written for putting the guesses last rather than dropping them or sending them first. Searched: the session transcript for any statement tying the ordering to bounce rates, sender reputation or deliverability. The only deliverability reasoning anywhere in the record concerns the burst pattern of cold outbound from a personal account, and it produced a different decision — pacing sends at five to thirteen minutes with one recipient per employer first, already recorded in ADR-46. That argument is about volume, not about bounces, and it is not the argument for this ordering. Two readings are available — that a bounce early in a run costs more than one late, or simply that the weakest addresses should follow the strongest — and the record does not say which was meant.
What was rejected — also not recorded. Nothing was written down. The three guessed addresses were not dropped, and the bounced address is not recorded as retried; whether retrying it was considered does not appear in the record either way.
Cost, not named at the time. What is visible is that three people whose addresses are guesses now hear last, or not at all if the run stops early. Whether that was the intended trade or a side effect of the ordering is not something this record can say.
Closed since this list was written. Question 1 — which model, and what the fallback is is settled by ADR-37: claude-opus-5, pinned in code rather than read from the environment, with three named fallback states that announce themselves rather than one silent one. Question 3 — event log or snapshots is settled by ADR-16, ADR-17 and ADR-31: an append-only hash-linked event log, with a correction written as a new row that supersedes an old one. The rollback half of that question has a column and no digest scheme behind it, which is ADR-32 and is a gap rather than an open question.
Still open — 2. Where the confidence threshold sits, and on what evidence. ADR-06 made the threshold configuration rather than a constant and did not say what number to configure. What would settle it: an eval set large enough that the cost of a false escalation and the cost of a false assertion can be counted separately at several threshold values. The current corpus cannot do that — see ADR-39 — so this stays open behind the corpus question, not beside it.
Still open — 4. What the eval set is for obligation extraction, and who labels it. Nobody has labelled one. What would settle it: a labelled set produced by somebody who does the job. Self-labelled ground truth measures agreement with oneself; it is a sanity check wearing the clothes of an accuracy claim, and it should not be reported as the second thing.
Settled — 5. What calls the overdue sweep in production. ADR-66. A separate process the entrypoint starts, off unless VERBATIM_JOBS_ENABLED asks for it, announcing on every start which jobs are scheduled and which are not. The third option listed here — computing overdue state lazily on read — was rejected for the reason it took writing down to see: reminders and escalations would then fire only when somebody opens the screen, which is exactly when they are least needed.
Settled — 6. Whether the ingestion boundary is a decision or a sketch. ADR-67. Built, for one source kind. The exact-host allowlist described in planning was rejected in favour of checking every resolved address, because an allowlist makes registering a new commission an engineering task and so defeats the screen it sits behind. Private-range rejection is there and goes further than planned: every address a name resolves to, and every redirect hop, rather than the one address that would have been used.
Newly open — 7. Whether a version id should be derived from its content. Planning proposed a version id computed from the source reference, the bytes and the extractor, so re-fetching the same filing produces the same id and changing the extractor correctly produces a new one. ingest_version takes the id from its caller. What would settle it: a second ingestion source, at which point caller-supplied ids start colliding or duplicating.
What forced the choice. The owner asked for permissions granular enough that an administrator can give any permission to any person regardless of the role label, because real workspaces exist where the administrator is also a superuser who approves. That reading came from the owner, drawn from the titles and signature blocks on public filings in data/real/ — not from user research, because there has been none. An earlier draft of this sentence said "the research showed", which would have put a finding in a reader's hands that no analyst ever said. Zero interviews have happened; docs/user-research.html opens by saying so. Granting that freely makes the administrator every role at once, and the segregation of duties the whole product rests on becomes a naming convention.
Decision. An administrator may ARRANGE authority that already exists in the company. They may never mint new authority. The ceiling applies to all three paths that would otherwise go round it — a direct grant, composing a role, and editing one — in app/state/permissions.py::_ceiling. The admin grid deliberately carries no approval permission, so an administrator cannot hand action.approve to an account they control, which is the move the rule exists to stop.
Why this is not the deadlock it looks like. A new tenant's first administrator cannot compose "Certifying officer" because they hold no approval. What they can do is grant the system role obligation_owner, which ships with every tenant and carries approval, and then give that person user.manage as a direct grant. That person now holds both and composes the approving roles the company actually needs. Authority enters a tenant only through the three roles that ship with the product; everything after is arranged by somebody who already holds what they are arranging. The two steps are the point, not an inconvenience.
Alternatives considered. (a) Let an administrator grant anything — what was asked for, and it makes the admin account a universal approver, so every approval in the audit chain becomes unfalsifiable; (b) forbid direct grants and allow only roles — keeps the ceiling and refuses the real workspaces the research found, where one person legitimately holds both halves in a four-person team; (c) allow anything and rely on the conflict report to shame it — a report nobody is required to read is not a control; (d) require a second administrator to approve a privilege change — the right answer and it needs an approval flow for privilege changes that does not exist, so it is named here as not built rather than claimed.
Cost accepted, and it is a hole rather than a trade. identity.py::grant_role has no ceiling and cannot have one as it stands: its actor is a display string, not an account, so there is nothing to compute a ceiling against. An administrator can therefore still grant an existing role — including obligation_owner — to themselves. That hole predates this module and is conceded in identity.py's own docstring; SEGREGATION_CONFLICTS names it as the user.manage/action.approve pair so the register shows it. Closing it needs grant_role to take a user id and a second approver. Every function in the new module takes a user id for its actor precisely so it is not a second instance of the same gap.
What forced the choice. Some permission pairs are a segregation-of-duties problem — proposing and approving the same action being the first one an auditor asks about. A product can refuse them or report them, and the two answers suit different companies.
Decision. Report. SEGREGATION_CONFLICTS in app/state/models.py holds the pairs this product has already argued are a conflict and no others; each carries the sentence a register prints beside the person's name. Nothing refuses. conflicts_in() is the single spelling of the check and the register is a read over it.
Why. A four-person regulatory team where one person proposes and approves is not a bug in their company, it is their company. A product that refuses it is uninstalled, and the refusal buys nothing: the same person does the same two things in a spreadsheet where nothing records it. Reporting keeps the arrangement and makes it answerable — which of two people holds both halves, which pair, and why anybody minds, in words a regulator would accept. Refusing produces compliance in the product and silence in the record.
Alternatives considered. (a) Refuse the pair outright — clean, and it makes the product unusable for small teams while moving the conflict somewhere with no audit trail; (b) refuse by default with an override — the override becomes the default within a week and nothing records who set it; (c) score a risk number per person — a number nobody can act on, and this repository has already argued (ADR-39) against printing a rate where the underlying counts are what a reader needs; (d) report only at grant time — the conflict is created by the second grant and read months later, so a warning at the moment of the grant is seen by exactly the wrong person.
Cost accepted. A company can hold every conflict in the list and ship. The register names it and no code stops it, so the control is a reader rather than a gate — and if nobody opens the register, nothing happened. The list is also closed by hand: a pair nobody has argued about is not reported, so absence from the register means "not considered" as much as "not a conflict", and the page must not be read as a clean bill of health.
What forced the choice. Three pieces of work existed with nothing calling them: the overdue approval sweep, the retention purges, and a source fetch. Open question 5 in this file named the gap. Two of the three can act on a customer's data without a person present — retention deletes rows and the approval clock can bypass an approval — so the scheduling decision is a safety decision, not a convenience one.
Decision. A loop in a separate process, started by deploy/entrypoint.sh only when VERBATIM_JOBS_ENABLED is true, which is not the default. scripts/run_jobs.py also runs one pass, for anybody who would rather own the scheduling with cron. No Celery, no APScheduler, no Redis, no new dependency. Retention runs as a dry run unless a second setting asks otherwise, and purge_all re-checks that flag itself rather than trusting its caller, because 0 is falsey and a configuration typo that armed a delete is the worst thing this could do.
The banner is the part worth defending. It prints one line per known job, on or off, plus the mode retention is in, on every start whichever way the configuration landed — and it exits non-zero when the configuration names a job this build does not have, so nothing starts rather than starting the jobs that happen to be spelled correctly. A reviewer reading the container log has to be able to tell "no job is scheduled" from "a job is scheduled and has not fired yet", and a silent boot reads as the second. This is best-practices §26 applied to an absence rather than a fallback.
Alternatives considered. (a) A thread inside the application — no second process to supervise, and a sweep then competes with a request for the same SQLite write lock, turning a slow page into a locked one; (b) an external scheduler such as cron or a platform timer — the better production answer and it cannot be demonstrated from this repository on a reviewer's machine, so it is offered rather than required; (c) compute overdue state lazily on read — no scheduler at all, and reminders and escalations then fire only when somebody happens to open the screen, which is precisely when they are least needed; (d) a real job framework — brings a broker, a second process model and a dependency, for three jobs.
Cost accepted. Nothing supervises the loop. If it dies the site keeps serving and the only sign is that the log lines stop; the pid is printed so a reader can check. The two processes take the same SQLite lock, so a long sweep can still make a write wait — the ADR-28 ceiling arriving in the deployment rather than being papered over. And a job that fails is recorded and the loop carries on, which means a job failing every cycle is a line in a log nobody is paged about.
What forced the choice. The Integrations screen registered source kinds and fetched nothing, which was honest but inert. Making one kind fetch means this becomes the first code in the product that opens a socket at all, and an administrator chooses the address. This process runs on a droplet sharing a kernel with another project; it can reach a cloud metadata service at 169.254.169.254, every neighbour on the private network, and itself on loopback. A screen that accepts a URL and fetches it hands all of that to whoever reaches the screen.
Decision. public_docket fetches, and the guard in app/sources/fetch.py is treated as the feature rather than as validation in front of it. Scheme restricted to http and https before any name is resolved, so a bad scheme costs no lookup of an attacker's choosing. No credentials in the URL, and a refusal never repeats what it refused. The host is resolved here and every address it answers with is checked — loopback, private, link-local, reserved, multicast, unspecified, and anything ipaddress will not call global. Any bad address refuses the whole name rather than the one address that would have been used, because a name with one public and one private record is the cheap version of DNS rebinding and a guard that checked only its chosen address lets it through on the next attempt. Every redirect hop is checked the same way. A timeout and a maximum body size, since a slow response and an enormous one are both denial of service against our own host.
What a fetch does. Retrieve, hash, compare against what is stored. Unchanged means say so and write nothing. Changed means store the new version and let app/pipeline.py::ingest_and_diff do the diff it already does. The attempt is recorded either way with its status and reason, so the registry can show when it last ran and what happened without inventing either.
Alternatives considered. (a) An allowlist of exact hosts — stronger, and it makes registering a new commission an engineering task, which defeats the screen; (b) check the URL and let the HTTP library follow redirects — the standard mistake: the first hop is public, the third is 169.254.169.254, and nothing looked; (c) fetch through an outbound proxy that enforces the policy — the right production answer, needing infrastructure this build does not have, so it is named as future work rather than implied; (d) no fetching at all — what shipped this morning, and it left the roadmap's live-ingestion item blocked on nothing.
Cost accepted. Between resolving the address and connecting to it there is a window where DNS could answer differently — a full defence pins the connection to the address that was checked, and this does not. Tests drive an injected transport and pass with no network, which proves the guard's logic and proves nothing about a real commission's server. There is no universal docket API, so one kind fetching is one kind, and every other jurisdiction is its own integration.
What forced the choice. app/state/rollback.py takes one decision back and does it well. It does not answer "what could this person see on the day they signed it, and put us back there" — the question an auditor asks first and the only one a regulator's letter ever asks.
Decision. Two functions in app/state/replay.py, kept apart on purpose. 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 every rule that module enforces still holds. The chain is never rewound, truncated, resequenced or rewritten — a restore only appends, and verify_chain() passes over the whole log including the restore itself.
Why they are separate. The read is the valuable half and it is the safe one. Bundling it with the write would put the question an auditor asks behind a function that can change the record, so asking it would need the authority to alter what is being asked about.
What it refuses, rather than approximating. A decision the chain does not record cannot be replayed, so it names the tables the chain covers and refuses with that list rather than silently restoring the half it knows — a partial restore presented as a restore is the worst outcome available. Restoring never makes a citation verify: nothing here can turn a withheld claim into an assertion, only the source agreeing with the quote does that. And it will not re-make a decision — putting one back into force needs a name and a time against a judgement nobody made — so a moment whose restoration would require re-deciding something is refused with the reason. A moment before the first audit row is "before the record begins", not "empty".
Alternatives considered. (a) Snapshot every table on a timer — answers the question directly and doubles the storage while creating a second source of truth that can disagree with the chain; (b) event-source the whole application so state is always derived — correct and a rewrite; (c) extend rollback.py with a time argument — one function whose blast radius depends on an argument, which is how a read becomes a write by typo; (d) rewind the chain to the moment — simple, and it destroys the evidence the product exists to keep.
Cost accepted. Replay is only as complete as the chain, and the chain does not cover every table, so the honest answer to many moments is a refusal with a list. No screen calls either function yet — this is a module with tests and no route, which is the shape this repository has shipped by accident five times today and is shipping deliberately once. It is named here so it is not mistaken for a feature a reviewer can click.
What forced the choice. Six screens — /users, /permissions, /admin/shares, /admin/invites, /admin/sources, /admin/feedback — were mounted, answered 200 to an administrator who typed the URL, and appeared on no screen anybody could reach. A feature nobody can navigate to is a feature nobody has. The masthead carried six analyst screens and there was nowhere for an administrative one to live.
Decision. An /admin index listing the administrative screens, linked once from the masthead, and drawn only for somebody who holds a permission behind at least one of them. The boolean reaches base.html through app/web/templating.py, a shared factory that builds every Jinja2Templates object in the product and registers what the base template reads.
Why the factory was not optional either way. Fifteen view modules each built their own Jinja2Templates, so anything base.html needs had to be registered fifteen times or not at all — and a base template that works on twelve screens and raises on three is worse than the bug it fixes. This is the reason the alternatives below collapse into one: an index page still needs the masthead to know whether to draw its link, or an analyst clicks "Admin" and gets a 403, which is worse than no link at all.
Why the permission is read on every render rather than cached at sign-in. app/web/deps.py::Principal deliberately carries no permissions: a copy taken when the session started would still offer the link after the grant behind it was revoked. So the menu asks policy.has() — the function written for deciding whether to draw a button, never as the gate — deduped by code, two reads per page, cached for the life of one request. A test holds a single session open while a role is granted and revoked underneath it.
Alternatives considered. (a) Six more items in the masthead — twelve items is a wall of words, six of them screens an analyst never opens, and there is no dropdown machinery anywhere in this product to hide them behind; it would also put a permission read per screen on every render, and leave nowhere to say which code opens each one, which is exactly what an administrator granting a colleague access needs to know; (b) the factory in app/web/__init__.py, which is where it was first proposed — that module promises it imports nothing beyond the standard library and deps.py imports from it, so a factory there closes an import cycle and drags FastAPI and SQLAlchemy into every from app.web import TEMPLATES_DIR; (c) draw the links for everybody and let each screen refuse — a link that 403s teaches the reader nothing and trains them to ignore refusals; (d) hard-code the menu in the template — the permission codes would then exist in two places, and the copy in the template is the one that would drift.
The item is hidden rather than greyed out, unlike the two conditional nav items beside it. "Open a proceeding to reach this screen" is an instruction somebody can act on. There is no instruction to give a person who does not hold user.manage, so a permanent greyed row would only tell them what they cannot have, on every page, for ever. Somebody who types /admin is answered there instead.
Cost accepted. Two of the seven screens are gated on a permission that stands in for one this product has not defined — /admin/sources and /admin/feedback both resolve to user.manage where source.manage and feedback.triage are what they mean. So an administrator cannot grant somebody the sources screen without granting them the account screen as well. Naming the real codes is a schema change and a migration, and it is not done. /admin/workflows is listed although it already had a way in from /workflow, because it is the only entry gated by a different code — without it, a menu whose every row shared one permission would pass its tests with the permission check deleted.
What forced the choice. Four places fetched a row by primary key with session.get and each was separately responsible for comparing company_id afterwards. Ids are unique across tenants and the corpus supplies them, so that post-check was the only thing between a caller and another tenant's row. workflow.py and rollback.py remembered it. routing.py::ensure_obligation did not: it called _require_scope, which validates the company_id string and says nothing about the row, then returned whatever carried that id. app/sources/fetch.py checked nothing at all and would have written this tenant's provenance onto another tenant's document version — and that file shipped this morning, which is the point. Three authors were asked to remember one rule; two did.
Decision. app/state/queries.py::row_for_company(session, company_id, model, row_id) does the fetch and the scope check as one call, and all four sites use it. Your row comes back. An id nobody holds returns None, so a loader may still create it. Another company's row raises CrossTenantRow, which subclasses ValueError so every existing handler still catches it while rollback.py can single it out — in the chain, a cross-tenant pointer is tamper evidence rather than a lookup miss. The refusal names nothing about the other tenant: it reads exactly as a missing row, which is all the asker is entitled to know.
Why this was latent and why that is not a defence. ensure_obligation has one caller outside its tests, and that caller passes one company. So nothing leaked. It also means nothing was ever going to find it — no test covered the function and the guard beside it looked like it was doing the job. Latent is the reason it was still there, not a reason to leave it.
Alternatives considered. (a) Fix the one line in ensure_obligation — the instance rather than the class, and fetch.py had already proved a fourth site arrives on its own; (b) return None when the row belongs to somebody else — it collapses "nobody holds this id" into "somebody else does", and both create-if-absent callers would then INSERT on a primary key another tenant holds, surfacing as an integrity error somewhere unrelated; (c) a session-level or query-level filter in SQLAlchemy — the strongest option technically, and it moves the check out of sight of a reader, which costs more than it buys in a submission whose argument rests on a reviewer being able to see the control; (d) leave the post-check to each caller and add a review rule — that is what was already in place, and it is how this happened.
The tests were not testing what they claimed, which is the finding worth carrying. _require_scope did not reject "%". Reduced to return company_id — the whole guard deleted — the isolation suites still passed eight tests, because they caught the ValueError and otherwise settled for an empty list, and an empty list is exactly what a deleted guard produces when = treats % as a literal. The guard now refuses %, _, whitespace-only values and padded ones, and refuses padding rather than trimming it — trimming would repair the guard's own copy and leave every caller filtering on the padded original. The tests carry a control that accepts the ids the product really uses, so a guard that refused everything cannot pass either.
Cost accepted. A call site wanting its own wording wraps a try/except, and three do. Verified the only way that counts: deleting the scope comparison in a scratch copy turns five tests red across three modules, including one that predates this change.
What forced the choice. search_claims matched a query against the claim's own statement. A claim reading "the plan is now due within sixty days" was unreachable by a question about a distribution system implementation plan, though the passage it cites carries every word of that question. The product was findable only through the sentences it had already written.
Decision. SQLite FTS5 with bm25 over the passage store, read only by the assistant's tool layer. No search box, no search screen, no nav item, and none planned. ADR-08 said lexical before semantic; this is that, built.
Why this does not reverse ADR-02, and why the distinction is not a form of words. ADR-02's argument stands as written: search assumes the analyst already knows what to look for, and the expensive part — interpretation — is exactly what search skips. A search feature hands a person a ranked list and asks them to judge it, which puts a ranking in front of the evidence and lets a good rank read as an answer. Retrieval inside the citation path hands the model a candidate that then has to earn its place through the gate every claim goes through. The first undercuts the citation story; the second is what makes it work on a corpus bigger than a page. docs/future-enhancements.html still excludes general regulatory search and that exclusion is unchanged.
Tokenisation, and the rule that makes "agree" precise. Both sides are normalised by app/text/normalize.py; FTS5 only splits words on already-folded text. The rule: the index may be more permissive than the verifier, never less. A permissive index offers extra candidates the gate rejects; a restrictive one loses a passage that exists and would have verified, and nobody can notice that. Measured against the raw text three of four PDF shapes were silent misses, all in the restrictive direction — file not found by "file", a soft-hyphenated maintain not found by "maintain", full-width 20 MW not found by "20 MW". In the strict direction it keeps what normalize() keeps, so 20² is not reachable by 202 and a footnote marker never becomes a digit.
The correction the real corpus forced, which is the part worth reading. On 8,707 seeded passages, exact-token curtailment found 9 passages where the substring scan found 15. All six it dropped said Curtailments. That is precisely the failure this was built to avoid, introduced by the change meant to improve retrieval, and it was caught by measuring against real text rather than a fixture. Fixed with a prefix rule: a term ending in four letters or more is also matched as a prefix, and a term ending in a digit never is — so 20 cannot reach 2000 and 5.4.1 cannot reach 5.4.10.
Alternatives considered. (a) Keep the substring scan — cheapest, and it is the reason claims were reachable only by our own words; it also reads every passage on every question, 366ms against 1.3ms indexed; (b) embeddings — rejected on ADR-08's reasoning and on cost, and because "why did it pick that passage" stops having an answer a reviewer can read; (c) an external index such as Lucene or Meilisearch — a service to run and a second copy of tenant data outside the database whose join carries tenancy, against ADR-07 and ADR-09's requirement that make run works on a stranger's machine; (d) FTS5 triggers keeping the index current automatically — rejected because a trigger makes the index look self-maintaining, and the day the tokenisation scheme changes it writes new-scheme rows beside old-scheme ones, which is a half-migrated derived corpus and exactly what best-practices §27 forbids; (e) rebuild inside migrate()'s column loop — a category error, since that function never backfills.
Rank is not verification, and it is visible in the product. Every candidate goes through the real verify_citation before it is offered, and carries is_evidence: False written explicitly rather than omitted — a missing key reads as "not applicable" where a present False reads as "asked and answered no". A retrieved passage carrying the cited span of a withheld claim is not handed over at all, and the count is said out loud. So a top-ranked passage can produce a withheld count and no text: the citation gate beating the ranking, where a reviewer can see it happen.
What happens when the index is stale, missing or unavailable. Three named reasons, each degrading to a complete unranked scan rather than a short list, with the reason carried into the tool result and into the note the model is handed. A caller cannot otherwise tell "nothing matched" from "the index was empty". Staleness is caught two ways: scoped coverage counts per company, so one tenant's new filing cannot condemn another's index, and a per-hit fingerprint that catches rowid reuse. One disagreement condemns the whole answer rather than being filtered out of it, because a filtered list is a short list.
Cost accepted. A whole-corpus read on every degraded query. The module is SQLite-specific and has never run against Postgres, where the read degrades with a named reason rather than raising. A passage edited in place, indexed under its old words and never returned as a hit, is not detected — catching it means fingerprinting the whole corpus per query, which is the cost the index exists to avoid. It is inert only while nothing in app/ updates a Passage row, and the module docstring names whoever writes that path as the owner. On the first hosted start the entrypoint migrates before it seeds, so the index is built over an empty corpus and every query reports it stale until the next restart; answers stay complete throughout. And no eval: speed and recall are measured, ranking quality is not.
Why these six are dated earlier than they were written. Invitations, retention, sharing, notifications, backup and the schema migration are among the largest and most security-relevant modules in this product, and none of them had a line in this file. The reasoning was not missing — it was in the module docstrings, argued at length and at the moment the code was written. What was missing was the entry in the log a reader is told holds the decisions. These six lift that reasoning into ADR-72 to ADR-77.
The dates are the code's, not this entry's. Five of the six landed in commit 97daf02 at 12:48 on 2026-08-04; migrate.py landed in 4ac7029 at 15:12 and was corrected in ba2b0cb at 20:04 the same day. The entries carry those dates because that is when the decisions were taken. They were written up in the evening, which is said here and in every meta line rather than left for a reader to work out. This repository has twice shipped a document claiming evidence that did not exist when it was written; an ADR that lies about its own date is the same defect in a smaller package.
Three of them contradict their own docstrings, and the contradiction is in the entry. Where a module argues something its code does not do, the ADR says so under its own heading rather than repeating the claim. That is the whole value of writing these late: a docstring is read by whoever edits the file, and nobody had read these against the code since the hour they were written. Checked against the tree on 2026-08-04; where a claim could not be checked, the sentence says so.
What forced the choice. The analyst works out which obligations a change touches and then has to reach the person who can approve acting on it — who sits in Legal, or Rates, or Engineering, and has no account here. What they send today is a quote pasted into an email, which the recipient has to take on faith. That is the exact thing this product exists to replace, happening in the last step of the workflow, every time.
Decision. app/state/sharing.py mints a bearer link to exactly one claim or one change, readable with no account, at /s/<token>. Five rows in the schema stand where the session guard stood, and each is a column rather than a convention: a token of 32 random bytes stored only as a digest; one artifact, never a project, never a proceeding, never a list; an expiry, seven days by default and thirty at most, never absent; revocation by the sharer or a holder of user.manage at any time; and an audit row for every mint, every open with its address and verdict, and every revocation, in the same chain as everything else. The tenant comes off the link row — a visitor carries nothing, so the lookup is by digest alone and company_id is read from the row that came back.
Why the verification re-runs on every open, which is the point of the feature. open_share() calls verified_claims(), which re-reads the stored source at the cited offsets during that call. Nothing rendered is stored and served back and there is no cached verdict to go stale, so a source edited after the link went out takes the statement off the page on the very next open. A recipient watching a claim withdraw itself is the strongest demonstration this product has, and it costs one read per open. This is ADR-03 made visible to somebody outside the company.
The token is stored as a plain SHA-256 and not through a key derivation function. What that costs and what it does not. hash_share_token is hashlib.sha256(token) — no salt, no scrypt, no work factor, unlike the password path in ADR-15. Against a password that would be indefensible. Against secrets.token_urlsafe(32) it is the right answer: the input is 32 bytes from the system generator, so there is no dictionary to run and no candidate cheaper to try than any other, and a slow hash would buy nothing while taxing every open. What it does cost is worth naming rather than waving away. A stolen copy of share_links plus one stolen token confirms which row that token opens, where a salted digest would not. And it removes the margin a KDF gives you when the assumption turns out to be wrong: if any future caller ever mints a share token from something guessable, the storage offers no second line of defence. Nothing in code enforces that assumption — it rests on new_share_token() being the only mint, which is true today and is checked by nobody.
Alternatives considered. (a) A signed link with nothing stored — no lookup and no row to keep, and a signature stays valid until the key rotates, so there is no way to withdraw one link; revocation at any moment is the second of the five controls and this alternative deletes it. (b) An account for the recipient, invited properly — the strongest answer, and it asks a lawyer in another department to accept an invitation and set a password before reading one paragraph, which is the friction the feature exists to remove. (c) A link that does not expire — what a "copy link" button usually does; an unauthenticated link that outlives the question it answered is a standing hole nobody remembers opening. (d) Store the token in the clear — then the registry screen is a list of working links and a database read is a break-in. (e) Run a KDF over the token anyway — cost on every open against an attacker it does not stop, and it would suggest the token is weaker than it is.
The redirect that leaked the credential it was guarding. app/web/deps.py sends any request with no session to /login?next=<path>. The share prefix was not on the public list, so the guard answered 303 to /login?next=%2Fs%2F<token> and put a live bearer token into a query string — where it reaches the access log, the referrer header and browser history. Leaving the path out did not make the feature private; it made it leak. A redirect that carries the credential it was protecting is worse than no guard at all, because it looks like a guard working. Fixed by PUBLIC_PREFIXES, which stores the prefix with its trailing slash: startswith("/s") would make every root path beginning with the letter s public, and a guard list that widens itself by accident is worse than none.
The tenant switch is a default and refuses to pass for a decision. sharing_enabled() returns a Setting carrying a value, a source and a note. Today the source is always default, because there is no companies table and no settings row; the note says so in words. On by default, because a product nobody can share is not this product — and the type exists so no screen can print an unqualified "on" for something nobody chose. Both minting and opening ask it, since a switch that only stopped new links would leave every link already in an inbox working, which is not what "sharing is off" means to whoever turned it off.
Cost accepted, and the module states most of it in its own docstring. No rate limit: guessing a 32-byte token is not a realistic attack, but a flood of opens against a real token is slowed here by nothing. No way to tell a recipient from anybody they forwarded the link to — ShareOpen records that somebody holding it opened it from that address, and reading that as attendance is a stronger claim than the mechanism supports. No check that the sharer still works there at open time; the link outlives the session that made it, on purpose, because that is what makes it sendable, and expiry and revocation are the two controls that bound it. And any analyst can mint one: the permission required is the read permission for the artifact, reading is free, and that is a decision about the go-to-market rather than an oversight.
Two loose ends this entry does not settle. The three ACTION_SHARE_* codes live in this module rather than in app/state/audit.py, because another agent held that file when this was written; the module says in capitals that they must be moved and not restated, since two spellings of one action code is a row that verifies perfectly and that no query for that action ever returns. And claim_for_company() is a single-claim read this module wrote because app/state/claims.py has none. Both are handoffs, and both are still open.
What forced the choice. The public site prints a privacy promise, and deploy/site/privacy.html is the page a buyer's procurement team reads first. A retention schedule is normally a table in a document — a set of claims nobody can check, which goes false the first time somebody adds a table and true again only if a person remembers.
Decision. SCHEDULE in app/state/retention.py is a tuple of Rule records: one table, one window, the column the clock is measured from, a reason, and a dotted path to whatever carries it out. tests/test_retention.py refuses a table in models.py that has no rule, resolves every non-empty mechanism by import rather than believing it, and reads the privacy page to check the windows printed there are the windows the code counts. Rule is frozen, so no call site can move a window and leave the page describing a schedule the code no longer runs. mechanism="" is the honest value and means the window is written down and nothing performs it — the difference between a control and a promise, made a field rather than a footnote.
Dry run by default, and the flag is checked again inside purge_all. Every purge takes dry_run: bool = True, and _require_flag refuses anything that is not a real bool rather than coercing it: dry_run=0 is falsey and would arm the destructive path, dry_run="no" is truthy and would disarm it, and both are mistakes only found afterwards, by which point rows are gone. purge_all runs that check itself rather than trusting the caller who already ran it. The duplication is the decision, not an oversight — the caller is a scheduler reading configuration, a typo in configuration is how this gets armed, and a re-check at the door of the destructive path costs one line. ADR-66 records the same rule from the scheduler's side.
The audit row outlives the record it names, and that is the whole shape of erasure here. The chain is append-only twice over: audit.py refuses an UPDATE or a DELETE from application code, and the hash over every field of every row, gapless per company, detects a row removed by other means. Retention cannot reach it. So a subject row is purged while the audit row naming it survives, pointing at an id that no longer resolves. A reader of the log can still learn that a record with that id existed, what type of thing it was, when each decision about it was taken, who took it, from what address, and the reason given. They cannot learn what the record said — the words of a chat turn, the text of a complaint, the address a link was opened from. That is not complete erasure and the module refuses to call it any. record_event stores subject_id, actor and reason verbatim, so wherever one of those is the personal datum, no purge reaches it: a failed sign-in records person:<address> as the actor and, with no matching account, the address again as the subject; an invitation's audit row quotes the invited address in its reason. Purging the invitation leaves that line standing for ever. The tests pin both, because a limit nobody wrote down is a limit a buyer finds instead.
What the destructive run writes, and when it writes nothing. The audit row goes in before a single row is touched, in the same transaction, naming the rule, the table, the count and the cutoff — and nothing out of the rows themselves, because a log that quotes what it deleted has moved the data rather than removed it. A run that matches nothing writes no row at all: a purge that purged nothing is not a deletion, and a log of non-events is a log nobody reads. Redaction is a marker and not a blank — IP_PURGED = "(purged)", because "" on that column already means the server recorded no address, and blanking a purged row would file a deletion as a gap with nobody able to tell them apart afterwards. Two action codes and not one, for the same reason: retention.purged means the rows are gone, retention.redacted means the row is there with one field taken out.
The order is chosen so that the dry run predicts the real run. No rule may change what a later rule matches, or the report a person approved describes a different deletion from the one that ran. Two places that could have gone wrong: the share-open redaction runs before the share-open purge, so marking a row does not remove it from the purge's selection; and the chat purge runs before the feedback purge, so a transcript held back by a live complaint stays held for the whole pass and goes on the next run, rather than being released halfway through by a rule that just deleted the complaint holding it.
Alternatives considered. (a) The schedule as a page in docs/ — the normal answer, and no test can fail when somebody adds a table, so the page rots silently and the first reader to notice is a buyer's counsel. (b) Destructive by default with a --dry-run flag — the conventional shape for a command-line tool, and it makes every mistake a deletion. (c) Coerce the flag, since a caller passing 0 obviously means false — one line shorter and it is precisely the bug this refuses. (d) A tombstone column, or a second redacted copy of the log — both edit a record that must not change, and the second log is the one nobody reads, which audit.py argues at length and ADR-16 records. (e) Push the timestamp comparison into SQL — right at a million rows, and it relies on UtcDateTime storing ISO-8601 text that sorts chronologically, which is true today and pinned by nothing, so the cheap correct thing is done and the reason written down rather than discovered later.
Cost accepted. Each purge loads the company's rows for its table and compares timestamps in Python, which is right at this size and wrong at a million rows. The schedule reaches rows in this database and nothing else: whatever web server sits in front keeps its own request log and nothing here touches it, and a copy of the database taken before a purge still holds every row the purge removed — so a retention window is only as short as the oldest snapshot anybody kept, and ADR-77 is the module that makes those snapshots. Rows holding the customer's own filings get no clock at all, because there is no account-closure event in this build to start one, and the honest entry is "while the account is open" with nothing counting.
The claim in this module that a later commit made false, found writing this entry. The docstring has a section headed "what would call this in production, and what calls it today", and its answer is: "Today: nothing. There is no scheduler in this product. No cron entry, no background worker, no queue, no startup hook. app/main.py does not import this module and neither does anything else." That was true at 12:48. At 18:49 the same day, commit 36cb2c5 added app/jobs/runner.py, whose line 138 reads from app.state.retention import purge_all. The test was rewritten in that commit to branch on whether a caller exists, so it passes with the caller in place — pytest tests/test_retention.py, 31 passed on 2026-08-04. The docstring was not touched and is now wrong. Nothing about the safety argument changed: the runner passes dry_run=not delete, purge_all re-checks it, and the destructive path is still off unless two settings ask for it. What changed is that the sentence a reader trusts is false, in the module whose whole design is that a promise must be checkable. It is a file this entry does not own; it belongs in the handoff list beside the two action codes.
Fixed the same day. The docstring section is now headed "what calls this" and names app/jobs/runner.py, the two settings that stand between that scheduler and a deleted row — VERBATIM_JOBS_ENABLED to start the loop at all, VERBATIM_JOBS_RETENTION_DELETE to arm the destructive path — and the two independent bool checks. It also says in place that the paragraph read "nothing" for six hours after that stopped being true, because the correction is worth more than a tidy page in the one module whose design is that a promise must be checkable.
What forced the choice, twice, and both were near misses rather than theories. First: deploy/entrypoint.sh seeds only when there is no database file, which is right, because a redeploy must never lay fresh demo rows over an audit chain. But it ran nothing when the file was present — not even create_all. So the first deploy after a column was added would have kept the old table while the new code selected the new column, and every proceeding, claim and verification screen on the live site would have answered (sqlite3.OperationalError) no such column: document_versions.source_url. Nothing in the test suite could catch it: tests build their schema from the current models every time, so they never see yesterday's database. The only place it shows up is production, on the deploy, in front of whoever is looking.
And then the fix nearly destroyed the one artefact this product exists to keep. The generic column loop adds a column the model declares, with no server default rendered. Run before migrate_audit_schema, it would find audit_events.digest_version missing, add it NULL, and migrate_audit_schema would then see the column present, return () and never backfill. verify_chain refuses the whole log at that point — app/state/audit.py:461, "seq 1 claims digest scheme None, which this build cannot compute" — so the audit trail is destroyed by its own deploy step, and the record ADR-17 was written to protect reports tampering on rows nobody touched. tests/test_audit_v2.py passed throughout, because it calls migrate_audit_schema itself and that path was always correct. Only the order the entrypoint actually takes was wrong, which is the kind of defect a unit test is structurally unable to see.
Decision. migrate(engine) asks SQLAlchemy what the models declare, asks the database what it has, and adds the difference. migrate_audit_schema runs first, before the generic loop, and that ordering is the fix rather than a preference — the comment above it says so and says why, at the line where somebody would otherwise reorder it. It returns a report with four keys, because they are four different events to a reader: a new table is a feature arriving, a new column is a feature growing, a refusal is something a person has to look at, and the index key is derived data that was recomputed. Only refused is fatal to a caller. Safe to run on every start, and it must be — an idempotent migration nobody calls is the same as no migration.
Why derived rather than a list of ALTER statements. Five ALTER TABLE lines for the five columns that caused this fixes the instance and leaves the class: the next column anybody adds, in any table, breaks the deploy the same way, and the person who adds it will not think of this file. A column added tomorrow is covered by code written today, which is the only kind of coverage that survives a team.
What it will not do, and each refusal has a reason. It never drops, renames or retypes — those are destructive and this product keeps an append-only hash chain that a rewrite would silently invalidate, so a type change refuses and says so rather than guessing. It never backfills a value: a new column is NULL on old rows, because NULL says "the schema of the day did not record this" and a default says something the record cannot support. It never adds a NOT NULL column to a table with rows, because SQLite cannot and because the honest answer for an existing row is that the value is unknown; the refusal carries the sentence a person needs — add it nullable, backfill deliberately, then tighten.
The one exception, and why it is not a backfill. The passage index is derived data, and derived data has the opposite rule (best-practices §27): it migrates all at once or not at all, because half of it answers every query plausibly and wrongly. So this file calls a rebuild — discard, recompute from the stored text, whole, in one transaction — and never a fill-in-the-missing. Nothing here writes a value into an existing row, which is the promise above and it still holds. Its outcome gets its own report key and never refused, which reads like a detail and is the whole safety of putting it there: scripts/migrate.py exits non-zero on anything in refused and deploy/entrypoint.sh stops the deploy on that, correctly, because a schema the code cannot read is an outage either way. A missing index is not. Retrieval without one answers from a complete scan and says so on every query (ADR-71), so a database with no FTS5 should serve a slower product rather than no product, and folding the two together would turn an optimisation into an outage.
Alternatives considered. (a) Alembic, or any real migration framework — the right production answer, versioned, ordered and reversible, and it adds a dependency, a migrations directory and a step a reviewer running make run has to get right, against ADR-07's rule that the primary risk is failing to start. (b) The five ALTER TABLE lines — the instance, not the class. (c) Drop and recreate on deploy — trivial, and it deletes the audit chain, which is the product. (d) create_all on its own — what a reader assumes it does, and it will not add a column to a table it already sees, which is the entire reason the loop exists. (e) Leave migration to a step in a runbook — a step nobody runs on the deploy that needed it, which is how the first of the two incidents above happened.
Cost accepted. This migration can only add. Everything else — a rename, a type change, a NOT NULL tightening, any backfill somebody actually wants — is refused with a sentence and left to a person, so a deploy stops rather than guessing; that is the right failure and it is still a stopped deploy. It reports what it did rather than proving it: there is no check afterwards that the models and the database now agree, so a refusal that nobody reads looks like a clean run in every way except the exit code. And the order on a first deploy is beyond this file — entrypoint.sh migrates before it seeds, so the index is built over an empty corpus and retrieval reports itself stale until the next restart, with answers complete throughout. make seed closes that on the local path; the first hosted start is still one restart behind, and that is a deploy file, not this one.
What forced the choice. Two different reasons to hand somebody a link that sets a password. Routing found the person who should own a duty and stopped, because that person has no account — the invitation names the work that is waiting. And an administrator needs to add a login, where there is no item and the justification is the administrator, who must hold user.manage and whose id goes on the row.
Decision. One module, one token scheme, two kinds. A handoff lives seven days by default, because it may sit until somebody notices the item and shortening it would push the escalation back to the shared queue overnight. A provision lives 24 hours and no caller can lengthen it — there is no ttl argument on that path — because an admin provisioning a login has a named person waiting who was told to expect it, and a credential-setting link is the most valuable thing in this product to steal. Everything below the split is shared: the token, the hashing, acceptance, revocation, and the one sentence a dead link gets. Two lifecycles for one kind of secret is two sets of expiry rules to get right.
A resend is a new invitation, never a longer one. Pushing expires_at forward on the standing row would keep whatever copies of the first link exist — in an inbox, a helpdesk ticket, a mail archive — working for another day, every time anybody pressed the button. So resend supersedes every non-terminal invitation to that address and mints a fresh one, and the invariant it keeps is one live token per address rather than per row. invite.superseded is a different action code from invite.revoked on purpose: pressing resend is a normal Tuesday, and revoked is an accusation.
The address is never invented. data/company_context.json carries owner_name and owner_title for every duty and no email address at all. app/seed.py builds first.last@mep.example so the demo has logins; doing the same for a colleague you intend to reach is the same guess as a citation whose quote nobody checked — and on this project an address inferred from a name pattern bounced, which is ADR-63. So the address comes from the person inviting, and its absence is a refusal rather than a pattern applied to a name. A malformed address is refused and not repaired.
One sentence for every dead link. Unknown, expired, revoked, already accepted, and approved-but-not-yet-released all return the same answer to the caller. Five sentences would be a probe: somebody holding a guess could learn which tokens are real, and somebody holding a withdrawn link could learn it was withdrawn rather than never issued. The real reason goes into the audit chain, where there is a reader entitled to it.
Alternatives considered. (a) Two modules, one per kind — cleaner boundaries, and the shared half is the security-critical half, so it would exist twice and drift once. (b) Extend the row's expiry on resend — one line, and it is the standing-copies problem above. (c) A role column on Invitation, so the inviter picks what the invitee gets — the obvious shape, and it makes the token decide authority, so whoever intercepts a link inherits whatever it names; instead acceptance grants obligation_owner and nothing else, on the handoff path, and on the provision path the role was granted at provision time and is read back at acceptance so a role revoked in between honestly reports as none. (d) Derive the address from the name — see above. (e) Default the tenant switch permissive when its environment value will not parse — the module raises instead, on the one class of setting where a silent permissive default is worst; the cost is that a typo in VERBATIM_INVITES_ENABLED turns every invite into an unhandled error rather than a refusal code, which is loud and is the intended direction.
Cost accepted. It sends no mail. It returns the token once, to the caller, and whoever holds that caller has to deliver it — so an invitation nobody delivered looks exactly like an invitation nobody accepted, and the product cannot tell the two apart. "Same organisation" is derived from the domains of a tenant's active accounts rather than held in the schema, so once a tenant admits somebody on a second domain and they accept, the derivation is ambiguous for ever and every invite thereafter goes to a holder of user.manage: fail-closed, visible, and a real cost, since the fast path closes for a company that hires one contractor. And there is no rate limit, no lockout and no attempt counter on acceptance — which the module does not mention anywhere, and is the only one of these gaps it does not concede.
The claim at the head of this module that its code does not keep. The docstring says, in capitals, that an inviter may never grant more than they hold, and names _grant_ceiling as what refuses it. _grant_ceiling is called from exactly one place — inside provision_login — and never from the handoff path, which at acceptance calls grant_role for ROLE_OBLIGATION_OWNER unconditionally. ROLE_ADMIN is the only stock role carrying user.invite or user.manage, and it holds neither action.approve nor action.reject; ROLE_OBLIGATION_OWNER holds both. So the one class of account that can invite always grants strictly more than it holds, through the door beside the one the ceiling guards. The module concedes a version of this a few lines further down and files it under a different actor — "an analyst inviting their own second address" — which in the stock grid no analyst can do, because the gate is user.invite or user.manage and user.invite sits on admin and nowhere else. The real actor is an administrator, and for an administrator the two defences the docstring names are thinner than it implies: the self-invite check compares plus-tags of one address, and an administrator controls their own company's mail domain. This is the same hole ADR-64 concedes for identity.py::grant_role, reached by a second route, and identity.py's own comment on the grid describes it. It is written here because the sentence in the module reads as a control and is not one.
Two smaller things the code does not do that the module says it does. The tenant switch is asked by invite and by provision_login and by neither approve_invitation nor resend — so a tenant that has switched invitations off can still have a queued invitation released into a real account with a live token, and can still have credential links reissued. And normalise_email requires an @ and rejects one at either end, but permits a second: victim@evil.example@mep.example is accepted, and the domain rule reads the last label, so it is judged same-organisation, skips the administrator and skips the self-invite check. Verified against the running code on 2026-08-04. Whether such a mail would reach anybody depends on the transport, which has never sent one (ADR-76); the authorisation decision is taken here regardless, on a string this module treats as a domain and which is not one.
All three fixed the same day, two in code and one in prose. The ceiling claim could only be fixed in prose, because the code is right and the sentence was wrong: applying a ceiling to the handoff path would mean nobody could ever create the first approver for a duty that has none, which is the feature. So the docstring now says the ceiling governs provisioning, which is the only path that takes a role, and states in its own words that the handoff path grants more than the inviter holds by design — with the residual named against the correct actor, an administrator rather than an analyst. The other two were code. approve_invitation and resend now both ask invite_policy and refuse with INV_DISABLED, so "invites are switched off for this tenant, so nobody can be pulled in" is true of the release queue and the resend button and not only of the front door. And normalise_email now requires exactly one @ rather than at least one, which closes the address whose last label is not its domain. The cost of that last one is real and is written into the function: a local part may legally quote an @, and those addresses are now refused. Taken knowingly — nothing in this product has ever held one, and the alternative is guessing which @ counts. Full suite green afterwards: 1963 passed, 1 xfailed.
What forced the choice. The product could not send mail at all, and an invitation that nobody delivers is indistinguishable from one nobody accepted (ADR-75). An administrator who provisions a login and is told nothing assumes the person got mail; that person never arrives, and the product has lost somebody in a way nothing on screen explains. It also still cannot send on a machine with no relay configured, which is the state a reviewer will be in, so that is the state the package is honest about first.
Decision. app/notify/ in two files. messages.py composes: pure text, no network, no session, no clock of its own, and every rule about what an invitation may say is enforced and tested there. transport.py delivers behind an injected protocol and turns the two ways that fails into named fallbacks with a sentence attached. The split is the design, not tidiness — enforcing the content rules in a tested pure function is only possible because composing a mail and sending one are different acts. deliver() gives three answers and never two: sent, not configured, failed. Only delivery.sent may be rendered as "we emailed them"; delivery.announcement is what to show otherwise, and it is set on every outcome including success, because a screen that has to branch to find out whether there is anything to say will eventually forget on one path.
Why the mail is written for the wrong recipient. It is composed on the assumption that it reaches somebody else, because the address is typed by a human and the one case nothing can catch is an address that does not bounce and is simply not theirs. So: no recipient name — on a mistyped address it names a colleague to a stranger and buys the right recipient nothing, and there is no parameter for it, so no caller can add one back. No project, docket, proceeding or claim text; the mail is about an account, not about work, and the work is what a competitor would want. No role and no counts, since "you will be an approver" and "you are one of forty users" are both facts about the company's shape. The two composers take five arguments between them and nothing else, so leaking more is not a matter of remembering not to — there is nowhere to put it. One exception is taken deliberately and named as a cost: the inviter appears, so a stranger learns that person works there, because without it the right recipient cannot tell a real invitation from a phish, which is the larger risk.
Two smaller decisions worth keeping. Nothing is loaded from another host — no image, no tracking pixel, no font, no stylesheet, no script — because a remote image in an invitation tells whoever hosts it when the mail was opened and from where. And the expiry is absolute rather than relative, named as a moment in UTC, with month names from a tuple rather than strftime, so the sentence does not change with the server's locale; a credential link that fails silently at hour 25 costs a support ticket and a first impression.
Alternatives considered. (a) One module that composes and sends — the usual shape, and then every rule about what a mail may say is testable only by sending one. (b) Jinja templates in files — the engine is already in this product, and a template is a place a later edit adds a field, where the argument here is that there is nowhere to put one. (c) A hosted mail API — better deliverability and one dependency, and it puts an account and a key between a reviewer and make run. (d) A console or file transport for local work — it would have exercised the send path and it was not built, which is why the class at the foot of transport.py is untested. (e) A protocol that returns False on failure — tempts a caller to ignore it, where an exception is caught in exactly one place and turned into the announced fallback. (f) A default acceptance path inside the composer, so callers need not supply one — refused, because a default written there would be this module's guess at somebody else's URL, right until the route moved, at which point every invitation would carry a dead link and nothing would say so.
Cost accepted, and the module states the worst of it before anything else. SmtpTransport has never sent a mail from this repository: no relay has been configured and no credential held here, so every test drives the fake. Treat that class as unexercised code and everything above it as tested code, because that is what they are. Beyond that: no retry, no queue, no idempotency — two calls send two mails. No bounce handling, because a relay that accepts an envelope has promised to try and nothing more, and a bounce comes back hours later to a mailbox this product does not read. No audit row, because app/state/audit.py holds no action code for "a mail was sent" and inventing a spelling here is exactly how two constants already drifted; whoever adds one must add it there, and must never record the link. And the mail settings are read from the environment and are global rather than per company, which is right for a relay and would be wrong for a policy.
Two defects found reading this package for the entry, both checked by running the code on 2026-08-04. First, the TLS is encrypted and not authenticated. smtplib.SMTP_SSL(...) and client.starttls() are both called with no SSL context, so Python falls back to ssl._create_stdlib_context() — verify_mode=CERT_NONE, check_hostname=False. Anything on the path can present any certificate, terminate the TLS and read both the credential link and the SMTP password. The docstring calls that body "the single most valuable string this product ever puts on a wire" and says the connection is encrypted or there is none; encrypted holds, authenticated does not, and unauthenticated TLS is the case the stated threat model does not cover. One argument on each call fixes it. Nothing has leaked, because nothing has ever sent — it is a defect waiting for the day somebody configures a relay, which is the worst moment to find it. Second, the raw token can reach an exception message: _checked_link refuses a relative acceptance URL — the exact mistake it exists to catch — and its message interpolates cleaned.split(':')[0], which is the whole string when there is no colon. Run against /accept/<token> it puts the token in a ValueError, and from there into a traceback and a log. The package's own rule is that the token goes in the link "and nowhere else: not in the subject, not in a header, not in a repr, not in a log line".
Both fixed the same day. ssl.create_default_context() is now passed to both SMTP_SSL and starttls, so the chain and the hostname are verified and a relay that cannot prove who it is is refused exactly as a relay that will not offer STARTTLS already was. The class docstring's headline changed with it — "encrypted, authenticated, or there is no connection" — and says that the second word was missing until 2026-08-04, because "encrypted" alone was true and was not the claim a reader took from it. The refusal message in _checked_link no longer repeats what it was given at all: it names what was required and says the value is withheld because it may carry the token. Neither fix can be proven against a real relay from this repository, for the reason the entry above gives — the transport has still never sent a mail — so the TLS change is verified by reading the standard library's behaviour and not by watching a handshake, and that is the honest strength of it.
One thing that looks like a defect and is a decision. accept_link() is the documented way to build the link and the production caller does not use it: app/web/views/users_admin.py builds the URL once from request.base_url, and says why in its own comment — composing it twice is two chances to get the host wrong, and the failure would be a mail whose link is not the one the administrator saw. The cost is that accept_link's percent-encoding of the token does not run on that path; the scheme and whitespace checks still do, inside the composer. That the base URL derives from the request's host header is a property of the view rather than of this package, and this entry does not claim to have audited it.
What forced the choice. Copying a SQLite database while something is writing to it can hand you a file that looks perfectly ordinary and is torn — pages from before a transaction next to pages from during it — and you find out on the day you restore. Whether it tears depends on where the copy falls against the commit, which is to say it depends on luck. This product's whole argument rests on a tamper-evident record, so a backup that silently broke the chain would be worse than no backup at all: it would look like the record and prove nothing.
Decision. scripts/backup.py uses sqlite3.Connection.backup in a single step, so the whole file is read under one lock and the result is one point in time rather than a stitch of several. The source is opened mode=rw through a URI and never rwc, so a typo in the path cannot create an empty database, copy it, and hand back a backup that passes every integrity check ever written. Then the copy is verified three ways: PRAGMA integrity_check, the presence and row count of audit_events, and the application's own verify_chain per company — imported and called rather than reimplemented, so it cannot drift from the thing it is checking, and imported lazily so that taking the copy never depends on the application tree being importable, since you want a backup most on the day a deploy went wrong. Anything but a pass is a refusal to certify, never a pass. A copy that fails verification is renamed .UNVERIFIED and kept rather than deleted: a failed copy keeps its bytes and loses its name, because the one file you must never restore is the one that looks like all the others.
The deadline, which is the least obvious decision in the file. Connection.backup retries a locked source for ever — no timeout argument reaches that loop, and the connection's own timeout does not govern it. A scheduled job that hangs looks exactly like one that is not scheduled at all, which is the worst of the failure modes because nobody finds out. So the deadline is enforced from the progress callback, which is the only place an exception can break the library's busy loop, and only time spent locked counts, so a large database that is merely slow to copy is never cut off. The connection's busy timeout is deliberately one second rather than the thirty the readers use: at thirty, the deadline could not bite until thirty seconds had already gone, whatever it said.
Two smaller decisions with reasons. --into has no default, because a default would have put database copies somewhere inside this working tree, where .gitignore does not cover them and the repository is published with its history intact — one git add -A and a copy of the database is public for good. And failures raise rather than returning a boolean, because a boolean handed back to a caller who does not look at it is how "the backup ran fine" gets believed for six months.
Alternatives considered. (a) cp or rsync the file — the tear above, discovered on the day you restore. (b) Shell out to the sqlite3 command-line tool's .backup — the same primitive, and that binary is not installed everywhere, so the backup fails exactly when nobody is watching. (c) .dump to SQL text — portable and slower, and it rewrites the bytes, so what verifies is a reconstruction rather than the file that would be restored. (d) A restore flag on this command — a foot-gun on the command that overwrites a live database, so restore() is a library function with no command behind it. (e) Return a status object instead of raising — see above. (f) Give BackupResult a problems field — refused, because a field empty on every result anybody could hold would read as "no problems found" while carrying nothing, which is the shape of claim this product exists to refuse.
Cost accepted, and the module states it in its first ten lines. It writes to a directory on the local filesystem, and no off-host destination is configured anywhere in this repository. A copy on the same disk survives a bad deploy, a bad migration and a mistaken DELETE. It does not survive a lost host, a stolen laptop, a wiped volume or a region going away. That is not disaster recovery and the file will not call it that. Nothing schedules it either — no cron entry, no timer, no hosted job — so no backup exists anywhere until a person runs it. And the restore path carries the honest limit: restore() has been run against test files and against a developer's own database copied to a scratch path, and no restore into a running system has been practised by anyone.
Four things this entry adds that the module does not concede, found reading it for this entry. (1) Nothing sets permissions on anything. mkdir(parents=True, exist_ok=True) and sqlite3.connect(destination) both take the process umask, so on a typical host the directory is 0755 and the file 0644 — and that file holds every password hash, every token digest, every session row and the whole audit chain. There is no chmod, no umask and no mode= anywhere in the file, while the docstring tells the operator to put the copy in /var/backups. On the shared droplet of ADR-10's correction, every local account can read it. (2) Nothing is encrypted, and the docstring concedes "local disk only" without ever conceding "unencrypted". (3) The chain is verified on the copy and never on the source, so a live database whose chain is already broken produces a faithful copy that fails verification and is quarantined with a message blaming the copy — and for this product that is the more important of the two readings, found and mislabelled by the same run. (4) _verify_chains builds its engine as create_engine(f"sqlite:///{path}"), glued, in a file that has a helper whose entire reason for existing is that a path must be escaped rather than glued; SQLAlchemy's URL parser reads everything after a ? as a query string, so a --into path containing one is truncated and the chain check opens — and creates — a different file. Verified by running it. It fails safe, since the missing table is recorded as a problem, and it fails wrongly, and it writes where it should not. None of the four is a reason to distrust the copy this script takes; all four are things a production deployment would have to close.
Two of the four fixed the same day; the other two are now stated instead of silent. The glued URL is gone — the engine is built with URL.create("sqlite", database=str(path)), which carries the path as a value rather than parsing it out of a string, so no character in a directory name means anything to it. Permissions are set rather than inherited: the directory is created 0700 and the copy 0600, the mode is read back off the finished file rather than assumed, and a copy that did not come out 0600 is deleted rather than kept — the same rule snapshot() already applied to a half-written file, since the dangerous artefact is the one that looks ordinary. What is not fixed is named in the module docstring now instead of being absent from it: the copy is not encrypted, permissions are not encryption, and anyone who can become root or read the volume gets everything, so a production deployment needs an encrypted destination and this repository provides none. The source chain is still verified only through the copy, so a live database already broken still produces a quarantine message that reads as though the copy were at fault; that one is unfixed and stays in this entry as a known defect rather than being written up as a trade-off.
What forced the choice, and it is the unflattering half. app/interpretation/propose.py had drifted from ADR-04. docs/prd.html had already caught it and said so in the product document; this entry records the repair and puts the reason in the file where the decision lives. ADR-04 says the model is asked only to interpret the materiality of a change it is shown, never to find the changes. The module sent the model the full text of both versions and asked it to report what changed, and its output schema carried no materiality field at all. So it was not an unwired version of the interpretation stage; it was a different design, on the exact path this product's central decision keeps a model off. Wiring it as it stood would have put the change-finding job back on the component with the highest error rate.
Why no test caught it. Thirty-seven tests covered that module and every one of them asked what came out of the call — what verified, what was withheld, what was dropped. None asked what went in. A prompt is an interface, and an interface nothing asserts against will drift to whatever the last edit found convenient. Two tests now hold the shape from the other side: one asserts that neither version's full text appears in the prompt, and one asserts the prompt names the deterministic diff as the thing that already found the change. The same drift now fails a test rather than waiting to be read against an ADR.
Decision. The proposer takes one change and returns one judgement. In: the change's before text, its after text, its section label, its offsets, and its stored draft-or-final status. Out: {material: bool, why: str, citation: {version_id, char_start, char_end, quoted_text}}. The model is asked one question — does this change matter to the utility that has to live with it, and which exact sentence says so. It is never asked what changed, because the diff knows that exactly and can be tested against known answers.
The judgement passes the gate every claim passes, and that is the whole point of building it. The citation goes through app/verification/verifier.py::verify_citation — the real one, the same function every rendered claim goes through. When the quoted words are not at the offsets the model gave, the verdict is withheld: not lowered in confidence, not shown with a caveat, withheld, with the verifier's own reason printed where the verdict would have gone. WithheldJudgement has no material field and no why, and is slotted so neither can be attached at runtime, so the template cannot render a verdict it was not given. A reviewer can now watch the gate refuse the model's own output on the same screen where it refuses a stored claim's, in the same shape, a few centimetres apart.
A citation outside the change is dropped, however well it verifies. The model is shown one change; a citation into text it was never handed is a claim about a document it cannot see, and it can verify perfectly while still being that. Structural, not a plea in the prompt: the offsets are checked against the change's own span before the verifier is called. The gate also reads only the two versions the change spans, so a citation into a third filing the company owns is named as such rather than quietly verifying.
The citation has no occurrence field, and that costs a judgement rather than buying one. Where the quoted words appear more than once in the version, the verifier withholds — the citation cannot say which of them it rests on, and the same sentence in two sections is two obligations. The tempting fix is to derive the occurrence from the offsets the model gave and hand that back as the expected one; that check would agree with itself every time and turn the gate into a formality. So the prompt asks for a span that is unique and a judgement quoting repeated boilerplate is withheld. Adding the field to the schema is the real fix and is not taken here.
Materiality is not confidence. ADR-06 owns the confidence floor and the queue below it. This is a different judgement with a different shape: a boolean with a citation, no score, no threshold, nothing to tune. A number here would become a second floor nobody decided the height of, and on screen the two would read as one thing. A test asserts the module declares no constant whose name mentions confidence or a threshold, because prose alone would not survive a helpful edit.
One call site, and it is visible. The change screen, where not assessed used to be printed. A module with tests and no route is the shape this repository has shipped by accident several times this week, and the rule that follows is that the judgement had to appear where the analyst already looks. Four states render there and none of them is a shrug: a verdict with its badge, its one sentence, the source's own words at the cited offsets and the model that said it; a withheld verdict with the verifier's reason and the quoted-against-source pair; a dropped answer with the field that was wrong; or the named absence when the model path is off.
Alternatives considered. (a) Wire the proposer as it stood and fix the prompt later — one afternoon cheaper, and it would have put a change-finding model call on the demo path that the whole submission argues against; (b) keep both entry points, the two-document proposer and the per-change judge — the drifted design would have stayed in the tree with tests passing over it, which is how it survived the first time; (c) ask for materiality and a recommended action in one call — two questions, and the action vocabulary is a decision ADR-05 keeps in Python; (d) judge every change in the pipeline and store the verdict — the right end state, and it needs an audit code, two columns and a writer that do not exist (see the cost below); (e) send the company's obligations register with the change so the judgement is genuinely company-specific — better answers, and it widens per-call exposure against docs/security.html and is a bigger change than this one.
Cost accepted, and there are four. One: the call happens on the render. With a key set, every load of a change screen is a model call — it costs money, it takes seconds, and two loads can disagree because nothing is stored. Two: nothing is written, so nothing is audited. app/state/audit.py has no action code for a materiality judgement; the spelling that fits is change.materiality_set, which tests/test_policy.py already writes as a bare string — the vocabulary this product keeps in one file already has a second home, which is exactly how the two codes that drifted got that way. Persistence needs that constant, a column for the reason and the citation beside the existing materiality column, and a writer that records who judged. Three: the judgement is not company-aware. The model reads the change and the docket, not the obligations register, so "material to this company" is at present "material on this record". Four: this path still has never run against the real API. Every test drives a deterministic fake through the injected transport. What is proven offline is the gate; what is unproven is everything only the endpoint can answer.
A guard the wiring forced, worth recording on its own. A real key sits in this repository's .env, app/main.py loads it at import, and several test modules import app.main — so the moment a request path read the environment, make test would have called the live API for real money. The suite's offline promise had been true only because nothing read the environment during a request. tests/conftest.py now removes the key for every test, autouse, and a test that wants the key-present branch sets it itself. Offline by construction rather than by habit.
Still open. Whether the screen should use the word material at all. docs/synthetic-interview.html has the persona refusing it outright — "the moment it is on the screen it is in the file, and the file is discoverable" — and this ADR ships the word anyway, in one constant, on one screen. That is a decision to defend or reverse with a real user, not to settle by preference.
What forced the choice. ADR-71 built retrieval as a ranked list, and bm25 ranks globally. The question an analyst actually asks is "does this hold across these filings, and where does it not" — and a global ranking cannot answer it. Ask about curtailment across eight filings and the five candidates MAX_CANDIDATE_PASSAGES allows can all come from the one filing that mentions it most, with nothing from the other seven. Nothing in the answer tells the caller whether those seven do not mention it or merely lost on rank, and the first is the answer they came for. Reproduced in one assertion in tests/test_coverage_map.py rather than argued from first principles.
Decision. A second read over the same index, app/state/search.py::map_coverage, returning a row for every document version in scope — its best verified spans, or an explicit statement that nothing in it matched — with the span cap applied per filing rather than once across all of them. Exposed as the coverage_map chat tool. Fully deterministic: no model call anywhere in it, every test offline with no key.
Why the silence has to be a row rather than an absence. If seven filings address a subject and the eighth does not, the eighth is the interesting one. A result that omits it is indistinguishable from one where it ranked ninth — no key missing, no count that disagrees with itself, nothing in the shape to notice. So a filing with nothing to offer comes back as a present, explicit zero, and refuse_a_short_map() makes that an invariant rather than a habit: a map whose rows do not cover its scope raises instead of answering, and names the filings that went missing. Every construction path runs through one function, so there is nowhere to return a map that skipped the check. This is app/state/retention.py's rule and the withheld-claim design applied to retrieval — absence is denial, and an unreported absence is a guess.
What a zero is not allowed to mean. "This version contains no passage matching these terms" is what the index can support. "This version does not address the subject" is a judgement no deterministic read may make: a filing can address curtailment for six pages while spelling it "interruption of service". The wire format keeps them apart and so does the sentence beside every zero, because that sentence is handed to a model that will paraphrase it, and the paraphrase of a blurred sentence is the confident guess this product exists to refuse.
Four empty span lists, four different facts, never folded. NO_PASSAGE_MATCHED — searched, and its passages do not carry these words. VERSION_HAS_NO_PASSAGES — holds no passages at all, a scanned exhibit whose text extraction produced nothing, so nobody has read it. VERSION_NOT_SEARCHED — past the version cap, or the question held no searchable words: unknown, and named as unknown. MATCHED_BUT_NOT_OFFERED — the tool layer's, because only it knows about claims: the filing matched and its spans were then not handed over. Reported as the same thing, three of those four are false. Adversarial review found this collapse reintroduced in the pill tray, which branched on an empty span list and so offered a button calling a filing silent when its match had in fact been refused for carrying a withheld claim's citation — sixty lines of comment and three reason codes undone at the last step, in the only layer a person reads. It now branches on the reason and three tests pin it.
The constants, and where they came from. MAX_SPANS_PER_VERSION = 2: one span answers "does this filing say it", and the second is there because the top-ranked span in regulatory text is very often a heading or a defined-terms entry that repeats the words and says nothing. MAX_VERSIONS_MAPPED = 12: measured against data/real, where the largest proceeding holds eight filings — ga-56002, va-scc-PUR-2025-00058, ut-24-035 and ky-2025-00113 each do — so a cap at eight would bite on the four biggest real cases. It caps what is searched, never what is reported: a zero row costs an id and a sentence, so there is no budget argument for dropping one.
Alternatives considered. (a) Raise MAX_CANDIDATE_PASSAGES — cheapest and it fixes nothing: a bigger global cap is still global, the talkative filing still takes the first N slots, and the context bill rises with no new information; (b) group the existing flat result by version in the tool layer — no new query, but it can only group what the global ORDER BY already returned, so a filing that lost on rank stays indistinguishable from one that has nothing. The defect is in the retrieval shape, not the presentation; (c) one query with a window function partitioned by version — one round trip instead of twelve, rejected because the per-filing limit would live inside a SQL string rather than beside its reasoning, and bm25() inside a window over an FTS5 join is the least-trodden path available here; (d) contradiction detection across filings — a more impressive claim, and it needs a model to decide whether two passages disagree, which puts a judgement where this product puts a citation. This one makes a claim nothing else in the product makes and carries no model risk, which is why it was built first; (e) return only the filings that matched and let the caller diff against the version list — pushes the honest half onto every caller, and the caller who forgets produces exactly the silent shortfall this exists to stop.
Everything ADR-71 guarantees still holds. Tenancy through the same guard and the same join — the per-filing query is the existing statement with one more bound clause, not a second copy of the tenant join. Every span goes through the real verify_citation by way of the existing _candidate_payload, so there is one gate and not two, and every span carries is_evidence: False. A passage carrying the cited span of a withheld claim is still not handed over, and the count is still said out loud: this tool must not become a second door into text the product has already refused to assert. A stale, missing or unavailable index degrades to the same complete scan with the same named reason — and a degraded map still reports every zero, because the slow path is the one somebody would be tempted to cut short.
Cost accepted, and the limit that cannot be fixed here. Twelve queries per map on the index path, one whole-corpus read on the degraded one, and a verification pass over every claim in the company to find the withheld spans — the same fix search_claims needs when the corpus grows, and not a cache, because a cached verdict is the stored verdict ADR-03 refuses. The withheld-span set is collected company-wide rather than narrowed to the docket: a superset drops more spans and never fewer. It is also reached through each change's proceeding, so a change whose proceeding row is absent contributes no spans and the guard would fail open — not reachable in this build, since nothing in app/ deletes a proceeding and foreign keys are not enforced, but the wrong direction to fail in, and the fix belongs in app/state/claims.py. Which filings fall past the version cap is decided by version id order, which has nothing to do with relevance. And the deepest limit: a zero means "no passage carries these words" and can never mean more, so a filing that says "interruption of service" throughout is reported as carrying none of the words for "curtailment". No lexical index closes that, and ADR-08's order is lexical before semantic. It is said in the wire, in the note the model is handed, and here.
What forced the choice. ADR-75 recorded that this module's headline claim — an inviter may never grant more than they hold — held on one of the two paths that grant authority, and fixed it in prose. That is the right decision about the handoff and the wrong shape for the rule. _grant_ceiling still had one caller, acceptance still reached identity.grant_role directly a few hundred lines away, and the reason the claim and the code came apart — two callers asked to remember one rule — was left exactly as it was. A third grant path added next month would miss it the same way, and the prose would go stale on the day it did.
Decision. _grant_within_ceiling is the only function in the module that calls identity.grant_role. Both paths go through it, and a test parses the module with ast and fails if another call site appears. It measures every grant against whoever RELEASED the invitation — the administrator who approved a queued one, the inviter otherwise, both as accounts rather than display strings — and gives three answers, each a different fact. Within the ceiling: granted quietly. Over it on a path CEILING_WAIVED_BY_KIND declares in writing: granted, and recorded. Over it anywhere else: GrantExceedsCeiling, and nothing written at all.
Why a new action code rather than folding it into user.role_granted. audit.py's own argument for approval.waived, applied again. "Somebody was given a role" and "somebody handed on authority they do not have" are different questions, and reading the second as the first would make a manufactured approver look like routine account administration a year later. One word to search for answers "who here approves through an account an administrator made".
What this does NOT close, said plainly. An administrator can still invite an address they control at their own domain, accept it, and approve through it. Refusing that would mean no company could ever create the first approver for a duty that has none, which is the feature this module exists for — ADR-75's reading, unchanged. The move is now visible at the moment it happens rather than derivable afterwards from a docstring, and that is the whole of the improvement. Closing it needs a second approver on a privilege change, which this build does not have; ADR-64 says the same of identity.py::grant_role.
Alternatives considered. (a) Leave it at the prose fix — honest and static: the claim was true the day it was written and nothing would keep it true, which is how this defect was born in the first place; (b) apply the ceiling to the handoff and refuse — the rule reads best and it deletes the feature, since admin is the only stock role carrying user.invite and it holds neither approval code; measured, it turns most of tests/test_invites.py red, and that is the product going red rather than a test; (c) grant only the subset of obligation_owner the releaser holds — a silent downgrade, landing the person on a duty they cannot approve, which is the handoff failing while reporting success; (d) a boolean argument saying "this one may exceed" — a flag every caller must pass correctly is the rule two callers had to remember, spelt differently.
Cost accepted. Two extra permission reads per acceptance, which nobody can measure. A disclosure row on nearly every handoff acceptance, because in the stock grid nearly every handoff exceeds — noise that a company giving user.invite to somebody who already approves stops producing, and the test proving the row is absent in that case is the guard against it becoming wallpaper. And the waiver table is a place a future author can add a path to rather than fix it; it is one dict with the argument beside the key, which is the smallest thing that can be reviewed.
What forced the choice. normalise_email began requiring exactly one at-sign on 2026-08-04, which stops another victim@evil.example@mep.example being written. It says nothing about the rows already in the table. app/state/invites.py::company_domain derives a tenant's own domain by reading User.email straight off that table, and _domain_of returned everything after the LAST at-sign — so a stored row of that shape voted for mep.example and then answered "same organisation" about itself. Verified against the running code. The live database in this repository predates the guard, which makes this a real path rather than a hypothetical one.
Decision. _domain_of returns NO_DOMAIN unless the address carries exactly one at-sign, and same_organisation refuses NO_DOMAIN by name rather than trusting that an empty string cannot match. That second half matters: a tenant whose every stored address was unreadable would otherwise derive NO_DOMAIN as its own domain, and the comparison would then answer True for exactly the addresses it must refuse — two failures cancelling into a pass.
Why fail closed. An unreadable address makes the derivation ambiguous in exactly the way a second real domain does, so every invitation goes to a holder of user.manage until somebody sorts the row out. The alternative is guessing which at-sign counts, on the decision that skips the approval queue.
Alternatives considered. (a) Repair the address — a guess about intent on a credential-bearing path; (b) migrate the table — right, and it needs a decision about what to do with each offending row that nobody has taken; (c) guard only at the write — what was already there, and it is how this happened. A guard on the forward path says nothing about the rows that predate it, and this product's own history is full of exactly that shape.
What forced the choice. app/web/views/review.py::resolve_escalation took the resolver's name from a text box. Whatever was typed went onto Escalation.resolved_by and into the audit chain as the actor, and no permission was asked at all — so any signed-in person could close any refusal under any name, and the name did not have to be one that exists. In a product whose entire argument is that a decision carries the name of the person who took it, that is the single defect that discredits the whole record. It is worse than an ordinary authorization bug because the chain is append-only: a forged name cannot be taken back out, only annotated.
Decision. The identity comes from current_user(request). No signed-in person, no resolution — a 403 rather than a row attributed to nobody. The route then asks policy.require(session, company_id, principal.user_id, "escalation.resolve"), which is not a new permission: it already sits on ROLE_ANALYST in app/state/identity.py because working the queue is the analyst's job. What was new is that anything asks for it. The reviewer form field is still accepted so existing forms do not 422, and is no longer believed for anything.
Why the permission is checked before the row is loaded. A caller who may not resolve anything must not learn which escalation ids exist by the difference between "denied" and "no such escalation". The cost is that another tenant's id now answers 403 where it used to answer 404, which is a smaller leak than the one it replaces and is recorded here rather than discovered.
Alternatives considered. (a) Keep the typed name and add a permission check — closes the authorization half and leaves the attribution half, so the chain still records a name nobody verified, which is the more damaging of the two; (b) keep the field and cross-check it against the signed-in person, refusing a mismatch — one more thing to get wrong for no gain, since the session already knows the answer and the field can only agree or lie; (c) drop the field from the form entirely — correct eventually, and it breaks every template and test that posts it on a night when the suite is the only thing holding the submission together, so the field is accepted and ignored instead; (d) gate on action.approve rather than escalation.resolve — wrong code: resolving a refusal is not approving an action, and using the approver's permission here would have handed the queue to the one role that is meant to be separate from it.
What the tests were asserting, which is the finding worth carrying. Four tests asserted the typed name — assert row.resolved_by == "person:J. Okonkwo" — so the defect was not merely uncaught, it was pinned. A fifth, test_an_unsigned_resolution_says_so_rather_than_borrowing_a_name, asserted that an unsigned resolution is recorded as person:unauthenticated, and its name shows the reasoning: labelling the anonymous case honestly was treated as sufficient. It is not the same as refusing it, and it left the signed-in case free to write any string at all. That test now asserts a refusal and nothing written. The suite was written before there was a login at all (ADR-15), and this is the second place today where a guard was missing because the tests predated identity.
Cost accepted. tests/test_screens.py now has to sign in, so its fixture mounts the auth router, seeds accounts and uses an https base URL — because the session cookie is marked Secure anywhere that is not loopback plaintext, and httpx silently declines to send a Secure cookie over http, which would have left a "signed-in" test behaving exactly like an anonymous one and passing for the wrong reason. That is three fixture concerns added to a screen test to prove one line of attribution, and it is the right trade. Still open: the other five writers docs/security.html names still record a display string with no user id, so this fixes the sharpest instance and not the class.
What forced the choice. The landing page and the login page both printed three addresses and a shared password, and argued for it in their own words: an account whose password is on a public page is not a secret kept badly, it is a door held open. The argument is sound and it was half of one. Two things it did not answer. A working credential on an unauthenticated page is a string somebody can paste into a deployment that forgot to set VERBATIM_DEMO_ACCOUNTS=0 — and it teaches every reviewer that this product prints passwords, which is the last impression a regulated buyer should be left with. And a door held open is only safe when the room is disposable, which it was not: anybody signing in as the administrator holds user.manage, user.invite, workflow.manage and threshold.set, every change they make is permanent because the chain is append-only, and there was no way back short of deleting the database on the host by hand.
Decision, first half. Three "Sign in as" buttons, one per role, posting to /login/demo. It is not a passwordless path, and that is the whole design. It calls the same login() as the form beside it, with the same seeded password supplied by the server rather than by somebody's fingers. There is no second way to authenticate, no branch inside login() for demo accounts, and nothing here that could ever be true of a real account. Change the seeded password and the button breaks exactly as typing it would. The posted address is checked against the same _demo_hints() the buttons are drawn from, so the guard and the panel cannot disagree, and an address that is not a seeded demo account gets the same sentence and the same 401 as a wrong password — so the route cannot be used to ask whether an account exists.
Decision, second half. scripts/reset_demo.py and make reset-demo put the tenant back. It takes no --company argument and never will: the id is read from the same data/company_context.json the seed reads, so the only tenant it can remove is the one the seed creates. Point it at a production database and the id is not there, so it deletes nothing and says so. It refuses without --yes, refuses unless VERBATIM_DEMO_ACCOUNTS says this workspace is a demonstration, and afterwards verifies the audit chain of every other tenant and exits non-zero if any of them moved — so "this touched nothing else" is a check that ran rather than a sentence somebody wrote.
Why this may delete an audit chain when nothing else in the product may. app/state/retention.py schedules audit_events as "never" and app/state/audit.py refuses a DELETE from application code. Neither is weakened. The chain is gapless per company: verify_chain walks one company's sequence, and a missing row inside that sequence reads as tampering. Removing one company's rows entirely leaves no gap in anybody's chain, because there is no longer a sequence to have a gap in. Deleting part of a tenant's history is tampering; decommissioning a whole tenant is not, in the way that closing an account is not forging its statements. The deletes go through SQLAlchemy Core so the ORM's append-only hook does not fire — which is that hook's own stated design, since it exists to catch the mistake and the hash chain exists to catch the attack, and this is neither.
Alternatives considered. (a) Keep printing the password — the status quo, and it is a working credential on a public page plus a room nobody can tidy; (b) mint a session directly for a named demo user, skipping the password — fewer lines, and it puts a second authentication path in the one part of the product where a second door is the entire risk, so a bug there would not resemble a login bug and would not be caught by login tests; (c) a "reset" button inside the product — a reset a visitor can press is a reset a visitor will press, so it is a command on the host and deliberately has no route; (d) reset by deleting the database file — simpler, and it destroys any other tenant on the instance and the schema with it; (e) create a fresh tenant per reset and leave the old one — nothing is deleted and the chain is untouched, and the seeded addresses collide on a unique index, so the second reset fails; (f) drop the demo accounts to one — fewer doors, and the permission boundary is the thing worth showing, which needs at least the analyst and the approver.
Cost accepted. The accounts still exist and the password is still whatever the seed set: VERBATIM_DEMO_ACCOUNTS=0 draws no buttons and makes the route refuse, and it does not remove the accounts or change the password, so anyone who learns the value can still sign in through the ordinary form. docs/security.html says so in those words. Five of the seven seeded accounts are obligation owners and only three buttons are drawn — one per role — so two seeded people have no button; they remain valid through the route, which is deliberate and is why the guard is checked against the full list rather than the drawn one. And a reset is not free: it removes the tenant's audit chain, so anything a reviewer did before it is gone, which is correct for a demonstration and would be indefensible anywhere else.
What forced the choice. This is the most consequential access decision in the product and until now it was argued only in marketing copy on the landing page. The demonstration publishes three sign-in buttons — analyst, obligation owner, administrator — posting to /login/demo. There is no password. A single unauthenticated POST returns 303 and a session cookie, verified against production.
Decision. Keep all three, administrator included. A reviewer reaches the permission grid and the approval route editor without asking anybody, and those are two of the strongest screens in the product. The alternative — publishing two and handing out the third on request — protects the demonstration and costs the thing the demonstration is for.
What it costs, in full, because a decision recorded without its cost is a preference. The administrator account holds user.manage, user.invite, workflow.manage and threshold.set. A visitor can create accounts, redraw the approval route and move the confidence threshold ADR-06 leaves as configuration. Through the handoff path (ADR-75, ADR-80) they can invite an address they control, accept it, and hold action.approve. Nothing rate-limits any of it. And there is no way back — which was FALSE WHEN THIS ADR WAS WRITTEN, and the correction stays here rather than being edited out. scripts/reset_demo.py and make reset-demo landed at 9ac91a7, 23:27; this entry was written at 00:33, an hour later, and asserted a limitation that had already been closed. A dry run reports 536 rows across 25 tables. It refuses unless VERBATIM_DEMO_ACCOUNTS says the workspace is a demonstration, takes no company argument so the only tenant it can touch is the one the seed creates, and verifies every OTHER tenant's chain afterwards, exiting non-zero if any moved. The narrower limit that IS true: deploy/entrypoint.sh enforces that a redeploy never reseeds, correctly, because reseeding would lay fresh rows over a hash-chained log — so anything a visitor does is permanent, and since the chain is append-only, damage can only be appended to, never cleaned.
What is NOT at stake, and this is why the trade is defensible. Confidentiality. There are no customers and no customer documents. The workspace holds an invented proceeding and public filings the commissions publish themselves. The exposure is the integrity of a demonstration during a review window, not anybody's data. The site carries noindex, though the public README links to it, so the realistic reader is a reviewer.
Alternatives considered. (a) Publish the analyst and the obligation owner only, administrator on request — removes every griefing capability and the escalation route, and the pair that remains still demonstrates the propose/approve boundary, which is the argument that sells; rejected because it puts a request between a reviewer and two of the best screens; (b) keep a published password rather than one-click buttons — what shipped earlier, and the sentence it carried was more honest, but a copied password is not a control, so it bought the appearance of one; (c) make the demonstration tenant explicitly reseedable, separate from the never-reseed rule that protects real chains — the right answer, roughly an hour, and it is a second seeding path to get wrong on the day of a deadline; (d) rate-limit the demo login — narrows griefing and not the escalation, and nothing here rate-limits anything yet.
What would reverse it. A real customer, or a second tenant of any kind, in this deployment. At that point the demonstration tenant needs its own instance rather than its own switch, because VERBATIM_DEMO_ACCOUNTS=0 turns the panel off and does nothing about a route that already exists.
What forced the choice. The build brief asks the product to map each change to the obligations it affects and recommend an action with reviewer routing. Everything downstream was built and tested — app/state/routing.py walks change → obligation → owner and refuses in fifteen named ways — and the mapping itself had no writer outside the tests. A grep for map_change_to_obligation found the definition and nothing else. After a full make seed: 171 changes, 8 obligations, change_obligations zero rows. So resolve_change_owner answered ROUTE_NO_OBLIGATION for every change in the product, and the wedge the product is named for could not complete once.
The constraint that decided the shape. ADR-008 reserves the semantic join — the company's own words against the docket's — for embeddings, which are not built. data/company_context.json is arranged to defeat anything less: every obligation carries a note_for_semantic_join saying where the wording does not line up. OBL-001 says "post security" where the docket says "post collateral". OBL-002's compliance date moves in the docket and the internal wording names no date at all. A lexical rule cannot reach those, and a model call here would be inventing the thing ADR-008 deferred.
Decision. The system proposes candidate obligations from lexical overlap and a person confirms, and the row records which it was. app/state/mapping.py holds the proposer; ChangeObligation.mapped_by_kind already distinguished AUTHOR_SYSTEM from AUTHOR_ANALYST and is now what it was built for. The change screen renders the two states as different dataclasses rather than one class and a flag — ConfirmedMapping carries no matched terms, CandidateMapping carries no confirmer, both slotted — so a template mistake cannot promote a guess to a finding; it can only fail to render.
Why not FTS5, given the index exists. search.py answers "which passage best matches these words". The question here is "of the passages this change touches, which carry the words of this duty". match_expression builds a conjunction of phrases and an obligation title is a sentence, so a conjunction over it matches nothing anywhere; an OR builder would be a second expression builder beside the one search.py argues at length for keeping single. What IS reused, imported rather than copied, is the part that must agree: index_body() so a passage folds here exactly as the index folded it, and PREFIX_MIN so a term widens exactly as far. Useful side effect: nothing queries the index, so a workspace whose index was never built still proposes.
Four rules, each measured against the corpus rather than chosen. Four characters — below that a word stops narrowing anything, and it also removes section numbers, since "5.4.1" splits into three digits and none survives. Prefix, not substring — what FTS5 does with a trailing star, recovering plurals without a stemmer. The substring version was written first and is wrong: OBL-001 carries "work", "work" is inside "network", and every network-upgrade passage proposed the security-posting duty. A word more than half the duties share is not evidence, because it cannot tell one duty from another: "large" appears in six of MEP's eight, and before this rule every change proposed six duties at once. Two words, not one — a single shared word is a coincidence more often than a link, and the near miss is reported as ONE_WORD_IN_COMMON rather than dropped, so the threshold can be judged.
Absence is denial, and it is half of what this does. A proposal naming two duties and saying nothing about the other six reads exactly like a complete answer about a company with two duties. So every obligation in scope gets a row, and a row with no candidate carries its reason: NOT_FOUND_BY_THESE_WORDS, ONE_WORD_IN_COMMON, OBLIGATION_HAS_NO_WORDS, OBLIGATION_NOT_SEARCHED. Reported as one thing, three of those four are false. refuse_a_short_proposal() raises rather than returning a short list — ADR-79's rule applied one layer up.
Alternatives considered. (a) Call a model to judge the join — the accurate answer, and the thing ADR-008 deferred; it would put the product's largest deferred decision into a screen without the ADR that owns it, and the mapping could not be checked by hand. (b) Keep the hand-written mappings in data/manifest.json — what shipped, and it demonstrates nothing: a mapping somebody typed proves the table exists, not that the product can produce one. (c) Write mappings straight from the overlap with no confirmation — one fewer screen, and it turns a word overlap into an assertion of accountability, which is the failure this product argues against. (d) Allow a person to confirm only what the proposer offered — rejected outright: OBL-002 shares no words with the change that moves its deadline, so a product that only accepted its own suggestions could never be told it was wrong.
What it costs, in full. The rule is crude and says so. "with", "each", "that" and "before" are four characters and appear in nearly every filing, and nothing here tells them from "network" or "curtailment", because the only corpus it measures against is the company's own eight obligations. A document-frequency weight over the passage corpus is the fix and is not built; what stands in for it is the report, since every candidate prints the words that produced it, so a candidate resting on "each" and "with" looks as thin as it is. There is no confidence figure and no column for one: a matched-word count is not a probability. The permission is borrowed — PERMISSION_CODES has no obligation.map, so the gate is action.propose, and the audit vocabulary has no obligation.confirmed, so a confirmation is obligation.mapped with the distinction in its reason. Both missing names are written into the code beside the borrowed ones rather than invented.
The limit this does not close, stated because it is the one that matters. resolve_change_owner does not read mapped_by_kind. A mapping the pipeline proposed routes an escalation exactly as a confirmed one does, and on that path a candidate has become a finding in the most consequential place there is. Not theoretical: a Kentucky vegetation-management budget table shares "project" and "budget" with MEP's cost-allocation duty, and the change screen printed "Sarah Lindqvist owns OBL-005" over it. Closing it properly means a sixteenth refusal code — a change to a contract every caller depends on — so it is named here rather than made in passing. What is true today: the change screen refuses to state accountability when no confirmed mapping is behind the name, the two states render in different shapes, and the chain records which kind was written. The escalation queue and the approval route carry no such caveat, because they do not go through that screen.
What is in the seeded workspace. 26 mappings over 171 changes: 24 proposed by the pipeline, 2 confirmed by the analyst account, 145 changes carrying no obligation because nothing reached the threshold. All 26 return ROUTE_OK. The two confirmed ones are chosen for what they show: CHG-v1-v2-004 → OBL-005 is the cost-allocation case where the words agree strongly and a person agrees with them; CHG-v1-v2-006 → OBL-002 is the compliance-date move the corpus says is invisible to a lexical match, where the proposer offers nothing, a person maps it anyway, and the audit row records that the words did not find it.
What would reverse it. Embeddings. The moment the semantic join ADR-008 defers is built, the proposer's rule is replaced — but the SHAPE stays: propose, show the evidence, let a person confirm, record which of them decided. A semantic match is a better candidate and it is still a candidate.
What forced the choice. ADR-78 conceded two costs and both were real. The judgement was computed on every render of the change screen and thrown away: with a key set, every page load was a model call — money, seconds, and two loads of the same screen able to disagree about the same change. And nothing was written, so nothing was audited: no row recorded who judged, when, or on what evidence. The build brief asks for a living, auditable project state, and a judgement that vanishes on the next render is neither.
The trap, named first, because the whole decision is about not falling into it. ADR-03 refuses a claim asserted from the record alone. A stored verdict looks exactly like that refused thing, and the edit that turns it into that thing is one line. So the distinction has to be stated before the decision: what is stored is the model's OUTPUT — the verdict, the sentence behind it, the citation it named, which model said it and when. What is NOT stored is the fact that the citation verified. That fact has no shelf life. It is a statement about bytes, and bytes move.
Decision. Seven columns beside the existing materiality, written together or not at all, plus ACTION_MATERIALITY_SET on the chain with actor_kind of model. A later read answers from the row instead of the model — and then re-reads the source at the stored offsets and matches the quote before it will show the word. Only the MODEL CALL is avoided by the store. The gate is not. A stored verdict whose quote is no longer at its offsets is withheld on the next render: no job runs, nothing is rebuilt, the page refuses.
Why seven columns and not one. materiality is 32 characters. It can hold "material" and it cannot hold a citation, and a verdict that cannot show its source is precisely the assertion this product refuses. The moment is the flag: materiality_judged_at is written last-and-together with the rest, so a row carrying a word and nothing else came from a loader, a hand-written UPDATE or an older build, and _stored_verdict refuses it BY NAME rather than reading the word out of it. It is not overwritten either — somebody put it there, and this module is not the place to decide they were wrong. All seven are nullable, which is what lets them ship: app/state/migrate.py is additive only and refuses a NOT NULL column with no default, correctly, because the honest value for a change judged before these columns existed is "unknown" and any default would write a fact into every historical row.
What would make it a cached verdict, written down so the edit is recognisable when somebody is tempted by it. Skipping verify_citation when a row is present. Trusting materiality because materiality_judged_at is set. Storing a "verified" flag and reading it. Comparing a hash of the version instead of re-reading the quote. Each turns this into the thing ADR-03 exists to refuse, and every test that does not move the bytes underneath a stored verdict keeps passing while you do it. That is why the guarding test edits the cited characters and calls no model at all.
THE LEAK THIS ADR SHIPPED, AND THE CORRECTION, kept here rather than edited out. The first version of this decision gated exactly ONE reader — the change screen, which is also the one that writes — and left two others reading the materiality column raw, with no citation, no verify_citation and no re-read of the source: the project list at app/web/views/projects.py, and change_detail in app/chat/tools.py. Both had been safe only because the column was permanently NULL. This change removed that safety and added no gate. So a verdict earned on the change screen, whose cited bytes then moved, was WITHHELD on that screen and printed as fact one link away — on the project list, which shows the word most often: one row per change, no click required. Worse in the chat tool, where the word goes to a model that restates it in prose with no column beside it to check. 2,066 tests stayed green over the hole, because not one of them judged a change and then read a different surface. The docstring, meanwhile, said "re-verified on every read" and "recomputed every single time the verdict is shown", and the change screen printed "It is never shown from the record alone" under every verdict. Those sentences were false of the product while being true of the screen they were written on.
The fix is the class, not the two call sites. Patching the two would have left the THIRD reader — the one nobody has written yet — to repeat it. shown_materiality_for_company is now the only supported way to read that column for display: it runs the same _reread gate and the same narrowing to the two versions the change spans, so the two surfaces cannot disagree about the same row; it calls no model and writes nothing, and there is no transport argument to change that. A test walks the syntax tree of every module under app/ and fails on any attribute read of .materiality outside the gate itself, the column's own declaration, and the ingest path that writes NULL. That is what makes "every read" a fact rather than an aspiration: the guarantee is worth exactly the number of readers that obey it, and something now counts them.
Two more things the leak had broken on the same page. The project-list template's {% else %} branch was dead for as long as the column was NULL and came alive with it, rendering ANY verdict — including "not material" — with badge--material, the alarm treatment. The first change ever judged HARMLESS would have shipped wearing the colour that means the opposite. A dead branch is not a safe branch; it is an untested one waiting for its condition to become reachable. And the paragraph under that table still told the reader materiality reads "not assessed" on every row because nothing calls a model — false the moment one change screen was opened, printed directly beneath a table showing the opposite.
Three words on that column, not two, because there are three facts. Nothing has judged this change. Something judged it and the verdict still stands up. Something judged it and the verdict may not be shown. "Not assessed" says nobody looked; "withheld" says somebody looked and the product will not repeat what it found. Collapsing those two is the same mistake a nullable boolean would make in the column itself, one layer up. The chat tool sends None in both cases, so its note carries the difference in words — left to a bare null a model would write "this change is not material" for a change whose verdict was withheld, which is the fluent wrong sentence this product exists to stop.
Alternatives considered. (a) Keep computing on every render — what ADR-78 shipped; correct and unaffordable, and it audits nothing. (b) Store a verified boolean alongside the verdict — one query cheaper and it is the cache, exactly: a promise about bytes that may have changed since, which is the argument app/state/claims.py already makes against a verified column on a claim. (c) Store the source hash and compare hashes instead of re-reading the quote — tempting because it is cheap, and it answers a different question: whether the document changed at all, not whether these words are still at these offsets. A filing edited elsewhere would withhold every verdict in it. (d) Re-judge on the project list when a citation goes stale — thirty rows becomes thirty model calls, thirty audit rows and thirty writes on a GET; refusing and pointing at the change screen is the honest half. (e) Have the readers call materiality_for_company with no transport — it would work today and it carries a writer and an audit append one if away from a list screen, which is a foot-gun rather than a design.
Cost accepted, in full. One: it writes on a read. The change screen is a GET, so the first view of an unjudged change appends to the audit chain. The alternative is a job that does not exist and a screen that says "unjudged" until it runs. What makes it defensible is that the write is the RECORD of a judgement genuinely made at that moment, by something the row names, and that a second GET writes nothing. Two: the project list refuses where the change screen recovers. A stale verdict is re-earned only by opening the change. That asymmetry is deliberate and it means the list can show "withheld" for a change whose verdict a single click would restore. Three: the judgement is still not company-aware — ADR-78's third cost, untouched. Four: this path has still never run against the real API. Every test drives a deterministic fake through the injected transport; the gate is tested code and AnthropicTransport is unexercised code. Five: nothing re-judges in the background. A verdict can sit withheld indefinitely if nobody opens the change, and the product will not tell anybody it is sitting there.
What would reverse it. A materiality pass in the pipeline that judges on ingest and re-judges when a version is superseded. At that point the store stops being a render-time side effect and becomes a derived corpus — and docs/best-practices.html section 27 applies: it migrates all at once, or the screens read two generations of verdict at the same time. The gate stays either way. Storing the verdict was always the cheap half; not storing the verification is the decision.
The hole ADR-85 left open, named there and closed here. ADR-85 split a change-to-obligation mapping into two kinds and wrote which one made it into ChangeObligation.mapped_by_kind: AUTHOR_SYSTEM for a mapping the pipeline proposed from a word overlap, AUTHOR_ANALYST for one a person confirmed. Then resolve_change_owner never read the column. It walked change to obligation to owner and answered ROUTE_OK with a live human's identifier, and the two kinds were indistinguishable at the only moment that matters: somebody is told the work is theirs. The change screen carried a caveat. The escalation queue and the approval route — the surfaces that actually assign — do not render that template, so they carried nothing. A candidate had become a finding in the most consequential place in the product.
The failing test is one sentence. A mapping written with mapped_by_kind=AUTHOR_SYSTEM, from the Kentucky fixture, walked to a named person and answered ROUTE_OK. Nothing in the codebase objected. This is the same failure the project has now hit seven times — built and not connected — and again no test caught it, because every test asked whether the mapping recorded its author, not whether anything downstream read what it recorded.
The name the product would have used goes in candidate_user_ids, never user_id. ROUTE_MAPPING_UNCONFIRMED is not in ROUTE_OK_CODES, and the invariant every caller already depends on — user_id is set if and only if the code is in that set — therefore does the enforcing. A caller cannot read user_id and be handed a name the product is refusing to stand behind, and it takes no new discipline from the caller to get that. The would-be owner is still computed and still returned, because refusing to say anything would be worse than refusing to assert: the analyst needs to know who it would have been in order to confirm or reject the mapping.
Partition by equality, so a third kind is a decision rather than a default. The obligations behind a route are split into person-confirmed and proposer-written by testing kind == AUTHOR_ANALYST, not kind != AUTHOR_SYSTEM. If a third author kind is ever added — an importer, another model, a regulator feed — it lands on the refusing side and somebody has to come here and argue it onto the other. The negative test would have silently promoted it to trustworthy.
Nine test_invites failures were fixed, not exempted. ROUTE_PENDING_ACCEPTANCE converts too: a person invited into the product and handed an escalation on the strength of a word overlap is the sharpest form of this bug, not an exception to it. They have no history with the docket, no basis to notice the mapping is wrong, and the invitation itself is what tells them the work is theirs. Exempting that path would have kept the tests green by keeping the worst case.
One spelling of the caveat, not two. changes.py::UNCONFIRMED_ROUTING was deleted rather than aliased, and the sentence moved into routing.py as UNCONFIRMED_MAPPING, so the queue, the route and the screen say the same words. A comment stands where the constant was, saying where the words went; the next person to look for it in the view finds the pointer instead of a blank. And the screen's caveat now covers a case nobody had looked at: every other routing refusal names its duties in its own sentence, so "the obligation this change touches has no owner (OBL-005)" states as settled fact that this change touches OBL-005. With only a word overlap behind it, the page was still asserting a mapping nobody made — in duller words, which is why it read as safe.
Alternatives considered. (a) Leave it and rely on the change screen's caveat — what shipped before today; it protects the one surface that assigns least. (b) Refuse outright and return no name — safe and useless: the analyst cannot confirm a mapping the product will not show them. (c) Let the proposer's confidence promote a mapping above a threshold — a number nobody calibrated standing in for a person, which is the substitution this whole product argues against; and the four rules in mapping.py produce no probability to threshold. (d) Add a trusted boolean to ChangeObligation — a second field that can disagree with mapped_by_kind, and the answer is already in the column. (e) Alias the old constant to keep the nine tests green — two names for one sentence, and the drift starts the day someone edits one.
Cost accepted. One: routing gets quieter before it gets better. Twenty-four of the twenty-six mappings in the demonstration corpus are proposed, so most changes now refuse to name an owner where they used to name one. That is the correct answer and it is a worse demonstration; the reviewer sees a product declining to assign rather than a product assigning. Two: the refusal is only as good as the column. Anything that writes ChangeObligation without setting mapped_by_kind honestly defeats this, and nothing yet forces the column to be set at write time. Three: sixteen refusal codes is a lot of vocabulary for one screen to explain, and no test asserts every code has words a person can read. Four: confirming a mapping is still a database write with no screen. The product now refuses in the right place and offers no way to resolve the refusal, which converts a wrong answer into a dead end until that screen exists.
What would reverse it. A confirmation screen that lets an analyst accept or reject a proposed mapping in one click. Once the refusal has a cure, the balance changes: refusing costs a click instead of costing the answer, and the case for refusing gets stronger, not weaker. Nothing here would need to change — the code, the candidate list and the audit trail are already the inputs that screen would need.
What forced the choice. restore() opened the live database and wrote pages into it. Every failure that can happen partway — a lock that never clears, a full volume, ctrl-c at the moment somebody realises they typed the wrong path — left a file that was neither the old database nor the new one, and the sentence the operator read said Nothing was written. Measured, on a target that did not exist yet: the refusal left a zero-length file at the target path, and a zero-length file is a valid empty SQLite database, so the next process to open it finds no tables rather than an error. tests/test_backup_atomic.py::test_a_refusal_that_says_nothing_was_written_leaves_nothing_behind takes a real exclusive lock on the source, uses the real deadline and the real message, and failed exactly that way before this change.
Why a FALSE refusal is a worse defect than a missing one, which is the whole reason this is an ADR and not a commit message. This product's position is ADR-03's: a claim that cannot be verified does not assert itself, and every refusal names a true reason. A missing refusal is a gap, and a gap is visible — somebody hits silence and goes looking. A false refusal is the one failure that recruits the operator into the damage. They read "Nothing was written", conclude the file on disk is the file they had, and act on it: they retry, or they stop investigating, or they start the service back up on something that is no longer a database. None of those is a choice they would make if the sentence had been true. A product whose entire argument is that its refusals are trustworthy cannot ship one that is a guess about the disk, and this one was not even a guess — it was a constant, printed whatever had happened. os.replace is the mechanism. Making the sentence true is the decision.
Decision. Nothing writes into the file it is producing. _staged_beside(destination) yields a private temporary file in the destination's OWN directory — created with O_CREAT|O_EXCL at 0600 and fchmod'd to exactly 0600, because the mode passed to open is masked by a umask that can only clear bits — the caller fills it, and it takes the destination's name in one os.replace at the end. The same directory is not a detail: a rename is atomic only within one filesystem, and a temporary in tempfile's default location is on another mount often enough to matter, where the fallback is a byte-by-byte copy, which is the in-place write again wearing a different name. The file is fsynced before the rename; the directory is fsynced after, and that one never raises, because the rename has already happened and reporting a failed restore that in fact succeeded is the same class of false sentence.
Why that shape and not the neighbouring one, in six parts, because "write to a temporary and rename" is the easy half and every one of these was found after it. A rename moves ONE file. SQLite leaves -journal, -wal and -shm beside a database, and both ends of that fact bite. A staged copy that still has a log beside it would be installed without the pages in it, so the log is checked and the swap refuses rather than reporting a success. Worse is the other end: a -wal that a dying process left beside the DESTINATION is not carried away by the rename. It stays, and SQLite decides a log belongs to a database by the NAME beside it, not by anything inside either file — so the frames of the old database are replayed into the new one, PRAGMA integrity_check calls the result ok, and the function returns normally. Writing in place never had this problem, because the write went through a connection on the target and SQLite recovered the log first; staging had to be given that back on purpose. The order of the two operations is chosen so the window fails loudly. Clearing the destination's log first and renaming second means a machine that dies in between has an unreplaced database missing its log: visible, and repaired by running the restore that was already under way. Renaming first means the NEW database with the OLD log beside it: silent, passing every check, and in no backup. Removing a journal is itself a write, so a refusal that got that far may not say "Nothing was written" — _what_the_destination_lost assembles the true sentence out of what actually went. The busy-target refusal had been an accident and is now deliberate. Writing in place refused a live database for free, because SQLite reported it busy; a rename does not care who has the file open. Atomicity would have quietly removed the only thing standing between an operator and pulling a database out from under a running server, and that outcome is worse than the corruption it replaces: the server keeps a handle on a file with no name, serves pre-restore data for ever, writes where nobody will look, and neither side reports a problem. _refuse_a_target_somebody_holds now runs before the copy and again at the swap. A rename carries the staged file's mode and this process's ownership, both of which the in-place write kept for free; _match_the_file_being_replaced reproduces them, and REFUSES when it cannot reproduce the owner, because a restore that lands a database the service cannot open has traded one outage for another and called it a success. A symlinked database path is a deployment shape, not an oddity — /var/lib/verbatim/verbatim.db pointing at a mounted volume is the ordinary way to move the data — and os.replace would have replaced the LINK, leaving the real database untouched and the operator told it worked. The link is followed first.
The orphan a staged file becomes, and why the claim beside it is a separate file. A staged copy is invisible to the rest of the module on purpose: it matches neither PREFIX*SUFFIX nor anything prune() reads, so no retention rule can delete a copy in flight. That is right while a run holds it and wrong the moment the run dies, because what is left is a full-size partial database under a dotted name that nothing will ever reap — on the backup volume they collect until it is full and every backup after that fails, and after a killed restore one sits in the live database's own directory. So each run claims a lock file first and creates the copy second, and _reap_orphaned_staging removes only what no live process holds. The lock cannot be taken on the staged database itself: SQLite takes its own whole-file lock on the file it is writing, measured on macOS, where an flock held on the staged file makes SQLite wait out its whole busy timeout and the copy fails outright. Reaping is conservative in one direction only — a claim that cannot be locked counts as alive, whether another run really holds it or the filesystem does not support locks — because deleting a copy in flight would break a run doing nothing wrong, and this is housekeeping, which earns no right to do that.
The class, not the line, and the guard is read off the module. The backward path was restore() and the forward path was snapshot(), which had unlinked its half-written destination on failure — a cleanup after the window rather than a closing of it: for as long as a copy ran, a half-written file sat under a name matching PREFIX*SUFFIX, where existing_backups() globs it, prune() counts it towards --keep, and anybody reading the directory takes it for a backup. Both stage now. The rule is enforced against whatever the module contains: _writers_that_take_a_destination() walks vars(backup) and takes every function whose second parameter is named destination or target, and the failed-write and stray-sidecar tests are parametrised over that list, so a third writer added next year is held to the rule without anybody remembering this file exists. A guard on the guard asserts the list is not empty, because an empty parametrisation passes. The mode test changed too: it used to stat the finished backup's name, which was the file being written only while the write went straight into it, so it now asks the destination CONNECTION which file it has open — the answer holds whatever the implementation calls its temporary, and still fails if a copy ever goes back to writing into the final name.
Alternatives considered. (a) Keep writing in place and correct only the sentence — the cheapest honest fix, and the true sentence would read "a partial file is at this path and it is not a database, do not start the service", which is a worse product than one that does not produce the partial file; the operator still loses the database. (b) Copy the target aside first and put it back on failure — a rollback rather than a swap: it needs the same second copy of the disk, and the restore-the-rollback step is itself a write that can fail, so the window moves rather than closing, and a machine that goes away mid-rollback leaves two broken files instead of one. (c) Stage in /tmp or in tempfile's default directory — reads identical and is not, because os.replace across filesystems raises and the usual fallback is a byte-by-byte copy into the destination, which reintroduces exactly the torn write with no verification behind it. (d) Refuse to restore over a file that already exists and make the operator move it aside — safe, and it turns the one command somebody runs on their worst morning into three, two of them a hand-typed mv at 3am. (e) Leave the destination's sidecars alone and trust SQLite to recover them — it does recover them, into the wrong file, and the result passes every check there is. (f) Reap orphans by age rather than by lock — a threshold on a timestamp eats a slow copy running beside this one; a lock is a fact about the live processes on this host rather than a guess about how long a copy should take. (g) Catch Exception in the cleanup instead of BaseException — reads as thorough and misses ctrl-c, which is the interruption an operator produces on purpose and the one most likely to happen during a restore.
Cost accepted, in full. One: a restore now needs room for two copies of the database at once. On a volume sized for one database and a little slack, a restore fails where the in-place write would have succeeded. It fails safely, which is the trade, and it is still a new way to be told no on the morning you need a yes. Two: the durability is weaker than the word suggests. os.fsync is the portable ask; on macOS F_FULLFSYNC is stronger and is not used, so what is guaranteed there is that the write left the OS, not that the drive flushed its cache. Three: the lock guard is a check, not a proof. An idle connection holds no lock, so a process that has the database open and quiet — which is precisely a running server between requests — is not detected. The refusal catches a writer, not a holder, and that unclosable gap is the honest reason there is still no make restore. Four: this change made the tool delete files in the operator's live data directory. _reap_orphaned_staging runs on the nightly backup path against source.parent. It only touches names carrying this module's own prefix, only ones nothing holds, and never during a dry run — and it is still an unattended delete in a directory nobody asked us to sweep. Five: the last sidecar sweep runs after the swap and can raise on a restore that succeeded. The message says the database IS installed, which is right, and it means the one function that returns a verification can also raise having completed its work, and no caller distinguishes those two today. Six: the crash tests are injected. A fake _copy writes real bytes and then raises. No test kills the process, pulls a mount or fills a volume, so what is proven is the cleanup path and not the crash, and the fsync ordering is argued rather than measured. Seven: the macOS flock finding, which the lock-file design is built around, was measured on one machine on one platform and has not been reproduced on Linux. Eight: none of this is reachable from a command. restore() is hardened and an operator can still only call it from a Python prompt, so every sentence above about what the operator reads is about a message nobody has yet read in anger.
What would reverse it. A filesystem that owns the swap — ZFS or btrfs snapshots, or a deployment where the database volume can be rolled back — makes staging in this process the wrong layer, and the right shape becomes "take a snapshot, write, roll back on failure" with none of the sidecar reasoning above. The other reversal is smaller and more likely: the day restore() gets a command with a --force, the lock guard has to become a proof rather than a check, because a flag that exists to skip a check is a flag that will be used to skip it.
What forced the choice, and the first thing to correct is ADR-87's own sentence. ADR-87 conceded fourth that "confirming a mapping is still a database write with no screen". That was false when it was written: the confirm control shipped with ADR-85 at f3b14f8 and is in change.html at HEAD, gated on action.propose, posting to a route that existed. The sentence stays there and this is the correction. What was true was the rest of the cost — the refusal had no cure a person could reach — and ADR-87's "what would reverse it" said it properly: a screen that lets an analyst accept OR REJECT in one click. The reject half is what did not exist, and its absence bent the screen it was missing from.
The failure in concrete terms. propose_obligations_for_change recomputes on every render. Nothing was stored about a candidate a person had read, so a candidate somebody disagreed with came back unchanged on the next visit, and the only control that made the page any shorter was the one that agreed with the machine. Twenty-four of the twenty-six mappings in the demonstration corpus are the pipeline's, so this is not an edge: it is what the analyst meets on every change they open. A screen where disagreement costs a click and changes nothing, and agreement costs a click and clears the row, does not collect judgement. It collects assent. It is the same shape as a fallback that does not announce itself — the cheap path is the wrong one and nothing on the page says so — and it is worse here, because the cheap path writes somebody's name into an audit chain that is then read as evidence a person decided.
What confirming means, said plainly, because this is the whole position in one click. A confirmation is a person taking responsibility for a machine's proposal. mapped_by_kind becomes AUTHOR_ANALYST, the chain gains an event naming the account that pressed it, and resolve_change_owner stops refusing: ROUTE_MAPPING_UNCONFIRMED becomes ROUTE_OK with a user id on it, and an escalation can now carry a human being's name. That is ADR-85's "propose, do not assert" and ADR-87's refusal meeting at a button: the product will not put a name on work until a person has put their own name on the mapping first. Everything before that step was already true when ADR-87 wrote its refusal, and none of it was any use.
Decision. A candidate has two answers and both are recorded. reject_obligation_for_change is a DECISION rather than a dismissal: it appends to the chain naming the person, the pair and the words the proposer had matched on, and propose_obligations_for_change reads those rows back and stops offering the pair. Four shape choices carry the argument. A rejection is an audit row and not a column. app/state/models.py::ChangeObligation already says a mapping somebody later disagrees with is a fact about what was believed, and that withdrawing one is "a new row somewhere that says so, never a DELETE here" — this is that row, and a mapping the pipeline stored keeps its author and its timestamp with the disagreement sitting beside it in the order it happened. mapped_by_kind cannot carry it either: that column answers WHO WROTE the mapping, and a third value meaning "and then somebody said no" is two facts in one column, which is the shape ADR-87 refused when it declined a trusted boolean. rejected is a third axis and not a fourth reason code. reason says what the WORDS found; rejected says what a PERSON decided about what the words found. Folding them would throw away the matched terms of every rejected row, and those terms are exactly what a later reader needs: a duty turned down over seven shared words is a different event from one turned down over two. The screen gets a third dataclass. RejectedMapping is slotted, carries no confirmer, and the template branches on the type it was handed, so the rejected block cannot draw a confirm-and-route control by accident and the candidate block cannot draw a rejection. Two routes, not a decision field on one — the confirm route's contract is already "post an obligation id and the mapping is confirmed", so a decision field arriving empty would have to default to confirm, which is a silent fallback on the one control in this product that turns a machine's guess into a person's judgement. Two paths cannot default into each other.
Two smaller decisions that are really the same one. The rejection test sits SECOND in _mapping_views, after confirmed and before candidate, and the placement is the bug it prevents: a rejected pair the pipeline had already stored still carries mapped=True, so the candidate branch would claim it and the page would offer a person the duty they had just turned down. And both buttons wear the same class. .btn--primary exists in the stylesheet; giving it to either answer would make one of them the easy one, and since the agreeing answer already clears the row from the page, a screen that also made agreement prettier would be collecting agreement with extra steps. The words tell the two apart and the styling takes no side.
Does the screen show enough for the analyst to judge, or does it move the guess to a human and relabel it confirmed? A real answer, in three parts. What it genuinely gives them. The words that produced the candidate, in a sentence only the candidate block can write; the company passage those words were found in, quoted with its address, so a reader can see that the second candidate rests on the same paragraph as the first rather than on one the page declined to show; the change's own text a few lines above; and an accounting of every duty in scope rather than a shortlist — every offered row is rendered, not MAX_CANDIDATES of them, and every duty with no candidate is printed with the reason it has none. For the question the proposer actually asked, which is narrow — do these shared words mean this change touches this duty — that is enough to answer without trusting anybody. What it does not give them, which is where the relabelling risk is real. The duty's own wording is not on the page. A missed row carries a title and a reason code; the obligation text lives in data/company_context.json and the screen never quotes it. So an analyst asked "did the words miss a duty that does apply" is judging from eight titles and their own memory, and the case the corpus was built around — OBL-001 saying "post security" where the docket says "post collateral" — is invisible on this screen in both directions. And nothing here can tell a considered confirmation from a reflex one. The audit row is identical either way. The product records who decided, when, and what the words were; it does not record that anybody read anything, and no design can make it. What this change does is remove the asymmetry that was actively producing reflexes — it does not prove the reading happened, and claiming it does would be the fluent wrong sentence this product exists to stop. So the defensible claim is narrower than the word "confirmed" sounds: a named person, with the matched words and the cited paragraph in front of them, said yes at this second, and the record can now be asked how often they said no. That last part is new. "How often is the proposer wrong" was unanswerable while agreement was the only outcome anything wrote down.
One correction the reviewer earned, kept here because the page had walked them into it. The rejected block used to end "map the duty anyway and the later judgement governs". A reviewer took the advice, pressed the button, changed their mind again, and got a 409 with no control left on the row: the later judgement does not govern, it is the LAST one the product accepts on that pair, because work is routed off a confirmation and app/state/mapping.py refuses to take one back by name. The invitation and the refusal have to agree. CONFIRMATION_IS_FINAL now hangs on both notes rather than on the one that was caught, since the candidate list asks for exactly the same act, and the screen stops drawing the reject control the moment the product starts refusing it — a page that keeps a control it answers 409 to is asking for an answer it has already decided not to accept.
What the guards had to become, because the first version proved the route and not the button. A reviewer deleted the reject form out of change.html and 244 screen tests stayed green: every test posted at reject_url() directly, so the capability was proved and the wiring by nothing. Dropping reject_url from the template context was the same story — five forms rendering action="", posting back at a GET route, 47 tests green. So the screen half now reads the CONTROLS off the rendered HTML with an HTMLParser and asks app.routes which of them the assembled application answers a POST at, across three renders, and fails if it finds no control at all. The same class of hole was in the words: every matched term is lifted out of the change's own text, which the page quotes a few lines above, so "network" in page is true whatever the candidate block renders — which is how emptying the term list on every candidate left two screen tests green while the page printed "No word in this change reaches this duty's wording" under duties that had matched on three. The lead-in moved into the view as LABEL_MATCHED_ON so a test can hold the whole sentence, which is a string only that block can produce.
Alternatives considered. (a) A decision field on the existing confirm route — one route and one form, and an absent value would have to mean confirm; a fallback that does not announce itself, on the control that converts a guess into accountability. (b) A rejected boolean on ChangeObligation — a second field free to disagree with mapped_by_kind, contradicting that model's own rule that nothing there unmaps, and useless for the common case anyway, because a rejection of a pair the pipeline never stored has no row to hang a column on. (c) A third value in mapped_by_kind — two facts in one column, and every existing reader of that column would silently mis-read the new value until somebody taught it. (d) Hide rejected duties instead of listing them — a shorter page, and a rejection that leaves no trace on screen is indistinguishable from a duty the words never reached, which is the exact confusion the missed list exists to prevent; it would also make a mistaken rejection unrecoverable by anybody without database access. (e) Make rejection reversible by deleting the audit row, or by a second rejection that toggles — both tidy the record, and an append-only chain that can be tidied is not one. (f) Build the symmetric product and let confirmations be withdrawn too — the honest design and a much bigger act: an escalation may already be on somebody's desk because of that confirmation, so withdrawing one needs its own record, its own effect on routing and its own permission. Refused by name with a sentence, rather than half-built. (g) Show a confidence figure to help the analyst decide — a number nobody calibrated standing in for a judgement, which is the substitution ADR-87 already refused, and the four rules in mapping.py produce no probability to show.
Cost accepted, in full. One: the busiest screen in the product now runs a scan, and the comment describing it was wrong first. Every render reads the audit table. The module said "one indexed query"; a reviewer ran the plan and it is SEARCH audit_events USING INDEX ix_audit_company_seq (company_id=?) — the tenant seek is indexed and action, subject_type and subject_id are then tested row by row over every audit row the company has ever written: logins, claims, escalations, the lot. Eighty-six rows in a freshly seeded workspace, growing for the life of the tenant. The fix is one line in app/state/models.py that migrate.py would build unasked, and it is not here because that file was not in this change, so the screen ships with a scan its own docstring names. Two: routing does not know about rejections. A rejected pipeline mapping still answers ROUTE_MAPPING_UNCONFIRMED, whose sentence ends "confirm it and this routes to X" — advice the analyst has already declined. The outcome is right and names nobody; the wording is stale, and fixing it properly means moving ACTION_MAPPING_REJECTED into app/state/audit.py so routing can read the rows without a cycle, since mapping imports routing. Three: that action code is in the wrong file for the same reason, and a second spelling of an action code writes a row that hashes perfectly and that no query for the right code ever returns. Four: the permission is still borrowed. action.propose gates both answers, because PERMISSION_CODES has no obligation.map and now no name for declining one either — so anybody who may propose an action may declare that a change does not bear on a duty, which is a different act with a different blast radius. Five: a rejection is one person's word and nothing reviews it. Confirming routes work and meets an approval gate downstream; rejecting takes a duty off somebody's page for good, shows up on one screen, and notifies nobody. The quiet answer is the unreviewed one. Six: the subject id is two ids joined by ::, guarded by a builder that refuses either half containing the separator, because "A::B" + "C" and "A" + "B::C" are the same string and rejecting one mapping would silently reject another. Nothing in the corpus carries it today. It is still a composite key living in a text column, and this is the first row type that needed two subjects. Seven: a confirmation recorded before today may carry a false sentence, and the chain is append-only. The confirm path read proposal.candidates, which is capped at three, so confirming a duty the words genuinely reached but that ranked fourth wrote "not proposed by these words" into the record — false, and false in the flattering direction, since it reads as somebody knowing something the machine could not reach when in fact they agreed with it. It now tests reason == "", which is the proposer's own threshold and knows nothing about a display cap. Rows already written stand. Eight: no user has touched any of this. docs/user-research.html reports zero interviews. Everything above about what an analyst needs in front of them is reasoning from the corpus and from one reviewer — and that reviewer found two defects in an afternoon, which is the best evidence available for how much is left.
What would reverse it. The semantic join ADR-08 defers. Better candidates make the reject button rarer, not unnecessary — and at that point the rejection rows become the first data in this product worth learning from, which is precisely when to be careful: a rejection collected to shorten a page is not a rejection collected to train a model, and reusing it that way is a decision that needs its own ADR and probably the analyst's own consent. The other reversal is the symmetric one: if withdrawing a confirmation is ever built, CONFIRMATION_IS_FINAL comes off both notes, the 409 becomes a different act, and the "later judgement governs" sentence that was wrong today becomes true.
What forced the choice. Every test in this repository drives a deterministic fake through an injected transport. That is the right default — make test passes on a reviewer's machine with no key and no network — and it left AnthropicTransport as documented design that had never run. ADR-86 conceded it in those words: "this path has still never run against the real API". Two module docstrings said the same, honestly, and honesty does not make a request legal. A parameter name invented from memory, a response field read from the wrong place, a combination of parameters the endpoint rejects outright: all three pass a fake and all three fail once.
Decision. Send exactly one request through the shipped transport, on the model the module pins, and write the whole transcript down in docs/.ai/live-transport-probe.html: the request bytes, the response fields, the token counts and the cost. scripts/probe_live_transport.py defaults to sending NOTHING — it drives the real transport against a client that records the request and refuses it, prints exactly the block the page carries, and stops — so --send is the deliberate act and the printed half is regenerable by anybody with no key and no charge. The transport is not modified to be observed: complete() returns a string and a transcript needs the model id, the stop reason and the usage block, so the SDK client is wrapped in a recorder that delegates every call and keeps the response object. What ran is the shipped class, building the shipped request, against the shipped model id.
What the one call settled. 2026-08-05, 15:43:37 UTC. claude-opus-5 accepted thinking: {"type": "adaptive"} and output_config.format together, with no sampling parameter, and answered end_turn. response.model echoed the id the module pins. The key loaded the way the application loads it — load_env(), nothing exported by hand. The structured output came back as the schema asks and parsed on the first read, and the offsets it returned landed exactly on the quoted span. 523 input tokens and 453 output, about 1.4 US cents by arithmetic on those counts at the published rate. One finding is worth more than the rest: a thinking block came back FIRST, so joining only the text blocks is required rather than tidy — and the earlier explanation of why was itself wrong, which is the next paragraph.
Recording the failures rather than the result, which is half of why this is a decision and not a note. Three things went wrong around this file and all three stayed on the page. A second call went out by accident, the same day, and cannot be described. An agent cleared ANTHROPIC_API_KEY from the environment believing that disarmed the script; load_env() puts back any name from .env that is not already set, which is exactly the state clearing it creates. The output was piped to head -2 and lost, so nothing about that request can be reported: not the response, not the tokens, not whether it even succeeded. The page names it rather than leaving it out, because a document that quietly says "once" while its author knows of a second call is the precise failure the document exists to correct, and the script now warns in its own header that clearing the variable does not disarm it — moving .env aside is the only real off switch. An earlier version reformatted the request block by hand while the sentence above it claimed the block was printed. Nothing on a page distinguishes a block that was tidied from one that was altered, and that block is the one artefact here a reader would quote back at the code. An earlier version gave the wrong reason for the text-block join — it said indexing content[0] would have returned the reasoning. It would have raised AttributeError: the SDK's ThinkingBlock carries signature, thinking and type and no text field at all, and there was no reasoning in it to return either, because this model never returns a raw chain of thought and the request sends no display, whose default is omitted. A plausible reason for a correct line is still a wrong claim, and on this page it is the kind a panel checks.
How a prose transcript is held to something outside itself, since it cannot be re-run. tests/test_live_transport_probe.py pins five claims. The marked model id must equal MODEL_ID read from both model modules, and no other model id may appear anywhere on the page — change the model and the suite fails naming this file, because a transcript describing a model nobody calls reads exactly like one describing the model everybody calls. The request block is rebuilt by running the shipped transport and compared byte for byte, so it cannot be tidied, retyped, or left behind by a change to the schema, the prompt or the token ceiling. The two module names are read OFF the page and their docstrings checked against it, in both directions: the module marked exercised may not still say it has never run, and the module marked unexercised must still say that it has — the first is the sentence a correction leaves behind, the second is the caveat a tidy-up deletes. And the thinking-block reason is checked against the installed SDK's own field list rather than against anybody's memory.
Alternatives considered. (a) Leave the path unexercised and keep the honest docstring — free, and it is exactly the state ADR-86 conceded; saying "we have never run this" is honest about a risk and does nothing about it, and the first real call was always going to find bugs in the transport rather than in the verifier, so the only question was whether it found them here or on a reviewer's screen. (b) Add a live test to the suite behind a marker or an environment flag — the obvious answer and wrong twice over: make test must pass with no key and no network, and a test that skips itself when the key is absent is a control that reports success for having done nothing; it would also charge money on every run, which turns a suite into a bill. (c) Record the response as a cassette and replay it — replay is a better fake, not a call; it would prove the parser against a genuine body, which is worth something, and it would sit in the suite where a later reader takes it for coverage of the endpoint, and it needs the live call first anyway. (d) Wire the model into the pipeline and let the demonstration be the evidence — the strongest evidence and the largest change, on the deadline, on the one path the citation gate sits on; if it broke it would break on a screen rather than in a script. (e) Sweep parameters until something is rejected — more information per dollar and no honest place to stop; the question asked was whether the shipped combination works at all, not where its boundaries are. (f) Quote the response in the module docstring instead of a document — a transcript in a docstring rots quietly and cannot be held by a test that reads a page.
Cost accepted, in full. One: the central claim is unverifiable by anybody who does not trust the author. Four fields on that page are pinned to the code; the token counts, the response body, the message id and the date are prose. A test can stop one field of a transcript from rotting. It cannot verify that a call happened, and no test in this repository ever will, because none of them may touch the network. Two: the count is not one. One call is recorded and a second almost certainly went out and cannot be described, so the honest statement is "one recorded, at least two made", and that is the sentence on the page. Three: what one call proves is small. The wire format, the authentication and the parse work once, on a 523-token prompt, on a quiet morning, from one machine. No rate limit was hit, so the client's back-off has still never run. No connection dropped, no response arrived malformed, no timeout fired. A real change carries two passages and a company context, so nothing is known about a long input or about the model's accuracy at counting characters across thousands of them. Four: the citation gate — the part the product rests on — was not exercised at all. No verifier ran, no judgement was stored, and a citation that fails to verify has still only ever been produced by a fake that was told to produce one. The refusal branch was not taken. Five: the OTHER transport is exactly as unexercised as before, and this page creates a hazard the old state did not have: app/chat/agent.py declares a second AnthropicTransport with a different parameter set — tools, and effort inside output_config — and a reader who learns "the transport has run" may carry it to the wrong module. The guard against that is a test that reads both docstrings, which defends against a misreading rather than fixing the gap. Six: the flag is the only safety and it has already failed. --send guards a script whose accidental invocation is the second call above. Seven: 1.4 cents is arithmetic, not a bill, so if the published rate is wrong the figure is wrong. Eight: nothing here makes the demonstration judge anything. This moves the model path from unexercised to exercised and no further; a reviewer opening the deployed product still sees whatever the host's key state allows, and one probe is not a claim about that.
What would reverse it. A recorded run over the real corpus, with the results stored and checkable. The moment a model judges a batch of real changes and the verdicts and their citations can be read back, this transcript stops being the evidence for the model path and becomes a footnote about the first request — and the page should say so and point at the run, rather than standing as the strongest thing this repository can show. Until then it is one call, written down, with the limits next to it.
What forced the choice. can_approve was the control this product is sold on and it had no caller. That was found by an independent review before it was found here, which is the part worth keeping: eight controls on this build have been written, tested and left unreachable, and the pattern never varies — the unit test proves the capability and no test asks whether anything reaches it. Two attempts to fix it wired the gate into review.py::resolve_escalation and both were reverted the same day. The reason is the shape of the mistake rather than a detail. Gate 2 demands action.approve; escalation.resolve sits on ROLE_ANALYST and action.approve does not; so the gate made the analyst's own screen refuse the analyst, and every screen test came back 403 from the wrong gate. DEMO_SELF_APPROVAL does not rescue that: it waives gate 4 alone, and ADR-19 says so.
The real gap was upstream, and docs/prd.html had already named it. "The action half is not surfaced: the monitor, comment and comply vocabulary exists in code and no screen renders it." app/interpretation/action.py had held the three words since the first week with no row to hang them on. There was nothing to propose, so there was nothing to approve, so the gate had no decision under it — and a control with no decision under it cannot be wrong, which is why it stayed green for a day while being useless. The escalation queue was never its home. Resolving a refusal and approving an action are two decisions taken by two people.
Decision. One table, one screen, one gate. ProposedAction carries a claim, the change beneath it, one of the three words, a rationale, the proposer and the decision. /actions renders the queue: an analyst holding action.propose proposes; a person holding action.approve approves or rejects. app/web/views/actions.py::decide asks can_approve against the proposal's claim id, honours the Verdict, and answers 403 with the sentence the gate produced. action.approve was already on ROLE_OBLIGATION_OWNER and needed no grant; it stays off ROLE_ADMIN, because administering accounts is not authority over regulatory work, and an admin who could approve would be an admin who could grant themselves whatever the approval needed and then take it.
Four shape choices, each with the failure behind it. The proposal points at a claim, not only at a change. can_approve resolves a claim id into the claim, the change beneath it and every escalation raised against it; a proposal hung on a change alone would have left the gate a claim short of its evidence. The gate is asked at the click and never at the render. The buttons are drawn from policy.has(), which is the function written for deciding what to draw; the answer that binds is taken inside the POST against the live rows, because a check made when the page was drawn is a statement about the past. Every outcome is decided inside the transaction and acted on outside it. can_approve and require append their access.denied row through the caller's session, so raising an HTTPException from inside session_scope() rolls the refusal back — the person is stopped and the record of stopping them is gone. review.py still has the older shape. The refusal is rendered on the screen rather than returned as a bare body. The hardest decision in this product is meant to be visible in the product: somebody who tries to approve their own work reads which audit row proved it, on the page they were already looking at, at status 403.
Rejection is gated differently, and that asymmetry is a decision. Rejecting goes through require(action.reject) and not through can_approve. The danger the control guards is waving your own work through; refusing your own proposal takes nothing forward and is closer to withdrawing it. The cost is real and is conceded rather than hidden: somebody holding action.reject can knock out a colleague's proposal and leave their own standing, and nothing in this build stops that.
Alternatives considered. (a) Wire the gate into the escalation queue — tried twice, reverted twice, and the strict xfail in tests/test_approval_segregation_wired.py failed the suite both times, which is what strict is for. (b) Grant action.approve to ROLE_ADMIN so an approver already exists on every screen — one line, and it folds account management into the decision path, which is the pair SEGREGATION_CONFLICTS already declares as a conflict. (c) Store the approval verdict on the row and read it back — the same mistake ADR-3 refuses for citations, pointed at a permission: the grant can be revoked between the render and the click. (d) Put can_approve in the store rather than the view — tidier layering, and it hides the control in the layer nobody looks at, when the requirement is that a reviewer can watch it refuse. (e) Let an approval also perform the action — the honest product and not a 48-hour one; it needs a filing calendar, a notifier and an owner's system of record, and half of it would be worse than none.
Cost accepted, in full. One: approving does not act. It records that the company decided to comply, or comment, or monitor. Nothing is filed, nobody is told, no project state moves, and the screen says so on the page rather than implying otherwise. Two: no screen can arrange the refusal, and the demonstration is a seed switch. Reaching gate 4 needs one person who holds action.approve and has also touched the claim, and the seeded grid gives nobody both — which is the grid working. The first version of this ADR said the arrangement was one grant away in the product, and README, app/seed.py and the panel brief all repeated that sentence. It was false, and an independent review found it by following the walkthrough. Nothing in Verbatim puts a second role on an account that already exists: /users provisions a new login, the role picker there offers only roles inside the granter's own ceiling, /permissions/grant refuses a code the granter does not hold, and the one declared ceiling waiver — the handoff invitation — refuses an address that is already active. Admin holds user.manage and no approval code on purpose, so there is no account in the grid that can do it. The demonstration is therefore VERBATIM_DEMO_REFUSAL=1 python -m app.seed, which grants the analyst the obligation owner role as the system actor and prints what it did and why no screen could. tests/test_seeded_refusal.py drives the same steps end to end and holds the four documents to the true sentence. The cost stands as a cost: the control this product is sold on can be watched refusing in an arranged workspace and in the suite, and not in the default demo. Three: within a tenant, the decide route resolves the proposal before it asks the gate, so a colleague without action.approve learns the id exists, and learns more than that: a waiting proposal answers 403, a decided one answers 409, and an id that never existed answers 404. Three states, three codes, to a caller who may decide none of them. The queue already prints all three to anybody signed in, so nothing is disclosed that the screen withholds; another company's id is still a 404 that reveals nothing, with the same body and the same headers as a real one. Four: the vocabulary is checked against the change's stored status and nothing checks the status against reality — if a version's status was wrong at ingest, a draft can be complied with and the product will not object. Five: one claim carries at most one live proposal in practice, because the propose form hides a claim that already has one; the table permits several and nothing merges them. The form used to hide a claim that had ever carried a proposal, so a rejected one removed that claim from the form for good and an analyst could not offer an alternative through the screen. That was a defect rather than a cost and it is fixed: the form now hides only claims with a live proposal, and a test holds it.
What would reverse it. An action that acts. The moment approving a comply writes a dated obligation onto a real calendar and tells its owner, this screen stops being a record of decisions and becomes the place work starts — and the segregation gate in front of it stops being a demonstration and starts being the thing that keeps a filing honest.
Three decisions, taken across the visual redesign of 2026-08-07 and written here in the same change. The rule at the head of this file is that the entry ships with the decision. These three did not, for a day, and that is the only thing about them that was done in the wrong order. Everything below is read off the tree at the commit that carries it, not remembered.
What forced the choice. Every heading in the product was set in the same stack as every label, every button and every table cell, because --face-ui was the only voice the interface had. Three faces existed and each stood for a kind of truth — the product speaking, the document speaking, the coordinates that make a quote checkable — and the product's own voice had no register above 17px. A page title, a hero line and a nav item were the same letters at different sizes. That is not a taste complaint: the masthead is where a reviewer decides whether this is a product or a prototype, and a system stack at 24px semibold is the default every internal tool ships with.
What ADR-012 actually said, and which third of it falls. ADR-012 reaffirmed the stack as "no Node, no build step, no framework", and verbatim.css's own header turned that into "no web font, no icon font, no framework, no build step". Three claims travelled together in one sentence and only one of them was ever about the font. No build step survives untouched — the file is committed, not compiled, and nothing generates it. No CDN survives untouched — and is now stronger than it was, because it is a test rather than a habit. No web font falls, and only that.
Decision, with the numbers. One file: app/web/static/fonts/manrope-latin.woff2, 24,576 bytes, byte-identical to deploy/site/fonts/manrope-latin.woff2 so the site and the application cannot drift apart on the mark. Manrope, under the SIL Open Font License 1.1, with OFL.txt beside it in both directories. Latin subset, declared in a unicode-range that stops at the Latin-1 block plus the punctuation and currency the interface uses. It is a genuine variable font, not a static weight wearing the name: the WOFF2 table directory carries fvar, gvar, HVAR and STAT, and @font-face declares font-weight: 400 800 against a real axis, which is what lets h1 ask for 750 and get 750 rather than snapping to bold. Served by this application at /static/fonts/. Fetched from nobody at runtime. A reviewer with no network gets the whole design.
Display only, and the reason is measurement rather than restraint. The face dresses h1, the two opt-in sizes .t-display and .t-title, and the name inside the wordmark's SVG. Interface text keeps the system stack. Not as a compromise: the dense screens were measured against those metrics. The 12px floor on --t-micro, the 0.09em label track, the 74px tally column that was wrapping to three lines, the 66ch reading measure — every one of those was set by reading a screenshot of the system stack at that size. Swapping the body face invalidates the lot and re-opens work that was already done twice. It is also what makes a page lurch on load: a display face reflows a heading, a body face reflows the document.
Alternatives considered. (a) Keep the system stack and get the register from weight and tracking alone — free, no new bytes, no new licence, and it was tried first; the ceiling is low, because the thing that makes a masthead look considered is the shape of the letterforms and San Francisco, Segoe and Roboto do not agree on what those shapes are, so the product looks different on three machines and considered on none. (b) Google Fonts, or any CDN — two lines, zero bytes in the repository, and it adds a third party to every page load in a product whose sub-processor page has already had to be corrected once for being untrue; it also breaks an offline run, which is the state a reviewer on a plane is in. Refused hard enough to be a test. (c) The full font, all scripts — simpler and about five times the bytes for glyphs no screen in this product renders. (d) Static instances at 600 and 750 — two files, two requests, and no 750 without shipping a third; the variable file is smaller than the pair. (e) Replace the body face too — the version that would look most designed in a screenshot, and it throws away every measurement above.
What holds it. tests/test_design_guards.py asserts both copies exist and both begin wOF2, and sweeps every template, both stylesheets and every page of the marketing site for five font CDN hosts. A CDN link is one paste away and this is the class of change that arrives inside an unrelated commit.
Cost accepted. One: 24KB on first load that was not there before, uncached, on the first screen a reviewer sees. font-display: swap means they read the fallback for a moment and then the heading changes shape, which is a visible flash on a slow connection and the honest price of not blocking the render. Two: a licence obligation the repository now carries. OFL 1.1 requires the notice to travel with the font; two copies of OFL.txt sit beside two copies of the file, and nothing tests that they stay there. Three: the wordmark is now clipped by a fixed viewBox. The name is a <text> in a 132×32 box; measured in Chromium with the font loaded it runs to x=100.9 against 132, where the system stack ran to 97.0. There is room, and not so much room that a longer word could be dropped in without measuring again. Four: two faces now describe the product's own voice, which is one more thing a later reader can get wrong — a comment in the type block says where the display face may be used, because a rule that lives only in somebody's head is how a body face gets swapped by accident. Five: nothing renders the two opt-in display sizes on most screens. They exist, they are argued for, and the templates mostly do not ask for them yet.
What would reverse it. Evidence that the swap costs more than the register buys — a reviewer describing the flash, or a first-paint measurement on a real connection, neither of which has been taken. Removing it is one @font-face block and one token: the fallback is the system stack that was there before, and every measurement in the file is still against it.
What forced the choice. The old palette was one hue and its own shadow: a deep petrol #0d5c6b for anything you can act on, a burnt sienna for escalation, and eight greys that were the petrol desaturated. Read on a screen it is a teal product. Teal is the house colour of the developer-tools category, and this is not one — the reader is an analyst who spends the rest of the day in a docket viewer and a spreadsheet, and the product is asking to be trusted with a regulatory record. The colour was doing no work toward that.
Why two hues was RIGHT for a 48-hour build. This is the half of the argument worth defending, because it is the one that looks like a mistake in hindsight and was not. A two-hue palette is self-checking under time pressure. Three properties, each of which paid: one, every colour question has one answer. Is this thing actionable? Then it is --accent. Is it a refusal? Then it is --alarm. Everything else is a grey. Nobody at 3am has to decide what a "warning" looks like, because there is no warning colour, so no screen grew one. Two, it makes meaning-on-colour-alone hard to write. With eight status hues available, draft-versus-final becomes two chips and the distinction dies in a monochrome screenshot; with two, the difference had to be carried by label, weight and shape, and it is — the print block at the foot of the stylesheet exists to prove it. Three, the contrast surface is small enough to hold in your head. Two hues over two schemes is a handful of pairs; the audits were tractable, and they were run.
Why it is WRONG for a finished product. Also three, and they are the mirror. One, it puts the whole burden of hierarchy on lightness. Eight greys derived from one hue means every distinction — a page from a card, a card from a sunk well, a rule from a stronger rule — is a step in lightness and nothing else, and the steps get small enough to vanish on a laptop screen in a meeting room. Two, the neutrals inherit the accent's character whether or not that is wanted. Desaturating petrol gives green-grey. Every surface in the product was faintly green and nobody could name why the pages felt clinical. Three, the rule cannot express depth. The glass-and-paper revision needed a tint, a composited fallback, an edge highlight and a cast shadow — four values that are not "actionable" and not "escalation" and not the page. They were added as achromatic exceptions with a paragraph each explaining why they did not break the rule. Four exceptions to a rule with two members is the rule telling you it has stopped being one.
Decision. --accent becomes an institutional indigo #2f4bd8, --alarm an oxblood #a02c1d, and the eight greys are re-derived on the blue axis rather than the teal one, across all four colour schemes the product answers: light, dark, and each of those again under prefers-contrast: more. The marketing site mirrors the tokens by hand, each naming the verbatim.css line it copies, because the site is allowed a mesh on its chrome and the application is not allowed one anywhere (ADR-94), so the two sheets stay separate rather than one importing the other.
And the count did not change, which is said here rather than left for a panel to find. A hue census of both stylesheets, run over every hex literal outside comments, returns two clusters and no third: hue 7–16, which is the oxblood family, and hue 220–231, which is everything else. The stylesheet's own header still says exactly two hues exist and it is still true. So this entry is not "we added colours". It is: the two hues were replaced, the neutrals were re-derived from the new one, and the rule is now held as a deadline rule with its expiry written down rather than as a principle. The first status colour a real product needs — and it will need one, because "in comment period, closing in nine days" is neither an action nor a refusal — is the moment this entry gets reopened. One stray value survives the sweep and is recorded rather than tidied: dark --glass-edge is #5c757e, hue 196 at 16% saturation, a teal remnant the indigo pass and its correction both missed. It is a one-pixel top edge on a translucent pane and grey enough that nobody has seen it. It is still wrong.
Alternatives considered. (a) Keep teal and fix only the greys — the smallest change, and it leaves the accent doing nothing for the product's credibility while still costing a full re-audit of the same fifteen ratios, so it buys the cost without the benefit. (b) A neutral grey scale unrelated to any accent — the conventional answer and the reason so many tools look like each other; the greys stop belonging to the page and the whole surface reads as a template with a colour dropped on it. (c) Add a third hue for status now — honest about where the product is going and wrong to do in the same change as a palette move: a new hue needs its own meaning, its own contrast audit in four schemes, and a screen that renders it, and none of those existed. Named and deferred rather than half-built. (d) Generate the scales from a colour-space library — better perceptual spacing and it puts a build step and a dependency in front of a stylesheet whose whole argument is that it has neither (ADR-012). (e) Import verbatim.css into the site — one source of truth and it drags the application's rules onto a page that is deliberately allowed to break one of them.
Cost accepted, in full — and this is the honest part of the entry. One: the palette shipped with three AA regressions on the surface that carries the record. --ink-3 against --paper fell from 4.821 to 4.494; against --surface from 4.691 to 4.494; against --glass-solid from 4.565 to 4.298. All three cross 4.5:1. --paper is the ground of every diff, every claim, every quoted source and every table in this product — the one surface a palette may not take under the floor — and it was taken under it. The suite did not notice, because only --bg and --surface-sunk were ever conceded under AA and the pinned ceiling rows moved with the colour they measure, so the test that should have failed was measuring the new value against itself. A review caught it and it was corrected the same day: --ink-3 moved to #65728f and the three readings come back to 4.82, 4.70 and 4.57. Two: four separate cases of a token family whose head moved and whose members did not. --glass-tint moved and --glass-1/2/3, which are that tint at three alphas, stayed on the old value. --accent moved and --accent-strong and --accent-wash stayed teal, so light rendered an indigo action colour on a teal wash and dark rendered a cyan one on an indigo page, because dark's accent was itself derived from light's. The light scheme moved and dark did not, twice: dark --paper sat at hue 198 while --bg, --surface and --glass-solid had all moved to 224, and both --hatch values and the light --glass-cast were teal remnants of the same shape. And --track-display, chosen for a 48px hero, was handed whole to h1 at 24px, where -0.02em closes the letters up — one value taken from the largest case and given to every case, which is the same error in type rather than in colour. Almost none of these pairings is covered by a test, which is why all four were found by eye and not by the suite; the guard is now a written rule in the plan — change the head of a family and you change the family, grep the prefix across all four schemes before committing — and a written rule is weaker than a test and is what exists. Three: every browser-measured pixel figure in the stylesheet expired with the teal palette. The pane samples, the eight ground-layer readings, the four dark-scheme lift figures: all were read off a screen before this change and none has been re-read. They are marked as expired in place rather than recomputed, because arithmetic is not what took them. The honest state of the rendered contrast on a translucent masthead over an indigo page is unknown. Four: two documents now describe a surface that no longer exists — Parts two and three of docs/web-design.html, addressed in Part five of that file rather than by deleting them.
What would reverse it. A user saying the colour is wrong. Nobody outside this repository has seen either palette, and the argument above for indigo over teal is a judgement about what a regulatory tool should look like, made by the person who built it, defended from the category rather than from evidence. docs/user-research.html still reports zero interviews. The palette is one token block in four schemes and can be moved again in an hour; what could not be moved again cheaply is the measurement it invalidates, which is the real reason to do this once and early rather than twice.
What forced the choice. The glass revision gave the product a vocabulary of soft surfaces — blur, tint, cast, edge — and a redesign that adds a mesh to a landing page hero teaches everybody working on it that a mesh is available. The distance from there to a wash behind a quoted tariff clause is one commit by somebody who is thinking about the page and not about what the page is for. A translucent tariff clause is a worse tariff clause, and a claim card with a gradient behind it is decoration applied to the one surface in this product that must not look decorated.
Decision. Chrome may be soft; the record is flat. Gradients, meshes and washes may sit on navigation, panels, rails, controls and section backgrounds. They may not sit on the diff, the claim, the quoted source, the citation viewer, the mismatch pair, or a table of record. tests/test_design_guards.py::test_no_record_surface_carries_a_gradient enforces it: it splits app/web/static/verbatim.css into declaration blocks, finds every block containing gradient, and fails if the selector names a record surface.
Two exemptions, and the condition each one hangs on. Running the rule found two real gradients already on record surfaces, and both are right. .claim--withheld draws the broken amber rule — a repeating gradient clipped to a 3px left-edge strip, a dashed line where a verified claim draws a solid one, and one of the six non-colour differences that let a withheld claim survive a monochrome screenshot. .source--internal::before draws the matching provenance hairline, also clipped to 3px. Neither is a wash behind evidence; both are rails beside it. The exemption is conditional and the condition is checked. Each entry carries the clamp that proves the rule is still a strip — background-size: 3px 100% in the withheld block, width: 3px in the shared .source::before rule — and the exemption holds only while that clamp is there. Naming a selector and waving it through would mean a full-bleed wash could land on .claim--withheld tomorrow and the guard would stay silent.
The first version of the exemption could not fail, and the mutation is what said so. It looked for the proof anywhere in the stylesheet. background-size: 3px 100% occurs five times, so deleting the clamp from .claim--withheld still found it in four unrelated rules and the exemption held — the guard passed the exact change it was written to catch. The lookup is now scoped: in the exempted block itself, or in one named sibling rule and nowhere else. Mutation-checked four ways — unmodified passes, removing either clamp fails, restoring passes. This is the whole reason the entry exists: a guard that has not been made to fail is a guard nobody has tested, and the difference between the two versions is invisible in a green suite.
One implementation detail that is not a detail. The obvious css.split("}") is wrong for this file. Its prose comments contain literal braces — .skip:focus { left: var(--s4) } appears inside a comment, among others — and a single stray brace desyncs every block boundary after it, handing one rule's declarations to another rule's selector. A guard built on that is checking rules it never correctly identified, and it would still be green. Comments are stripped before the split.
Alternatives considered. (a) Write the rule in a comment and trust it — free, and this repository has a file full of comments that went untrue while nothing failed; it is the exact shape the guard exists to catch. (b) Ban gradients everywhere, in both sheets — enforceable in one line and it costs the landing page the thing a landing page needs, which is atmosphere in one screenful; the site's mesh is argued for in its own comment and switched off whole under prefers-contrast: more. (c) Narrow RECORD_SELECTORS until the two rails fall outside it — a green suite bought by making the guard look at less, and it would have dropped .claim--withheld, which is the single most important surface in the product. (d) Parse the CSS with a real parser — correct, and it is a dependency in front of a stylesheet whose argument is that it has none; the comment-stripped split is documented with the failure that forced it. (e) Check the rendered pixels instead of the source — what a reviewer actually sees, and it needs a browser in the suite, which make test does not have and should not need.
Cost accepted. One: the guard reads app/web/static/verbatim.css and nothing else. deploy/site/site.css is swept for CDN hosts by the sibling test and not for gradients, so the site's own rule — that a mesh may only ever sit on chrome, and never on .rec, .withheld, .mismatch, .stage, .note or .srctext — is held by a comment. That is the state this entry argues against, in the file where the redesign put the hero refusal. Recorded as a gap rather than left implied. Two: it is a substring match on a selector. .claim matches .claim__foot and would match a chrome element somebody names .claim-toolbar; the failure mode is a false positive, which is the direction to fail in, and it is still a match on text rather than on meaning. Three: .passage is in the list and matches no class in this stylesheet — kept because narrowing a guard is not a task to do casually, and a passage-scoped class is a plausible name for a later surface. It is a selector the guard watches for that nothing renders. Four: it catches the word gradient, not the intent. A flat wash written as an image, an SVG background, or a color-mix layered behind text would all pass.
What would reverse it. Nothing about the rule. What should change is its reach: the same check pointed at site.css with that file's own selector list, which is a small job and is written down in docs/.ai/gaps.html rather than done here.
The decision. /explain draws the path a pair of filings takes, every box and arrow opens into a plain reading and a technical one, and a question box beside it answers from the model. It sits behind the sign-in the product already has. The knowledge it draws and the knowledge the model answers from are the same Python dict.
Why a screen at all. The argument this product makes is not visible from any one page. A reviewer meets a withheld claim on the change screen and sees the result of verification; the mechanism — that the citation is re-read against the stored filing on every render, and the sentence removed from the object rather than hidden on the page — is in docs/tdd.html and in the code. Somebody who has just met a refusal asks why immediately, and the answer was two documents away.
Why it is behind sign-in, which was not the first plan. The endpoint spends an Anthropic key. The first shape considered was public and unauthenticated, because a reader should not have to sign in to read an explanation. That is an open relay on somebody else’s bill: nothing in this build rate-limits, and it does not merely happen not to — app/auth/sessions.py says throttling “belongs at the edge, and this build has no” edge, and four other modules say the same about themselves. Weighed against adding nginx limit_req and a page token, reusing the existing session was cheaper and stronger: every model call now carries a principal, exactly as app/web/views/chat.py does. The cost is real and is conceded — a reviewer must click one demo sign-in button before asking anything.
One dict, two readers. app/explain/knowledge.py holds twenty-one nodes. The template renders it as JSON for the drawing; app/explain/answerer.py folds the same object into the system prompt. Written twice, the two would drift and nothing would fail — the diagram would say one thing and the assistant another about the same box. This is the derived-corpus rule in docs/best-practices.html section 27, applied to prose.
The nodes cite code, so the citations are checked. Each node names a file, a function and a line. That is a citation made by a product whose whole argument is that a citation is re-checked rather than trusted, so tests/test_explain.py opens every file and asserts the named function is on the named line. Move verify_citation and the suite fails, rather than a reviewer being sent to the wrong line by a page that claims precision. It caught one on the first run: class Claim is at 384, not 385.
The fallback announces itself. With no key the endpoint answers from a keyword index over the same knowledge and says in the reply that it is the index, not the model. A fallback that reads like the real thing is the defect in the .env story: three modules each politely reporting a limitation they did not have, every message true, the product broken.
Two things the build got wrong and the suite caught. First, /explain was reachable by nobody — test_every_screen_a_person_can_open_is_reachable_by_following_links failed it, which is the eight-unreachable-controls defect arriving for the ninth time, caught this time before it shipped. It is now the eighth item in the masthead. Second, a first cut of the offline scorer answered “what is the capital of France?” with a paragraph about Unicode normalisation, because “what”, “the” and “capital” each matched something somewhere. Explicit triggers per node replaced it, and five off-topic questions are pinned as tests.
And one the model got wrong until the prompt was hardened. Asked the capital of France, it first answered “Paris — but that is outside what this page covers.” That is answering the question and then apologising for it, on a page arguing a system should decline when it cannot ground a statement. The prompt now says so in as many words: you may know the answer; knowing it is not the point.
What this does not do. No history, no session, no stored transcript — one question, one answer. A conversation would need a store, a retention window and a tenant scope for it, which is four things to get right for a feature nobody asked for. And the honest limit of the whole screen: it explains the mechanism, it does not prove it. The proof is make test.
Eight decisions, taken on the five branches that answered I1 to I5 in docs/.ai/gaps.html, written here after the merge rather than beside each one. ADR-61 says the decision log is a serialised stage that runs after the builders, and this is that stage: five worktrees ran at once and none of them was allowed to touch this file. Every figure below is read off the tree at the merge commit or off the module that computes it, not off the report the branch handed over — three rounds of review on this work found published numbers that did not reproduce from committed code, which is this repository’s cardinal sin and is recorded as such in ADR-100 and in the board’s risk register. One of the five is a negative result. I4 built its parser, measured it, and did not wire it in; that is ADR-102 and it is the entry worth reading first.
What forced the choice. An interview said the scalability strategy “remained high-level, primarily noting memory and CPU constraints”. That was fair. Nothing in app/ partitions a document, bounds memory against its size, queues work or applies backpressure, and the honest reason was that nobody had measured what the diff path costs. A queue designed against an unmeasured bottleneck is a week spent making the fast part faster.
Decision. docs/scalability.html records what the path costs, measured by scripts/bench_diff.py, and section 4 designs the pool, the queue and the admission check. None of it is built. autojunk=False in app/diff/engine.py::diff stays, and the quadratic cost of keeping it is accepted here rather than discovered by somebody profiling in six months.
What the measurement changed, which is the point of taking it. The interview’s premise — that SequenceMatcher is the constraint — is wrong on this codebase’s shapes. On a 3.29-million-character pair split at 1,600 passages, normalization over the two passage lists is 87% of the call and the list-level matcher is 1.5%; the cost is a per-character Python loop in app/text/normalize.py::normalized_projection that is O(n) and simply written in Python. Partitioning was measured rather than assumed: index partitioning turns the real Kentucky pair’s 144 changes into 777, and content anchors reproduce the answer exactly and run slower, because finding the anchors normalizes the document and every sub-diff normalizes it again. Neither does anything for the quadratic case, because a run of repeated passages yields no anchors.
The answer to “ten thousand concurrent” is that nobody runs ten thousand concurrent. A million-word pair is about 13 million characters, which is a row in the page’s own table rather than an extrapolation: app/pipeline.py::ingest_and_diff costs 2.930 seconds and 38.51 MB of peak heap on it. Ten thousand of those is 8.1 CPU-hours as a backlog and about 376 GB of heap if they were truly concurrent. The first is a scheduling problem. The second is not available on any machine this would be deployed on, and a bigger box moves the wall rather than removing it. The gap between those two lines is the whole design, and the thing that closes it is a bounded pool, which is eighteen workers and 693 MB.
Alternatives considered. (a) Build the pool now — a bet placed with no measurement, and the measurement says the first thing to optimise is a per-character loop rather than concurrency. (b) Turn autojunk on and take the speed — measured: on 100 unique passages plus a run of 300 identical ones with a single amendment it reports 150 changes where 2 occurred, 149 of them pairing a passage with a passage carrying the same text. Every one of those fabricated changes carries a citation that verifies, because the words really are in the document. The diff is the last place the truth is known. (c) Partition the document — measured and rejected above, and it has a second cost: the answer stops being a function of the two documents alone and becomes a function of the documents and the cut points, which would then have to be stored with the changes. (d) Say nothing until a customer asks — the question arrives first in procurement, and an unmeasured answer there is the one that costs the deal.
Cost accepted. One: nothing is bounded. There is no pool, no queue and no admission check; the size of the corpus is what keeps this honest, and the size of the corpus is not an argument. Two: one bad document is the real limit, not the ten thousand. Sixteen thousand repeated lines take 15.1 seconds, and a million-word filing that is mostly a rate table extrapolates on that curve to roughly twelve minutes for one pair. The design says what a refusal would look like and no code implements it. Three: nothing on the page was measured under concurrency at all — every multi-worker figure is single-worker measurement multiplied, so it ignores allocator contention, the GIL and a shared database, and the page says so above the arithmetic rather than below it. Four: the page is the only place these numbers live, and a page of numbers rots. tests/test_diff_scale.py holds the script and the page to each other, which is narrower than holding either to reality.
What would reverse it. A corpus that does not fit. The trigger is named rather than felt: the first filing this refuses to finish, or the first deployment where two ingests run at once, and the design in section 4 is what gets built rather than what gets invented at that moment.
What forced the choice. app/diff/engine.py::_similarity builds SequenceMatcher(None, a, b) and takes difflib’s default, which is autojunk=True. At that call site the sequences are a passage’s characters, so any paragraph over 200 characters is past difflib’s cutoff and every character appearing in more than one per cent of the positions is junked. In English prose that is most of the alphabet, and the ratio is then computed over what survives: punctuation, digits and rare letters. The list-level matcher twenty lines away already passes autojunk=False and ADR-96 is the price of that setting. The character-level call was never given the same argument. Nobody chose this; the scaling measurement found it.
What the measurement says. scripts/bench_diff.py::similarity_accuracy_measurement, over 800 paragraphs of real filing text from data/real/, 200 in each of four length bands, each changed by exactly one word. LOW_ALIGNMENT in app/web/views/changes.py is 0.50. 11 of the 800 — one in 73 — cross that line after a one-word edit, and the worst scores 0.444 against a true similarity of 0.964. Section 7 of docs/scalability.html carries the table and it reproduces from the script.
Decision: it is not fixed here, and it is recorded as open rather than quietly carried. Finding 17 in docs/.ai/findings.html holds the defect with no guard and the reason there is none. The fix is one keyword. The reason it is not a one-line commit is that it rewrites alignment_confidence on every stored change row, which makes it a derived-corpus migration under best-practices §27: until the corpus is reloaded the screen would compute one number, the database would hold another and the page would quote a third, and a product whose thesis is that a claim carries its evidence cannot ship three answers to one question.
What shipped. app/diff/engine.py::_similarity now passes
autojunk=False, which is what the list-level matcher in the same file had done from the
start. scripts/remeasure_alignment.py is the reload: it recomputes
alignment_confidence from the same two spans of the same stored source text that produced
the original, whole rather than in part, per best-practices section 27. It writes one column and
touches nothing else — not a citation, not an offset, and not a verdict a reviewer
recorded against a pairing, because a human judgement is not invalidated by the machine
re-measuring its own confidence in the thing they judged.
What the reload actually moved, measured on the seeded corpus. 333 modified changes read, 25 moved, 2 cautions lifted, 0 added. Largest single move 0.253 to 0.830, on a real Kentucky filing. Running it a second time moves nothing, which is the property to check after any edit to that script.
The zero is the interesting number. This entry argued the defect could only push a score down — junking removes matches and cannot add them — so a correction should only ever push one up. Not one caution was added by the reload. That is the argument above holding up against 333 real rows rather than against reasoning alone.
One correction to this entry’s own measurement. It records 11 of 800
paragraphs crossing the caution line, one in 73. An independent sample taken before the fix —
800 paragraphs from data/real/, one word replaced in each, a different sampling method
from the one above — put it at 59 of 800, one in 13, with a worst case of 0.000 against
a true 0.976: a paragraph reported as sharing nothing with itself. The two are not directly
comparable and neither is being discarded. The honest statement is that the defect was at least as bad
as this entry recorded and probably worse.
Why shipping it broken is survivable, which is the part to be able to defend out loud. The direction is safe. Junking only removes matches, so the score can fall and never rise: the defect over-flags and cannot under-flag, and a change whose text really did move a long way cannot be rescued into a high score. It fails toward review, which is what ADR-3 requires. What it costs is not a wrong approval but trust in the caution — a reviewer who keeps meeting the low-alignment flag on changes that plainly moved one word learns to ignore the flag, and then it is worth nothing on the day it is right. That is a slow cost, which is why this has a date on it rather than a shrug.
Alternatives considered. (a) Set autojunk=False now and leave the stored rows — one keyword, and it puts the screen, the database and the page into three-way disagreement for as long as the old rows live, which is exactly what §27 exists to name. (b) Set it and reload the corpus in the same change — correct, and it is a migration wearing a bugfix’s clothes: a reload command somebody has run, a before-and-after count of the rows whose confidence crossed 0.50, and a check that no stored citation moved. That is the option that should be taken, deliberately, and not inside a scaling commit. (c) Lower LOW_ALIGNMENT until the false flags stop — treats the symptom by making the product less cautious, and stops flagging the changes that deserve it. (d) Cap the input at 200 characters so the cutoff never fires — cheap, and it silently scores a 2,000-character paragraph on its first tenth, which is a second wrong answer with no announcement. (e) Compute the ratio over words rather than characters — plausible and faster, and a different measure with a different distribution; every threshold in the product was chosen against the character measure, so it re-opens all of them.
Cost accepted. One: a live defect ships, and the product cannot tell the reviewer meeting it why. Two: no test can see it. Every fixture in tests/test_diff.py is under 200 characters, so difflib’s cutoff never fires there and the ratio is exact — the suite is green and blind at once, which is the condition that let this live. Three: the fix has a measured price that will not shrink — 9x to 42x on the character path depending on passage length, rising with length, because the honest setting is the quadratic one. Paragraphs over 20,000 characters are not in the sample, so nothing here says what one long exhibit costs. Four: the record is spread over three files — the page, the findings table and this entry — and until the fix lands a reader has to find all three.
What would reverse it. The reload. When there is a corpus reload anybody is willing to run, the keyword goes in the same change, the 11-in-800 measurement is re-run to show the flags gone, and this entry is superseded rather than edited. A user calling the caution noise would move it up the queue, and docs/user-research.html still reports zero interviews.
What forced the choice. The interview asked for “token truncation strategies for long user sessions”. Checked against the code the request inverts: app/chat/engine.py takes a history argument and drops it, on an argument written out in its own docstring, and app/explain/answerer.py sends one message and keeps nothing. There is no long session. What there is, is the certainty that the first person to build one reaches for a sliding window, because that is what every chat product does — and a sliding window is the one policy this product cannot have. Citation offsets are context items like any other. Evict one to fit two turns of chat and the claim resting on it stops verifying: it vanishes from an answer that still reads complete, or the model answers from its memory of a passage it can no longer cite. That is ADR-3’s failure arriving through the door marked context management, and nothing in the repository would have stopped it. history=history in the run-turn call is a five-second edit in a branch about something else.
Decision. The policy is written before the feature. app/chat/budget.py holds one ordering: the question, the citation spans and the diff offsets are the floor and are never evicted; conversational turns are evicted oldest first and contiguously, never skipped over to fit a cheaper older one. When the floor alone exceeds the budget the module refuses the turn and says what the floor cost against what was allowed. Every eviction is announced — the count, the labels, the characters freed, and the sentence that nothing cited was dropped — because a truncation nobody can see is a truncation nobody can audit. Nothing calls it today and the docstring says so.
Refuse rather than shave. The obvious kindness is to trim the floor to fit — shorten a span, drop the oldest offset — and answer anyway. A trimmed citation verifies against nothing and the answer that comes back is confident and unauditable. So the module returns a refusal with an empty result: not the floor, nothing. Handing the floor back would let a caller who forgot to read the flag send the turn and never learn the budget said no. It is the trade app/web/views/chat.py already makes when it refuses an over-long message with a 400 rather than cutting it in half.
Characters, not tokens, and the two are not allowed to disagree. No offline tokenizer is a dependency here — no tiktoken, no transformers, no sentencepiece — and the provider’s needs a network call, so a token figure would be a ratio somebody made up. The measure is len, the unit is the string characters, and both travel on every plan and into every announcement. A caller may inject a real tokenizer, and then the measure and the unit must agree in both directions or the call raises. The second half was missing at first and is the likelier mistake: unit="tokens" with the tokenizer left for later, and every figure in every sentence comes out as a character count wearing the word tokens, wrong by about four, in the direction that makes the budget look roomier than it is.
Two tiers, not five. A floor and the turns, because there is exactly one thing anybody has argued about evicting. An item of a kind the policy does not classify is refused outright rather than defaulted into a tier: “shrink your question” is useless advice when the real trouble is that nobody has decided where a tool result ranks. A second evictable kind is a decision and gets its own entry here.
The tripwire is what makes it a rule rather than a preference. tests/test_history_needs_a_budget.py walks the syntax tree of app/, taints every name bound from the conversation, follows the taint through assignments, for targets and comprehensions to a fixpoint — the walk in tests/test_clock_pinned.py, borrowed rather than reinvented — and fails when a tainted value reaches a model-bound sink. The taint clears at plan_context, and only when every mention of the conversation in that expression sits inside the call, because “the line contains a budget call” would wave through the planned turns and the raw ones side by side. A runtime assertion inside the transport would fire only on a path somebody executed, and the failure this guards against arrives in a branch about something else, tested by hand, on a machine with no key.
Seeding from locals, because the one function that holds a transcript has no parameter. The first version seeded only from parameters, which made the sweep a statement about the shim and nothing else: app/web/views/chat.py::turn takes no history argument, it binds one from a call. A review inserted a message list and a model call into that function and the guard stayed silent.
Alternatives considered. (a) A sliding window over the last N items — the standard answer, one function, and it silently evicts the offsets that make a claim checkable; it is the defect, not the design. (b) Summarise older turns — puts a model’s paraphrase into the context of a product whose whole argument is equality rather than likeness; a summary of a citation is a citation that no longer verifies. (c) Drop the lowest-confidence citation first — worse than a sliding window, because it evicts precisely the claims a reviewer most needs to watch refuse themselves. (d) Trim the floor proportionally — always returns an answer, and the answer has citations shortened to fit a budget. (e) Build multi-turn now and budget it as it is built — the feature has a retention window, a tenant scope and a prompt-injection surface behind it, which is what engine.py’s docstring already refuses by name. (f) Write the policy as a document with no code — free, and a document does not fail a build.
Cost accepted. One: characters, not tokens, as argued above. Two: the guard matches by name — somebody who calls the parameter past and builds a list called wire walks past it, which is the hand-maintained word list this repository has already been burnt by twice, conceded in the file’s own docstring. Three: the taint stops at the function boundary, so a conversation handed onward through **kwargs is invisible past the first frame. Four: it says nothing about whether the number was sensible — a caller passing a billion satisfies the whole file. Five: the other budget is unwritten. A single turn already grows across up to four tool rounds, bounded by the per-tool caps in app/chat/tools.py and by no total, and the size of that has not been measured. Six: twelve turns are read out of the database on every chat request and thrown away — HISTORY_TURNS in app/web/views/chat.py — and that is left alone, because removing it would be a change to the surface in a task about the policy.
What would reverse it. A tokenizer that runs offline, which turns the character budget into a real one. Or multi-turn actually being built, at which point the pin in the tripwire is rewritten once, on purpose, to assert that what reached the model came through the budget with the dropped turns named in the reply. Rewriting it is a decision. Deleting it is how the behaviour arrives without one.
What forced the choice. A reviewer wrote the honest fix the way app/web/views/chat.py::turn would really take it — bind history from the store, then rebind the same name to the planned value — and the guard fired on correct code. The guard’s own quiet cases exist to stop exactly that: a guard that goes red on correct code is a guard somebody widens until it is green on everything.
Decision. Keep the taint flow-insensitive. A name is a source if the function binds it any way other than through plan_context, whatever order the statements are in. The false positive is written down as a numbered limit in the file’s docstring, pinned by test_reusing_the_source_name_for_the_planned_value_still_fires, and the fix is stated in the same place: bind the planned value to a name of its own.
Why. The two errors are not the same size. A false positive costs one rename. A false negative costs a product that quietly evicts the offsets a citation verifies against, which is ADR-98’s whole subject. The rule is generous in that direction on purpose, and making one clause behave the other way to save a rename would trade the bias for a tidier line.
Alternatives considered. (a) Compare line numbers — clear a name from its last plan_context binding onward when no plain binding follows. It also clears the shape where a short conversation never meets the policy at all, which is a false negative on a shape somebody will write. (b) Real statement-order dataflow, with branches and loops — rejected on proportion: a dataflow engine inside a test file, for a rule whose other limits are cruder than this one, and ast.walk gives no ordering guarantee to build it on. (c) Say nothing and let the next person meet it in a branch about something else — an unexplained red guard is how a guard gets widened to silence rather than fixed.
Cost accepted. When turn adopts the budget it must call the planned value something other than history, or this file goes red with a message about a citation policy. The message names the file, the function and the line, and the limit names the fix, but the first person to hit it loses a few minutes to a guard that was wrong about them. If that happens twice, reopen this and pay for (b).
What forced the choice. tests/test_history_needs_a_budget.py claimed that every clause of its rule was reached by a case. The claim was false three times running — six clauses, then eight, then three — and each time it was found the same way, by a reviewer deleting a clause and watching the suite stay green. Twice the sentence was rewritten and the same universal was re-made in the same breath as the fix. The first mutation pass that “proved” it had read stale bytecode: CPython validates __pycache__ on modification time and size, mtime has one-second granularity, and a same-length edit applied and reverted inside one second reuses the previous run’s bytecode. The harness was answering questions it had stopped asking and printing a table that looked like measurement.
Decision. The harness is a committed file, scripts/mutate_context_budget.py, and its printed line is the only coverage claim a docstring in this area may make. Three rules follow. A mutation that survives is either given a case or the clause is deleted, because a clause no realistic case can reach is a clause that is not there. A mutation that provably cannot change an answer is named as equivalent, with the argument, and left out of the list, so that a survivor always means a missing case. Every run clears every __pycache__ and sets PYTHONDONTWRITEBYTECODE=1 before and after the edit.
Why. An overclaiming docstring is worse than an honest gap, because it is read as measurement and stops anybody measuring. This one survived two rewrites because the sentence was cheap to write and the check behind it was not run in the order that would have caught it. Making the number the claim removes the room: the sentence is either what the command prints or it is not there. This is the class the three review rounds on this change kept hitting. ADR-102 records the same failure in a different module, five published figures deep, and the board’s risk register now carries it as a standing risk rather than as two incidents.
Alternatives considered. (a) A mutation tool from PyPI — nothing in requirements.txt, minutes per module across a tree this size, and a reviewer’s clone has to run make test in about four. The hand list is honest about being only as complete as the last person who read it. (b) Delete the coverage sentence and say nothing — the sentence is what makes anybody run the harness; the defect was the universal, not the claim. (c) Report a coverage percentage instead — refused by ADR-38 already: line coverage measures execution, not verification.
Cost accepted. The list is hand-written and can only be as complete as the last person to read it, which is exactly how three clauses were missed on the previous commit; it needs adding to every time the rule grows. It covers two files, neither of them one of the four modules ADR-38 names, so ADR-38’s “stated and not yet met” still holds for those. The harness edits two tracked files and restores them in a finally, so an interrupt mid-run can leave a mutation on disk; the file says to check git status.
What forced the choice. app/evals/report.py has carried the sentence “the model path is evaluated separately or not at all” since it was written, and until now it resolved to not at all. The product’s hardest claim is that a judgement whose citation does not re-read is withheld rather than shown, and nothing measured it. Every number on the two existing scorecards describes the deterministic spine.
Decision. app/evals/model.py is a third harness with its own entry point, its own page and its own exit codes, in the shape app/evals/obligations.py already takes for the extraction task. It builds 26 golden cases — five labelled changes from data/manifest.json and 21 drawn from seven pairs of real filings — sends one model call per case only when --send is passed, and scores four metrics. One of them blocks: an assertion whose citation the harness cannot re-read fails the build at a threshold of zero. Exit 0 means a model was evaluated and every blocking metric cleared, 1 means a blocking metric failed, and 2 means the model was not evaluated at all. make model-eval is the dry run and requires 2; make model-eval-send is the only path that spends money.
Alternative rejected: a sixth metric inside make eval. That harness opens no socket and its own caveat says so, which is the reason a reviewer can run it on a clean checkout with no key. A metric in it that reached the API would turn that sentence into a lie the first time it ran, and the lie would be found by the reviewer rather than by us. A flag on the same target is not a second safety either: env -u ANTHROPIC_API_KEY does not help, because load_env() puts the key back from .env. The deliberate act has to be the command.
Alternative rejected: run it on every push. Twenty-six model calls per run on the owner’s account. A gate that bills somebody for a typo in a docstring is a gate that gets switched off inside a week. .github/workflows/ci.yml runs the harness with no --send and fails the build unless it gets 2, which proves the nothing-sent path and nothing more; the model-eval workflow is dispatch-only.
Alternative rejected: label the real filings so the real cases could be graded. Nobody has labelled those 102 filings for materiality, and writing the labels here would be the eval grading itself — the oracle failure app/evals/corpus.py names. The 21 real cases are a fabrication surface and nothing else: whether an offset re-reads needs no ground truth. Two of the five manifest changes state no materiality expectation either, and they are reported unlabelled rather than guessed, which costs the reasoning metric a denominator of three. Writing those two labels would have doubled the sample in ten seconds, which is precisely why the temptation is named in the code rather than resisted quietly.
Alternative rejected: one combined score. A fabrication and a miss are averaged into a single number only by somebody who has paid for neither. A miss costs an analyst an afternoon and never blocks; a fabrication is a false statement with a citation attached and always blocks. Folding them lets a good miss rate buy a fabrication, and it lets a looser gate buy a better number: a model that produced ten unverifiable citations and a gate that let none of them through is the design working, and the page says so in those words.
Alternative rejected: ask the verifier whether the verifier was right. The harness re-reads a cited span with plain slicing and folds ASCII whitespace only; app.verification.verifier is the code under test, and a test walks this module’s syntax tree to keep it out, import included. The narrower fold has a real consequence and it is deliberate: a citation that passes the verifier’s Unicode folding and fails this one is reported as a blocker for a person to adjudicate, because failing closed costs somebody reading two strings and failing open reports a fabricated citation as clean.
Alternative rejected: print a percentage. The identity behind every count is the DOCUMENT, not the case: three citations out of one filing are three answers and not three independent tests, because whether an offset re-reads is a property of a PDF’s line breaks as much as of the model. Seven real pairs plus one synthetic corpus is eight, and app/evals/report.py refuses a rate below ten. Counting cases gives 26 and clears the floor by an accounting choice rather than by evidence; counting the synthetic corpus as three versions gives exactly ten, which is the same trick with a smaller step. Both readings are printed and neither is what anything rests on. The fabrication threshold is exempt and says why: an absolute zero is not an estimate and needs no sample to carry one.
Two guards it shipped without, found by review. main() ended by returning the scorecard’s exit code and nothing tested it: replaced with a bare return 0, the whole file stayed green and the harness would print RELEASE BLOCKER while telling the workflow the run was clean. And the “not answered” announcement — the only mitigation for a run where almost nothing answered — could be deleted from the page, because the one assertion reaching it was satisfied by an outcome-table row twenty lines higher. Both are pinned now, with mutations run against the new tests. The lesson is the one this repository keeps relearning: a thing a module advertises is not tested by a test that would pass without it.
Cost accepted. One: nobody has ever run it with --send. Every outcome path is driven by a deterministic fake, so nothing here says what a real model does over a 900-character span of regulatory text, and the docstring says so. Two: the reasoning metric rests on three labelled booleans. Three: the minimum span is a judgement and not a derivation. The comment beside it states what the floor is for and says plainly that no measurement picks 40 out from 35 or 60; an earlier version claimed a derivation that did not reproduce, and a review caught it and the claim was deleted rather than reworded. Four: a run in which almost every call fails still exits 0 on the fabrication metric’s empty denominator. The page shouts it — a run where most calls failed is not a run with a small sample, it is a run that did not happen — and the exit code does not, because gating it means choosing a threshold over a sample of eight, which this module refuses to do anywhere else. Five: a third entry point and a third page for a reviewer to learn.
What would reverse it. Two more real version pairs. At ten independent documents the small-sample rule stops suppressing rates, and every count on the page would have to be re-argued as a percentage or explicitly refused as one. Whoever runs --send first records the counts and the date, the way docs/.ai/live-transport-probe.html records the one live call.
What forced the choice. app/diff/engine.py::_alignment_confidence decides how much to trust that two passages are the same passage, and its only structural evidence is the integer at the head of each one. That integer is a proxy and the docstring has always said so. Measured over data/real, it fires on 42,546 of 107,122 passages — 39.7% — and the labels it hands back include 2025, 2028, 207, 423 and 480: two years and three line numbers off a deposition page. Where two such labels disagree the engine caps confidence and the change escalates. Across the eight genuine version pairs, 112 of 421 modified changes escalated for no reason better than a printed gutter number shifting by one, because a witness inserted a line upstream. The proposal was to read each filing’s own numbering into a tree and let it adjudicate the proxy.
Decision. The parser is kept. The wiring is not. app/diff/structure.py reads a filing’s numbering into a tree where it can and returns a refusal naming its evidence where it cannot — 27 of 102 real filings yield a hierarchy, and the other 75 are refused with a cause. Nothing under app/ imports it. _alignment_confidence takes no structure argument, app/pipeline.py builds no signal, and a dump of every pipeline confidence over the synthetic proceeding and all eight real pairs is byte-identical to the tree before this work. tests/test_structure.py::test_nothing_in_the_application_imports_the_structure_parser keeps it that way.
Why, and this is the whole entry. The signal was wired in, and then it was measured. The two branches it was built for — a hierarchy contradicting two agreeing labels, a hierarchy confirming that two passages sit under one node — fired zero times each on real filings. The first of those is the only branch that makes an alignment be better rather than look better, and it never fired. Every confidence that moved — all 112, all inside one pair — moved on a third branch nobody had argued for: the parser failing to read both documents, and that failure being used to withhold the restructure cap. A parse refusal lifting a score from 0.50 to a median of 0.9872 is absence licensing certainty, which is the thing ADR-3 forbids by name. It was not hypothetical. The branch was written as “either side is a gutter refusal”, and pairing Missouri’s non-unanimous stipulation, which parses and numbers its own paragraphs, with Utah’s Ellis testimony, which is a gutter refusal, stripped the cap off the stipulation’s paragraphs 21 and 22 — two different paragraphs setting the same credit condition for two customer classes, 0.9877 similar — and reported them as one paragraph edited, on evidence that came entirely from the other document in the pair. Narrowing the branch to “both sides” fixed the crossed pair, left the principle exactly where it was, and made clear that the refusal branch was the entire feature.
Alternatives considered. (a) Ship it as it stood, on the strength of the 112. The 112 are real alarms worth removing, and this buys them at the price of writing “a document I could not read is evidence that two paragraphs match” into the confidence path of a product whose argument is that it declines when it cannot ground a statement. The alarms are not worth that sentence. (b) Ship only the two authorised branches and drop the gutter one. Defensible in principle, and it ships a feature with a measured effect of nothing: two branches that fired zero times, in the hot path of every diff, with a 1,200-line module behind them, which every future reader would have to re-derive as dead. (c) Keep it wired behind a flag — a flag is a decision deferred, and an unset flag is dead code with a switch on it. (d) Fix the proxy directly, by having the section label decline to read a leading integer on a page the gutter detector flags. This is the right answer and it is not this change: it moves 112 real confidences, so it needs its own record, its own tests and its own review rather than a rider on a decision to remove something. Named and deferred; the gutter detector is the part of this work it would reuse, and that part is measured. (e) Delete the parser too — the cleanest diff, and it throws away the measurement, which is the thing worth having.
Cost accepted, in full. One: app/diff/structure.py is application-shaped code under app/ with no caller under app/. A test enforces the boundary and the module’s first line says so, and it is still the “built and not connected” shape this repository has been bitten by repeatedly — the difference is that here it is the finding rather than an oversight. Two: the alarms are still there. An analyst still opens and dismisses 112 escalations produced by a shifted gutter number, and this change fixes none of them; it establishes that this was the wrong instrument for them. Three: the backward path is untouched. app/ingestion/ingest.py::_section_of uses the same pattern and writes the result to Passage.section, so the changes screen still renders Sec 193 for a testimony line number. Correcting it re-labels stored passages across every ingested filing, which is a migration under best-practices §27; it is filed as P2-19 in docs/.ai/gaps.html. Four: the withdrawn rule survives in scripts/measure_structure_signal.py. It has to, or the counterfactual cannot be reported; it is named so nobody mistakes it for product code, and that is a naming convention rather than a guard. Five: every constant was tuned on one corpus in one sitting, and there is no gold set for document structure, so the measurement reports how many documents the parser reads and why it refuses the rest. It cannot report accuracy and does not claim to. Six: a tree can be partial. Whole documents are refused loudly and individual out-of-sequence headings are dropped quietly, and the second half is the weaker guarantee.
And the reason this entry exists rather than a quiet revert. Four consecutive reviews of this work found a published number that did not reproduce from the committed code: a candidate count published as 1,164 that measures 1,212, a gutter fraction published as 0.20 that measures 0.35, a run published as 27 that measures 21, a heading count published as 113 that measures 98, and an offset the parser never produces. Each fix corrected the sentence it was handed and left the rest standing. The class is closed rather than the instances: every figure lives in app.diff.structure.MEASURED, 91 entries re-derived from the committed bytes by scripts/measure_structure_signal.py; each “what this rule bought” figure is measured by deleting that one rule from the shipped source and re-parsing all 102 filings; docs/structure-measurement.html marks every figure with the key it came from, one test checks each marked figure against the dict and a second fails if any number survives anywhere else in the page’s prose; and a third applies the same rule to the module’s own comments. Ten mutations were run against those guards and every one goes red. ADR-100 is the same lesson taken in a different module on the same day.
What would reverse it. A corpus where the two authorised branches fire — version pairs of filings that both parse and that really renumber. The search for one across these eight pairs found nothing, and the demonstration on the page is synthetic and labelled synthetic. Two or three real renumberings would settle it. Until they exist, wiring this in is a decision taken against the only evidence there is.
What forced the choice. ADR-4 caps alignment confidence when two passages carry different leading section labels, because a renumbered section reads almost identically to the one it replaced: text similarity runs high exactly when structural identity has changed. The change screen printed a caution when the cap bit and stopped there. Measured on the seeded corpus by tests/test_alignment_decision.py::test_the_boundary_is_the_ceiling_itself: 16 of the 27 changes carry that caution and 8 of them sit at exactly 0.5, the value the cap itself writes. A reviewer who read one could do nothing with it — no control to confirm the pairing, none to dispute it, none to say the honest third thing, and no record anywhere that a person had looked. The product asked for judgement on more than half its own corpus and threw every answer away. A screen that repeats a question it never records teaches the reader to skim the one paragraph saying the product is unsure.
Decision. A reviewer records one of three verdicts against a change — the pairing is right, the pairing is wrong, or cannot tell — with optional reasoning. It is an audit event filed against the change under its own action code per verdict, carrying the actor, the account and the moment, read back through app/state/alignment.py and stored nowhere else. POST /changes/{id}/alignment is the route. Of the four states a pairing can then be in, two keep the caution and two lose it, and one predicate, pairing_caution_stands, decides that for the screen and for the chat clerk together.
The verdict is a chain row and not a column. A column beside Change.alignment_confidence would be a second record of one decision, free to disagree with the log, and the log is the artefact a regulator is shown. Three action codes rather than one code with the answer inside a reason sentence, for the reason app/state/audit.py already gives twice: a verdict that lives in prose is a verdict no query for it will ever return.
The number is never overwritten and never gated. alignment_confidence is what the machine computed and the row is what a person decided. Lowering the confidence on a dispute would destroy the evidence the dispute turns on and make the machine’s own error unauditable. The two sit side by side, joined by the change id and nothing else, and the number as it stood at the moment of the answer is quoted into the audit reason, so a later re-diff cannot rewrite what somebody was looking at when they answered.
Which states keep the caution, and why that is not symmetric. The caution says the diff is not sure these two passages are the same passage. Unanswered keeps it, because that is the whole truth, and the page says so in words rather than leaving a blank that reads as reassurance. Cannot tell keeps it: an alignment nobody could resolve is exactly as uncertain as one nobody has read, and a shrug that cleared the caution would be a shrug dressed as a resolution. What the row adds is the different fact that a person spent the attention. Confirmed loses it: a named person read both passages and put their judgement in an append-only chain, and a guess is no longer what the pairing is. Disputed loses it too — not because a dispute is safe, it is the worst state a pairing can be in, but because the caution understates the record. A hedge printed above a person’s flat statement that the two passages are not the same passage reads as doubt about the person rather than about the machine.
Answering is authorship, and that is the wanted outcome. The row is filed against the change, so app/auth/policy.py puts the change on the authorship surface of every claim over it: whoever answers can no longer approve what follows from it. That is the same consequence ADR-86 accepted for a materiality verdict. Somebody who declares a pairing settled has shaped the record an approval rests on. The control sits on action.propose, which is the analyst’s code, and deliberately not on the obligation owner’s. If this ever stops being wanted the fix is a line in the policy’s exclusion list with the argument in it, never a change to where the row is filed.
What the reviews found, all of one family: a rule written down and guarded by nothing. The note limit refused with a bare ValueError while the route catches refusals by name, so the one refusal written in plain English for a reviewer escaped as a 500; it is NoteTooLong now, answered 400, and a guard reads both syntax trees and fails if the state layer raises a named refusal the route does not catch. The rule “every surface that shows the number reports the answer” was tested against whole files, so one function covered for another in the same module while that tool shipped the number with no verdict; the question is now asked of every function that reads the column. The doubt predicate existed in two hand-typed copies, screen and clerk, and flipping one copy’s comparison left 74 tests green while silently moving the caution on the eight changes sitting at exactly the ceiling in one surface and not the other. And the screen gated its caution on the diff’s doubt alone, so a pairing somebody had confirmed still printed “read the pairing above as a guess” four lines above the badge carrying that person’s name, while the clerk said nothing about the pairing at all: one record, two surfaces, two accounts of it. Three pieces of prose already described the behaviour the code did not have.
Alternatives considered. (a) A verdict column on Change — a second record able to drift from the chain. (b) One action code with the verdict in the reason string — cheaper vocabulary, and a disputed pairing becomes something no query can find. (c) Fold the answer into the number — destroys the evidence and hides the machine’s error. (d) Treat “cannot tell” as clearing the caution — a shrug promoted to a resolution, on the one control that turns a machine’s guess into a person’s judgement. (e) Keep the caution in all four states and correct the prose instead — the cheaper repair on paper, and it puts the dead end back one turn later: the reviewer answers, reloads, and the page says exactly what it said before. (f) A new permission code such as change.interpret — the right answer eventually and a schema decision rather than a line of code: the strings go into role grants and audit rows that outlive a release, and which roles hold it is the segregation grid. Borrowed with the argument written down at the call site instead. (g) Let each surface decide locally — rejected because this control had already drifted on exactly that shape. (h) Split a disputed change into an addition and a removal — what a reader might expect “these are not the same passage” to mean, and a different act with different consequences: new change ids, claims hanging off a row that no longer describes the same thing, routing decisions already taken against it. Not built, and recorded as not built.
Cost accepted. One: answering costs the answerer their approval. On a small team the person best placed to judge a pairing is often the person who would approve what follows from it, and this makes those two acts exclusive on that change. That is the segregation the product argues for, and it is a real cost. Two: the permission is borrowed, so action.propose now gates two different acts. Three: a dispute changes nothing downstream — the diff is not re-run and the change is not split, so the claims, the mappings and the routing all still rest on the pairing the diff made. The product records that somebody thinks it is wrong and carries on using it. Four: one person’s answer removes the caution for everyone in the tenant, with no second-reviewer requirement. Five: the answer cannot be edited or withdrawn; the chain is append-only, changing your mind is a new row beside the first, and the page says so above the box before anybody types in it. Six: a confirmed or disputed pairing now shows a low number with no words of doubt beside it, so a reader who skims the number and ignores the badge is worse off than before. Seven: nobody has used it. No regulatory-affairs person has answered one of these; the three verdicts are the ones this question looks like it has, and there may be a fourth. Eight: the note limit is a number chosen rather than measured.
What is still open. No control is drawn on a modified change the diff was confident about, and a test pins that, because a confident pairing adds no noise to the page. The route accepts one, so a confidently wrong pairing can be disputed and the answer renders — but a person has no button for it, and the case the diff cannot see is exactly the unlabelled renumbering that scores high. Closing it means printing the pairing question on every modified change, which is a design change with its own entry here. Separately, three comments in the state layer quoted a millisecond figure for this read that a reviewer could not reproduce — 4.1958 against 0.0054, re-run as 2.3188 and 0.0053, and 2.7059 and 0.0232 on a third machine. The figures are deleted and scripts/measure_audit_subject_read.py ships in their place, because the query plans reproduce verbatim and are what the argument actually rests on.
What would reverse it. A real permission code in the segregation grid retires the borrow with a one-line change and no call site touched. Evidence that reviewers want a dispute to act — to split the change or re-run the diff — rather than only to be recorded would reopen (h), which is the one alternative rejected on scope rather than on principle.
/explain embeds against OpenAI, and the first live embedding call this code has ever made proved a comment in it wrongWhat forced the choice. Document retrieval on /explain was built against voyage-3 and nobody on this project has ever held a Voyage key. Every constant in app/explain/embedding.py — the endpoint, the request body, the response shape, the width of a vector — was therefore a documentation reading typed into Python and checked by nothing, and three separate files said so honestly rather than fixing it. An OpenAI key is in the team’s hands. That is the whole of the reason: a provider we can call turns four guesses into four measurements, and a provider we cannot call keeps them guesses however carefully they are written.
This entry supersedes a decision that was never recorded, which is itself the failure this file exists to prevent. The choice of Voyage has no ADR. It lived in a module docstring and in the name of an environment variable, so the alternatives it beat, the reason it won and the cost it accepted are gone and cannot be recovered — the same hole ADR-05, ADR-06 and ADR-34 record in older corners of the repository. A reader who wanted to know why the vectors were 1024 wide had nowhere to look. That is why the width and the model are now derived rather than typed, and why this entry exists at the size it does.
Decision. app/explain/embedding.py calls https://api.openai.com/v1/embeddings with text-embedding-3-small, 1536 dimensions, over urllib from the standard library. requirements.txt gains nothing. OPENAI_API_KEY replaces VOYAGE_API_KEY everywhere the code reads, documents or announces it, and the sentence a reviewer sees when no key is set is built from the one constant the code reads rather than repeating the name. VECTOR_SCHEME in app/explain/docindex.py is now built from the model id and the width instead of being a string somebody types, so it cannot lag the model that produced the vectors under it.
What the first real call found, and every line of this is observed. The corpus embedded whole: 145 chunks — 8 from the MRD, 16 from the PRD, 121 from the TDD — 243,820 characters, two requests of 96 and 49, and 51,854 tokens by the provider’s own usage field rather than by our count of them. The request body is {"input": [...], "model": "text-embedding-3-small"}. The response is {"object": "list", "data": [{"object": "embedding", "index": i, "embedding": [...]}], "model": ..., "usage": {...}}. The width is 1536, not the 1024 the previous code named. Rows came back in the order they were sent, every time; the sort by index stays anyway, because an ordering everybody observes and nobody promises is the kind that changes quietly, and a permuted batch attaches every vector to the wrong chunk and then answers every question plausibly and wrongly. Vectors arrive at 0.99982 to 1.00005 — near unit length and not unit length — so the code still normalises at write rather than assuming. A question was then put through app/explain/answerer.py end to end, retrieval took the vector path, and all five citations the model returned were re-read against the files on disk and survived.
The finding that matters, and it is about our own code. The comment first written in this file said the endpoint would answer an unknown field with an HTTP 400, and that input_type therefore had to be dropped. Put to the wire, it is false. input_type: "document" comes back 200, with the same 1536 floats as a request without it, and the field is silently ignored. That is the worse of the two outcomes. A 400 teaches you something on the first run; silence lets a request carry a word that reads like a parameter, changes no byte of any answer, and passes every check anybody will ever write for it. It is the exact shape of failure this product is built to catch — a confident statement nothing verifies — found in our own source, and found only by making a call we had spent weeks describing instead of making. The field is still dropped, for the opposite reason to the one first given. The wrong guess is kept in the source beside the measurement, because a comment that only ever shows the right answer teaches nobody how it was got.
Alternatives considered. (a) Stay on Voyage. The cheapest diff and the reason for this entry: nobody here holds a key, so every constant in the file stays a documentation reading, and the shape of the request stays unproven for as long as the submission lasts. (b) Add the openai package. A second HTTP client and a second retry policy to reason about, for one POST with a JSON body — and one more thing that can fail on a reviewer’s machine before make run works, which is the constraint ADR-07 and ADR-09 set and the pin that cannot break is the one that is not there. (c) text-embedding-3-large. 3072 dimensions at $0.13 per million tokens against $0.02, to reorder a candidate list that is re-read against the source anyway. The gate does not care which candidate arrived first; it cares whether the words are where the claim says they are. (d) Ask the endpoint for 1024 dimensions and keep the stored width. The playbook’s section 27 actually recommends this shape, and it is rejected here for a reason particular to this corpus: there are no stored vectors worth preserving. The index is derived, it rebuilds whole in about two seconds, and truncating a model’s native output to a width chosen by a provider we no longer use would be a permanent quality cost paid to avoid a migration that does not exist. (e) Local embeddings. torch would be the largest dependency in the build, for the smallest feature in it.
Cost accepted. One: some asymmetric recall is gone, and it was not measured. voyage-3 took an input_type of document or query and weighted the two differently; this endpoint has no such parameter. Nothing here compares retrieval before and after, on any corpus, so the size of that loss is unknown and this entry does not estimate it. The seam survives in the signature so a provider that does distinguish them can be dropped in without touching app/explain/docindex.py. Two: money, in small amounts. About $0.001 per full rebuild at $0.02 per million tokens, taken from OpenAI’s published pricing on 2026-08-11, plus one embedding per question asked on /explain. Three: the migration itself. Every stored vector changed width, so the whole index is refused and rebuilt — and the honest detail is how the refusal works. Old rows are refused twice: by scheme name, and by byte width. The byte-width check is luck rather than design. It caught this swap only because 1024 and 1536 are different numbers of bytes; two models of the same width would walk straight past it, and there are several such pairs. A table left entirely under the old scheme name is now refused as firmly as a mixed one, because the earlier guard only caught a table whose rows disagreed with each other, and a provider swap produces a table that is perfectly consistent and entirely stale. Deriving the scheme name from the model and the width is the part that is design.
What is still unproven. The refusal path. No bad key and no wrong model name have been put to this endpoint, so the two RuntimeError branches that read an HTTPError body and a URLError reason are still written from documentation, exactly as the whole file was yesterday. Nothing is known about behaviour under load, about retries, or about inputs longer than these sections. One call proves the wire format, the auth and the parse, once, and nothing else — the same sentence ADR-90 wrote about the other transport, and it is worth as much here.
What would reverse it. A measured recall loss large enough to matter would reopen (d) or send the query and document sides to a provider that weights them apart. Nothing else in this entry turns on the provider: the seam, the derived scheme name and the whole-index rebuild all survive a move to any endpoint that returns a list of floats.