Engine atlas · edition 0.0.8
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) and 0.0.8 (commit 98842e2) on 9 October 2026. The sample log, certificate and evidence bundle were re-run on 0.0.8; plate XV marks what 0.0.7 and 0.0.8 fixed.
- Source
- attestari/attestari @ 98842e2 (0.0.8)
- Engine
- 24 Python files · 5,396 lines
- Core dependencies
- none; extras add the rest
- Tests
- 522 collected · 520 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.8. 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 folded 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 a projection the backend keeps current, folding in only what changes_since() returns. 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), read from the keyring inside the write's transaction (0.0.8). |
| Forgotten subjects | An episode or fact for a subject with a SubjectForgotten tombstone is refused with ForgottenSubjectError, checked under the write lock, and nothing is written (0.0.8). Someone who comes back needs a new subject_id. |
| Write lock | Extraction runs first, outside any lock. Reading the live facts, deciding dedup and supersession, and writing happen under the store's write lock: an advisory-lock transaction on Postgres, BEGIN IMMEDIATE on SQLite, a lock in memory. The facts commit together or not at all (0.0.8). |
| 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(), still under the lock: a no-op for the in-memory fold; on Postgres it updates only the edge and entity rows the new events touched, in the same transaction (0.0.8). |
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, and folding the log in pieces gives the same result as folding it whole, so a reader folds only what is new. A tombstone drops the subject's lineage from the fold.
Projector.apply() folds new events, in order, into a new projection; build() is apply() from empty (0.0.8). Each backend keeps its projection and folds in only what was appended since its last read (plate V). Embeddings are memoised by fact text, and a forget drops the subject's texts from the memo. The Postgres backend writes the rows each write touched 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.8. 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 | 6b137905 | payload = nJfxSG4olpD8lY… | "Hi, I'm Alice. I live in Toronto and I work at Acme." | 40daa80d | fa65d277 |
| 2 | asserted | e604674c | alice name_is TSuMtnHbeNRr… | Alice | dc0fdb01 | acc4040e |
| 3 | asserted | ffb77f00 | alice lives_in rEaExJWTBUi5… | Toronto | 4f933b99 | dfed128f |
| 4 | asserted | 4e73f4a1 | alice works_at J5/j8SxZYiXl… | Acme | 4387d6b0 | 1d652e4e |
| 5 | episode | 9d518ac6 | payload = N4qfk14lAj/Ix2… | "Update: I moved to Berlin and I now work at Globex." | e5dbc4f2 | 7349fe14 |
| 6 | invalidated | ffb77f00 | fact ffb77f00 · superseded by 211f31a3 · valid_to 2026-01-01 | not sealed | ead82746 | 0707f0f5 |
| 7 | asserted | 211f31a3 | alice lives_in OItD89a1xqAd… | Berlin | 92049265 | 7b8dded9 |
| 8 | invalidated | 4e73f4a1 | fact 4e73f4a1 · superseded by 8650f099 · valid_to 2026-01-01 | not sealed | b4a364e1 | d852e7df |
| 9 | asserted | 8650f099 | alice works_at T6It/+hGgUiD… | Globex | d7364908 | 825b3172 |
| 10 | episode | ec38c6c8 | payload = d99d3Gk1aKTcy2… | "I use Python and I use Rust." | 46fbe793 | 2f44d432 |
| 11 | asserted | c2dd2d01 | alice uses Eb2NUG8k8zNl… | Python | 18126841 | 09e6d6d3 |
| 12 | asserted | cc3a2338 | alice uses tV6MaroMIDM1… | Rust | 6455a1cb | 354bb33d |
| 13 | episode | ea777ea4 | payload = gJOc9/VVe76pyW… | "I'm Ravi. I live in Chennai." | 3e814af3 | 73802481 |
| 14 | asserted | c88a7151 | ravi name_is cgyl/ScSLhD8… | Ravi | 2d457b67 | b2cf49dd |
| 15 | asserted | 2d349794 | ravi lives_in D2xX0n2bCOTs… | Chennai | a707a1c6 | 6bd0f7cd |
| 16 | subject_forgotten | alice | alice · requested by dpo@example.com | not sealed | 9cc951bf | 58d281fa |
After the forget: chain ok=True, deep verify ok=True, 10 entries skipped as sanctioned erasures, head 58d281fa599a8ad5…. 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 | the in-process projection, caught up with what was appended since the last read, 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 | the in-process projection, sorted by valid_from |
get_provenance(fact_id) | source episode, recorded_at, the exact snippet, source_ref | looks the fact and its episode up in the projection and slices the payload by char_span; None once the subject is forgotten (0.0.8) |
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 |
How a read stays current
Each backend keeps its last projection in a ProjectionCache, with the log position it reflects: an audit entry's seq and hash (0.0.8). A read first asks the store for the events appended since that position. The store checks the position against the audit chain, whose hash commits to every entry before it, and returns only the new events, decrypted; the cache folds them into a new projection. If the position is no longer on the chain, after a rolled-back write or a truncate, the cache folds the log again from the start.
- Each process reads the whole log once, on its first read. After that, a read with nothing new costs one indexed query, and at 30k events a SQLite
search()takes 0.5 ms where 0.0.7 took 334 ms. - A projection never changes once returned, so a result you hold stays as it was, and threads read without a lock.
- A forget reaches every process at its next read, before it answers anything. Until then an idle worker still holds the subject's decrypted facts in memory (plate XV).
- A change made directly in the database, such as a key deleted without
forget()or a row edited in place, reaches a process that already holds those events only after a restart. The deep audit reports both.
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. From 0.0.8 a real run holds the store's write lock from counting to the projection update, so a concurrent add() lands before the manifest is counted or is refused after the erasure, and the subject id stays closed afterwards.Deletion certificate
- certificate_id
- baf5d3df-7b75-4492-8513-26d5b8e14c11
- subject_id
- alice
- requested_by
- dpo@example.com
- episodes_deleted
- 3
- facts_deleted
- 7
- manifest_hash
- 22e286ed6af0a75f74ab5e5357c7153b63c207aed76d866104adc37680a3b597
- issued_at
- 2026-10-09T17:59:19.149232+00:00
- algorithm
- hmac-sha256-kek-v1
- signature
- gBW21IBrPsYMHwW+5iwcCB4M5CK2z1OVBGx7NOKkfvU=
- signed payload
- attestari-cert-v1|baf5d3df-7b75-4492-8513-26d5b8e14c11|alice|dpo@example.com|3|7|22e286ed6af0a75f74ab5e5357c7153b63c207aed76d866104adc37680a3b597|1791568759149232
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) | deleted in the forget's own transaction (0.0.8) | deleted in the forget's own transaction (0.0.8) |
| certificate | signed (hmac-sha256-kek-v1); kept in deletion_certificate on Postgres | unsigned (signature = null) |
| the subject id | closed: later writes for it are refused with ForgottenSubjectError (0.0.8) | closed, the same way |
| projections held by running processes | dropped at once by the process that forgets; by any other worker at its next read, before it answers | the same |
| 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; a lock orders its threads (0.0.8) | threads and processes on one machine: RLock, BEGIN IMMEDIATE, file lock | many; each append, and each add()'s decision, writes and table update, is one transaction under pg_advisory_xact_lock, so writes take turns across the log; a lock guards the shared connection |
| Read path | a projection held in the process; each read folds in only what is new (0.0.8) | the same, decoding and decrypting only the events since the last read (0.0.8) | search runs in SQL on the edge table; add(), timeline, provenance and conflicts read the in-process projection, kept current the same way (0.0.8) |
| Latency, measured on 0.0.8 | search p95 0.10 ms at 200 subjects; at 30k events with a KEK, add() 1.5 ms and search() 0.5 ms (median) | at 30k events with a KEK, add() 1.9 ms, search() 0.5 ms, timeline() 0.3 ms (median); 0.0.7 took 349, 334 and 354 ms | search p95 0.69 ms at 500 subjects; at 30k events with a KEK, add() 12 ms (0.0.7: 12.1 s); 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 0.0.8 adds projection_state. 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. From 0.0.8 the data key is read from the keyring inside that transaction on every write, so a key from a rolled-back write is never reused. - In memory: from 0.0.8 a lock orders appends, key changes and reads across threads, and a block of appends rolls back together if it raises.
- 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: from 0.0.8 a write updates only the
edgeandentityrows its events touched, in the same transaction as the events, so the tables and the log commit together and a rolled-back write leaves them as they were.projection_staterecords the audit entry the tables reflect. A missing row, or one whose hash isn't on the chain, means a fullrebuild()(one transaction, TRUNCATE and re-insert), as does attaching to tables a restore left empty. Events from a writer that doesn't update the tables, such as a 0.0.7 worker during an upgrade, are caught up on the next write.
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; 409, storing nothing, for a forgotten subject (0.0.8) |
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, and so does an add for a forgotten subject, before anything is recorded or forwarded (0.0.8) |
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; for a forgotten subject, a refusal that tells the agent not to retry (0.0.8) |
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 0.0.8, governed.add() for a subject the ledger has forgotten raises ForgottenSubjectError before anything reaches the downstream store.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. - Give a person who comes back after an erasure a new
subject_id. From 0.0.8,add()refuses an erased one withForgottenSubjectError, a 409 over REST. - 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: The server reads the whole log once when it starts; after that a read takes well under a millisecond (plate VIII). Leave forget_subject off, the default since 0.0.7, 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.
- Very high write rates on one log. Writes take turns under one log-wide lock, about 12 ms each at 30k events (plate XV).
- 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. From 0.0.8 the subject id stays closed afterwards: writes for it are refused.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, 0.0.8 a table). |
| 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. After a restore without them, the first connection rebuilds them. |
| 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.8",
"generated_at": "2026-10-09T17:59:19.149596+00:00",
"store": "InMemoryEventStore",
"verification": {
"mode": "deep",
"ok": true,
"entries": 16,
"head": "58d281fa599a8ad5\u2026",
"broken_at": null
},
"erasures": [
{
"subject_id": "alice",
"requested_by": "dpo@example.com",
"recorded_at": "2026-10-09T17:59:19.149252+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 | 226 | log | EventStore protocol; LogPosition; ForgottenSubjectError; InMemoryEventStore with list log, audit list, dict keyring, a lock, changes_since() and erased_refs() |
store_sqlite.py | 355 | log | SQLiteEventStore: event, audit_entry, keyring tables; JSON events; atomic BEGIN IMMEDIATE appends that refuse forgotten subjects; changes_since() in one read transaction |
store_postgres.py | 855 | log + read | PostgresEventStore with advisory-locked appends and a locked shared connection; changes_since() with a fact-scope join; PostgresProjectionBackend updating only the rows a write touched, projection_state, rebuild() as fallback, SQL hybrid search, certificate register |
db/schema.sql | 173 | storage | episode, fact_event, deletion_certificate, keyring, audit_entry, entity, edge, projection_state; 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 | 364 | governance | NullCipher, EnvelopeCipher (AES-256-GCM), Keyring, KeyManager reading the keyring on every write, open() for reads, seal() and keyed commitments, certificate signing and verify, generate_kek |
records.py | 41 | shared | DeletionCertificate, Provenance |
projection.py | 229 | read | frozen Edge with valid_at(), Entity, Projection; Projector.apply() folds new events into a new projection, build() is apply() from empty; embeddings memoised, dropped on forget |
backend.py | 122 | read | ProjectionBackend protocol; ProjectionCache folds only what changes_since() returns; InMemoryProjectionBackend, which 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 | 441 | facade | Memory: local(), postgres(), add (deciding under the store's write lock), merge, resolve, search, answer, timeline, provenance, conflicts, forget (with expected_manifest), verify_audit |
server.py | 411 | 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 | 254 | 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 | 394 | surface | Adapter, mem0_adapter, zep_adapter, WrappedMemory, DeletionReceipt, wrap() |
wrap_http.py | 120 | surface | HTTPMemoryClient over stdlib urllib; http_adapter; UpstreamError |
__init__.py | 96 | public exports; __version__ = 0.0.8 |
| 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
522 tests collected in 0.0.8; 520 pass with a local Postgres, and 2 skip unless mem0 or zep is installed. 300 of them compare the incremental fold with the 0.0.7 fold on random logs, and every projection the cache returns in any test is also checked against a full fold. 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 | 0.69 ms at 500 subjects, 200 queries | eval.bench, run 9 Oct 2026; CI gate 200 ms |
| In-memory search p95, 200 subjects | 0.10 ms on 0.0.8 · 0.84 ms on 0.0.6 | eval.bench, runs 9 and 4 Oct 2026 |
| Median per call at 30k events, with a KEK, 0.0.7 → 0.0.8 | SQLite add() 349 → 1.9 ms, search() 334 → 0.5 ms, timeline() 354 → 0.3 ms · in-memory add() 152 → 1.5 ms · Postgres add() 12.1 s → 12 ms, timeline() 344 → 2.1 ms | local run 9 Oct 2026, Postgres in Docker |
| 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, unchanged on 0.0.8; 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 and 0.0.8 on 9 October 2026: eleven items are still open, seven of them stated in the docs; five were fixed in 0.0.8 and five in 0.0.7.
Still open in 0.0.8
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.
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.
Writes take turns across the whole log
Every append, and each add()'s decision, writes and projection update, holds one log-wide lock, so writes from all workers run one at a time. add() also scans every live fact to decide supersession.
Evidence: On 0.0.8, Postgres add() took a median of 12 ms at 30k events with a KEK. One write at a time at that speed is roughly 80 writes a second for one log.
Direction: Decide supersession under a per-subject lock and hold the log-wide lock only for the append, and index live facts by subject and predicate so the decision stops scanning them all. Snapshots would also remove the full fold each new process does on its first read.
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.
A running process keeps decrypted facts in memory
Documented in 0.0.8Each process keeps its projection, decrypted, between requests. The process that runs forget() drops the subject at once; another worker drops them at its next read, before it answers anything. An idle worker holds them in memory until then.
Evidence: By design: ProjectionCache in backend.py. tests/test_projection_cache.py checks that another engine's forget reaches a cached projection.
Direction: Crypto-shred's guarantee covers storage and backups, and the threat model now says so. Recycle idle workers if memory residency matters; a maximum age for a cached projection would bound it.
Changes made outside Attestari reach a warm cache only after a restart
Documented in 0.0.8A cached projection follows the audit chain, not the keyring or the rows' contents. A key deleted without forget(), or an event row edited in place, changes what a fresh fold reads but not what a running process already holds. On Postgres the search tables keep that subject's rows until a full rebuild().
Evidence: ProjectionCache checks its position against the audit chain, which neither change touches.
Direction: Both are attacks or mistakes, and verify --deep reports both. After any manual repair, restart the processes and call rebuild() on Postgres.
Fixed in 0.0.8
A write after forget() made the whole store unreadable
Found and fixed in 0.0.8With a KEK, add() for a subject after forget() minted a new data key for them. Every later read then tried to decrypt the erased content with it and raised InvalidTag, for every subject, until someone deleted the new key by hand. An agent steered into calling add_memory with an erased subject's id would trigger it.
Fix: A forgotten subject's records stay closed. The stores refuse an episode or fact for a subject with a tombstone, checked under the write lock: ForgottenSubjectError in Python, 409 over REST, a refusal from the MCP tool.
Evidence: Reproduced on 0.0.6 and 0.0.7. tests/test_key_cache.py covers all three tiers. A log already in that state recovers once the subject's new keyring row is deleted.
Still true: Without a KEK such writes used to be stored and never returned. They are refused too now, so someone who comes back needs a new subject_id.
A cached data key could outlive its keyring row
Found and fixed in 0.0.8KeyManager cached each subject's key. A write that minted a new subject's key and then rolled back left it cached but unsaved, so that subject's later writes were unreadable for good on SQLite and Postgres. A worker that still held a forgotten subject's key sealed new writes under it, and they were lost.
Fix: The keyring is read on every write, inside the write's transaction; only the unwrap is memoised, keyed by the wrapped bytes.
Evidence: tests/test_key_cache.py, failing on 0.0.7.
Two concurrent add() calls broke single-valued supersession
Fixed in 0.0.8add() read the live projection, then appended. Two writers for one subject both saw the old value and both asserted a new one, so Berlin and Paris were both live for lives_in.
Fix: add() runs extraction first, then reads the live facts, decides and writes under the store's write lock: an advisory-lock transaction on Postgres, BEGIN IMMEDIATE on SQLite, a lock in memory. forget() counts its manifest, appends the tombstone and destroys the key under the same lock. The in-memory store, which had no lock at all, has one now.
Evidence: tests/test_concurrent_add.py fails on 0.0.7 in memory, with three engines on one SQLite file, and on Postgres.
Whole-log reads, and a full rebuild on every write
Fixed in 0.0.8The in-memory and SQLite tiers read, decrypted and folded the whole log on every read and write. On Postgres, add(), timeline, provenance and conflicts did the same, and every write truncated and re-inserted both projection tables.
Fix: Each process keeps its projection and folds in only the events appended since its last read, checked against the audit chain (plate V). Postgres updates only the search-table rows a write touches, in the write's own transaction (plate VIII).
Evidence: Median at 30k events with a KEK, 0.0.7 to 0.0.8: SQLite add() 349 to 1.9 ms and search() 334 to 0.5 ms; Postgres add() 12.1 s to 12 ms. LOCOMO recall is unchanged. tests/test_projection_incremental.py compares the new fold with the old one on 300 random logs, and tests/conftest.py checks every cached projection in the suite against a full fold.
Still true: Each process folds the whole log once, on its first read.
Erased text lingered in two read paths
Found and fixed in 0.0.8Without a KEK, get_provenance() still returned an erased message's snippet after forget(). And the embedding memo, keyed by fact text, kept a forgotten subject's facts for the life of the process.
Fix: get_provenance() reads the projection, so it returns None for a forgotten subject's fact. A forget drops the subject's fact texts from the memo unless another fact has the same text.
Evidence: tests/test_projection_cache.py and tests/test_projection_incremental.py.
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 and 0.0.8 on 9 October 2026. Diagrams are hand-placed SVG. The certificate, log and evidence bundle come from running the 0.0.8 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.