Security

How Verbatim protects a regulated enterprise's confidential context, and what an attacker who reads this document could still try.

What Verbatim holds

Two kinds of data flow through the system, and they carry different sensitivity. Regulatory filing text — dockets, orders, comments — is, for the persona in ADR-001, mostly public record. A minority carries confidentiality markings (protected critical-infrastructure information, testimony filed under seal, third-party trade secrets attached as exhibits) and must be handled as confidential regardless of its public-docket origin; the ingestion step must check for those markings rather than assume filing text is safe by default. Company context — obligations, projects, internal documents, the mapping between a regulatory change and what it costs the business — is confidential in every case. It reveals what the company is exposed to and where, which is exactly the information a competitor, a plaintiff's lawyer, or a hostile regulator would want. This document treats company context as the asset to protect and filing text as the untrusted input to defend against — both postures matter, and they point in opposite directions.

Threat model

AttackerWantsCan reach
Party to the docket, or anyone submitting public comment into itSteer the system's materiality call or action recommendation — suppress a real deadline, manufacture a false one, or get a bogus action auto-approved — by shaping the text of a filing or comment it knows or hopes gets ingestedOnly the content of documents that later get ingested. No direct access to Verbatim itself.
Compromised or malicious analyst accountPush a favourable reading of an obligation past review, self-approve an escalation, or read another company's context if multi-tenant isolation has a gapWhatever the analyst role is authorized to reach — bounded by the authorization model in this document, not by trust in the person
Negligent insiderNo malicious intent, but exposes confidential context by misrouting it — pasting it somewhere unprotected, or letting it flow to a third-party model call that should have redacted it firstEverything a legitimate session can reach, plus whatever leaves the trust boundary in a model call
External network attackerStandard web-app objectives: session hijack, credential stuffing, injection against the FastAPI service, SSRF against any endpoint that fetches a URL on the caller's behalf
No committed code fetches a URL. Over the files git ls-files 'app/*.py' lists, grep -n "httpx|urlopen|requests\.|urllib.request|socket\." returns three hits and all three are prose in comments. So the SSRF objective has no surface today, and this row states an attacker's aim rather than a reachable one. The moment a fetcher lands, the control that bounds it belongs in this document before the code ships.
The public HTTP surface, which after the 2026-08-04 deployment is the marketing site plus the named path list in deploy/nginx.conf. Not the model vendor's infrastructure, not the SQLite file, unless the host itself is compromised first.
Model vendor, as a semi-trusted processorNothing adversarial by default, but anything sent to it that is logged, cached, or used for training persists outside Verbatim's controlExactly what the application sends in a prompt — see the next section for what that is

Data isolation between tenants

Chokepoint scoping over a shared schema, not row-level security — yet

Mechanism. Every row that holds company-scoped data carries a company_id. All reads and writes go through a single repository-layer function that requires the current session's company_id and injects it into the query; application code has no other path to the tables. A CI test instantiates two company contexts, seeds each with data the other must never see, and asserts that every read scoped to company A returns zero rows of company B's data. That test is the actual isolation guarantee — a mechanism nobody tests is not trustworthy however it reads on paper. Why this and not something else. ADR-007 already chose SQLite over Postgres for the prototype to remove a service dependency from the reviewer's critical path. That choice forecloses Postgres row-level security, which is the mechanism that would make isolation a database-engine guarantee rather than an application-code one. Given SQLite, the chokepoint pattern is the strongest isolation available: a single, tested, hard-to-bypass-by-accident seam, versus scattering WHERE company_id = ? across every query site and trusting every future contributor to remember it. Alternatives considered. (a) Database-per-tenant — the strongest isolation, and some regulated enterprises require it contractually, but it turns migrations and backups into an N-database operation and is disproportionate for a single-company prototype; (b) a filter applied per query rather than a chokepoint — cheaper to write, and exactly the pattern where one missed filter is a full cross-tenant breach with no test to catch it; (c) Postgres row-level security — the right production answer, deferred only because Postgres itself was deferred. Trade-off accepted. The chokepoint is an application-layer promise, not an engine-enforced one. One function bypassing it is a full breach. That is a materially weaker guarantee than RLS. Gap. The 48-hour corpus is one company (ADR-001) — isolation is designed but not exercised by real multi-tenant data, only by the CI fixture test. A production build on Postgres must move to RLS with company_id as a session-bound variable, not carry the chokepoint pattern forward as the permanent answer.

What reaches a third-party model

Filing text by default; company context only in the smallest form the step needs

