Skip to content

Enforce revocation at retrieval, add erasure and provenance - #11

Merged
sunilp merged 4 commits into
mainfrom
revocation
Oct 11, 2026
Merged

sunilp merged 4 commits into
mainfrom
revocation

Conversation

@sunilp

@sunilp sunilp commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Why

"Revoked but Still Authoritative" (Shen, Toyoda, Leung, arXiv 2609.08258) loaded five agent-memory systems with a revoked policy and its replacement, across nine policy scenarios and nine models. No system enforced revocation by default. Where the revocation label was visible to retrieval, the revoked fact was returned in 81/81 scenarios, ranked first in every one, and agents took the unsafe action in 43.1% of trials (pooled). The paper also shows the write-back path: an agent that acts on the revoked fact and records its decision puts that conclusion back in the store as an ordinary current record, which a store-level filter does not catch.

This library had the same gap. supersede() set superseded_by, the retriever filtered on it behind RetrievalConfig.exclude_superseded, and recording the old text again created a fresh unmarked fact. tests/unit/test_revocation.py reproduces the scenario: a control test shows the old deploy-approval policy ranks first and a deterministic agent that follows the top memory deploys after one approval. On main, the write-back test and the history-mode test fail with the old policy ranked first; on this branch they pass.

Design

Threat model. A fact is revoked by an operator with a reason. After that, nothing that reads memory on behalf of an agent should see it: not the facade, not the reader tools, not query-time re-extraction from transcripts, not HTTP or MCP, not a second process whose in-memory vector index still holds the vector. Writers (agents, ingestion, an unauthenticated /v1 client) must not be able to undo a revocation by writing.

One choke point. Visibility is decided by a single SQL view, hidden_facts (in schema.sql, recreated on every open). Every store read selects from live_facts, live_events or live_messages, built on it. The vector index returns ids only and ids resolve through live_facts, so a stale index cannot hand back text. The retriever's last step fetches provenance from live_facts, which doubles as a re-check after reranking (a revocation that lands while a reranker awaits is still applied). test_revocation_choke_point.py parses sqlite.py and fails if any method outside an exact allowlist of five governance methods reads facts, events or messages directly; none of those five returns fact text.

