What Verbatim records, and the one record it cannot erase.
Privacy · 2026-08-04
Verbatim holds the documents a regulated company puts into it and writes a permanent trail of every decision taken over them. This page says which fields exist, who can read them, and where a deletion request runs into a wall.
Verbatim is a pre-production build by one person. It has no customers. The repository carries two corpora, both labelled. One is invented -- a fictional commission, a fictional docket, a fictional company -- and the eval numbers are measured against it, because its traps were built on purpose. That is the one the setup command loads. The other is 102 real public filings retrieved from eight state commissions, each with the URL it came from and a hash of the bytes; a script loads one pair of them, and nothing runs that script for you. Neither corpus is confidential: one is fiction, the other is what the commissions publish. Nobody's customer documents are here.
Nothing on this page describes a control the code does not have. Where a buyer would expect one and it does not exist, the page says so instead of implying it.
What the product stores
Field by field, as the tables actually stand. Nothing here asks for a payment card, a date of birth, a telephone number or a home address, and no column exists to put one in.
Your email address, your display name, a scrypt hash of your password with its own random salt and its own cost settings, the state of the account, when it was made, when you last signed in, how many sign-ins failed in a row, and the time any lock runs to. The password itself is never written down.
One row for each sign-in. It holds a SHA-256 of the session token and never the token, when the session started, when it expires, when it was last used, the address the request arrived from, the browser's user-agent line, and — once it ends — the time it ended and the reason.
Each filing you load, kept byte for byte with a SHA-256 beside it, and the passages cut from it with their character offsets. The product re-reads those bytes every time it checks a citation, so it cannot discard them and still work.
Proceedings, changes, claims and their citations, escalations, projects, research threads, plans, findings, questions, deliverables, obligations and their owners, and anything typed into the chat. Every row names the company it belongs to.
A row for every decision, with who, what, when and from which address. That one has a section of its own below, because it is the field a privacy policy usually leaves out.
The trail records your address, and that is the point
Every decision the system takes writes a row naming the person as a display string, the account id, whether a person or a machine acted, the login session it happened under, the IP address the request arrived from, and the time in UTC. All of it sits inside the row's hash, not merely beside it, so nobody can rename the actor afterwards and leave the record looking sound.
Failed sign-ins are recorded as well as successful ones, so anyone probing accounts leaves a trace. The row names the address that was typed and never the password tried against it. An entry that does not look like an address at all is stored as a marker instead, because a password typed into the wrong box would otherwise be kept for ever.
The address is the one the connection itself reported. The product does not read the
X-Forwarded-For header, because a header the caller writes is not evidence.
Why say this so plainly: an audit trail is the product, not a side effect of it. A regulator asking who said what, when, and from where is the question Verbatim exists to answer. A policy that quietly omitted the address would be hiding the main feature.
What cannot be erased
The trail is append-only twice over. Application code has no path that updates or deletes an audit row — the attempt raises. And a SHA-256 chain runs over every field of every row, per company, numbered from one with no gaps, so a row altered or removed by other means is detected: a gap in the sequence is a deletion.
Several laws give a person the right to have their data deleted. That right and this design pull against each other, and pretending otherwise would be dishonest. A deletion request cannot take your audit rows out, because removing one breaks the chain that makes every remaining row worth reading.
What stays. Your email address as the actor on rows you caused. Your account id, your session id and the IP address on the rows the sign-in path wrote. What each decision was and why.
What the trail still says about a record after that record is deleted. The audit row naming a thing outlives it, and goes on pointing at an id that no longer resolves. A reader still learns that a record with that id existed, what kind of thing it was, when each decision about it was taken, who took it, from which address, and the reason given. They do not learn what it said — the words of a chat turn, the text of a complaint, the address a share link was opened from. Those live only in the row that went. That is the honest shape of erasure against a log that cannot forget, and it is less than erasure; calling it anything else would be the failure this product exists to stop.
Two addresses no deletion can reach at all. A failed sign-in writes the address that was tried into the trail as the actor, and as the subject too when no account matched it. An invitation's row quotes the invited address in its reason. Both sit inside the hash. Every other table in the database could be emptied and those two lines would still be there.
What is true today. Six purge functions exist, in
app/state/retention.py, and they are tested. They cover the tables holding an
address, an email or a person's own words: sessions, share opens, chat transcripts,
feedback and invitations. A scheduler can now call them, and by default it does not
delete anything. app/jobs/runner.py reaches them, and three separate
settings stand between that and a deleted row: no job loop starts at all unless
VERBATIM_JOBS_ENABLED is true, the purge runs as a dry run unless a
second setting asks for the other thing, and purge_all checks that flag again
itself rather than trusting its caller — because 0 is falsey, and a
configuration typo that armed a delete would be the worst bug this page could hide. Whichever
way it lands, the container log says so on every start. There is still no erasure path for a
user, a document, a claim or a proceeding: suspending an account keeps the row, revoking a
role keeps the row, and signing out keeps the session row and marks it revoked with a
reason. The next section sets out the whole schedule and where it stops.
How long each thing is kept
A retention schedule is usually a table in a document, which is to say a set of claims
nobody can check. This one is code. Every table in the database carries a window and a
reason beside it in app/state/retention.py, and a test walks the schema and
fails on a table nobody has given a window — so a table added next week cannot arrive
quietly with no answer. A window with no reason is a number somebody changes without
thinking, so the reason is part of the rule rather than a note about it.
The rows holding an address, an email or a person's own words get the shortest windows. The rows holding your filings get no clock at all, and the reason is below.
Never. Every row's hash covers the row before it, so removing one does not delete a record — it destroys the evidence value of every record after it, and a gap in the numbering is itself read as a deletion. The section above says what that costs.
90 days after the session expires. An expired session row answers one question — was this sign-in used, from which address, on which browser — and after a quarter no investigation is still asking it. It holds an IP address and a browser line, so it gets the shortest window of anything kept at all. Measured from the expiry rather than from a sign-out, because the expiry is the last moment the token could have worked.
90 days for the address, 365 days for the row. Two different things sit in it: the address of somebody who never signed in, and the record of what a recipient was shown and whether the citation held at that moment. The address comes out at ninety days and the row stays; the row goes at a year. Where the address has been taken out the field says so rather than going blank, because blank on that column already means the server never recorded one.
365 days after the last thing said, or after it was opened where nobody ever spoke. What you typed is yours. One exception, and it announces itself: a conversation holding a turn somebody complained about waits until the complaint goes, so that a reviewer never opens a complaint and finds the thing complained about missing. The report names the row doing the holding.
365 days after it was written, whether or not anybody triaged it. Keeping untriaged rows for ever would turn the queue nobody worked into an archive nobody agreed to. The backlog item it was triaged into survives and carries the substance without your words.
90 days after it stops being acceptable. It holds the email address of somebody who may never have had an account here, which makes it the row least likely to belong to a customer at all. The trail row saying they were invited outlives it, and quotes that address.
No clock. Documents, passages, proceedings, changes, claims, escalations, projects, findings, deliverables, obligations, approval routes, accounts and role grants are kept while the account is open. They are what the product is for — verification re-reads the stored bytes every time it checks a citation, so discarding them would turn every claim into an unverifiable assertion. There is no account-closure event anywhere in this build, so there is nothing for a clock to start from, and no code here pretends otherwise.
Every purge is a dry run unless the caller asks for the other thing, and the dry run reports exactly what would go. Every real purge writes a trail row naming the rule, the table, the count and the cutoff before it removes anything, because a deletion nobody recorded cannot be told apart from a breach. That row names nothing out of the rows it deleted: an address copied into an append-only log to record its own deletion has been moved rather than removed. Running the purge twice is safe, and the second pass writes nothing.
Where the schedule stops. It reaches the rows in Verbatim's own database and nothing else. Whatever web server sits in front of the application keeps its own request log, and no code here reads or removes that. Nor does a purge reach a copy of the database taken before it ran.
Who else sees it
One company is sent your text: Anthropic, through the assistant. A second holds the disk it all sits on — the machine is rented, and the sub-processors page names it and the two others behind the domain and the certificate.
- The assistant sends your text to a model vendor. The chat panel sits on every
screen. Each turn goes from
app/web/views/chat.pythroughapp/chat/engine.pytoapp/chat/agent.py, which calls Anthropic's API. What travels: the question you typed, your display name, the company's name, the permission codes you hold, and whatever the model looked up — obligations and who owns them, open escalations, the approval route, claims whose citation verifies, and up to 600 characters of filing text on each side of a change. A claim whose citation does not verify is never sent as a statement. With noANTHROPIC_API_KEYset, nothing is sent and the panel says it is unavailable. This page said no model call ran for most of a day after one did; the sub-processors page now carries the detail and this bullet is checked against the same files. - No analytics, no trackers, no advertising, no tag manager, no session recorder.
- No content delivery network, no third-party fonts, no third-party stylesheets, no third-party scripts. This site and the application load nothing from another host. Two web addresses appear in the front end and neither is fetched: the SVG namespace identifier, which is a name rather than a request, and an example address printed in a form's placeholder.
- No payment processor, because nothing is for sale yet.
If you write to the address at the foot of this page, the message sits in an ordinary Gmail mailbox. That is Google's service, not mine, and their terms govern it.
The cookie
One cookie, named verbatim_session. It carries a random 256-bit token and
nothing else — no identifier the product follows you with, no profile, no preferences. The
token resolves a row in the database, which is what lets a sign-out end the session on the
server rather than merely clear your browser.
It is set HttpOnly, so a script cannot read it; SameSite=Lax, which is the whole of the cross-site request defence on this build; Secure, so it does not travel over plain HTTP; and it expires with the session, twelve hours after you sign in.
One narrow exception: for plain HTTP to your own machine, which is what the local run serves, the Secure flag comes off, because Safari would otherwise drop the cookie and the login page would not work. The login page prints on the page when it has done that.
What the demonstration contains
The repository carries two corpora and the setup command loads one. That one is invented: a fictional commission, a fictional docket, a fictional utility, fictional people. No text in it is copied from a real filing, and nobody's real documents are in it. It is one docket in three versions, and it is what a workspace set up the documented way holds.
Beside it sit 102 real regulatory filings downloaded from eight state commissions' own
public document systems, each with a note recording where it came from and when. Those are
public record and no customer supplied them. A separate script,
scripts/ingest_real.py, loads one pair of them — a witness's direct
testimony in a Kentucky rate case, filed and then corrected — so the product can be
seen working against a document a person really filed. Nothing runs that script for you:
neither the setup command nor the deployment start-up calls it. So unless somebody runs it by
hand, all 102 sit on disk unread.
Where it runs, and who to ask
The data sits in a single SQLite file on whichever machine runs the product. Since
2026-08-04 one of those machines is a virtual machine rented from DigitalOcean, which serves
verbatim.citelocal.ai; the sub-processors page says
what that company holds and names the other two. There is no encryption at rest beyond
whatever the host's disk provides, and no backup has been taken of any deployment. The
security page lists what is built and what is not, without
softening either.
Write to jsahasi@gmail.com. One person reads it.
This page changes when the code changes, and the date at the top moves with it.