Two model calls exist, and neither is one this section was designed around. Materiality interpretation and obligation extraction were never built, and ADR-008's semantic retrieval was chosen and then never written. The two calls that did ship are the rows below. Both were dormant for most of the build — nothing loaded .env into the process, so a deployment holding a perfectly good key behaved exactly like one with none — until app/config.py fixed it, which moved the exposure described here from theoretical to live. Sent, and it is two calls. The claim proposer, app/interpretation/propose.py, sends passage text from two versions of a filing and asks what moved. The assistant, app/chat/agent.py, runs a turn of chat with tools. Both pin claude-opus-5 as a module constant rather than reading it from the environment, so which model answered is part of the record and a deployment cannot swap it silently. Neither sits on the verification path: a proposal is re-read against the stored source at the offsets it names and refused unless the words match, so a claim survives its citation whatever produced it. grep -rn "MODEL_ID\|model=" app/chat/agent.py app/interpretation/propose.py is the check. Sent, but minimised — and the minimisation is per tool call, not per corpus. The assistant's tools return company-scoped records into the model's context: project names and detail, changes, open escalations, claims and the passages behind them, notes, the live approval route, and obligation titles with the display name and account id of whoever owns each. That last is company context by any reading. Three things bound it. Every result is capped at MAX_ROWS and reports what it omitted rather than silently truncating. The model supplies the tool name and its arguments and never the tenant or the actor; both are injected from the signed-in session, and a call that names an identity argument is refused, audited under its own action code, and halts the turn (ADR-51). And the tool set is a closed allowlist of ten declared tools in app/chat/tools.py, capped at 25 rows a call — there is no free-form query tool and no SQL, so what can reach the vendor is the union of what those ten return and nothing wider. What that costs. A company's obligation register, its approval route and the names of its approvers are precisely the compliance posture the paragraph below argues must not go to a vendor in bulk. The assistant sends them a slice at a time instead of all at once, which bounds the exposure per turn but does not remove it: a long enough conversation reaches most of the register. That is a real cost of shipping an assistant at all. Never sent. Raw uploaded files with embedded metadata (author, revision history, comments); the company's document set as background context "for the model to have"; any secret or credential. Gap: nothing holds back a filing that carries a confidentiality marking. grep -rin confidential app/ingestion/ app/text/ app/state/models.py returns nothing: there is no marking field on a document, no check at ingestion, and no gate between a marked filing and a model call. The corpus is one invented company plus public record, so nothing confidential has in fact been sent — that is a property of the data, not of the code. The real corpora carry the marking in their own words: the Georgia set runs TRADE SECRET and PUBLIC DISCLOSURE copies of the same document in parallel, and one of those pairs is where a redaction step broke a formula. A product that ingests both and cannot tell them apart is a product that will eventually send the wrong one. Why this and not "send everything, the model needs context." The tempting design sends the whole obligation register alongside every filing so the model can reason freely. That is also the design that puts the company's entire compliance posture — what it worries about, what it is behind on, where its deadlines sit — into a vendor's logs on every call. Scoping each call to only the passages the specific step needs bounds exposure to what that step could leak, not to what the whole system knows. Alternatives considered. (a) Send everything, rely on the vendor's data-handling terms — cheapest to build, and puts the company's most sensitive planning data outside its own control by default; (b) run a local, self-hosted model for anything touching company context — removes the exposure entirely, and neither the model's quality nor the ops burden is provable in 48 hours; (c) per-call minimisation, chosen here — costs an extra structuring step per call, buys a bounded, auditable exposure surface. Trade-off accepted. Minimising context per call can starve the model of information it would have used well — a materiality judgment made without seeing an adjacent obligation may miss a connection a human would have caught. That cost is accepted because it fails toward under-informed, not toward leaked. Gap: the vendor's retention terms are not settled. The model is settled — claude-opus-5, pinned in both callers — and nobody has asked whether it runs under a zero-data-retention agreement. The requirement this design imposes on that choice is unmet: no training on inputs, no persistent logging of prompt content beyond what operational debugging requires, and a documented retention window. Until somebody confirms those terms in writing, every row above is a real exposure rather than a theoretical one. Gap: neither call has ever run against the real API from this repository. Both modules say so in their own docstrings, and both drive a deterministic fake through an injected transport in the tests. What the tests prove is the gate — that identity is injected and never accepted, that a proposal is bound to a citation and re-read against the source, that a withheld count is surfaced. What no test can prove is anything only the endpoint answers: that the model accepts this combination of parameters, that the response parses first time, or how either behaves under a rate limit. Treat the transport code as unexercised and the gate above it as tested. It also means the exposure described in this section is what the code will send, established by reading it, not what anybody has watched it send.

Prompt injection through ingested documents

A filing is untrusted input; the defense is bounding what a model's output can do, not trusting what it says