What a tombstone hides.

  • the fact by id, even if its text is later rewritten through upsert_fact;
  • every fact, transcript turn and event in the same scope whose normalized text equals the revoked text, or quotes it whole when it is 4+ tokens, written before or after (NFKC; all default-ignorable characters removed; casefold; punctuation as separator; symbols, leading minus and % # @ & kept so "> 10000" and "< 10000" differ). Matches are computed at write time into content_matches so the view stays an indexed lookup;
  • facts recorded with derived_from pointing at a hidden fact, transitively (the paper's write-back case);
  • events whose source_fact_ids include a hidden fact;
  • transcript turns linked by source_message_id; for a fact an operator revoked by id with no link, all turns of its session (keyed on the revocation, so a plain write cannot withhold a session).
  • Query-time re-extraction and extract() drop anything a tombstone would withhold.

Re-insertion rule (decided). A write cannot un-revoke. Re-recording the claim stores it born hidden; record(reject_revoked=True) raises RevokedContentError instead. Tombstones live in their own table, so re-upserting the fact object (clearing superseded_by, invalid_at, metadata) changes nothing. reinstate(fact, actor, reason) on the revoked fact itself is the only way back and is recorded on the tombstone; reinstating a born-hidden copy is refused. Paraphrases, "one" vs "1", and cross-script homoglyphs are not matched, by design; there is a test pinning that so changing it is deliberate.

API (Python). revoke(fact_or_id, *, reason, actor), supersede(old, new, actor=, reason=) (now returns the Revocation; rejects a revoked replacement and a replacement with the same normalized text), reinstate(...), erase(...), explain(fact_id), revocations(), erasures(), context_entries(), record(actor=, derived_from=, source_message_id=, reject_revoked=). Revoke, reinstate and erase are not exposed over HTTP or MCP in this PR because /v1/memory/* is unauthenticated by design (v0.2 spec); that should follow auth on /v1.

Erasure. erase() deletes, in one transaction: the fact, same-text copies in scope, derived facts (transitively), events listing them, cited transcript turns, and any transcript turn in scope containing an erased text. Also removes facts and events that quote the text, and scrubs any audit text quoting it. Tombstones on erased facts lose their normalized text but keep the content hash by default so revoked content stays blocked (block_reinsertion=False drops it; the README notes a short text's hash can be confirmed by guessing). Discovery and deletion run in one BEGIN IMMEDIATE transaction. The vector index for the scope is rebuilt without the erased vectors (hnswlib mark_deleted keeps them in memory). Returns an ErasureRecord with ids, counts, actor, reason and per-layer guarantees, and no content; a reason or actor containing any three-token run of the content is rejected (a single distinctive word can still pass; documented).

What it can and cannot show:

  • SQLite file and WAL: PRAGMA secure_delete=ON, FTS5 secure-delete (SQLite 3.44+, reported), WAL checkpoint with truncate. A test greps the db, -wal and -shm files for the erased token (control: present before). If another connection holds a read snapshot the checkpoint cannot finish and the record says wal_checkpointed: false; a test holds a reader open to check that.
  • Vector index: rebuilt; the old index memory is freed, not zeroed. The index is never written to disk.
  • Not provable from here: filesystem or SSD remnants, backups and snapshots, OS page cache, other processes' memory, anything sent to an embedding or LLM provider.

Provenance. ScoredFact.provenance on every recall result: writer, source (record/extract/upsert/legacy), recorded_at, session, source message, derived_from, supersession links, status and checked_at. explain() also reports why a hidden fact is hidden (revoked, content_tombstone, derived_from_revoked) and which tombstones cause it. HTTP adds POST /v1/memory/recall_with_provenance; MCP memory_recall takes include_provenance.

Compatibility. No Rust LTS wire change: existing HTTP routes keep their exact response shapes (asserted in a test); new request fields (actor, derived_from) are optional; new route and MCP arguments are additive. Governance state is in side tables (fact_meta, fact_derivations, revocations, erasures) created on open; 0.2 rows are backfilled with content hashes (tested). RetrievalConfig.exclude_superseded=False no longer surfaces facts revoked via supersede().

Cost. Recall latency on 5,000 facts / 50 tombstones / 200 queries: 5.9 ms on main, 5.7 ms here (the per-id get_fact loop in the retriever became one batched get_facts).

Also fixed

HNSW search returned no vector hits after a vector was deleted and re-added (the live count included stale labels, k exceeded the live set, hnswlib raised and the error was swallowed). Separate commit with its own failing test.

Independent review

An adversarial reviewer (separate agent, read-only) reproduced: unlinked transcript turns and extractor-produced events still carrying revoked text, re-extraction feeding it to the reader, invisible characters outside category Cf escaping the hash, symbol folding that merged opposite claims, reinstate of a copy un-revoking the original, erasure re-opening revoked content and leaving quoting facts and audit text behind, a session-withholding write, and a cross-scope upsert by id. Copilot flagged the erase race, scope of source_message_id, hidden copies crowding the vector pool, and the supersede-on-revoked link. All are fixed in the last commit with tests that fail on the commit before it; the reviewer's repro scripts now hit the guards.

Still open by design and documented: paraphrases; a single distinctive word in an erasure reason; a 0.2 library opening the same file ignores the views; erasure finalization (vector purge, WAL checkpoint) runs after the SQL commit, so a crash in between leaves the record without the vector entry (the index is in-memory, so a crash drops it anyway).

Verification

  • ruff check, ruff format --check, mypy --strict, full pytest (443 passed, 5 skipped for local Ollama) with coverage gate.
  • Mutation check after the review fixes: 65 guards, each selected test set run unmutated first as a control, bytecode caches cleared per run. All 65 killed. An earlier pass: 37 guards (view seeds, scope join, taint recursion, each live view, the post-rerank gate, every normalization step, scope checks, supersede and reinstate rules, each erasure cascade, secure-delete pragmas, WAL reporting, vector purge, backfill). Each selected test set was run unmutated first as a control, then against the mutant. All 37 killed. Three survived on the first pass and got new tests: the id seed (now covered by rewriting a revoked fact's text), the revoked-replacement message, and the WAL-checkpoint report.

Version bumped to 0.3.0; not published.

🤖 Generated with Claude Code

The live count used element_count, which also counts labels left behind
when an id is added again. After delete-then-add, k exceeded the real live
set, hnswlib raised, and search() swallowed it as "no matches". Count live
ids from the uuid map instead, and tolerate mark_deleted on a label that is
already deleted. Reinstating a revoked fact depends on this path.
arXiv 2609.08258 found that agent-memory systems keep a revoked fact
retrievable: it outranks its replacement and drives the unsafe action.
This store had the same gap. supersede() only set superseded_by, which the
retriever filtered behind a config flag, and re-recording the old text
produced a fresh, unmarked fact that ranked first again.

Visibility is now decided by one SQL view, hidden_facts. Every store read
selects from live_facts, live_events or live_messages, so vector hits
(including from a stale in-memory index), keyword search, session ranking,
events, transcript reads, tools, HTTP and MCP inherit the same rule. The
retriever re-checks visibility after reranking, which closes the window
for a revocation that lands mid-query. A structural test fails if a store
read touches the base tables outside the governance methods.

A tombstone hides the fact by id, every same-scope fact whose normalized
text matches (so a write cannot un-revoke by re-inserting the claim), and
facts recorded with derived_from pointing at a hidden fact, which covers
the paper's write-back path. reinstate() is the only way back and is
recorded on the tombstone.

erase() hard-deletes the fact, its copies, derived facts, linked events
and transcript turns in one transaction, turns on SQLite and FTS5 secure
delete, checkpoints the WAL, rebuilds the vector index without the erased
vectors, and leaves a record with ids and counts but no content.

Every recall result now carries provenance (writer, source, time,
derivation, status and when it was checked). Governance state lives in
side tables so 0.2 databases upgrade on open; existing HTTP response
shapes are unchanged.
The README section states where enforcement lives, what a tombstone hides,
the re-insertion rule, and a table of what erase() can and cannot vouch for
per layer, so callers do not read more into "deleted" than the backend
delivers. uv.lock was also stale on main (0.1.0, missing typer).
@sunilp
sunilp requested a balanced review from Copilot October 10, 2026 19:57
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 296b7c0e-3c45-46e0-8467-1c8536e4c895

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Supersession, reinstatement, tenant isolation, erasure concurrency, and vector lifecycle issues remain unresolved.

6 open findings
What changed in this PR

Adds centralized revocation enforcement, hard erasure, provenance reporting, and related vector-index fixes.

Changes:

  • Enforces tombstones across storage and retrieval paths.
  • Adds erasure and provenance APIs, HTTP/MCP support, and documentation.
  • Fixes HNSW delete/re-add behavior and expands governance tests.
File Description
uv.lock Updates package metadata and Typer dependency.
pyproject.toml Bumps version to 0.3.0.
CHANGELOG.md Documents governance features and fixes.
README.md Documents revocation, erasure, and provenance.
src/​engram/​__init__.py Exports governance types.
src/​engram/​engram.py Adds governance facade APIs.
src/​engram/​governance.py Defines normalization and governance models.
src/​engram/​retrieve/​base.py Adds provenance to retrieval results.
src/​engram/​retrieve/​hybrid.py Adds batched lookup and final visibility checks.
src/​engram/​server/​http.py Adds provenance HTTP support.
src/​engram/​server/​mcp.py Adds provenance and derivation options.
src/​engram/​store/​base.py Extends the storage contract.
src/​engram/​store/​schema.sql Adds governance tables and live views.
src/​engram/​store/​sqlite.py Implements revocation, provenance, and erasure.
src/​engram/​vector/​base.py Adds physical vector purging.
src/​engram/​vector/​hnsw.py Fixes re-adds and implements index rebuilding.
tests/​unit/​test_cli.py Updates version assertion.
tests/​unit/​test_engram_init_exports.py Tests governance exports.
tests/​unit/​test_erasure.py Covers hard-erasure behavior.
tests/​unit/​test_provenance.py Covers provenance surfaces.
tests/​unit/​test_revocation.py Covers revocation enforcement.
tests/​unit/​test_revocation_choke_point.py Guards centralized visibility enforcement.
tests/​unit/​test_vector.py Covers vector re-add behavior.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/engram/engram.py
Comment on lines +353 to +356
source_message_id=source_message_id,
)
await self._store.upsert_fact(
fact, writer=actor, source="record", derived_from=derived_from

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0050d70: upsert_fact refuses a source_message_id that is not a message in the fact's scope (NotFoundError). Test: test_source_message_must_belong_to_the_same_scope.

Comment thread src/engram/store/sqlite.py Outdated
Comment on lines +854 to +858
root = await self._governance_fact(fact_id, scope)
if root is None:
raise NotFoundError(f"fact {fact_id} not found in scope")

erased: dict[str, Fact] = {}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0050d70: erase now runs discovery and deletion inside one BEGIN IMMEDIATE transaction (revoke does the same).

Comment thread src/engram/engram.py Outdated
Comment on lines +227 to +230
rev = await self._store.revoke(fact_id, scope, reason=reason, actor=actor)
# Not needed for correctness (the store withholds it), but keeps the
# revoked vector from taking a slot in the candidate pool.
await self._vec.delete(fact_id, scope)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0050d70: revoke and supersede drop the vectors of every fact the tombstone now hides, and record() skips the vector for a fact that is born hidden. Test: test_hidden_copies_do_not_occupy_vector_slots.

Comment thread src/engram/engram.py Outdated
Comment on lines +276 to +279
record = await self._store.erase(fact_id, scope, reason=reason, actor=actor)
removed = await self._vec.purge(record.fact_ids, scope)
return await self._store.finalize_erasure(
record, {"vectors": removed}, {"vector_index_rebuilt": True}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. The SQL side is atomic and the record exists once it commits. Vector purge and the WAL checkpoint run after; the vector index is in-memory only, so a crash in between drops it anyway, and the record's guarantees only report what finalize actually did. A resumable pending state is noted as open in the PR body rather than added here.

Comment thread src/engram/store/sqlite.py Outdated
Comment on lines +719 to +720
if existing is not None:
return self._row_to_revocation(existing)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0050d70: supersede on an already revoked fact attaches the replacement to the existing tombstone and links both facts; a different replacement than the recorded one is refused. Test: test_supersede_of_an_already_revoked_fact_still_links_the_replacement.

Comment thread src/engram/store/sqlite.py Outdated
Comment on lines +885 to +888
norms = {normalize_text(f.text) for f in erased.values()} - {""}
norm_reason = normalize_text(reason)
if any(_contains_phrase(norm_reason, n) for n in norms):
raise ValueError("erasure reason must not quote the content being erased")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened in 0050d70: reason and actor are refused if they contain any three-token run of an erased text, and other tombstone reasons quoting it are scrubbed. A single distinctive word can still pass; that limit is stated in the README, which recommends keeping audit fields to ticket numbers and roles.

An adversarial review of the first commit reproduced paths where revoked
content still reached the agent or survived erasure:

- Transcript turns and extracted events carry the claim without a link to
  the fact, so the link-based view rules missed them, and query-time
  re-extraction fed revoked text straight back to the reader. Tombstones
  now also match items that equal or quote the revoked text (whole-token
  containment for texts of four or more tokens). Matches are computed when
  the revocation or the item is written and stored in content_matches, so
  the view stays an indexed lookup. Re-extraction and extract() drop
  anything a tombstone would withhold.
- Invisible characters outside category Cf (variation selectors, CGJ,
  Hangul filler, braille blank) split words and changed the hash. All
  default-ignorable code points are now removed. In the other direction,
  mapping symbols to spaces made "> 10000" and "< 10000" one claim, which
  hid the correct replacement; symbols, a leading minus and % # @ & are now
  kept, and empty normalized texts get no content tombstone.
- Reinstating a born-hidden copy lifted the original's tombstone, so a
  write was a handle to un-revoke. Reinstate now only works on the revoked
  fact itself.
- Erasing a revoked fact dropped its hash, so the claim could be written
  back. The hash is kept by default (block_reinsertion=False drops it).
  Erasure now also removes facts and events that quote the text, scrubs
  other audit text quoting it, checks the actor as well as the reason
  against three-token runs, and holds a write transaction from discovery
  to commit so a concurrent copy cannot slip in.
- The session-wide transcript rule keyed on any hidden fact, so an
  unauthenticated write of revoked text could withhold a session. It now
  keys on an operator revocation.
- Store upserts could overwrite a row in another scope by id, and
  source_message_id was not scope-checked.
- supersede() on an already revoked fact dropped the replacement link;
  governance calls given a Fact ignored its scope; hidden copies kept
  their vectors and crowded the candidate pool.

Each fix has a test that failed on the previous commit, and every new
guard was mutation-checked against an unmutated control.
@sunilp
sunilp merged commit 5e07811 into main Oct 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants