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
Event log and write side Read side, rebuildable Governance: keys, chain, certificates Outside Attestari

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.

CALLERSSURFACESFACADEENGINESTORAGEEventStore (port) · memory · SQLite · Postgrespl. VIIIAppend-only event log · the source of truthEpisodeIngestedFactAssertedFactInvalidatedEntityMergedEntityUnmergedSubjectForgottennever edited · totally ordered by seq · PII sealed per subject when a KEK is setfold rules on plate III · write path on plate IIAudit chainH(prev ‖ payload)one entry per eventdigests, not raw PIIKeyringsubject → wrapped DEKAES-256-GCMKEK stays outside:env var or KMSper-subject DEK seals payload + objectYour agent codePython processLangChain appLCEL chain or agentMCP hostClaude Desktop, IDEsHTTP clientsTS SDK, console, curlComplianceDPO, CI job, auditorApp on Mem0 / Zepkeeps its own storeimportinvoke()stdioHTTP JSONshelldrop-inPython SDKMemory, in-processpl. XILangChain adapterRetriever · ChatHistorypl. IXMCP server3 tools · forget opt-inpl. IXREST + consoleFastAPI · /v1/* · /pl. IXCLIverify · evidencepl. XIIIwrap()governs an existing storepl. XMemory · memory.py · the one facade every surface callsadd · search · answer · timeline · get_provenance · conflicts · merge / resolve_entities · forget(dry_run, expected_manifest) · verify_audit(deep)pl. IIExtractor (port)regex rules · Claude structured outputpl. IIDedup + supersedePredicateRegistry: single vs multipl. IIadd()factsappend(event)ProjectionBackendfold, or SQL tablespl. VEmbedder (port)hash · MiniLM 384-dpl. VProjectionedges · entities · episodespl. IIIHybrid retrievalsemantic + keyword, as_ofpl. Vsearch() · project()apply()changes_since()vectorsAuditReportverify · deeppl. VICertificateHMAC-SHA256pl. VIIverify_audit()forget()chain entriesdelete DEK
Plate I. Callers on top reach six surfaces; all of them call one 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 same KeyManager, 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, EventStore and ProjectionBackend are Python Protocols. 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.

Calleryour codeMemorymemory.pyExtractorregex · ClaudeProjectionBackendbackend.pyEventStore+ audit chain, keyringstore write lockone transaction (0.0.8)add(text, subject_id, …)append(EpisodeIngested · content_hash: sha256, keyed when sealed)payload sealed (DEK)audit entry, seq nproject().live_edges()latest value per (subject, predicate)extract(text, scope)[(s, p, o, confidence, span)]loop · each extracted factdedup: skip a triple that is already live, or repeated in this messagealt · single-valued predicate, live value differsappend(FactInvalidated · superseded, valid_to = new valid_from, superseded_by)append(FactAsserted · source_episode_id, char_span, confidence, scope)object sealed (DEK)audit entry, seq n+1on_write()Postgres: update the rows it touched[fact_id, …]
Plate II. The order matters. The episode is appended before extraction, so a message that yields no facts still leaves a provenance and audit record. Extraction, which can be a slow Claude call, runs before the write lock; reading the live facts, deciding and writing happen under it, so concurrent calls for one subject take turns (0.0.8).

The rules add() applies

RuleWhat the code does
Episode firstEpisodeIngested 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 subjectsAn 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 lockExtraction 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).
DedupA (subject, predicate, object) triple that is already live, or repeated within the same message, is skipped. No event is written.
SupersedeFor 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).
CoexistMulti-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.
Timevalid_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.
Idsuuid4 strings in canonical form, so Postgres UUID columns round-trip byte for byte and audit digests re-derive exactly.
After the loopbackend.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'm
  • lives_in: I live in, I now live in, I moved to, I relocated to
  • works_at: I work at, I work for, I joined, I now work at
  • uses: I use, I switched to, I now use, I prefer

Confidence 1.0, exact character span. Drives the tests, CI and every demo. "I have a dog" yields nothing.

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.

Retries 408, 409, 429, 5xx and dropped connections with exponential backoff (2 s doubling to 60 s, honours retry-after, up to 8 retries). The span is the first text.find(object). default_extractor() picks Claude when ANTHROPIC_API_KEY is set and the SDK is installed; the REST and MCP servers use it, bare Memory() stays deterministic.

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.

EpisodeIngestedraw text + scope + source_refFactAsserted(s, p, o) · valid_from · span · confidenceFactInvalidatedfact_id · reason · valid_toEntityMergedalias_id → canonical_idEntityUnmergedundo a mergeSubjectForgottensubject_id · requested_byepisodes {}kept for provenance snippetsedges {} · one Edge per factfact_id · subject · predicate · objectvalid_from · valid_tovalid timetx_from · tx_tosystem timeconfidence · char_span · source_episode_idsubject_id · alive · embeddingentities {} · alias_of {}one node per subject seen in a factalias → canonical, from mergesforgotten {}subject ids with a tombstonestore by episode_idadd an Edge · alive = (valid_to is None) · embed its textclose it: valid_to, tx_to = recorded_at, alive = Falsealias_of[alias] = canonicaldrop that aliasadd the id · drop their lineageAt a tombstone: drop the subject's episodes and edges and the entity named by that id, and skip anything recorded for them later.apply(base, events) returns a new projection, and folding the log in pieces gives the same result as folding it whole.
Plate III. 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

EventFieldsSealed at rest when a KEK is set
EpisodeIngestedepisode_id, content_hash, payload, scope, ingested_at, source_refpayload
FactAssertedfact_id, subject, predicate, object, source_episode_id, valid_from, valid_to, confidence, char_span, scope, recorded_at, object_hashobject
FactInvalidatedfact_id, reason, valid_to, superseded_by, recorded_atnothing
EntityMergedcanonical_id, alias_id, evidence, recorded_atnothing
EntityUnmergedcanonical_id, alias_id, recorded_atnothing
SubjectForgottensubject_id, requested_by, recorded_atnothing

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.

seqkindrefAt rest in the storePlaintext (needs the DEK)payload_hashentry_hash
1episode6b137905payload = nJfxSG4olpD8lY…"Hi, I'm Alice. I live in Toronto and I work at Acme."40daa80dfa65d277
2assertede604674calice name_is TSuMtnHbeNRr…Alicedc0fdb01acc4040e
3assertedffb77f00alice lives_in rEaExJWTBUi5…Toronto4f933b99dfed128f
4asserted4e73f4a1alice works_at J5/j8SxZYiXl…Acme4387d6b01d652e4e
5episode9d518ac6payload = N4qfk14lAj/Ix2…"Update: I moved to Berlin and I now work at Globex."e5dbc4f27349fe14
6invalidatedffb77f00fact ffb77f00 · superseded by 211f31a3 · valid_to 2026-01-01not sealedead827460707f0f5
7asserted211f31a3alice lives_in OItD89a1xqAd…Berlin920492657b8dded9
8invalidated4e73f4a1fact 4e73f4a1 · superseded by 8650f099 · valid_to 2026-01-01not sealedb4a364e1d852e7df
9asserted8650f099alice works_at T6It/+hGgUiD…Globexd7364908825b3172
10episodeec38c6c8payload = d99d3Gk1aKTcy2…"I use Python and I use Rust."46fbe7932f44d432
11assertedc2dd2d01alice uses Eb2NUG8k8zNl…Python1812684109e6d6d3
12assertedcc3a2338alice uses tV6MaroMIDM1…Rust6455a1cb354bb33d
13episodeea777ea4payload = gJOc9/VVe76pyW…"I'm Ravi. I live in Chennai."3e814af373802481
14assertedc88a7151ravi name_is cgyl/ScSLhD8…Ravi2d457b67b2cf49dd
15asserted2d349794ravi lives_in D2xX0n2bCOTs…Chennaia707a1c66bd0f7cd
16subject_forgottenalicealice · requested by dpo@example.comnot sealed9cc951bf58d281fa

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.

2026-10-04
name_islives_inworks_atusesmulti-valuedAliceTorontoBerlinAcmeGlobexPythonRust2021202220232024202520262027todayrecordedsystem timemsg 1msg 2msg 3as_of 2026-10-04
Plate IV. Message 1 (sent 1 Jun 2021): Toronto, Acme. Message 2 (sent 10 Feb 2026): "I moved to Berlin and now work at Globex" with 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.

AxisFieldsSet byUsed for
Valid timevalid_from, valid_toadd(valid_from=…); closed by a superseding factas_of filtering (Edge.valid_at), timeline(), conflicts()
System timerecorded_at, stored as tx_from and tx_tothe clock at append; tx_to is the invalidation's recorded_attie-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.

search()q · subject_id · as_ofCandidatesas_of given: valid at totherwise: alive edgesthen: one subject_idSemanticcosine(embed(q), edge vector)Keywordshared tokens ÷ query tokensBlendw_sem·sem + w_kw·kwhash embedder 0.4 / 0.6real model 0.7 / 0.3Orderscore, high firsttx_from, oldest firstthen fact_idtop-k SearchResultedge + score + fact_idlimitanswer() returns the top result's objectget_provenance(fact_id) explains itno score floor: low scores still return
Plate V. The weights come from 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

StageIn-memory and SQLite tiersPostgres tier
Candidatesthe in-process projection, caught up with what was appended since the last read, then live edges or the valid-time sliceWHERE alive = TRUE, or valid_from <= as_of AND (valid_to IS NULL OR valid_to > as_of), on the materialised edge table
Semanticdot product of L2-normalised vectors, memoised per fact text1 - (embedding <=> qvec) with the pgvector HNSW index
Keywordshare of query tokens found in the fact textts_rank over subject, predicate and object, query terms OR-ed, squashed with LEAST(kw * 10, 1)
Weights and orderweights_for(); score, then tx_from, then fact_idsame weights imported from retrieve.py; ORDER BY score DESC, tx_from ASC, fact_id

The other read calls

CallReturnsHow
answer(q, …)the object of the top result, or Nonethin wrapper over search(); no relevance floor
timeline(subject_id=…)every fact, live and superseded, oldest firstthe in-process projection, sorted by valid_from
get_provenance(fact_id)source episode, recorded_at, the exact snippet, source_reflooks 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 recordprojection'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.

seq 1episoderef ep-1prev 00000000…payload 9e9a80f5…entry 5e4ad080…committedEpisodeIngested"I live in Toronto."seq 2assertedref f-1prev 5e4ad080…payload d381f8da…entry 91032c3f…committedFactAssertedlives_in Torontoseq 3episoderef ep-2prev 91032c3f…payload 4bd800c9…entry 9d7fece9…committedEpisodeIngested"I moved to Berlin."seq 4invalidatedref f-1prev 9d7fece9…payload 9237c7c2…entry ce9d3ea2…committedFactInvalidatedf-1 · supersededseq 5assertedref f-2prev ce9d3ea2…payload 4f2b18b4…entry 1000ea79…committedFactAssertedlives_in Berlinseq 6subject_forgottenref aliceprev 1000ea79…payload 34be5984…entry 943d1975…committedSubjectForgottenalice · dpo@example.comdigest of
Plate VI. A six-event log for one subject. Each card is an 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.

Checking…

What each digest commits to

Entry kindpayload_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.

Before forget("alice")KEKATTESTARI_KEK · env or KMSDEK · alicekeyring row, wrappedwrapsalice's rowspayload nJfxSG4olp…object rEaExJWTBU…sealsDEK · ravikeyring row, wrappedwrapsravi's rowspayload gJOc9/VVe7…object D2xX0n2bCO…sealsAfter forget("alice")KEKATTESTARI_KEK · env or KMSDEK · alicedeleted by forget()××alice's rows · unreadablepayload nJfxSG4olp…object rEaExJWTBU…DEK · ravikeyring row, wrappedwrapsravi's rowspayload gJOc9/VVe7…object D2xX0n2bCO…seals
Plate VII-a. The ciphertext prefixes are real rows from the run on plate III. After the forget, the arrows into alice's data key and from it to her rows are the only things that change. AES-256-GCM with a fresh 12-byte nonce per value, base64 at rest.
Count lineageepisodes + facts in scopeManifestsha256(sorted ids)Build certificatecounts · manifest · timedry_run?SignHMAC, KEK-derived keyskipped without a KEKAppend tombstoneSubjectForgotten + audit entryReturn previewunsigned, nothing destroyedShred keydelete wrapped DEK · persist cert (PG)Update projectionssubject's rows droppedReturn certificatesigned if a KEK is setnoyes
Plate VII-b. 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

ItemWith a KEK (crypto-shred)Without a KEK (logical delete)
alice's payloads and fact objectsciphertext stays; nothing can open itplaintext stays in the log; dropped from every read
subject, predicate, scope ids, source_ref, timestamps, char_spanstay in plaintextstay in plaintext
content_hash, object_hash and audit payload hashesstay; 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 chainverifies; deep verify skips the erased entriesverifies
SubjectForgotten tombstoneappended, permanentappended, permanent
wrapped data keydeletednone 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)
certificatesigned (hmac-sha256-kek-v1); kept in deletion_certificate on Postgresunsigned (signature = null)
the subject idclosed: later writes for it are refused with ForgottenSubjectError (0.0.8)closed, the same way
projections held by running processesdropped at once by the process that forgets; by any other worker at its next read, before it answersthe same
other subjectsuntoucheduntouched

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()
Storagea Python listone SQLite file, default ~/.attestari/attestari.db, mode 0600Postgres 16 + pgvector, schema shipped in the package
Survives restartnoyes: WAL, synchronous=FULLyes
Writersone process; a lock orders its threads (0.0.8)threads and processes on one machine: RLock, BEGIN IMMEDIATE, file lockmany; 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 patha 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.8search 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 mssearch 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 keptno, returned to the callerno, returned to the calleryes, deletion_certificate table
Default fortests and demosMCP server, REST server and CLI when no DSN is setREST, MCP and CLI when ATTESTARI_DATABASE_URL is set
EVENT LOGGOVERNANCEPROJECTIONSepisodeepisode_id uuidcontent_hash sha256 · k1: HMAC with a KEKpayload ciphertext with a KEKsubject_id · agent_id · session_id · org_idsource_ref · ingested_at · event_seqback up · sealed when a KEK is setfact_eventop: asserted, invalidated,entity_(un)merged, subject_forgottenfact_id · subject · predicateobject sealed · object_hash keyedvalid_from · valid_to · recorded_atsource_episode · char_span · event_seqback up · sealed when a KEK is setaudit_entryseq · kind · refpayload_hash · prev_hashentry_hash · recorded_atseq = event_seq of the log rowback up · digests onlydeletion_certificatecertificate_id · subject_idepisodes_count · facts_countmanifest_hash · issued_atsignature · algorithmback up · this is the proofkeyringsubject_id → wrapped_dek (bytea)own policy: separate backup, rotate, KMSedgefact_id · subject · predicateobject PLAINTEXTvalid_from · valid_totx_from · tx_to · aliveconfidence · char_spansubject_id · source_episodeembedding vector(384)HNSW cosine · trigram GINexclude · rebuildable · plaintextentitycanonical_id · aliases[]entity_type · summaryembedding vector(384)exclude · rebuildableprojection_statelast_seq · last_hash (0.0.8)source_episodeeach write updates the rows it touched, in its own transaction (0.0.8)seals
Plate VIII. The backup policy follows from what each table holds. 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 IMMEDIATE transaction, so a crash cannot leave the chain out of step with the log. Each event row's seq equals 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_seq and 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() merges episode and fact_event back into one order by event_seq.
  • Postgres projections: from 0.0.8 a write updates only the edge and entity rows 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_state records the audit entry the tables reflect. A missing row, or one whose hash isn't on the chain, means a full rebuild() (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.

available partial or indirect not exposed
OperationPython SDKREST APITS clientMCPLangChainCLI
Rememberadd()POST /v1/addadd()add_memoryadd_messages, human turns
Recall, now or as_ofsearch(as_of=)GET /v1/searchsearch({asOf})search_memoryAttestariRetriever
Top answeranswer()answer()
Historytimeline()GET /v1/timelinetimeline()messages: live facts
Provenanceget_provenance()GET /v1/provenance/{id}getProvenance()get_provenancefact_id in metadata
Conflictsconflicts()GET /v1/conflictsconflicts()
Entity mergemerge / resolve_entities
Forgetforget()POST /v1/forget/{id}forget()forget_subject, opt-inclear()
Forget previewforget(dry_run=True)?dry_run=trueforget(id, by, true)first call, opt-in
Verify the chainverify_audit(deep=)GET /v1/audit/verifyverifyAudit(deep)verify --deep
Check one erasureis_forgotten + timelineverify --user ID
Evidence bundlebuild_evidence()evidence --deep
Graph viewGET /v1/graph and /
Govern another storewrap(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 pathParametersReturns
GET /healthz{"status": "ok"}
POST /v1/addJSON: text, subject_id, agent_id, session_id, org_id, valid_from, source_reffact_ids; 422 on a malformed valid_from; 409, storing nothing, for a forgotten subject (0.0.8)
GET /v1/searchq, subject_id, as_of, limit ≥ 1results: score and fact for each
GET /v1/timelinesubject_id or subjectedges, 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_runthe certificate, with signature, algorithm and dry_run
GET /v1/conflictssubject_idgroups with value history and resolution "recency"
GET /v1/audit/verifydeepok, entries, head, broken_at, deep
GET /v1/graphsubject_idnodes 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 setthe 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.

ToolArgumentsReturns
add_memorytext, subject_id, agent_id, source_reffact_ids; for a forgotten subject, a refusal that tells the agent not to retry (0.0.8)
search_memoryquery, subject_id, as_of, limitscore, subject, predicate, object, fact_id; a bad as_of returns an error the agent can correct
get_provenancefact_idsnippet, source_ref, source_episode_id
forget_subjectsubject_id, requested_by = "mcp", confirm_manifest_hashoffered 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

VariableEffect
ATTESTARI_DATABASE_URLPostgres DSN; the server, MCP and CLI use Postgres instead of SQLite
ATTESTARI_SQLITE_PATHwhere the SQLite ledger lives (default ~/.attestari/attestari.db)
ATTESTARI_KEKbase64 root key; turns on sealing, crypto-shred and certificate signing
ATTESTARI_MCP_ALLOW_FORGET1 offers forget_subject on the MCP server; off by default from 0.0.7
ANTHROPIC_API_KEYswitches extraction to Claude in the server and MCP
ATTESTARI_EXTRACTOR_MODELoverrides the extraction model (default claude-opus-4-8)
ATTESTARI_WRAP_UPSTREAMbase URL of a memory service to govern; mounts /v1/wrap/*
ATTESTARI_WRAP_UPSTREAM_TOKENsent upstream as a bearer token
ATTESTARI_WRAP_*_PATHoverride upstream ADD, SEARCH, DELETE, GET_ALL paths; an empty GET_ALL disables read-back
ATTESTARI_PG_PORThost port for the bundled docker compose Postgres
ExtraAdds
postgrespsycopg and pgvector
embeddingssentence-transformers (all-MiniLM-L6-v2, 384-d)
cryptocryptography, for AES-256-GCM
serverFastAPI, uvicorn, mcp (pinned below 2.0)
anthropicthe Anthropic SDK
devpytest, 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.

Your appalready on Mem0 / ZepWrappedMemoryattestari.wrapDownstream storeMem0 · Zep · HTTPAttestari ledgerMemory.local / .postgresforget("u1")adapter.call_delete(u1)returns, or raises → error keptread back: get_all / user.getempty → verified · rows left → not verifiedadd(outcome, agent_id="_attestari_wrap")outside u1's scope, so itsurvives the shredforget(u1): shred + signed certificateDeletionCertificateDeletionReceipt
Plate X. Downstream first, then the local shred: if the downstream delete fails, a live local copy remains to reconcile against. The outcome record uses agent id _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.certificate

Reading a receipt

Downstream deleteRead-backdownstream_verifiedcompleteREST status
raisednot attemptedNoneFalse409
returnedadapter has noneNoneTrue200
returnednothing leftTrueTrue200
returnedrows still thereFalseFalse409
returnedread-back raisedFalseFalse409
dry runnot calledNoneFalse200, 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

Storeaddsearchdeleteread-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 servicePOST /addPOST /searchPOST /deletePOST /get_all (optional)
Anything elseAdapter(add=…)method name or callablemethod name or callableoptional 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.

Userchat, voice, appYour agentloop + prompt builderAttestariSDK, REST or MCPLLMany vendor"I moved to Berlin last month."add(text, subject_id="u_8f3a", source_ref="chat:991")[fact_id]"Any coworking spaces near me?"search("where does the user live", subject_id)u_8f3a lives_in Berlin · score 0.21 · fact 6f3a…system + known facts (with fact ids) + user turnanswer grounded in Berlin"Three spaces in Berlin: …""Delete what you know about me."forget("u_8f3a", requested_by="dsar-4411")signed DeletionCertificaterememberrecallanswererase
Plate XI. The fact ids travel into the prompt, so any answer can be traced back with 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

SituationStart withWhy
One agent on one machineMemory.local() or attestari-mcpdurable, zero infrastructure, all guarantees hold
Several app servers or workersMemory.postgres() behind the REST serverconcurrent appends, indexed search, certificates kept
Claude Desktop, Cursor, other MCP hostsattestari-mcpthree tools, durable by default; forget is opt-in
A LangChain or LangGraph appattestari-langchainretriever with provenance metadata, history with provable clear()
Memory already in Mem0 or Zepwrap() or /v1/wrap/*no migration; deletion becomes evidence
Not PythonREST server + @attestari/clientthe 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 that get_provenance returns. Keep personal data out of it for the same reason.
  • Backdate with valid_from when a message reports a past change ("I moved in March").
  • Threshold search() scores instead of trusting answer(); 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 with ForgottenSubjectError, a 409 over REST.
  • Preview every erasure with dry_run, pass the preview's manifest_hash as expected_manifest so 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.

core reason to use it useful not the point
DomainProvenanceas_ofCrypto-shredAudit chainEvidencewrap()Start with
Health and patient assistantsPostgres + KEK in a KMS
Banking and fintech copilotsPostgres
Insurance claims and underwritingPostgres
HR and recruiting assistantsPostgres
Support bots with customer memorywrap() or Postgres
Tutoring and educationPostgres, or SQLite per learner
Legal and compliance copilotsPostgres
Personal assistants over MCPSQLite via attestari-mcp
Multi-agent internal platformsPostgres + scope conventions
Teams already on Mem0 or Zepwrap()

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.

Set up: Pseudonymous patient key as subject_id; message or document id as source_ref; KEK held in a KMS.

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.

Set up: valid_from on every reported change; deep verify on a schedule; head hash recorded outside.

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).

Set up: source_ref = claim document id; one subject per claimant.

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.

Set up: One subject per candidate; interviewer notes ingested with the candidate's subject_id.

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.

Set up: wrap() if memory already lives in Mem0 or Zep; otherwise Postgres behind the REST server.

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).

Set up: Postgres for a school platform; Memory.local() for a single-learner app.

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.

Set up: subject_id per matter or client; source_ref = document id and page.

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.

Set up: Set ATTESTARI_KEK so forget is cryptographic and certificates are signed.

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.

Set up: Encode tenancy in subject_id, or run one deployment per tenant.

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.

Set up: A durable ledger (Postgres for a shared service); read-back configured so receipts can say verified.

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.

Request inperson → subject_idPreviewforget(dry_run=True)Reviewcounts + manifestEraseforget() → certificateConfirmverify --user IDEvidenceevidence --deepReplycite certificate iduse the opaque id your app assigned; keep names out of subject_idwith wrap(): check receipt.complete and downstream_verifiedexit code 0 means the erasure holds
Plate XIII-a. An erasure request, end to end. Every step is a command or an API call that exists today. From 0.0.7, pass the preview's manifest hash to the erase step as 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.
Schedulercron or CI, dailyattestari verify --deepwalk the chain, re-hash contentExit code0 holds · 1 brokenAlertpage the control ownerExternal recordticket, email, timestampingon 1head hash: compare with the last recorded head at every run
Plate XIII-b. attestari verify exits non-zero on any failure, so it runs as a scheduled control rather than a manual exercise.

Production checklist

AreaDo this
StorageMemory.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).
ServerRun uvicorn under a process manager behind a reverse proxy, with your own authentication in front.
Root keyInject ATTESTARI_KEK from a KMS or secret manager. Never bake it into an image or store it in the database.
Key backupsPick one: exclude keyring from ordinary backups, rotate the KEK and destroy old versions, or use KMS version destruction.
Projection backupsExclude edge and entity; they hold plaintext and rebuild from the log. After a restore without them, the first connection rebuilds them.
ExtractionSet ANTHROPIC_API_KEY, install [embeddings] for real vectors, and declare your multi-valued predicates.
ControlsSchedule 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

ObligationWhat Attestari suppliesWhat it does not
EU AI Act Art. 12, record-keeping for high-risk systemsautomatic, append-only event recording; tamper-evident chain; provenance and as_of reconstruction; records that survive erasurelogs for the rest of the system (inference, oversight, model changes); classification; Annex IV documentation; retention limits
GDPR Art. 17, right to erasurecryptographic erasure of the memory layer's content, a signed certificate, and a re-checkable erasure registerregulatory 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/)LinesLayerWhat it holds
events.py126logScope and the six frozen event dataclasses; utcnow()
store.py226logEventStore protocol; LogPosition; ForgottenSubjectError; InMemoryEventStore with list log, audit list, dict keyring, a lock, changes_since() and erased_refs()
store_sqlite.py355logSQLiteEventStore: event, audit_entry, keyring tables; JSON events; atomic BEGIN IMMEDIATE appends that refuse forgotten subjects; changes_since() in one read transaction
store_postgres.py855log + readPostgresEventStore 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.sql173storageepisode, fact_event, deletion_certificate, keyring, audit_entry, entity, edge, projection_state; HNSW and trigram indexes
initdb.py58storagepython -m attestari.initdb DSN applies the packaged schema
audit.py303governancedigest(), next_entry(), verify_entries(), verify() with erased refs and keyed-commitment checks; GENESIS
crypto.py364governanceNullCipher, 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.py41sharedDeletionCertificate, Provenance
projection.py229readfrozen 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.py122readProjectionBackend protocol; ProjectionCache folds only what changes_since() returns; InMemoryProjectionBackend, which shreds on forget
retrieve.py94readweights_for(), SearchResult, search(): blend plus deterministic order
embed.py99readEmbedder protocol; HashEmbedder (blake2b buckets, 384-d); SentenceTransformerEmbedder; cosine
extract.py203writeExtractor protocol; DeterministicExtractor; AnthropicExtractor with retries; default_extractor()
predicates.py60writeCardinality, PredicateRegistry; unknown predicates are single-valued
resolver.py109writeLexicalEntityResolver: token subset 0.9, Jaccard, Levenshtein, cosine; auto-merge at 0.85, review from 0.5
memory.py441facadeMemory: local(), postgres(), add (deciding under the store's write lock), merge, resolve, search, answer, timeline, provenance, conflicts, forget (with expected_manifest), verify_audit
server.py411surfaceFastAPI app: /healthz, /v1/*, console at /, /v1/wrap/* when an upstream is configured
console.py138surfacesingle-page ReactFlow console: graph, time slider, provenance on click, forget, deep-verify badge
mcp.py254surfaceFastMCP server: three tools, plus a two-step forget_subject when opted in; tool_* functions testable without mcp
cli.py120surfaceattestari verify [--deep] [--user ID]; attestari evidence [--deep] [--out DIR]
evidence.py178governancebuild_evidence(), re-derived from the live ledger; render_markdown() with reproduction commands
wrap.py394surfaceAdapter, mem0_adapter, zep_adapter, WrappedMemory, DeletionReceipt, wrap()
wrap_http.py120surfaceHTTPMemoryClient over stdlib urllib; http_adapter; UpstreamError
__init__.py96public exports; __version__ = 0.0.8
ClientLinesWhat it holds
clients/ts/src/index.ts172AttestariClient: add, search, answer, timeline, getProvenance, forget(dryRun), conflicts, verifyAudit
clients/langchain/…/__init__.py125AttestariRetriever (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

MeasureValueSource
Postgres search p950.69 ms at 500 subjects, 200 querieseval.bench, run 9 Oct 2026; CI gate 200 ms
In-memory search p95, 200 subjects0.10 ms on 0.0.8 · 0.84 ms on 0.0.6eval.bench, runs 9 and 4 Oct 2026
Median per call at 30k events, with a KEK, 0.0.7 → 0.0.8SQLite 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 mslocal run 9 Oct 2026, Postgres in Docker
Postgres add() median, about 600 facts730 ms on 0.0.6 · 367 ms on 0.0.7local Docker Postgres, run 9 Oct 2026
LOCOMO evidence recall@10 proxy0.711 with MiniLM · 0.661 with the hash embedderall 10 conversations, unchanged on 0.0.8; internal signal, not leaderboard-comparable
Built-in eval6 of 6eval.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

privacy

Only payload and object are sealed

Documented in 0.0.7

subject, 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.

behaviour

forget() is scope-based, not mention-based

Documented in 0.0.7

A 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.

behaviour

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.

behaviour

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.

behaviour

Entity merges do not change reads

Documented in 0.0.7

Merges 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.

behaviour

as_of slices valid time only

Documented in 0.0.7

tx_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.

scale

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.

operations

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.

operations

Certificates persist only on Postgres

Documented in 0.0.7

The 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.

privacy

A running process keeps decrypted facts in memory

Documented in 0.0.8

Each 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.

behaviour

Changes made outside Attestari reach a warm cache only after a restart

Documented in 0.0.8

A 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

availability

A write after forget() made the whole store unreadable

Found and fixed in 0.0.8

With 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.

behaviour

A cached data key could outlive its keyring row

Found and fixed in 0.0.8

KeyManager 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.

behaviour

Two concurrent add() calls broke single-valued supersession

Fixed in 0.0.8

add() 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.

scale

Whole-log reads, and a full rebuild on every write

Fixed in 0.0.8

The 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.

privacy

Erased text lingered in two read paths

Found and fixed in 0.0.8

Without 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

security

Retained hashes confirmed guessed values after a shred

Fixed in 0.0.7

Up 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.

concurrency

Postgres broke under concurrent writes

Fixed in 0.0.7

Listed 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.

operations

The MCP server could erase a subject in one call

Fixed in 0.0.7

forget_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.

docs vs code

Retrieval has two channels, not three

Docs corrected in 0.0.7

README 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.

minor

Small drift

Fixed in 0.0.7

add() 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.