The threat. Many dockets accept public comment, which means an adversary can place text into a proceeding hoping a company's tooling later ingests it. That text can carry instructions rather than facts — "disregard the prior order," "this obligation is satisfied," "recommend no action" — aimed not at a human reader but at whatever reads the docket automatically. A system that reads documents and recommends actions must assume any document it reads could be shaped by someone who wants a specific wrong action taken, and wants it taken quietly. Defense. Ingested passage text is always carried as data, structurally separated from the system prompt — the model is never asked to follow instructions found inside a filing, only to interpret it. The action vocabulary the model's output can select from is closed and typed, never free text that could become a command. That vocabulary is three actions. app/interpretation/action.py declares monitor, comment and comply, and the permitted set is dispatched on the version's stored status — a draft may be monitored or commented on, a final order may be complied with. Escalation is not in the enum, because it is not something the model may choose: it is what happens when a claim fails its citation or its threshold. And the model is never asked whether a version is draft or final (ADR-005); the status is read from an explicit field and an unknown one raises before the call is made. Acting on a draft wastes money on something that may not survive comment; treating a final order as a draft misses a binding deadline. That belongs in a line of Python a reviewer can read, not in a sentence a model produces. Nothing the model outputs changes project state directly: every claim still passes through the citation verifier (ADR-003) and the confidence threshold (ADR-006) before it is treated as fact, and every action is designed to require the obligation owner's sign-off before it touches project state. An injected instruction has, at most, a small enum to influence and a human in front of it before anything happens. The sign-off half of that is now real on one path (ADR-91): /actions is where a proposed action is approved, it calls can_approve, and the approver is the signed-in account rather than a name typed into a form. It is still only a record — approving files nothing and tells nobody — so what the enum can reach remains a row, not an act. Where the permission gate runs. grep -rn "policy\.require(\|policy\.has(" app/ answers with six view modules and four state modules — the admin screens, the feedback queue, the users and shares registries, integrations, invitations, source registration, share creation and the assistant's own tool dispatcher. can_approve now has one caller and had none for a day: grep -rn "can_approve" app/ answers with its own definition, comments about it, and app/web/views/actions.py::decide. That is the whole point of ADR-91 — the gate had spread widely across the screens that manage people and configuration and had not moved at all on the screen where an approval actually happens, because no such screen existed. That is the shape a control takes when it is wired where it is easy rather than where it is load-bearing, and the fix was not a line but the missing decision underneath it. A second injection surface arrived with the assistant, and it is bounded differently. Filing text now reaches a model a second way: through the assistant's tools, which return passages a person asked about. The bound there is not a closed action enum but a closed tool list. Of its ten tools, eight read and two write, and both writes are narrow: open a project, record a note. An injected instruction reaches only a tool the signed-in person could have called by hand. It cannot approve, cannot reject, cannot resolve an escalation and cannot change the threshold, because none of those are tools at all. The two writes are gated differently: create_project is gated on project.create through policy.require and refuses with the reason audited; record_note declares itself a write and is gated on no permission code. So any signed-in person, and any injection that reaches their turn, can append a note to a project. A note asserts nothing and approves nothing, so the exposure is small — small, not gated. A deterministic screen in app/chat/persona.py also runs before the model does and cannot be talked out of its answer, so the cheapest refusals never reach a prompt at all. Why this and not something else. Prompt hardening — a stronger system prompt telling the model to ignore embedded instructions — is well documented not to hold reliably against a motivated adversary, and it is not something this design can test to a guarantee. A separate classifier trained to detect injection attempts adds a model call with its own false-negative rate, and a missed detection there is silent in exactly the way ADR-006 exists to prevent. Bounding the blast radius — closed action vocabulary, mandatory re-verification, mandatory review — works even when the injection succeeds, because it does not depend on the model recognising the attack. Trade-off accepted. This defense slows the system down on purpose: an injected recommendation still has to clear the same review gate as a legitimate one, so speed is not the reward for an attack that fails, but it is also not the cost of one that never should have needed review in the first place. That friction is accepted. Gap. The citation verifier confirms a quoted passage exists at the offsets claimed. It does not confirm the passage actually supports the claim it is attached to. An adversary who plants a real, verifiable sentence next to a false conclusion produces a citation that passes verification while backing a wrong claim — exact-but-irrelevant is not a failure mode ADR-003 catches. No mitigation for this is built into the 48-hour design; it belongs in the failure-mode notes (tasks.html, T18).

The audit trail as a security control

Append-only and attributable, not a history view

Decision. Every state change — an extraction accepted, a claim escalated, the obligation owner's approve or reject, a threshold changed — is written to an append-only record with actor, timestamp, and the citation or reasoning behind it. If a record needs correcting, a new record supersedes it; it does not overwrite it. Built, and what actually enforces it. app/state/audit.py and the AuditEvent model implement this, with two independent mechanisms because they fail differently. A before_flush listener registered on the SQLAlchemy Session class refuses any UPDATE or DELETE of an audit row from application code — that stops mistakes, in every session in the process rather than only the ones a careful caller remembered to guard. A SHA-256 chain over every field of every record detects a row rewritten out of band, by direct SQL or by anyone who reaches the file — which the listener cannot see. Each company's chain is independent and gapless from 1, so a deleted record shows as a sequence gap. tests/test_audit.py and tests/test_audit_v2.py cover it, including proof that editing a recorded reason and removing a record are both caught. Attribution, and why the digest is versioned. Every row has room for who acted: the user id, whether a person, the system or a model acted, the session it happened under, and the address it came from. Those four fields are inside the hash, not beside it — a row edited to say a different person approved the action breaks the chain, which is what lets the approval gate read authorship out of the chain instead of out of a column anyone could rewrite. Adding them changed the hashed field set, and rows already written could not be re-hashed: their hashes are the evidence, and a log rewritten by the process that verifies it proves nothing. So every row states which scheme hashed it, and verification dispatches per row. A chain that starts under the old scheme and continues under the new one verifies end to end. Downgrading a row's stated scheme to hide an edit fails too, because the schemes hash different field sets and so produce different hashes. The migration that adds the columns backfills the scheme number and touches no hashed field, so a log written before attribution existed still verifies byte for byte afterwards (ADR-17). There are three digest schemes, not two. DIGEST_V3 in app/state/audit.py covers a row that carries reverts_event_id — the pointer a reversal writes at the row it takes back — and a scheme that cannot cover that pointer refuses the row rather than hashing round it (ADR-53). The scheme a row uses is a property of what the row carries, not of when it was written, which is harder to reason about than a date and has to be read out of the row. CURRENT_DIGEST_VERSION is still 2: three is chosen per row, by content. The cost accepted is three hash functions to maintain for ever, none of which may ever be edited. Room for attribution is not the same as attribution. The columns are on every row; the writers that fill them are counted in the gap below. The grant and revoke paths fill none of them. All four writes in app/state/identity.py — user created, user suspended or reinstated, role granted, role revoked — pass actor as a display string and no actor_user_id and no session_id. Check it: grep -c "record_event(" app/state/identity.py answers 4 and grep -c "actor_user_id" app/state/identity.py answers 0. A privilege grant is exactly the row a reviewer would go to the chain to settle, and the approval gate reads authorship out of that chain. Attribution does reach fifteen committed modules, including sharing, invitations, routing, rollback, the approval route, the feedback queue, the admin screens and the assistant. Seven committed writers still take the default and are named in the gap below. Logins are in this log, not a second one. A failed login is recorded as well as a successful one, so anyone probing accounts leaves a trace. The row names the account that was tried and never the password tried against it, not even hashed — an append-only table cannot be redacted later. A failed attempt against an address with no account carries no user id and no session, because there is nobody to name; a successful one carries both. There is no second security log beside this one, and there is not going to be: two logs drift, and the one nobody reads goes wrong first (ADR-16). A grant-based control is unavailable on this stack. Withholding UPDATE and DELETE from the database user is the right control, and SQLite has no users, no roles and no GRANT statement — it is only ever true of a Postgres deployment, and an earlier draft of this section claimed it here. What the chain does not do. It detects tampering; it does not prevent it. Anyone who can write the whole database file can recompute every hash from the altered record forward, and nothing here would notice. Closing that needs the chain head published where the same attacker cannot reach it — external write-once storage, or periodic notarisation. Not built. Moving to Postgres would additionally make the grant-based control real, which is one of the two concrete things SQLite cannot give this design. Why a security control and not only a feature. A history view that can be edited by the same process that writes it proves nothing under dispute — which is the exact situation this product exists to prevent: an analyst or regulator asking "why did the system say X." An append-only log is also the retrospective defense against the injection gap above — if a recommendation traces back to a document with an unusual instruction-shaped passage, the log is what lets someone find that after the fact, not just watch it happen once. Relation to the open architecture question. Decisions.html leaves open whether project state is an event log or periodic snapshots (open question 3), weighed on rollback capability versus build complexity. That trade-off is about state, not about the audit log. Whichever way it resolves, the append-only action log described here is a separate, non-negotiable artifact — even a design that stores state as snapshots still needs an immutable record of every decision that produced each snapshot. Gap: the chain lives in the file it protects. Anyone who can write that file can recompute every hash forward from the row they altered and leave nothing to find, as above. Attribution inherits the same limit from a different direction: a stolen session token writes a correctly hashed row naming its victim, and nothing in this design can tell.

