Engine atlas · edition 0.0.7
Attestari Atlas
How the auditable memory layer works, plate by plate: what happens on every write, read and erase, which guarantee holds where, how to put it inside an agent, where it fits, and where it is still rough. Drawn from the source at commit 21d24e0 (0.0.6) on 4 October 2026, and revised for 0.0.7 (commit 1daa5f7) on 9 October 2026. The sample log, certificate and evidence bundle were re-run on 0.0.7; plate XV marks what 0.0.7 fixed.
- Source
- attestari/attestari @ 1daa5f7 (0.0.7)
- Engine
- 24 Python files · 4,863 lines
- Core dependencies
- none; extras add the rest
- Tests
- 140 collected · 138 pass with Postgres
- Surveyed
- 4 Oct 2026 · revised 9 Oct
Plate I is the map and links to everything else. Plates II to VIII open up the engine, IX to XI show how to use it as a memory layer, XII and XIII cover where to apply it and how to run it, and XIV and XV are the implementation index and the sharp edges found while surveying.
This edition describes Attestari 0.0.7. If you run a newer version, the README and ARCHITECTURE.md take precedence where they differ.
Plate I
The whole map
Attestari is one engine behind six entry points. Every write becomes an event on an append-only log, every read comes from a projection rebuilt out of that log, and the proofs hang off the same log.
Memory facade. Writes run down the left into the event log. Reads come up the middle from projections the backend rebuilds from events(). Proofs sit on the right: the audit report reads the chain, and forget() deletes the subject's wrapped key from the keyring. Click any box to open its plate.Three ideas carry the whole design
- The log is the database. Six immutable event types are the source of truth. The knowledge graph, the vectors and the keyword index are projections that can be dropped and rebuilt (plate III).
- Every guarantee lives in shared code. All three storage tiers extend the chain with the same
audit.next_entry()and manage keys through the sameKeyManager, so the audit chain and the shred cannot drift between in-memory, SQLite and Postgres (plate VIII). - Ports keep the core dependency-free.
Extractor,Embedder,EventStoreandProjectionBackendare PythonProtocols. The defaults use only the standard library; Claude, sentence-transformers and Postgres plug in behind the same shapes (plate XIV).
Plate II
The write path
One add() call records the raw message as an episode, extracts triples, skips what is already known, closes the old value of a single-valued predicate, and asserts the rest. Every append also extends the audit chain, and seals personal data when a KEK is set.
The rules add() applies
| Rule | What the code does |
|---|---|
| Episode first | EpisodeIngested with content_hash = sha256(text) is appended before extraction runs. With a KEK, the store replaces that hash with a keyed k1: HMAC under the subject's commitment key (0.0.7). |
| Dedup | A (subject, predicate, object) triple that is already live, or repeated within the same message, is skipped. No event is written. |
| Supersede | For a single-valued predicate, a different value closes the live fact with FactInvalidated(reason="superseded", valid_to=<new valid_from>). The old row stays, and superseded_by names the new fact (0.0.7). |
| Coexist | Multi-valued predicates only add: uses, speaks_language, likes, knows, owns, visited, interested_in, member_of. Any predicate not in that set is treated as single-valued. |
| Time | valid_from defaults to now in UTC; strings go through datetime.fromisoformat; naive datetimes are read as UTC. recorded_at is always the moment of the call. |
| Ids | uuid4 strings in canonical form, so Postgres UUID columns round-trip byte for byte and audit digests re-derive exactly. |
| After the loop | backend.on_write(): a no-op for the in-memory fold, a full rebuild of edge and entity on Postgres, in one transaction under the event-log lock (0.0.7). |
The two extractors
DeterministicExtractor · zero dependencies
Four regex rules for first-person sentences, with the object required to start with a capital letter:
name_is: my name is, I am, I'mlives_in: I live in, I now live in, I moved to, I relocated toworks_at: I work at, I work for, I joined, I now work atuses: I use, I switched to, I now use, I prefer
AnthropicExtractor · production path
Claude structured output through messages.parse with a schema of subject, predicate, object and
confidence. First-person statements are attributed to the subject id. Default model claude-opus-4-8,
overridable with ATTESTARI_EXTRACTOR_MODEL.
Plate III
The event log
Six frozen dataclasses are the whole database. The projection is a pure fold over them, so removing a subject from the read model is a rebuild without that subject's lineage.
Projector.build() walks the events once, in order. Embeddings are memoised by fact text, so rebuilding after every write does not re-embed the whole graph. The Postgres backend runs the same fold and writes the result into its edge and entity tables.The six events
| Event | Fields | Sealed at rest when a KEK is set |
|---|---|---|
EpisodeIngested | episode_id, content_hash, payload, scope, ingested_at, source_ref | payload |
FactAsserted | fact_id, subject, predicate, object, source_episode_id, valid_from, valid_to, confidence, char_span, scope, recorded_at, object_hash | object |
FactInvalidated | fact_id, reason, valid_to, superseded_by, recorded_at | nothing |
EntityMerged | canonical_id, alias_id, evidence, recorded_at | nothing |
EntityUnmerged | canonical_id, alias_id, recorded_at | nothing |
SubjectForgotten | subject_id, requested_by, recorded_at | nothing |
Every write carries a Scope: subject_id (the data subject, and the unit of deletion), agent_id, session_id and org_id. Only the subject id is used to filter reads today.
A real log, as stored
Four add() calls (three for alice, one for ravi) and one forget("alice"), run against the in-memory store with a KEK on 0.0.7. The plaintext column is what the subject's data key would reveal; for alice that key was destroyed at seq 16, so those values are struck through.
| seq | kind | ref | At rest in the store | Plaintext (needs the DEK) | payload_hash | entry_hash |
|---|---|---|---|---|---|---|
| 1 | episode | 68c91d74 | payload = xRJega2sSrOcZP… | "Hi, I'm Alice. I live in Toronto and I work at Acme." | 11e072fd | 5962abed |
| 2 | asserted | 41eef9b0 | alice name_is uKJPfCtBZCG3… | Alice | dbf29427 | 485ac0e4 |
| 3 | asserted | 70af2637 | alice lives_in xFLzkXz9523i… | Toronto | 0d4ee926 | 9de6b4b7 |
| 4 | asserted | 045ea05e | alice works_at 8ZD8zZnpwY7v… | Acme | c078eac9 | 31e963e8 |
| 5 | episode | 2a74260d | payload = sa20bcruZDUUc/… | "Update: I moved to Berlin and I now work at Globex." | e8ee0204 | 90221e78 |
| 6 | invalidated | 70af2637 | fact 70af2637 · superseded by 37262574 · valid_to 2026-01-01 | not sealed | c8ab8efa | 415e9b3d |
| 7 | asserted | 37262574 | alice lives_in W+AuqM0McGYU… | Berlin | 9a087977 | b73d60bd |
| 8 | invalidated | 045ea05e | fact 045ea05e · superseded by bcb25335 · valid_to 2026-01-01 | not sealed | d5d855f5 | 74d4502f |
| 9 | asserted | bcb25335 | alice works_at DFtl8zSYWxXI… | Globex | d001f914 | 362fcf04 |
| 10 | episode | 9719fca9 | payload = Pt2Ml4TCnFsLi0… | "I use Python and I use Rust." | 487af63a | a62e04e5 |
| 11 | asserted | 5b4aaa15 | alice uses zS8LqT8/2Q7M… | Python | 9b634aec | 35dcda68 |
| 12 | asserted | 9d8317df | alice uses RmJUlaYLW4Up… | Rust | 8e0f1f45 | ae80608b |
| 13 | episode | 4658f4b2 | payload = mWEm0BdVPCfO6A… | "I'm Ravi. I live in Chennai." | 709828bb | b381f96e |
| 14 | asserted | d9be9ec1 | ravi name_is NBF4/Y+VwWZD… | Ravi | a10fc02e | 2926d424 |
| 15 | asserted | bcb8257c | ravi lives_in SLW3VcDDa072… | Chennai | d31355ab | 5594f3f9 |
| 16 | subject_forgotten | alice | alice · requested by dpo@example.com | not sealed | a4a1d72f | 616733f0 |
After the forget: chain ok=True, deep verify ok=True, 10 entries skipped as sanctioned erasures, head 616733f040a6c407…. Alice's recall returns None; ravi still returns Chennai.
Plate IV
Two kinds of time
Each fact carries a valid-time window, when it was true in the world, and a system time, when Attestari recorded it. Corrections close windows instead of deleting rows, so a search can replay any date.
valid_from=2026-01-01, which closes Toronto and Acme on 1 Jan. Message 3 (20 Apr 2026): Python and Rust, which coexist because uses is multi-valued. Filled bars are the facts search(as_of=…) can return at the cursor.| Axis | Fields | Set by | Used for |
|---|---|---|---|
| Valid time | valid_from, valid_to | add(valid_from=…); closed by a superseding fact | as_of filtering (Edge.valid_at), timeline(), conflicts() |
| System time | recorded_at, stored as tx_from and tx_to | the clock at append; tx_to is the invalidation's recorded_at | tie-breaks in ranking; kept in the edge table |
A query with as_of answers "what was true on that day". It does not answer "what did the system believe on that day": the system-time columns are stored, but no query reads them yet. The 15 Jan 2026 preset shows a day where the two answers differ.
Plate V
The read path
search() filters first and scores second. Only facts valid at the requested instant, for the requested subject, become candidates. Each is scored on two channels, and the weights depend on whether the embedder carries real meaning.
weights_for(embedder): an embedder that does not declare semantic = True is treated as lexical, so retrieval stays keyword-led. Exact ties go to the earliest-established fact, so memory does not reshuffle when equal facts arrive later.A worked example, from a real run
query "where does the user live" tokens: doe · live · the · user · where fact "alice lives_in Berlin" tokens: alice · berlin · in · live keyword 1 shared (live) ÷ 5 query tokens = 0.200 semantic cosine of the two hash vectors = 0.224 score 0.4 × 0.224 + 0.6 × 0.200 = 0.209 (hash-embedder weights) top 5 for alice: 0.209 lives_in Berlin 0.000 name_is Alice 0.000 works_at Globex 0.000 uses Python 0.000 uses Rust
The cheap stemmer drops a trailing s from tokens longer than three letters, which is why lives matches live and does becomes doe. The four facts that scored zero still came back, because there is no score floor.
The same ranking on two engines
| Stage | In-memory and SQLite tiers | Postgres tier |
|---|---|---|
| Candidates | Python fold of the whole log on every search, then live edges or the valid-time slice | WHERE alive = TRUE, or valid_from <= as_of AND (valid_to IS NULL OR valid_to > as_of), on the materialised edge table |
| Semantic | dot product of L2-normalised vectors, memoised per fact text | 1 - (embedding <=> qvec) with the pgvector HNSW index |
| Keyword | share of query tokens found in the fact text | ts_rank over subject, predicate and object, query terms OR-ed, squashed with LEAST(kw * 10, 1) |
| Weights and order | weights_for(); score, then tx_from, then fact_id | same weights imported from retrieve.py; ORDER BY score DESC, tx_from ASC, fact_id |
The other read calls
| Call | Returns | How |
|---|---|---|
answer(q, …) | the object of the top result, or None | thin wrapper over search(); no relevance floor |
timeline(subject_id=…) | every fact, live and superseded, oldest first | folds the log, sorts by valid_from |
get_provenance(fact_id) | source episode, recorded_at, the exact snippet, source_ref | scans events() for the assertion and its episode, slices the payload by char_span |
conflicts(subject_id=…) | single-valued predicates that held two or more values, with history and resolution "recency" | groups edges by (subject, predicate) |
is_forgotten(subject_id) | True when a tombstone is on record | projection's forgotten set |
Plate VI
The audit chain
Every append adds an entry whose hash covers the previous entry and a digest of the event. The chain-only check catches reordering, insertion and deletion. The deep check also re-derives each event's digest and catches silent edits. Both accept a sanctioned crypto-shred and flag a rogue key deletion.
AuditEntry; the arrow carries its entry hash into the next card's prev hash. The verdicts come from the same algorithm as audit.verify_entries and audit.verify.What each digest commits to
| Entry kind | payload_hash = sha256 of these fields joined with | |
|---|---|
episode | "episode", episode_id, content_hash, sha256(source_ref), scope, ingested_at in µs |
asserted | "asserted", fact_id, subject, predicate, object_hash or sha256(object), valid_from µs, valid_to µs, confidence at 6 dp, char_span, source_episode_id, scope, recorded_at µs |
invalidated | "invalidated", fact_id, reason, valid_to µs, superseded_by, recorded_at µs |
entity_merged | "entity_merged", canonical_id, alias_id, sha256(evidence), recorded_at µs |
entity_unmerged | "entity_unmerged", canonical_id, alias_id, recorded_at µs |
subject_forgotten | "subject_forgotten", subject_id, requested_by, recorded_at µs |
From 0.0.7, when a KEK is set and the event has a subject, content_hash and object_hash are keyed commitments: a k1: HMAC under a key derived from the subject's DEK. Shredding the DEK destroys that key, so a retained hash can't confirm a guessed value. Deep verification re-derives them with the keys of the subjects that are still live. Without a KEK both stay plain sha256, and entries written by 0.0.6 or earlier keep theirs.
entry_hash = sha256(prev_hash + payload_hash), and the first prev hash is 64 zeros. Timestamps are committed as epoch microseconds so a Postgres round-trip re-derives byte for byte. Deep verification walks entries against readable events and skips an entry only when its ref is in erased_refs(): the subject's key is gone and a tombstone exists. The default check needs no event content at all, which is what lets it vouch for a shredded log.
A full rewrite of the whole chain is internally consistent. Detecting that requires recording the head hash somewhere the operator cannot rewrite; Attestari prints it, publishing it is a manual step today.
Plate VII
Crypto-shred and forget
Each subject's payloads and fact objects are sealed with that subject's own data key, and one root key wraps every data key. forget() deletes the wrapped data key, so every copy of that ciphertext, in backups and replicas included, goes dark at once, while the audit chain and a signed certificate remain.
Memory.forget(subject_id, requested_by=…, dry_run=False, expected_manifest=None). The manifest hash is sha256 of the subject's episode and fact ids, sorted and newline-joined, so a preview and the real run produce the same manifest. From 0.0.7, passing a preview's manifest hash as expected_manifest adds one check after the certificate is built: if the subject's records no longer match, ManifestChanged is raised and nothing is destroyed. The MCP tool's two calls use it. On Postgres, the key deletion and the certificate commit in one transaction.Deletion certificate
- certificate_id
- 1338c1a4-2237-41a6-aeb6-a5fffbb932a1
- subject_id
- alice
- requested_by
- dpo@example.com
- episodes_deleted
- 3
- facts_deleted
- 7
- manifest_hash
- f0077b722496caed5a0098225f95d1af1532f9f7195966f768b9b735289158bd
- issued_at
- 2026-10-09T11:51:01.941064+00:00
- algorithm
- hmac-sha256-kek-v1
- signature
- h27W8JSOMx9k7teZFRzWG3J/Z7ugQmeB6EpjBKP/cto=
- signed payload
- attestari-cert-v1|1338c1a4-2237-41a6-aeb6-a5fffbb932a1|alice|dpo@example.com|3|7|f0077b722496caed5a0098225f95d1af1532f9f7195966f768b9b735289158bd|1791546661941064
Issued by the run on plate III. verify_certificate(cert, kek) returned True; the same certificate with 40 extra deletions claimed returned False. The dry run before it reported the same 3 episodes, 7 facts and manifest, with signature=None and dry_run=True. The signing key is HMAC(KEK, "attestari/certificate-signing/v1"), domain-separated from content encryption. It is symmetric: whoever holds the KEK can issue and verify.
What survives a forget
| Item | With a KEK (crypto-shred) | Without a KEK (logical delete) |
|---|---|---|
| alice's payloads and fact objects | ciphertext stays; nothing can open it | plaintext stays in the log; dropped from every read |
| subject, predicate, scope ids, source_ref, timestamps, char_span | stay in plaintext | stay in plaintext |
content_hash, object_hash and audit payload hashes | stay; keyed from 0.0.7, so they can't confirm a guess. Entries written by 0.0.6 or earlier still can (plate XV) | stay, unkeyed; the content is plaintext anyway |
| audit chain | verifies; deep verify skips the erased entries | verifies |
SubjectForgotten tombstone | appended, permanent | appended, permanent |
| wrapped data key | deleted | none exists |
edge and entity rows (Postgres) | dropped by the rebuild | dropped by the rebuild |
| certificate | signed (hmac-sha256-kek-v1); kept in deletion_certificate on Postgres | unsigned (signature = null) |
| other subjects | untouched | untouched |
Two stores sit outside the shred and need their own backup policy: the keyring table, because a backup taken before the forget still holds the wrapped key, and the edge and entity projections, because retrieval needs plaintext. Plate VIII shows which tables go where.
Plate VIII
Storage tiers
Three EventStore adapters share one set of guarantees. They differ in durability, concurrency, how reads happen and whether certificates are kept. All three call the same audit.next_entry() and KeyManager, so the chain and the shred behave identically.
Memory() | Memory.local() | Memory.postgres() | |
|---|---|---|---|
| Storage | a Python list | one SQLite file, default ~/.attestari/attestari.db, mode 0600 | Postgres 16 + pgvector, schema shipped in the package |
| Survives restart | no | yes: WAL, synchronous=FULL | yes |
| Writers | one process | threads and processes on one machine: RLock, BEGIN IMMEDIATE, file lock | many; each append, and each projection rebuild, is one transaction under pg_advisory_xact_lock; a lock guards the shared connection (0.0.7) |
| Read path | fold the log on every read | fold the log on every read: JSON decode, decrypt | search runs in SQL on the edge table; add(), timeline, provenance and conflicts still fold the log |
| Search latency, measured | p95 0.84 ms at 200 subjects | p95 6.6 ms at 803 events, 32 ms at 4,003, 98 ms at 12,003; about twice that with a KEK | p95 about 1 ms (documented); CI gate 200 ms |
| Certificates kept | no, returned to the caller | no, returned to the caller | yes, deletion_certificate table |
| Default for | tests and demos | MCP server, REST server and CLI when no DSN is set | REST, MCP and CLI when ATTESTARI_DATABASE_URL is set |
python -m attestari.initdb DSN applies the schema idempotently; the docker-compose file mounts the same file on first boot. Run it again after upgrading: 0.0.7 adds fact_event.object_hash, and until it runs, writes stop with that instruction while reads and verification keep working.How an append stays atomic
- SQLite: the event row, its audit row and a freshly minted data key commit in one
BEGIN IMMEDIATEtransaction, so a crash cannot leave the chain out of step with the log. Each event row'sseqequals its audit entry's seq. - Postgres: one transaction per append takes an advisory lock, reads the last entry hash, inserts the event row with
event_seqand the audit row. From 0.0.7 the event is sealed inside that transaction (ciphertext plus keyed hashes), and a lock keeps threads that share one connection from mixing their transactions.events()mergesepisodeandfact_eventback into one order byevent_seq. - Postgres projections:
rebuild()truncatesentityandedgeand re-inserts every row after each write. From 0.0.7 it runs as one transaction under the same advisory lock as appends, so rebuilds from several workers queue instead of colliding, and a search waits for the new table rather than reading a half-built one. It is still O(n) per write; incremental projection is the next step (plate XV).
Plate IX
Surfaces
Six ways in, each carrying a different slice of the facade. The matrix shows which operations each surface exposes today.
| Operation | Python SDK | REST API | TS client | MCP | LangChain | CLI |
|---|---|---|---|---|---|---|
| Remember | add() | POST /v1/add | add() | add_memory | add_messages, human turns | |
| Recall, now or as_of | search(as_of=) | GET /v1/search | search({asOf}) | search_memory | AttestariRetriever | |
| Top answer | answer() | answer() | ||||
| History | timeline() | GET /v1/timeline | timeline() | messages: live facts | ||
| Provenance | get_provenance() | GET /v1/provenance/{id} | getProvenance() | get_provenance | fact_id in metadata | |
| Conflicts | conflicts() | GET /v1/conflicts | conflicts() | |||
| Entity merge | merge / resolve_entities | |||||
| Forget | forget() | POST /v1/forget/{id} | forget() | forget_subject, opt-in | clear() | |
| Forget preview | forget(dry_run=True) | ?dry_run=true | forget(id, by, true) | first call, opt-in | ||
| Verify the chain | verify_audit(deep=) | GET /v1/audit/verify | verifyAudit(deep) | verify --deep | ||
| Check one erasure | is_forgotten + timeline | verify --user ID | ||||
| Evidence bundle | build_evidence() | evidence --deep | ||||
| Graph view | GET /v1/graph and / | |||||
| Govern another store | wrap(client) | /v1/wrap/* when configured |
REST API
uvicorn attestari.server:app. Durable by default: Postgres when ATTESTARI_DATABASE_URL is set, otherwise the local SQLite file. It ships without authentication; put your own in front.
| Method and path | Parameters | Returns |
|---|---|---|
GET /healthz | {"status": "ok"} | |
POST /v1/add | JSON: text, subject_id, agent_id, session_id, org_id, valid_from, source_ref | fact_ids; 422 on a malformed valid_from |
GET /v1/search | q, subject_id, as_of, limit ≥ 1 | results: score and fact for each |
GET /v1/timeline | subject_id or subject | edges, live and superseded |
GET /v1/provenance/{fact_id} | source_episode_id, recorded_at, snippet, source_ref; 404 if unknown | |
POST /v1/forget/{subject_id} | requested_by, dry_run | the certificate, with signature, algorithm and dry_run |
GET /v1/conflicts | subject_id | groups with value history and resolution "recency" |
GET /v1/audit/verify | deep | ok, entries, head, broken_at, deep |
GET /v1/graph | subject_id | nodes and edges for the console |
GET / | the console: graph, time slider, click-to-trace provenance, forget button, deep-verify badge | |
POST /v1/wrap/add · search · forget/{id} | only when ATTESTARI_WRAP_UPSTREAM is set | the upstream's answer; a partial erasure returns 409 |
MCP server
attestari-mcp over stdio, or uvx --from "attestari[server]" attestari-mcp straight from PyPI. Listed in the MCP Registry as io.github.attestari/attestari.
| Tool | Arguments | Returns |
|---|---|---|
add_memory | text, subject_id, agent_id, source_ref | fact_ids |
search_memory | query, subject_id, as_of, limit | score, subject, predicate, object, fact_id; a bad as_of returns an error the agent can correct |
get_provenance | fact_id | snippet, source_ref, source_episode_id |
forget_subject | subject_id, requested_by = "mcp", confirm_manifest_hash | offered only with ATTESTARI_MCP_ALLOW_FORGET=1, and flagged destructive. Without a hash: a preview (counts, manifest hash) that erases nothing. With the preview's hash: the certificate, or a refusal if the records changed |
Configuration
| Variable | Effect |
|---|---|
ATTESTARI_DATABASE_URL | Postgres DSN; the server, MCP and CLI use Postgres instead of SQLite |
ATTESTARI_SQLITE_PATH | where the SQLite ledger lives (default ~/.attestari/attestari.db) |
ATTESTARI_KEK | base64 root key; turns on sealing, crypto-shred and certificate signing |
ATTESTARI_MCP_ALLOW_FORGET | 1 offers forget_subject on the MCP server; off by default from 0.0.7 |
ANTHROPIC_API_KEY | switches extraction to Claude in the server and MCP |
ATTESTARI_EXTRACTOR_MODEL | overrides the extraction model (default claude-opus-4-8) |
ATTESTARI_WRAP_UPSTREAM | base URL of a memory service to govern; mounts /v1/wrap/* |
ATTESTARI_WRAP_UPSTREAM_TOKEN | sent upstream as a bearer token |
ATTESTARI_WRAP_*_PATH | override upstream ADD, SEARCH, DELETE, GET_ALL paths; an empty GET_ALL disables read-back |
ATTESTARI_PG_PORT | host port for the bundled docker compose Postgres |
| Extra | Adds |
|---|---|
postgres | psycopg and pgvector |
embeddings | sentence-transformers (all-MiniLM-L6-v2, 384-d) |
crypto | cryptography, for AES-256-GCM |
server | FastAPI, uvicorn, mcp (pinned below 2.0) |
anthropic | the Anthropic SDK |
dev | pytest, ruff, httpx |
Plate X
Governing Mem0 and Zep
wrap() leaves retrieval with the store you already run. It records every write in Attestari's chain before passing it on, passes searches straight through, and turns the downstream delete into evidence by reading the subject back.
_attestari_wrap and no subject scope, so the subject's own erasure cannot shred it, and it holds only the subject id and the result.from attestari import Memory
from attestari.wrap import wrap
governed = wrap(mem0_client, ledger=Memory.postgres()) # adapter inferred from the client's module
governed.add("I live in Berlin.", subject_id="u1") # ledger first, then Mem0
governed.search("where do I live", subject_id="u1") # straight to Mem0
receipt = governed.forget("u1")
receipt.complete, receipt.downstream_verified, receipt.downstream_error, receipt.certificateReading a receipt
| Downstream delete | Read-back | downstream_verified | complete | REST status |
|---|---|---|---|---|
| raised | not attempted | None | False | 409 |
| returned | adapter has none | None | True | 200 |
| returned | nothing left | True | True | 200 |
| returned | rows still there | False | False | 409 |
| returned | read-back raised | False | False | 409 |
| dry run | not called | None | False | 200, nothing touched |
None means nobody checked, which is reported as such and never as a pass. An unrecognised non-empty read-back counts as data still present, because guessing "probably empty" is the dangerous direction for a deletion check.
Adapters
| Store | add | search | delete | read-back |
|---|---|---|---|---|
| Mem0 2.x (verified on mem0ai 2.0.18) | add(text, user_id=) | search(q, filters={user_id}) | delete_all(user_id=) | get_all(filters={user_id}) |
| Zep Cloud v3 (verified on zep-cloud 3.28.0) | graph.add(data=, type="text", user_id=) | graph.search(query=, user_id=) | user.delete(user_id) | user.get; NotFoundError means gone |
| Any HTTP service | POST /add | POST /search | POST /delete | POST /get_all (optional) |
| Anything else | Adapter(add=…) | method name or callable | method name or callable | optional callable |
Calls made through attribute passthrough on the wrapped client are not recorded; only add, search and forget are governed. Attestari cannot shred data inside someone else's service, because it does not hold their keys. A wrapped deployment proves that the deletion was requested, that the downstream delete was called and what it returned, that Attestari's own copy is unrecoverable, and that none of that was altered afterwards.
Plate XI
Using it as a memory layer
Every integration runs the same loop: add the user's words after they speak, search before the model answers, put facts and their ids into the prompt, and forget on request.
get_provenance. Only the user's turns are added: the LangChain history deliberately skips assistant messages, because first-person statements are attributed to the subject.Five ways to wire it
1 · In-process Python
One agent process, or a service with its own Postgres.
from attestari import Memory
mem = Memory.local() # Memory.postgres() for a shared service
def remember(user_id, text, msg_id):
mem.add(text, subject_id=user_id, source_ref=f"chat:{msg_id}")
def recall(user_id, query, min_score): # tune min_score on your own data
hits = mem.search(query, subject_id=user_id, limit=5)
return [f"{h.edge.predicate} {h.edge.object} [fact {h.edge.fact_id[:8]}]"
for h in hits if h.score >= min_score]2 · MCP for Claude Desktop and IDE agents
The host spawns the server; memory lands in the SQLite file and survives restarts.
{
"mcpServers": {
"attestari": {
"command": "attestari-mcp",
"env": {
"ATTESTARI_KEK": "<base64 key from your secret store>",
"ANTHROPIC_API_KEY": "<for Claude extraction>"
}
}
}
}Erasure: from 0.0.7 the server offers three tools. To let the agent erase people, add "ATTESTARI_MCP_ALLOW_FORGET": "1" to env; forget_subject then takes two calls, a preview and a confirmation that passes back its manifest hash.
3 · LangChain
A retriever for LCEL chains and a history for RunnableWithMessageHistory.
from attestari_langchain import AttestariRetriever, AttestariChatMessageHistory retriever = AttestariRetriever(mem=mem, subject_id="u_8f3a", k=5) # Documents carry fact_id, valid_from, source_episode_id, confidence, score history = lambda sid: AttestariChatMessageHistory(mem, subject_id=sid) # learns from human turns; .messages = live facts; .clear() = forget()
4 · REST service and the TypeScript client
Any language, several app servers, one ledger.
import { AttestariClient } from "@attestari/client";
const mem = new AttestariClient("http://memory.internal:8000");
await mem.add("I moved to Berlin.", { subjectId: "u_8f3a", sourceRef: "chat:991" });
const hits = await mem.search("where does the user live", { subjectId: "u_8f3a" });
const preview = await mem.forget("u_8f3a", "dsar-4411", true); // dry run
const cert = await mem.forget("u_8f3a", "dsar-4411");5 · Govern the store you have
Keep Mem0 or Zep for retrieval; add the ledger and deletion evidence.
governed = wrap(mem0_client, ledger=Memory.postgres())
# or, for a non-Python stack, put the server in front:
# ATTESTARI_WRAP_UPSTREAM=https://memory.internal uvicorn attestari.server:app
# POST /v1/wrap/add · /v1/wrap/search · /v1/wrap/forget/{id}Choosing a starting point
| Situation | Start with | Why |
|---|---|---|
| One agent on one machine | Memory.local() or attestari-mcp | durable, zero infrastructure, all guarantees hold |
| Several app servers or workers | Memory.postgres() behind the REST server | concurrent appends, indexed search, certificates kept |
| Claude Desktop, Cursor, other MCP hosts | attestari-mcp | three tools, durable by default; forget is opt-in |
| A LangChain or LangGraph app | attestari-langchain | retriever with provenance metadata, history with provable clear() |
| Memory already in Mem0 or Zep | wrap() or /v1/wrap/* | no migration; deletion becomes evidence |
| Not Python | REST server + @attestari/client | the TS client mirrors the Python surface |
Rules that keep it honest in production
- Use an opaque, pseudonymous
subject_id. It stays in plaintext after a shred. - Pass
source_ref(message id, document id) on every add; it is the receipt thatget_provenancereturns. Keep personal data out of it for the same reason. - Backdate with
valid_fromwhen a message reports a past change ("I moved in March"). - Threshold
search()scores instead of trustinganswer(); it returns the top object even for an unrelated question. - Declare multi-valued predicates your extractor produces, with
PredicateRegistry.declare, or Claude's free-form predicates will supersede each other. - Serialise writes per subject. Supersession is a read-then-write without a lock.
- Preview every erasure with
dry_run, pass the preview'smanifest_hashasexpected_manifestso the erasure covers exactly what you reviewed (0.0.7), and keep the certificate the real call returns.
Plate XII
Where it fits
Attestari earns its place where a system has to remember people and also prove what it knew and what it erased. Where neither proof matters, a plain memory layer is simpler.
| Domain | Provenance | as_of | Crypto-shred | Audit chain | Evidence | wrap() | Start with |
|---|---|---|---|---|---|---|---|
| Health and patient assistants | Postgres + KEK in a KMS | ||||||
| Banking and fintech copilots | Postgres | ||||||
| Insurance claims and underwriting | Postgres | ||||||
| HR and recruiting assistants | Postgres | ||||||
| Support bots with customer memory | wrap() or Postgres | ||||||
| Tutoring and education | Postgres, or SQLite per learner | ||||||
| Legal and compliance copilots | Postgres | ||||||
| Personal assistants over MCP | SQLite via attestari-mcp | ||||||
| Multi-agent internal platforms | Postgres + scope conventions | ||||||
| Teams already on Mem0 or Zep | wrap() |
Health and patient assistants
An intake or follow-up assistant remembers symptoms, medications and preferences across visits.
Patients can ask for erasure, and clinicians ask why the assistant believed something. Provenance answers the second; crypto-shred and the certificate answer the first.
Watch: The shred covers the memory layer only, not the record system, application logs or model providers. From 0.0.7 the hashes a shred leaves are keyed; entries written by 0.0.6 or earlier can still confirm short, guessable values (plate XV).
Banking and fintech copilots
A relationship-manager copilot or collections agent remembers circumstances and commitments.
Disputes ask what the assistant knew when it recommended something: as_of plus provenance plus a tamper-evident history.
Watch: Retention law can require keeping some records. Gate forget() behind your retention policy and preview with dry_run.
Insurance claims and underwriting
A claims agent extracts facts from claimant messages and adjuster notes.
Each fact links to its source document, and as_of reconstructs the file on the decision date. Life and health insurance pricing is listed as high-risk in the AI Act (Annex III).
Watch: as_of slices valid time; reconstructing what the system knew on a date needs a system-time query (plate IV).
HR and recruiting assistants
A screening assistant keeps candidate facts across interview rounds.
Rejected candidates ask for erasure, and employment uses are high-risk under the AI Act (Annex III), so record-keeping matters.
Watch: forget() is scope-based. Notes about a candidate stored under an interviewer's subject_id survive the candidate's erasure.
Support bots with customer memory
A support agent remembers a customer's plan, devices and past issues.
Erasure requests arrive in volume. Each gets a certificate, a verify --user check and an evidence bundle for the DPO.
Watch: With wrap(), Attestari proves the downstream delete was called and read back; it cannot shred inside the vendor.
Tutoring and education
A tutor remembers a learner's level, goals and accommodations.
Learners or guardians request deletion, and education uses are high-risk under the AI Act (Annex III).
Watch: Declare multi-valued predicates such as studies or struggles_with, or new values will close old ones.
Legal and compliance copilots
Matter-scoped memory for a legal assistant.
Provenance to the exact source passage, a history nobody can quietly edit, and as_of for what was known at filing.
Watch: Entity merges are recorded but reads do not follow aliases yet (plate XV).
Personal assistants over MCP
A person runs attestari-mcp beside Claude Desktop; memory lives in one local file.
User-owned, durable memory with provable forget and no infrastructure.
Watch: SQLite reads scan the whole log (about 100 ms at 12k events). Leave forget_subject off, the 0.0.7 default, unless the agent is meant to erase people.
Multi-agent internal platforms
Several agents share memory about customers or projects.
Per-fact provenance, agent_id recorded on every episode, one audit chain across agents.
Watch: Reads filter only by subject_id; agent, session and org scopes are recorded but not enforced at query time.
Teams already on Mem0 or Zep
Keep your retrieval stack and add the audit trail and deletion evidence.
No migration: wrap(client) in Python, or ATTESTARI_WRAP_UPSTREAM in front of an HTTP service.
Watch: Searches pass through untouched, so Attestari's provenance and time travel apply to its own ledger, not to the vendor's answers.
Poor fits today
- Chasing retrieval leaderboards. The internal LOCOMO evidence-recall proxy is 0.711 with MiniLM embeddings and 0.661 with the hash embedder; the project does not compete on that axis.
- Multi-hop graph reasoning. Retrieval has a semantic and a keyword channel and no graph traversal.
- High write rates on one Postgres. Every write rebuilds both projection tables.
- Erasing every mention of a person across other people's memories. forget() removes one subject's scope.
- Policies that require physical overwriting of media. Crypto-shred destroys keys, not bytes.
- Query-time tenant isolation by org or agent. Only subject_id filters reads.
Plate XIII
Running it for compliance
Operating Attestari is mostly key policy, backup policy and a scheduled check. The evidence bundle is regenerated from the live ledger by anyone with read access, which is what makes it evidence.
expected_manifest, so the erasure covers exactly what was reviewed.attestari verify exits non-zero on any failure, so it runs as a scheduled control rather than a manual exercise.Production checklist
| Area | Do this |
|---|---|
| Storage | Memory.postgres() for anything concurrent; apply the schema with python -m attestari.initdb "$ATTESTARI_DATABASE_URL", and run it again after each upgrade (0.0.7 adds a column). |
| Server | Run uvicorn under a process manager behind a reverse proxy, with your own authentication in front. |
| Root key | Inject ATTESTARI_KEK from a KMS or secret manager. Never bake it into an image or store it in the database. |
| Key backups | Pick one: exclude keyring from ordinary backups, rotate the KEK and destroy old versions, or use KMS version destruction. |
| Projection backups | Exclude edge and entity; they hold plaintext and rebuild from the log. |
| Extraction | Set ANTHROPIC_API_KEY, install [embeddings] for real vectors, and declare your multi-valued predicates. |
| Controls | Schedule attestari verify --deep; record the head hash outside the system; keep certificates from forget() on tiers that do not store them. |
The evidence bundle, from the plate III run
attestari evidence --deep --out ./evidence writes evidence.json and a readable EVIDENCE.md with the commands to reproduce every claim. This is the JSON for the in-memory run; certificates is null because that tier does not persist them, which the bundle reports rather than implying an empty history.
{
"attestari_version": "0.0.7",
"generated_at": "2026-10-09T11:51:01.941362+00:00",
"store": "InMemoryEventStore",
"verification": {
"mode": "deep",
"ok": true,
"entries": 16,
"head": "616733f040a6c407\u2026",
"broken_at": null
},
"erasures": [
{
"subject_id": "alice",
"requested_by": "dpo@example.com",
"recorded_at": "2026-10-09T11:51:01.941079+00:00",
"erased": true,
"live_facts": 0
}
],
"certificates": null,
"ok": true
}Where the regulations come in
| Obligation | What Attestari supplies | What it does not |
|---|---|---|
| EU AI Act Art. 12, record-keeping for high-risk systems | automatic, append-only event recording; tamper-evident chain; provenance and as_of reconstruction; records that survive erasure | logs for the rest of the system (inference, oversight, model changes); classification; Annex IV documentation; retention limits |
| GDPR Art. 17, right to erasure | cryptographic erasure of the memory layer's content, a signed certificate, and a re-checkable erasure register | regulatory approval: the EDPB has not formally endorsed crypto-shredding, and erasure from backups is an open question |
The auditor pack in the repository (auditor/) carries the DPO one-pager and both mappings. None of it is legal advice, and the position on cryptographic erasure is favourable but unsettled.
Plate XIV
Implementation index
Where each piece lives, how big it is, and how the project measures itself.
| File (src/attestari/) | Lines | Layer | What it holds |
|---|---|---|---|
events.py | 126 | log | Scope and the six frozen event dataclasses; utcnow() |
store.py | 131 | log | EventStore protocol; InMemoryEventStore with list log, audit list, dict keyring; erased_refs() |
store_sqlite.py | 308 | log | SQLiteEventStore: event, audit_entry, keyring tables; JSON events; atomic BEGIN IMMEDIATE appends |
store_postgres.py | 651 | log + read | PostgresEventStore with advisory-locked appends and a locked shared connection; PostgresProjectionBackend with transactional rebuild(), SQL hybrid search, certificate register |
db/schema.sql | 163 | storage | episode, fact_event, deletion_certificate, keyring, audit_entry, entity, edge; HNSW and trigram indexes |
initdb.py | 58 | storage | python -m attestari.initdb DSN applies the packaged schema |
audit.py | 303 | governance | digest(), next_entry(), verify_entries(), verify() with erased refs and keyed-commitment checks; GENESIS |
crypto.py | 312 | governance | NullCipher, EnvelopeCipher (AES-256-GCM), Keyring, KeyManager with seal() and keyed commitments, certificate signing and verify, generate_kek |
records.py | 41 | shared | DeletionCertificate, Provenance |
projection.py | 181 | read | Edge with valid_at(), Entity, Projection, Projector.build with memoised embeddings |
backend.py | 80 | read | ProjectionBackend protocol; InMemoryProjectionBackend folds per read and shreds on forget |
retrieve.py | 94 | read | weights_for(), SearchResult, search(): blend plus deterministic order |
embed.py | 99 | read | Embedder protocol; HashEmbedder (blake2b buckets, 384-d); SentenceTransformerEmbedder; cosine |
extract.py | 203 | write | Extractor protocol; DeterministicExtractor; AnthropicExtractor with retries; default_extractor() |
predicates.py | 60 | write | Cardinality, PredicateRegistry; unknown predicates are single-valued |
resolver.py | 109 | write | LexicalEntityResolver: token subset 0.9, Jaccard, Levenshtein, cosine; auto-merge at 0.85, review from 0.5 |
memory.py | 419 | facade | Memory: local(), postgres(), add, merge, resolve, search, answer, timeline, provenance, conflicts, forget (with expected_manifest), verify_audit |
server.py | 400 | surface | FastAPI app: /healthz, /v1/*, console at /, /v1/wrap/* when an upstream is configured |
console.py | 138 | surface | single-page ReactFlow console: graph, time slider, provenance on click, forget, deep-verify badge |
mcp.py | 245 | surface | FastMCP server: three tools, plus a two-step forget_subject when opted in; tool_* functions testable without mcp |
cli.py | 120 | surface | attestari verify [--deep] [--user ID]; attestari evidence [--deep] [--out DIR] |
evidence.py | 178 | governance | build_evidence(), re-derived from the live ledger; render_markdown() with reproduction commands |
wrap.py | 392 | surface | Adapter, mem0_adapter, zep_adapter, WrappedMemory, DeletionReceipt, wrap() |
wrap_http.py | 120 | surface | HTTPMemoryClient over stdlib urllib; http_adapter; UpstreamError |
__init__.py | 95 | public exports; __version__ = 0.0.7 |
| Client | Lines | What it holds |
|---|---|---|
clients/ts/src/index.ts | 172 | AttestariClient: add, search, answer, timeline, getProvenance, forget(dryRun), conflicts, verifyAudit |
clients/langchain/…/__init__.py | 125 | AttestariRetriever (BaseRetriever) and AttestariChatMessageHistory |
Tests and CI gates
140 tests collected in 0.0.7; 138 pass with a local Postgres, and 2 skip unless mem0 or zep is installed. CI runs a pgvector service and gates every push on: the zero-dependency spike, the full pytest suite on all tiers, the LangChain tests, the built-in eval (6 of 6), a Postgres p95 latency gate under 200 ms at 50 subjects and 200 queries, and the audit-and-forget demo.
Measured numbers
| Measure | Value | Source |
|---|---|---|
| Postgres search p95 | about 1 ms | documented project benchmark; CI gate 200 ms |
| In-memory search p95, 200 subjects | 0.84 ms | eval.bench, run 4 Oct 2026 |
| SQLite search p95, plaintext | 6.6 ms at 803 events · 32 ms at 4,003 · 98 ms at 12,003 | run 4 Oct 2026 |
| SQLite search p95, with a KEK | 11.7 ms at 803 events · 59 ms at 4,003 | run 4 Oct 2026 |
| Postgres add() median, about 600 facts | 730 ms on 0.0.6 · 367 ms on 0.0.7 | local Docker Postgres, run 9 Oct 2026 |
| LOCOMO evidence recall@10 proxy | 0.711 with MiniLM · 0.661 with the hash embedder | all 10 conversations; internal signal, not leaderboard-comparable |
| Built-in eval | 6 of 6 | eval.harness |
Plate XV
Sharp edges
What the code does that a deployer should know before relying on it. Surveyed against 0.0.6 on 4 October 2026 and revised for 0.0.7 on 9 October 2026: ten items are still open, six of them now stated in the docs, and five are fixed.
Still open in 0.0.7
Only payload and object are sealed
Documented in 0.0.7subject, predicate, scope ids, source_ref, timestamps and char_span stay readable in the log after a shred. With Claude extraction, subject can be a third party's name.
Evidence: Read from store.py, store_sqlite.py and store_postgres.py.
Direction: Use opaque subject ids and keep personal data out of source_ref; the README and the GDPR Article 17 page say this from 0.0.7. Sealing these fields too is the code fix.
forget() is scope-based, not mention-based
Documented in 0.0.7A fact about alice extracted from bob's message lives in bob's scope and survives forget("alice").
Evidence: Reproduced: after the forget, timeline() still held alice lives_in Paris with scope bob.
Direction: The erasure docs now say so. A mention index would let an erasure reach facts in other subjects' scopes.
Two concurrent add() calls break single-valued supersession
Documented in 0.0.7add() reads the live projection, then appends. Two writers for one subject both see the old value and both assert a new one. The 0.0.7 Postgres fix makes concurrent writes safe for the log and the projection tables, not for this check.
Evidence: Reproduced with two threads and a 200 ms extractor: Berlin and Paris were both live for lives_in.
Direction: Serialise writes per subject in the caller, as the README's deploy checklist now says. In the engine, re-check the live value inside the append lock.
Unknown predicates are single-valued
Claude produces free-form predicates. has_pet Rex followed by has_pet Mia closed Rex.
Evidence: Reproduced with a fixed extractor.
Direction: Declare multi-valued predicates, have the extractor return cardinality, or default unknown predicates to multi. Score the change on the cached LOCOMO run first.
answer() has no relevance floor
"What is the user's favourite colour" returned "Alice" with score 0.147, because answer() returns the top object whatever its score.
Evidence: Reproduced on the in-memory tier.
Direction: Threshold search() scores in the caller; an optional min_score on answer() would make the safe path the easy one. Hash and semantic scores need different thresholds.
Entity merges do not change reads
Documented in 0.0.7Merges and unmerges are recorded and reversible, but search, timeline and answer never consult alias_of.
Evidence: Reproduced: after merging "the founder" into u1, timeline(subject="the founder") returned nothing.
Direction: The README and ARCHITECTURE now say reads don't follow merges yet. Resolving aliases during the fold is the code fix.
as_of slices valid time only
Documented in 0.0.7tx_from and tx_to are stored and break ties, but there is no "what did it believe at system time T" query, which is the question an audit or dispute usually asks.
Evidence: Read from projection.py and retrieve.py; plate IV shows a day where the answers differ.
Direction: The comparison tables now ask "what was true on date D?", and the docs say system time isn't queryable yet. An as_of_system parameter that filters on recorded_at and the invalidation's recorded_at would add it.
Whole-log reads, and a full rebuild on every write
The in-memory and SQLite tiers fold the entire log on every read and write. On Postgres, search is indexed, but add(), timeline, provenance and conflicts fold the log, and every write rebuilds both projection tables.
Evidence: SQLite search p95 was 98 ms at 12,003 events, about twice that with a KEK. On Postgres, add() took a median of 730 ms at about 600 facts on 0.0.6; batched inserts bring that to 367 ms in 0.0.7, and it still grows with the log.
Direction: Snapshots and incremental projection, both already named in ARCHITECTURE.md. On Postgres this should land before real traffic.
The REST server has no authentication
Anyone who can reach the port can read, write and erase. The README's deploy checklist says to put your own auth in front.
Evidence: Read from server.py.
Direction: An optional bearer token with POST /v1/forget behind it. Until then, keep the server on localhost or a private network.
Certificates persist only on Postgres
Documented in 0.0.7The in-memory and SQLite tiers hand the certificate back once from forget(). The evidence bundle reports certificates: null for them.
Evidence: Read from evidence.py and backend.py.
Direction: Store the returned certificate yourself on those tiers, as the README now says. A SQLite certificate table would close it.
Fixed in 0.0.7
Retained hashes confirmed guessed values after a shred
Fixed in 0.0.7Up to 0.0.6, an encrypted episode was committed as an unkeyed sha256 of its text, and a fact's value as an unkeyed sha256 inside an audit digest whose other fields stay readable. After forget("alice") with a KEK, recomputing that digest for six candidate cities confirmed "Berlin", and the sha256 of a guessed sentence matched the episode's content_hash.
Fix: With a KEK, both are now HMACs (k1:) under a per-subject key derived from the subject's DEK, so shredding the DEK destroys that key too. The same attack on 0.0.7 confirms nothing, and deep verification still passes.
Evidence: Reproduced on 0.0.6 and re-run on 0.0.7 on 9 October. tests/test_keyed_commitments.py runs the attack on the in-memory and SQLite tiers and verifies a log written by 0.0.6.
Still true: Entries written by 0.0.6 or earlier keep their unkeyed hashes, because rewriting them would break the chain. A fact's span still reveals the length of its value.
Postgres broke under concurrent writes
Fixed in 0.0.7Listed from code reading on 4 October and reproduced on 9 October. With uvicorn --workers 4, 44 of 60 add() calls raised although all 60 had been saved, and 39% of searches for an existing fact came back empty. Four threads sharing one engine failed every write, because psycopg doesn't isolate transaction blocks between threads.
Fix: rebuild() runs as one transaction under the event-log lock, a lock guards the shared connection, and a shred commits together with its certificate. The same reproduction raised nothing and returned no empty searches.
Evidence: Two regression tests in tests/test_postgres.py, both failing on 0.0.6.
The MCP server could erase a subject in one call
Fixed in 0.0.7forget_subject erased on a single tool call with no dry run at the server, so a prompt injection could request an irreversible erasure.
Fix: The tool is offered only with ATTESTARI_MCP_ALLOW_FORGET=1, is flagged destructive, and takes two calls: a preview that returns the manifest hash, then a confirmation that passes it back and is refused if the subject's records changed. Memory.forget(expected_manifest=...) does that check.
Evidence: tests/test_mcp.py and tests/test_spike.py.
Retrieval has two channels, not three
Docs corrected in 0.0.7README and ARCHITECTURE described semantic, keyword and graph retrieval. retrieve.py and the Postgres SQL implement semantic and keyword only.
Fix: The docs now describe two channels and say graph expansion isn't implemented.
Small drift
Fixed in 0.0.7add() never filled FactInvalidated.superseded_by, and the README badge said 122 tests.
Fix: add() records the superseding fact's id, and the badge reads 138, the count that passes with Postgres.
Built from the Attestari repository on 4 October 2026 and revised for 0.0.7 on 9 October 2026. Diagrams are hand-placed SVG. The certificate, log and evidence bundle come from running the 0.0.7 engine on 9 October 2026, the measured numbers carry their own run dates, and the chain demo re-runs its verification logic in your browser.