Inspiration
Every assistant with a memory feature has the same three holes in it. You cannot see why it said what it said. You cannot remove one thing it knows without wiping all of it. And you never agreed to any of what it decided to keep.
The 2026 AI-memory field — Mem0, Zep, Letta, MemPalace and the rest — is developer infrastructure optimising for agents remembering better. Every one of them is a service an application buys. Not one is accountable to the person being remembered.
They make agents remember. Paper Trail makes remembering answerable to the person it is about.
What it does
Bring anything, get a memory file. Paste a bio, a résumé, an export from another assistant, or three sentences. One model call proposes candidate memories, each classified as a special category (health, beliefs) or an attested figure (a salary, a budget), and nothing is written until you tick it. Policy re-checks every flag rather than trusting the model, so an imported health fact still cannot ride along with dinner preferences.
Every answer says what it cost. A turn is two model calls, not one. The first sees only what each memory is about — "a diagnosed medical condition", never "coeliac" — and has to say which ones it needs and why. Only then is anything projected and handed over. Stamps appear at about 0.6s, before the answer starts streaming, so you see what the model was given and then watch it work from precisely that.
Pull a memory off and watch the answer change. The demo persona lives in a hostel with a
kettle and a microwave. Ask what to cook and you get microwave khichdi. Pull kitchen.equipment
off the stamp row and the same question comes back telling him to soften an onion in a pan. He
has no pan. Revocation you can see working in one click is a very different thing from a
settings toggle you have to trust.
Be asked first. Tick "ask me first" and a question stops after the scope call: the request
appears as a card, each memory with the reason the model gave, nothing yet out of the database.
The special category sits in its own block, unticked. Grant it deliberately and it becomes a
second independent request, stamped sensitive_read apart from everything beside it.
Hand someone a slice, or take the whole thing with you. A share link covers a subset, expires in an hour, counts every open, and dies the moment you revoke it. The rule on a link is stricter than the rule on a request: a special category cannot travel on one at all, and an attested value never enters the payload. Export gives you the whole file and its ledger as JSON or Markdown.
The governance follows you into other assistants. Mount the file over MCP. describe shows
subjects and never values. request_context returns projected values for whatever a standing
grant covers and a pending request for everything else. propose_memory offers a memory and
never writes one. Standing grants exist because consent that interrupts a person on every call
is consent they will switch off — so a grant is listed, counted, and revocable, and the next
call after a revoke is pending again.
The claim, and why you should not take my word for it
Plenty of projects answer the explainability hole by asking the model which facts it used and printing the reply. That is the model's account of itself: unverifiable, and wrong often enough to matter.
The receipt is written by the code that hands the value over, not claimed by the model afterwards. The model cannot see a memory that was never placed in its prompt, and the stamps under an answer are rendered from rows written in the same database transaction as the read.
That is testable rather than promised, so it is tested. Three tests hold the line:
test_scope_prompt_never_contains_a_memory_value— the scoping call is handed subjects onlytest_attested_memory_yields_proof_and_never_its_value— an attested salary reaches the model asbudget.confirmed: true, never as the numbertest_a_read_that_cannot_be_stamped_does_not_happen— drop the receipts table and the read raises instead of quietly succeeding
That last one is the whole architecture in one test. The read and its receipt are the same transaction, so losing the receipt loses the read.
How I built it
Six pure functions carry the entire model, in policy.py — about 100 lines, no I/O, no state:
| Function | What it guarantees |
|---|---|
labels_for_scope |
the scoping call is shown what a memory is about, never what it holds |
validate_request |
unknown, revoked, and ride-along special categories are all refused |
split_by_category |
a special category becomes its own request rather than a dead end |
authorize_share |
a link carries live, ordinary memories only |
project |
the only route a value takes to a model; attested yields proof, never value |
stamps |
the row under an answer is built from what was handed over, not what was claimed |
Everything else is deliberately thin. llm.py and extract.py ask and parse and decide nothing.
turn.py is the only module that knows the order of the steps, split into propose and answer
so there is a moment where nothing has been handed over yet and a person can still say no.
store.py is the only module that speaks SQL, and every read and write puts its receipt down in
the same transaction as the effect. mcp.py and app.py hold no rules at all — which is why the
browser and an external agent get identical guarantees rather than two implementations that drift.
Stack. FastAPI, SQLite (stdlib sqlite3, no ORM), and openai/gpt-oss-20b on Groq at
reasoning_effort: low. React 19, Vite, TypeScript under strict with
noUncheckedIndexedAccess and exactOptionalPropertyTypes; one useReducer, no state library,
no router. Answers stream over SSE. One Docker container on loopback behind Caddy, shipped by
GitHub Actions over SSH; CI runs ruff, mypy strict, pytest, prettier, tsc, vitest and a
production build on every push, and the deploy waits on it.
The model was chosen by measurement, not vibes:
| Model | Scope call | Result |
|---|---|---|
openai/gpt-oss-20b |
0.64s | clean JSON, asks for six memories including the special category |
openai/gpt-oss-120b |
0.53s | clean JSON, asks for two — a thinner, less honest stamp row |
qwen/qwen3.6-27b |
1.27s | leaks <think> blocks, spends the budget reasoning |
groq/compound-mini |
1.23s | clean JSON, four memories |
The bigger model was faster and worse for this job: a scope call that under-asks produces a stamp row that under-reports, which is exactly the failure this project exists to prevent.
Challenges I ran into
Proving a negative. "The model never saw this value" is not something you can demonstrate by looking at output. It had to become a structural property — one function is the only route a value takes to a model — and then a test that asserts the prompt string does not contain it.
Consent that people will actually leave switched on. The first version asked before every single read. It was correct and unusable. Standing grants with a TTL, a read count, and a visible revoke button are the compromise: the interruption happens once, and the accountability keeps running afterwards.
A public demo of a revocation feature, with no accounts. Every row is keyed by session, so your revoke cannot change what the next visitor sees. Sessions and everything they hold are purged after 24 hours, because visitors paste personal text into this and there is nobody to ask for a deletion.
A model that is allowed to be wrong. The scope call can hallucinate a path, over-ask, or
return unparseable JSON. parse_needs intersects with the offered set, validate_request
refuses the rest, and if the upstream is unreachable the app serves clearly labelled stand-in
answers while every mechanic — scoping, refusals, receipts, revocation, proposals — still runs
for real against them. The governance does not depend on the model behaving.
The deploy lying about itself. The health poll ran under set -euo pipefail with an
unguarded curl, so the first connection refused by a still-booting container killed the script
before its own retry loop. Deploys had been succeeding while the job reported failure. Fixed by
guarding both probes.
Accomplishments that I'm proud of
- The honesty invariant is enforced by a database transaction, not by a prompt.
- 60 Python tests and 19 TypeScript ones, written as the specification: one per property, named so a failure explains itself rather than needing a debugger.
- ruff, mypy
strictand TypeScriptstrictall clean, gating the deploy. - The whole authorization model is six pure functions you can read in five minutes and check against the promises on the landing page.
- It is genuinely live, with no sign-up and no key, and the interesting parts — the stamp row, revocation, ask-me-first, sharing — work on a phone.
What I learned
Consent is a UI problem before it is a policy problem. And an audit trail that the audited component writes about itself is not an audit trail — the only receipts worth anything are the ones produced by the code doing the handing over, in the same transaction as the effect.
What's next
Conflict detection: a new memory that contradicts an old one should be surfaced rather than appended beside it. That is the failure mode of every memory system that only ever adds.
Provenance
Built independently in August 2026, during the hackathon window. The policy and receipt engine
is adapted from Agent Visa, my own earlier MIT-licensed project — project(),
validate_request(), the same-transaction receipt discipline, and the visual language.
Everything else here was written for Paper Trail. This note is here so nobody has to take that
on trust.
Log in or sign up for Devpost to join the conversation.