Schema change, and the two ways this build nearly destroyed its own evidence

An append-only chain is only evidence for as long as nothing drops the table it lives in

Why this is a security section and not an operations one. Everything above argues that the audit chain is the artefact a regulator would be shown. A chain that is gapless, hash-linked and attributable proves nothing if a routine deploy can remove every row and leave a clean-looking install behind. Both defects below were found by asking what a change would do to a live host — a question the test suite cannot ask, because tests build their schema from the current models every time and so never meet yesterday's database. The first: a function that dropped every table while its docstring said it did not. init_db() dropped unconditionally, against whatever engine it was handed, while its docstring claimed it only did so for an in-memory test database. In this product that is the sharpest default available. The chain is append-only and hash-linked precisely so that no row can be removed unnoticed; drop_all removes every row and leaves nothing behind to notice. One call on a production path destroys the evidence and looks like a clean install. The fix keeps the sharp default and makes it explicit: init_db(engine=None, *, drop_first: bool = True). 480 test call sites depend on the drop for isolation, so removing it would have been a rewrite of the suite, and a caller that means "make sure the tables exist" now has to say drop_first=False and gets exactly that. deploy/entrypoint.sh passes False, and only when there is no database file at all. The docstring is now true, which is the part that matters: the previous version was a security control described in prose and absent from the code. The second: a deploy that ran no migration, which is how the first one nearly got called. deploy/entrypoint.sh seeded only when there was no database — right, because a redeploy must never lay demo rows over an audit chain — but when the file existed it ran nothing, not even create_all. The first deploy after a column was added would have kept the old table while the new code selected the new column, and every screen would have answered no such column: document_versions.source_url. The tempting repair for that is to reseed, which is the call that destroys the chain. What runs now, and what it refuses. app/state/migrate.py runs on every start, through python scripts/migrate.py in the entrypoint, before anything reads a table and before the seed. It is derived rather than listed: it asks SQLAlchemy what the models declare, asks the database what it has, and adds the difference, so a column added tomorrow is covered by code written today. It is additive only, and every refusal is deliberate. It never drops, never renames, never retypes — those are destructive against a hash chain that a rewrite would silently invalidate. It never backfills: 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 refuses a NOT NULL column with no default rather than inventing one, and it exits non-zero, so a deploy stops rather than starting the application against a schema it cannot read. It also calls migrate_audit_schema, which had been exported with no production caller at all — so the live database may have been missing its attribution columns from the day they landed until the day this ran. Gap: nothing records which migrations have been applied. This is idempotent rather than versioned. It re-derives the difference on every start, which is safe and is not the same as a migration tool that knows what it has done. There is no down path, by design, and no way to ask a database what version it is at. A schema change that needs data moved rather than a column added is outside what this can do: somebody writes it by hand, and this file refuses in the meantime. Gap: the module that stands between a deploy and a broken live schema has the lowest coverage in the tree. No test file exercises app/state/migrate.pyls tests/ | grep migrate returns nothing — and the last coverage run measured it at 0%. Every other claim in this section is about a control that exists; this one is about a control nothing tests, sitting on the path where the failure it prevents can only appear in production.

Secrets handling

Environment variables, never in the repo, never in a prompt, never in a log

