Updated August 2026 for v0.2.0. I published this, four readers found real defects, and most of what follows changed as a result. Two numbers in the original were wrong in my favour and one was wrong against me. All three are corrected below, and there is a full account of what the reviewers found at the end.
There's a hole in almost every AI memory library, and once you see it you can't unsee it: the model that hallucinates is also the thing you've put in charge of the record.
You hand an LLM a conversation and ask it to remember what matters. It reads, it decides, it writes its interpretation into long-term memory as if it were a fact. Three sessions later your app confidently "knows" something the user never said — and you can't even find where it came from. The fox is guarding the henhouse, and it files the incident report afterward.
I spent a long, humbling year on this. What I ended up building — published as an MCP server — inverts the arrangement. The model doesn't get to decide what's remembered. It gets to propose. Deterministic code, with no model and no prompt anywhere in it, decides what's admitted.
The thesis: propose, then adjudicate
The rule is one sentence: the model proposes, deterministic code decides, and nothing ungrounded is committed.
Your agent doesn't just assert a fact. It asserts a claim and quotes the exact text it's grounding on. Then a gate checks whether the quote supports the claim. Watch it refuse me when I try to smuggle an interpretation past the evidence:
remember(claim="Priya joined Acme in 2019 under duress.",
evidence="Priya Raman joined Acme in 2019 as a logistics analyst.")
REFUSED (asserts_more_than_evidence) — the claim adds something the evidence does not say.
claim : Priya joined Acme in 2019 under duress.
evidence: Priya Raman joined Acme in 2019 as a logistics analyst.
"Under duress" is nowhere in the evidence, so it never enters the store. I can't argue my way in, because the gate isn't a conversation — it's a function. Prompt injection, jailbreaks, a model being confidently wrong: none of them help, because there's no model on the deciding side to fool.
Provenance you can audit — and now redact
An admitted claim is stored with the byte range it came from:
ADMITTED — Dana Kim has a cat named Pepper.
grounding : grounded_verbatim
receipt : bytes [0:71] of sha256:b410428a2b58…
verify_receipts re-hashes the source and re-slices the range. Edit the document underneath a stored fact and the receipt doesn't quietly stay green — it fails. That's provenance you can falsify, which is the only kind worth having.
In 0.1.x that created a problem I had to disclose and couldn't solve. Erasing subject A means removing A's sentences from the source document — which changes the document hash, which breaks every other party's receipt into that file. I wrote the fix, measured it breaking an innocent bystander's receipt from 1/1 checkable to 0/1 checkable — FAILED, and reverted it. The article said you couldn't have all three of A's text is gone, B's claim survives, B's receipt still verifies.
That was wrong, and a reader pushed me to look properly. It's unsolvable under a flat document hash, not in general. The standard answer is a redactable signature over a hash tree, and it predates this project by about two decades. Receipts now bind a Merkle root over the document's sentences plus an inclusion proof. Redacting A replaces A's sentences with their leaf hashes; the root is unchanged; B's proof still verifies; and the verifier never learns what was removed. All three at once.
It abstains instead of confabulating
> What is Dana Kim's salary?
ABSTAINED (unknown_predicate) — no claims ground "salary"; 2 claims about Dana Kim exist,
grounding: named, pepper, cat, plays, weekends, basketball
Next: ask about one of: named, pepper, cat, plays, weekends — or commit a claim grounding "salary".
The refusal redirects. Most callers are agents, and an agent can't browse the store to work out what to ask instead — so a bare "no" is where the interaction dies.
The number I had wrong, against myself. The original said this refuses about 37% of answerable questions. That figure conflated two completely different failures, and a reviewer told me so: "refusal precision can hide write-path loss." He was right. Split properly on a 410-question corpus:
| items | outcome | |
|---|---|---|
| the answer is in the store | 367 | gate refused 6 — a 1.6% read-side miss |
| the answer never reached the store | 43 | gate refused 30 (correct), answered 13 |
83% of what I'd been publishing as a recall failure was the benchmark's own extraction losing the fact before it was ever stored — an LLM step paraphrasing "I am a lawyer" into "works at a law firm" and destroying the word. The shipped server has no such step; it refuses that paraphrase outright. When the fact is actually present, the gate finds it 98.4% of the time.
I'm stating the split rather than the flattering half, because a single pooled number was exactly the mistake that got my earlier benchmark retracted.
Provable deletion, and exactly where it stops
forget(subject="Priya Raman")
→ ERASED Priya Raman — certificate issued
signature : ed25519:ae5a9c6526839f0f…
nodes in closure : 1
bystanders surviving : 1
cipher : AES-256-GCM
content key destroyed: True
source parts redacted: 1 in 1 document(s)
verifiable by others : yes — ed25519, public key 9e0ba77ed9fe8752…
In 0.1.x this was much weaker than it looked, and I didn't know until people checked. A reviewer put the test plainly: "a delete flag is not a wipe. The useful test is whether a later read can still return the bytes." So I ran it — erase, then grep the store. The name and sentences came back from four places: the source file, the evidence span stored on another party's claim, entities mentioned only inside the erased claims, and unencrypted entity payloads in the ledger.
All four are closed. Content is encrypted under a per-subject key that erasure destroys, source documents are redacted through the Merkle binding, and entities left with no surviving grounding go with the subject.
What still survives, stated on the certificate itself: entity ids are derived from names (ent_jane_doe) and persist in the append-only ledger by design. No content field survives, but a name is recoverable from such an identifier. The fix is opaque ids and it's a schema change.
Two things are true at once
The idea is solid. Model proposes, code decides, receipts, provable erasure with a stated scope.
The code is young, and everything above is evidence of it — every one of those defects was found by someone who wasn't me.
The night it lied to me
Before the original post I finally did the thing I should always have done: installed my own package like a stranger, drove it over the real protocol, and tried to break it.
It lied within minutes. I told it to remember "Ada Lovelace wrote the first algorithm." It returned ADMITTED. It had stored nothing. The firewall recognised verbs by spelling — anything ending in -s, -ed, -ing — so it had never heard of "wrote." Or "went." Or "built." Nine of sixteen ordinary sentences were rejected as meaningless, and the survivors mostly passed by luck: "Marcus Webb sold his bookshop" cleared the gate because his ends in an s.
That was the third time in this codebase I'd made the same mistake: compute a decision, then ignore it. It's now up to five.
What the reviewers found
The audit log did not exist. An independent audit found the append-only hash-chained ledger — implemented, tested, documented — had never been attached to anything. attach_ledger had zero callers. Every mutation this server ever made recorded nothing. The guard that raises on an unlogged write was also written, and never armed.
The certificate signature was forgeable. Signed with a literal key checked into public source. The HMAC construction was correct; the secret wasn't a secret. It's now Ed25519 with a published public key, so a third party can verify without being able to forge — and where the optional dependency is absent, the certificate says it's a checksum rather than an attestation.
Erasure could be silently undone. The audit asked what happens if you re-submit an erased claim. I tested it: identical claim, identical evidence, silently re-admitted. There was no tombstone, partly because forget wrote its ERASE event to a throwaway in-memory database. Now the write path refuses, with an explicit override — and the tombstone stores a hash of the name, because writing the name into an append-only log would survive the very erasure it records.
remember could report success while storing nothing. A reader pointed out verify_receipts structurally cannot catch this: it proves stored content hasn't drifted, and a dropped fact has no receipt to verify. He was right, and my earlier fix only covered the one case I'd found by hand.
And a diagnosis better than mine. Two readers independently said the 37% was a vocabulary problem, not a retrieval one, and the structural fix was on the write side. Checking that against my own code turned up something neither of us expected: the canonical record they described already existed, was already populated on every claim, was never read for matching — and its verb lemmatiser used the same suffix heuristic as the "wrote" bug, fixed in one place and never ported to the other.
Then the measurement disagreed with all of us. Canonical predicates bought 0.7 points. Extending a hand-written category table — the thing that critique called unscalable — bought 28. So I tried to replace the table with public knowledge bases. WordNet failed: it files vegan under person, not diet, and cost 8.7 points of correct refusal for 1 point of recall. Wikidata looked excellent — until I built a corpus that could actually test it, and it collapsed correct refusal from 96.3% to 35.7%. The vocabularies were accurate; they were just large enough that some term appears in almost any store, and the gate started answering questions whose answers were absent. Reverted.
That's the one I'd most want another builder to take: the change was measured, the measurement was favourable, and the measurement ran against a corpus structurally incapable of detecting the harm. Same shape as the benchmark I retracted. The defence isn't more caution — it's building the corpus that can say no before trusting the one that says yes.
Try it
uvx fireweed-mcp
Or in an MCP client:
claude mcp add fireweed -- uvx fireweed-mcp
Zero dependencies, no API keys, no cloud, no model, no GPU. Nothing in the server runs inference. Storage is an open format with a stdlib-only reader. Two optional extras: [semantic] for paraphrase matching, [signing] for Ed25519 certificates.
Licence, up front: FSL-1.1-ALv2 — source-available, not OSI open source, free for anything except building a competing product, converts to Apache-2.0 in 2028.
https://github.com/Starksood/fireweed-mcp
Five separate times in this codebase, a check ran correctly and its result was thrown away before anyone saw it. That's worse than no check, because it looks defended. If you take one thing from this, take that — and then go and grep your own store after a delete.