The model API key and any other credential live in a git-ignored .env, read once at process start. They are never interpolated into a prompt sent to the model, never included in an API response or rendered page, and application logging must redact them from error traces rather than trust that no code path ever logs a raw exception containing a config object. What "read once at process start" means. app/config.py is fifteen lines of standard library that put .env into os.environ before anything reads it, and app/main.py calls it above every other import. Three rules are written into it and each one is a security property rather than a convenience. The real environment always wins: a name already set — by a container, a systemd unit, an export — is never overwritten by the file, because a deliberate act must beat a default on disk, and reading it the other way round means a deployment cannot override its own checked-out configuration and the surprise lands in production. It never logs a value: the function returns the names it set and nothing else. The file holds an API key, an OAuth refresh token and a client secret, and a helpful startup line echoing them would put all three into every log aggregator the host ships to. It is not a shell: it strips one matching pair of surrounding quotes and does nothing cleverer, so no value in that file can execute anything. Why plumbing belongs in a security document. Before it existed, the key sat in the file and never reached the process. The chat surface, the claim proposer and the mail transport were all permanently on their fallback paths, each announcing honestly that it was unavailable — a deployment with a perfectly good key behaving exactly like one with none. Nothing failed and nothing looked broken. That is the worst shape a defect can take, and it is the same shape as a security control that is present, announces itself, and is not connected to anything. The example file is derived, not remembered. .env.example listed five names while the code read sixteen; every setting that changes how the product behaves was missing, including VERBATIM_DATABASE_URL, which is the one that matters most and was absent entirely. It is a SQLAlchemy URL and not a path, and setting a path does not fail — SQLAlchemy quietly opens ./verbatim.db relative to the working directory, which is how a run can look healthy while writing its audit chain somewhere nobody is backing up. The file now documents all sixteen, every name derived from the code with grep, and the four Gmail credentials are moved to the foot under a heading saying they are not used by the application — they belong to the outreach script, and the old file implied the reverse. An example file listing variables nothing reads is worse than none: it sends somebody hunting for the effect of setting one. Because the repo stays public with full, unsquashed commit history per the submission rules, a secret committed even once and later removed is still exposed in history to anyone who clones it — the practical control is a pre-commit check that refuses to stage anything matching a key pattern, checked before the repo goes public, not after. That check is not yet installed; it is on the pre-submission checklist in docs/submission.html, and until it exists the control is manual review of what is staged. Gap. No secret manager, no rotation policy, and the model API key for a 48-hour build almost certainly carries more privilege (billing, account-level scope) than the application needs. A production deployment needs a least-privilege, rotatable key issued specifically for this service, held in a manager rather than a file.

Authentication and authorization

Local password login, a permission grid, and segregation of duties enforced from the audit chain

This section is written against app/auth/ and app/state/identity.py. The gaps at the foot of it are the part a reviewer should read hardest: they are where the library and the screens disagree.

What authenticates. A local password, checked against a hash this system stores. Nothing else. hashlib.scrypt derives it — OpenSSL-backed, memory-hard, a real key derivation function — at n=214, r=8, p=1, dklen=64, over a fresh 16-byte random salt per user. The cost parameters are written onto the user row and read back at every verify, so raising them later is one constant and every password already stored keeps working. No dependency was added for any of this: hashlib, secrets and json are the standard library, and requirements.txt is unchanged, which keeps the reviewer's make run on the same clean path (ADR-15). Every comparison of a secret uses secrets.compare_digest; there is no == on a hash, a token or a password anywhere in app/auth/ or app/state/identity.py. The session. secrets.token_urlsafe(32), so 256 bits, returned once and never stored. The database holds SHA-256 of it — a plain digest on purpose, since there is nothing to guess in 256 random bits and a slow KDF would tax every request. A stolen table is a drawer of hashes, not of working sessions. The lifetime is twelve hours, fixed when the row is written and never extended, so a stolen token has a ceiling it cannot be talked past. Expiry is enforced at the next request rather than by a sweep, and the first request to notice closes the row and records it once. The cookie. HttpOnly, so one cross-site scripting bug does not also hand over every session. SameSite=Lax, which is the whole of the CSRF defence on this build. Secure, with two ways off and both deliberate: it comes off by itself only for a plain-http request addressed to this machine (localhost, 127.0.0.1, ::1), because Safari drops a Secure cookie there and make run would serve a login page nobody could pass; and VERBATIM_COOKIE_SECURE overrides in either direction for a deployment behind a proxy that terminates TLS elsewhere. The login page prints, on the page, when the cookie it is about to set is not Secure. What a refusal says, and what it costs. One sentence — "email or password is incorrect" — for an unknown address, a wrong password, a suspended account and a locked one alike. The exception carries no reason code, so no renderer can leak the difference by accident. verify_password runs on every attempt, including against an address with no account, using a dummy hash built at import, so the response time does not answer "does this person have an account" either. Five consecutive wrong passwords lock the account for fifteen minutes; the lock is checked before the password, so a correct password does not walk through it, and attempts made during the lock do not extend it — an attacker who waits gets five more guesses, and nobody can keep a real person locked out for ever by guessing at their address every fourteen minutes. A success clears the counter, which is what makes the threshold count consecutive failures rather than failures ever. The wall. AuthMiddleware resolves the cookie once per request and sends every anonymous request to the login page. Refusing at the middleware rather than per route means a route added next week is protected because it exists, not because somebody remembered a decorator. The redirect carries where the person was going, and only a same-site path survives the check, so the login page is not an open redirect. The wall lets through four things, not three. The list in app/web/deps.py is two exact paths — PUBLIC_PATHS, holding /login and /healthz — and two prefixes — PUBLIC_PREFIXES, holding the static mount and /s/, the share links. A share link is meant to work for somebody with no account; that is the whole feature. Note also what is deliberately not public: /logout, because signing out is something a signed-in person does and an anonymous POST to it has nothing to end. The share prefix is on that list because leaving it off did not make sharing private — it made it leak. With /s/<token> outside the list, an anonymous open answered 303 to /login?next=%2Fs%2F<token>, which puts a live bearer token into a query string, where it reaches the access log, the Referer header and browser history. A redirect that carries the credential it was protecting is worse than no guard at all, because it looks like a guard working, and the fix is not "add an exception" but "recognise that this path was never guardable by session in the first place". The trailing slash on each prefix is the control, not decoration. Matching on startswith("/s") would make /settings public — and every other root path beginning with that letter. A guard list that widens itself by accident is worse than none, so each prefix is stored with its slash and /s bare is still refused. What stands in for the session on that path. Five things, each a row in the schema rather than a convention: a token of at least 32 random bytes, stored only as a SHA-256 digest, so reading share_links hands nobody a working link; exactly one artifact per link, a claim or a change, never a project or a list; an expiry, seven days by default and thirty at most, never absent; revocation by the sharer or by a holder of user.manage, at any time; and a full audit of every creation, every open with its address and verdict, and every revocation, in the same hash-chained log as everything else. Verification re-runs at open time, every time — nothing rendered is stored and served back — so a source edited after the link went out takes the statement off the page on the next open. What it does not do: it does not rate-limit, and it cannot tell a recipient from anybody they forwarded the link to. A bearer secret is a bearer secret, and reading a recorded open as attendance is a stronger claim than the mechanism supports. What does NOT authenticate, and is required for production. There is no SSO and no external identity provider — no SAML, no OIDC, nothing that asks the enterprise's directory who this person is. There is no MFA and no second factor of any kind. There is no CSRF token on the login form. An invitation flow is the only way a password gets set outside the seed. app/state/invites.py, app/web/views/invite_accept.py and app/web/views/users_admin.py build it. Two clocks, different on purpose: a handoff invitation lasts seven days because it may sit until somebody notices the item, and an admin-provisioned one lasts twenty-four hours with no caller able to lengthen it, because a credential-setting link is the most valuable thing in this product to steal. A resend is a new invitation and never a longer one — it mints a fresh token and supersedes the old row, so at most one token is ever live for a person and copies of the first link in an inbox or a helpdesk ticket stop working. And _grant_ceiling refuses an inviter granting more than they hold, which closes the shortest path from user.manage to any authority at all; the cost is that an ordinary admin can provision another admin and nothing else. What is still absent. Setting a password on an invited account is not changing one. There is no password reset and no password-change path for an account that already has a password: ACTION_PASSWORD_CHANGED exists in the audit vocabulary and grep -rn ACTION_PASSWORD_CHANGED app/ finds no writer. So a person who believes their password is known has nothing in this product they can do about it, and neither has an administrator except to suspend the account. The lockout is per account, so an attacker spreading guesses across many addresses is throttled by nothing this build owns; that belongs at the edge and this build has no edge. Nothing binds a session to an address or a user agent — both are recorded as evidence for an investigation and neither is an input to a decision, because mobile networks move people between addresses far more often than thieves steal cookies. And one deployment serves one tenant at the login: the form authenticates against current_company(), which is VERBATIM_COMPANY_ID or the demo tenant, so a hosted multi-tenant build would still need the company taken from the address's domain or from a chooser on the page. How roles and permissions resolve. Fifteen permission codes — thirteen when this section was written, plus two that landed after — declared in PERMISSION_CODES in app/state/models.py; three system roles, held as a grid in app/state/identity.py and asserted whole on every seed rather than appended to, so a code dropped from the grid is dropped from the role instead of lingering. The three rows, read against the file. Analyst holds the three reads — proceeding.read, change.read, claim.read — plus action.propose, escalation.resolve, steer.issue, project.create and knowledge.write. The absent row is the control: no action.approve, no action.reject. Obligation owner holds the three reads plus action.approve, action.reject and audit.read, and nothing that shapes what reaches it, because an approver who can also steer the work is not a second pair of eyes. Admin holds the three reads, threshold.set, user.manage, audit.read, user.invite and workflow.manage. It still holds no approval permission, so the routine account-management role is not also a way into the decision path. Both of those last two sit on admin for the same reason, and it is the reason the whole grid exists. user.invite belongs on analyst by every product instinct — the land-and-expand case wants an analyst to pull a colleague in without filing a ticket — and it is on admin and nowhere else, because an invited person is granted obligation_owner. An analyst holding user.invite could invite an address they control at their own company's domain, give it that role, and approve their own action through it. The grid would then grant by omission exactly what it refuses by column. workflow.manage is the same argument turned on the routing: whoever draws the approval route decides who gets asked, whoever holds action.approve decides the answer, and one person holding both could route an approval to themselves. So it is kept off obligation_owner too. Both choices make the product slower — the expansion loop now needs an admin in it — and both are the only version a regulated buyer would accept. A user's permissions are the union of their live grants, filtered on the user's company, the grant's company, the role's scope, revoked_at IS NULL and status active. An unknown user, another tenant's user, a suspended user and a user with no roles all return the empty set and are indistinguishable — an authorization read has one safe direction to fail in. Grants are revoked, never deleted, so "who could approve this in March" stays answerable. policy.require() is the gate at the point of action and writes an access.denied row for every refusal; policy.has() answers the rendering question and writes nothing; a permission code the product does not define raises rather than being answered either way. Segregation of duties, enforced in code. app/auth/policy.py::can_approve runs four gates: the user exists in this company and is active, they hold action.approve, the id names 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, and a user can hold every permission the product defines and still be refused by it. Authorship is read from the hash chain, not from an authored_by column — a second record of who did what, kept beside the first, is free to disagree with it, and the chain is the artefact a regulator would be shown. Approvals, rejections, denials and waivers are excluded from what counts as authorship, so an approver does not become an author by approving and a refusal cannot become the evidence that refuses again. require() refuses to gate on action.approve at all: leaving a shorter call that looked like it would do is how the control gets retired by accident. The verdict type is falsy when the answer is no, so the careless spelling if can_approve(...) and the careful one agree. tests/test_policy.py is where this is proved, including that a person who acted on the change beneath a claim is refused, and that a recorded user id beats a shared display name. The demo downgrade, which reports itself. VERBATIM_APPROVAL_MODE unset or empty means SEGREGATED — the safe value is what you get for doing nothing. DEMO_SELF_APPROVAL lets one operator play both roles, which is what a single-person demonstration needs. It never permits silently: the fourth gate still runs and still reaches its verdict, the returned reason names the downgrade and states what SEGREGATED would have decided, and an approval.waived row lands in the chain saying the separation was set aside. That code is separate from action.approved on purpose, so a waived separation cannot be read a year later as a clean approval. The other three gates are untouched by the mode. An unrecognised value raises at import and stops the process, because a control a typo can switch off is not a control. The approval gate is wired now, and the gap this paragraph carried is closed where it belongs. require and has run in six view modules and four state modules — the admin screens, the feedback queue, the users and shares registries, integrations, invitations, source registration, share creation, and the assistant's tool dispatcher — each writing an access.denied row for every refusal. What had not moved was the one that matters, and ADR-91 moved it. app/web/views/actions.py::decide asks can_approve at the moment of the click, honours the Verdict, and answers 403 with the sentence the gate produced — including the audit sequence number that proves authorship. The refusal is committed rather than rolled back, so the access.denied row survives the request. "One operator cannot approve their own interpretation" is now true of the screen as well as of the library, and tests/test_actions_screen.py proves it end to end. What is still open, and it is three things. Approving performs nothing — no filing, no notice, no project state moves. Rejecting gates on action.reject rather than on can_approve, so somebody can knock out a colleague's proposal and leave their own standing. And app/web/views/review.py and app/web/views/review_centre.py still close an escalation under escalation.resolve alone, which is the right code for that decision — working the queue is the analyst's own job — so one operator can still resolve an escalation they raised, in either approval mode, and a signed-in person with no roles still reaches every screen the middleware lets through. Gap: attribution reaches the log unevenly, and worst where it counts. Fifteen committed modules pass actor_user_id, actor_kind and session_id into record_event. Seven committed writers still take the default, and the way to check both halves is one loop rather than a claim: for f in $(grep -rl "record_event(" app/); do echo "$f $(grep -c 'record_event(' $f) $(grep -c actor_user_id $f)"; done. The seven with a zero in the last column are app/pipeline.py, app/seed.py, app/state/identity.py, app/state/projects.py, app/state/review.py, app/web/views/review.py and app/web/views/review_centre.py. Five of the seven are the decision path, and the overlap with the gap above is not a coincidence. The review screen, the review centre, the review state module, project writes, and every grant and revoke in identity.py. Those rows say a machine acted and carry no user id and no session, even where a person was signed in. They are honest about what was recorded and useless for tying a decision to a sitting. And they weaken the fourth approval gate on exactly the rows it needs most — authorship then has to be matched on a display string rather than an identity, so two people sharing a display name are one person to the check, and one person using two spellings of their own name is two. tests/test_policy.py pins the case where a recorded user id beats a shared display name, which is the behaviour that never gets exercised on the paths above because they record no user id. Gap: the demo accounts. The seed creates seven accounts from the corpus — one analyst, one admin, five obligation owners, one role each so that no seeded person holds two — all sharing one password. The login page no longer prints it. The panel is three "Sign in as" buttons posting to /login/demo (ADR-83). That route is not a passwordless path — it calls the same login() with the seeded password supplied by the server, so there is no second way to authenticate and nothing to copy off the page into a deployment that forgot to turn the demonstration off. VERBATIM_DEMO_ACCOUNTS=0 draws no buttons and makes the route refuse. What has not changed, and is the part that matters: the accounts still exist, the password is still what the seed set, and that switch does not remove either — anyone who knows the value can still sign in through the ordinary form. A deployment that is not a demo has to seed its own people, which is a task nobody has automated. What is new is that the workspace behind those accounts is now disposable: make reset-demo puts it back, so a visitor cannot leave it broken (ADR-84). Gap: privilege change needs no second party. An admin holds user.manage, so an admin can grant themselves the obligation owner role and then approve work they did not touch. The chain records the grant, so it is visible afterwards; nothing prevents it. Preventing it needs a second approver on privilege changes, which is not built. One half of it is narrower than that. app/state/invites.py has two paths that grant, not one. Provisioning is held to a ceiling: an inviter cannot grant a role carrying more than they hold. The handoff path deliberately is not, because a ceiling there would mean no company could ever create the first approver for a duty that has none, which is what that path exists to do. Both now go through one function (_grant_within_ceiling, ADR-80) and an AST test fails if a third call site appears, so the rule is a door rather than something two callers had to remember. Where the handoff exceeds the releaser's own authority it is granted and recordedinvite.ceiling_waived names the codes and the person — so an administrator manufacturing an approver is visible at the moment it happens rather than reconstructable afterwards. It is not prevented. grant_role in app/state/identity.py has no such ceiling and cannot have one as it stands, because its actor is a display string rather than an account and there is nothing to compute a ceiling against. Closing it needs grant_role to take a user id — which is the same missing identity that leaves those four rows unattributed in the gap above. One defect, two symptoms. In the same family: one person holding two accounts in the same company is two people to every check here, and closing that needs identity from outside the product — the enterprise directory this section has already said production requires. Gap, and a disclosed cut. tasks.html lists a fuller org chart of routing rules — a compliance officer, a project owner, a document owner, each routed separately — as first to go if time runs short. The obligation owner is what survives every cut; it is the design, not a fallback. Production requires SSO against the enterprise's identity provider, which a regulated buyer will name in the first security review, before any of this touches real data.

Failure posture: absence is denial

Every uncertain state fails toward escalation, never toward a confident guess

This is ADR-006's rule restated as a security control, because that is what it is. No session: denied, not treated as a default low-privilege viewer. No verified citation: the claim is held for review, not shown as fact with a caveat. No confident tenant scope: the query returns nothing rather than the wider set "to be safe." No confidence above threshold: escalate, never assert. The common failure this guards against is a system that degrades gracefully into looking like it still works — the exact failure named in ADR-006 from the peer project, where a provider silently degraded and a ranking ran on noise for months because every check still passed. A security posture that fails open in the name of availability produces the same shape of quiet, undetected wrong answer, in a domain where the wrong answer is a missed compliance deadline.

What this document does not address

Not addressed in the 48-hour prototypeWhat production requires
Real multi-tenant data, only a single synthetic company plus a CI fixture testPostgres with row-level security; the chokepoint pattern retired, not carried forward as the permanent mechanism
Local password login only — stdlib scrypt, per-user salt, per-account lockout, one refusal sentence, every attempt audited. No SSO, no external identity provider, no MFA, no password reset, no password-change path, no CSRF token on the login form. One deployment authenticates one tenant. Seven demo accounts share a shared password the seed prints, and the variable that hides the panel does not remove the accountsSSO against the enterprise's IdP, which a regulated buyer names first; MFA; per-source throttling ahead of the per-account lockout; a CSRF token; a password-change and reset path; accounts provisioned by the buyer, not by our seed
policy.require and policy.has run in six view modules and four state modules, and can_approve now has one caller — app/web/views/actions.py::decide (ADR-91) — so the approval path is gated on the request path rather than in a library beside it. Rejection is gated on action.reject alone, and closing an escalation on escalation.resolve alone, so one operator can still resolve an escalation they raisedRejection held to the same gate as approval, and an approval that performs the action rather than recording it. Until then the record is honest and inert
Attribution reaches the chain from fifteen committed modules, not only from authentication. Seven writers still record a machine with no user id and no session, and five of the seven are the decision path — the review screen, the review centre, app/state/review.py, project writes, and every grant and revoke in app/state/identity.pyEvery write that a signed-in person causes carrying their user id and session, so a decision can be tied to a sitting and the approval gate can match on identity rather than on a display name. grant_role taking a user id is the single change that closes the most of this
app/state/migrate.py runs on every start through scripts/migrate.py in the entrypoint, and it calls migrate_audit_schema. What is still missing is the versioning: nothing records which migrations have been applied, there is no down path, and no test file exercises the moduleA migration tool that records what has been applied, and tests on the one module that stands between a deploy and a live schema the code cannot read
A retention schedule exists in app/state/retention.py covering every table with a window and a reason, and the purges are tested. Nothing on any committed path calls them, so no row has ever been deleted on a clock. Two classes of email address cannot be reached by any purge at all: a failed sign-in records the address that was tried, and an invitation records the address it was sent to, and both sit inside the hash chainSomething that runs the purges, and an erasure path for a user, a document and a claim. The chain's own limit stays whatever else is built: what a record said can be purged, that a record existed and who decided what about it cannot
Backups are a working script and not disaster recovery. scripts/backup.py snapshots through SQLite's own backup call, then re-opens the copy, runs PRAGMA integrity_check, counts the audit table and re-verifies every chain in it, renaming any copy that fails so nobody restores it by mistake. Nothing schedules it, nothing sends it off-host, nothing encrypts it, and no restore has ever been practisedA schedule, an off-host encrypted destination, and one rehearsed restore. A backup on the same disk as the database survives a bad deploy and not a lost host
Share links are bearer credentials on the only unauthenticated read path. Bounded by a hashed 32-byte token, one artifact, a mandatory expiry, revocation and a full audit — and not by rate limiting, and not by any way to tell a recipient from whoever they forwarded it toRate limiting on the share path, and an honest account at the point of sharing that a link is as private as the person it was sent to
No secret manager or key rotationLeast-privilege, rotatable keys held in a manager, not a file
Model vendor's retention terms not yet confirmed against the requirement stated aboveA contracted zero-retention or enterprise data-handling agreement before any real company context is sent
No rate limiting, no WAF, no DoS protection on the public surfaceStandard for anything internet-facing handling confidential business data
No dependency/SCA scanning, no penetration testBoth, before the first regulated customer, given the ingestion pipeline parses untrusted third-party file formats
No encryption at rest beyond the OS disk default on the SQLite file; no field-level encryption for the most sensitive company-context fieldsEncryption at rest as policy, not as a property inherited from the host
Citation-supports-claim is not verified, only citation-exists-at-offset (named above under prompt injection)A second verification pass — plausibly a separate, narrower model call, itself citation-checked — before this gap can be called closed
No incident-response plan or breach-notification processRequired before handling a real regulated enterprise's data, not before a demo of one
Single process serves ingestion, interpretation, and the UI with no network segmentationSeparation between the surface that parses untrusted documents and the surface that serves authenticated sessions