Engine atlas · edition 0.0.7

Attestari Atlas

How the auditable memory layer works, plate by plate: what happens on every write, read and erase, which guarantee holds where, how to put it inside an agent, where it fits, and where it is still rough. Drawn from the source at commit 21d24e0 (0.0.6) on 4 October 2026, and revised for 0.0.7 (commit 1daa5f7) on 9 October 2026. The sample log, certificate and evidence bundle were re-run on 0.0.7; plate XV marks what 0.0.7 fixed.

Source
attestari/attestari @ 1daa5f7 (0.0.7)
Engine
24 Python files · 4,863 lines
Core dependencies
none; extras add the rest
Tests
140 collected · 138 pass with Postgres
Surveyed
4 Oct 2026 · revised 9 Oct
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.7. If you run a newer version, the README and ARCHITECTURE.md take precedence where they differ.

Plate I

The whole map

Attestari is one engine behind six entry points. Every write becomes an event on an append-only log, every read comes from a projection rebuilt out of that log, and the proofs hang off the same log.

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()build()events()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 projections the backend rebuilds from events(). Proofs sit on the right: the audit report reads the chain, and forget() deletes the subject's wrapped key from the keyring. Click any box to open its plate.

Three ideas carry the whole design

  • The log is the database. Six immutable event types are the source of truth. The knowledge graph, the vectors and the keyword index are projections that can be dropped and rebuilt (plate III).
  • Every guarantee lives in shared code. All three storage tiers extend the chain with the same audit.next_entry() and manage keys through the 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, keyringadd(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: rebuild edge/entity[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. Supersession reads the live projection once per call, which is also why two concurrent calls for one subject can race (plate XV).

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).
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(): a no-op for the in-memory fold, a full rebuild of edge and entity on Postgres, in one transaction under the event-log lock (0.0.7).

The two extractors

DeterministicExtractor · zero dependencies

Four regex rules for first-person sentences, with the object required to start with a capital letter:

  • name_is: my name is, I am, I'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, so removing a subject from the read model is a rebuild without that subject's lineage.

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 subject idAfter the loop: drop episodes and edges in a forgotten subject's scope, and the entity named by that id.build() is a pure function of the events, so rebuilding without the subject is the deletion of its read model.
Plate III. Projector.build() walks the events once, in order. Embeddings are memoised by fact text, so rebuilding after every write does not re-embed the whole graph. The Postgres backend runs the same fold and writes the result into its edge and entity tables.

The six events

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.7. The plaintext column is what the subject's data key would reveal; for alice that key was destroyed at seq 16, so those values are struck through.

seqkindrefAt rest in the storePlaintext (needs the DEK)payload_hashentry_hash
1episode68c91d74payload = xRJega2sSrOcZP…"Hi, I'm Alice. I live in Toronto and I work at Acme."11e072fd5962abed
2asserted41eef9b0alice name_is uKJPfCtBZCG3…Alicedbf29427485ac0e4
3asserted70af2637alice lives_in xFLzkXz9523i…Toronto0d4ee9269de6b4b7
4asserted045ea05ealice works_at 8ZD8zZnpwY7v…Acmec078eac931e963e8
5episode2a74260dpayload = sa20bcruZDUUc/…"Update: I moved to Berlin and I now work at Globex."e8ee020490221e78
6invalidated70af2637fact 70af2637 · superseded by 37262574 · valid_to 2026-01-01not sealedc8ab8efa415e9b3d
7asserted37262574alice lives_in W+AuqM0McGYU…Berlin9a087977b73d60bd
8invalidated045ea05efact 045ea05e · superseded by bcb25335 · valid_to 2026-01-01not sealedd5d855f574d4502f
9assertedbcb25335alice works_at DFtl8zSYWxXI…Globexd001f914362fcf04
10episode9719fca9payload = Pt2Ml4TCnFsLi0…"I use Python and I use Rust."487af63aa62e04e5
11asserted5b4aaa15alice uses zS8LqT8/2Q7M…Python9b634aec35dcda68
12asserted9d8317dfalice uses RmJUlaYLW4Up…Rust8e0f1f45ae80608b
13episode4658f4b2payload = mWEm0BdVPCfO6A…"I'm Ravi. I live in Chennai."709828bbb381f96e
14assertedd9be9ec1ravi name_is NBF4/Y+VwWZD…Ravia10fc02e2926d424
15assertedbcb8257cravi lives_in SLW3VcDDa072…Chennaid31355ab5594f3f9
16subject_forgottenalicealice · requested by dpo@example.comnot sealeda4a1d72f616733f0

After the forget: chain ok=True, deep verify ok=True, 10 entries skipped as sanctioned erasures, head 616733f040a6c407…. Alice's recall returns None; ravi still returns Chennai.

Plate IV

Two kinds of time

Each fact carries a valid-time window, when it was true in the world, and a system time, when Attestari recorded it. Corrections close windows instead of deleting rows, so a search can replay any date.

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
CandidatesPython fold of the whole log on every search, 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 firstfolds the log, sorts by valid_from
get_provenance(fact_id)source episode, recorded_at, the exact snippet, source_refscans events() for the assertion and its episode, slices the payload by char_span
conflicts(subject_id=…)single-valued predicates that held two or more values, with history and resolution "recency"groups edges by (subject, predicate)
is_forgotten(subject_id)True when a tombstone is on recordprojection's forgotten set

Plate VI

The audit chain

Every append adds an entry whose hash covers the previous entry and a digest of the event. The chain-only check catches reordering, insertion and deletion. The deep check also re-derives each event's digest and catches silent edits. Both accept a sanctioned crypto-shred and flag a rogue key deletion.

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 xRJega2sSr…object xFLzkXz952…sealsDEK · ravikeyring row, wrappedwrapsravi's rowspayload mWEm0BdVPC…object SLW3VcDDa0…sealsAfter forget("alice")KEKATTESTARI_KEK · env or KMSDEK · alicedeleted by forget()××alice's rows · unreadablepayload xRJega2sSr…object xFLzkXz952…DEK · ravikeyring row, wrappedwrapsravi's rowspayload mWEm0BdVPC…object SLW3VcDDa0…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)Rebuild 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.

Deletion certificate

certificate_id
1338c1a4-2237-41a6-aeb6-a5fffbb932a1
subject_id
alice
requested_by
dpo@example.com
episodes_deleted
3
facts_deleted
7
manifest_hash
f0077b722496caed5a0098225f95d1af1532f9f7195966f768b9b735289158bd
issued_at
2026-10-09T11:51:01.941064+00:00
algorithm
hmac-sha256-kek-v1
signature
h27W8JSOMx9k7teZFRzWG3J/Z7ugQmeB6EpjBKP/cto=
signed payload
attestari-cert-v1|1338c1a4-2237-41a6-aeb6-a5fffbb932a1|alice|dpo@example.com|3|7|f0077b722496caed5a0098225f95d1af1532f9f7195966f768b9b735289158bd|1791546661941064

Issued by the run on plate III. verify_certificate(cert, kek) returned True; the same certificate with 40 extra deletions claimed returned False. The dry run before it reported the same 3 episodes, 7 facts and manifest, with signature=None and dry_run=True. The signing key is HMAC(KEK, "attestari/certificate-signing/v1"), domain-separated from content encryption. It is symmetric: whoever holds the KEK can issue and verify.

What survives a forget

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)dropped by the rebuilddropped by the rebuild
certificatesigned (hmac-sha256-kek-v1); kept in deletion_certificate on Postgresunsigned (signature = null)
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 processthreads and processes on one machine: RLock, BEGIN IMMEDIATE, file lockmany; each append, and each projection rebuild, is one transaction under pg_advisory_xact_lock; a lock guards the shared connection (0.0.7)
Read pathfold the log on every readfold the log on every read: JSON decode, decryptsearch runs in SQL on the edge table; add(), timeline, provenance and conflicts still fold the log
Search latency, measuredp95 0.84 ms at 200 subjectsp95 6.6 ms at 803 events, 32 ms at 4,003, 98 ms at 12,003; about twice that with a KEKp95 about 1 ms (documented); 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 · rebuildablesource_episoderebuild() after every write: one transaction, TRUNCATE then re-insertseals
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 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.
  • 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: rebuild() truncates entity and edge and re-inserts every row after each write. From 0.0.7 it runs as one transaction under the same advisory lock as appends, so rebuilds from several workers queue instead of colliding, and a search waits for the new table rather than reading a half-built one. It is still O(n) per write; incremental projection is the next step (plate XV).

Plate IX

Surfaces

Six ways in, each carrying a different slice of the facade. The matrix shows which operations each surface exposes today.

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

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
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 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.
  • Serialise writes per subject. Supersession is a read-then-write without a lock.
  • 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: SQLite reads scan the whole log (about 100 ms at 12k events). Leave forget_subject off, the 0.0.7 default, unless the agent is meant to erase people.

Multi-agent internal platforms

Several agents share memory about customers or projects.

Per-fact provenance, agent_id recorded on every episode, one audit chain across agents.

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.
  • High write rates on one Postgres. Every write rebuilds both projection tables.
  • Erasing every mention of a person across other people's memories. forget() removes one subject's scope.
  • Policies that require physical overwriting of media. Crypto-shred destroys keys, not bytes.
  • Query-time tenant isolation by org or agent. Only subject_id filters reads.

Plate XIII

Running it for compliance

Operating Attestari is mostly key policy, backup policy and a scheduled check. The evidence bundle is regenerated from the live ledger by anyone with read access, which is what makes it evidence.

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.
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).
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.
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.7",
  "generated_at": "2026-10-09T11:51:01.941362+00:00",
  "store": "InMemoryEventStore",
  "verification": {
    "mode": "deep",
    "ok": true,
    "entries": 16,
    "head": "616733f040a6c407\u2026",
    "broken_at": null
  },
  "erasures": [
    {
      "subject_id": "alice",
      "requested_by": "dpo@example.com",
      "recorded_at": "2026-10-09T11:51:01.941079+00:00",
      "erased": true,
      "live_facts": 0
    }
  ],
  "certificates": null,
  "ok": true
}

Where the regulations come in

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.py131logEventStore protocol; InMemoryEventStore with list log, audit list, dict keyring; erased_refs()
store_sqlite.py308logSQLiteEventStore: event, audit_entry, keyring tables; JSON events; atomic BEGIN IMMEDIATE appends
store_postgres.py651log + readPostgresEventStore with advisory-locked appends and a locked shared connection; PostgresProjectionBackend with transactional rebuild(), SQL hybrid search, certificate register
db/schema.sql163storageepisode, fact_event, deletion_certificate, keyring, audit_entry, entity, edge; 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.py312governanceNullCipher, EnvelopeCipher (AES-256-GCM), Keyring, KeyManager with seal() and keyed commitments, certificate signing and verify, generate_kek
records.py41sharedDeletionCertificate, Provenance
projection.py181readEdge with valid_at(), Entity, Projection, Projector.build with memoised embeddings
backend.py80readProjectionBackend protocol; InMemoryProjectionBackend folds per read and 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.py419facadeMemory: local(), postgres(), add, merge, resolve, search, answer, timeline, provenance, conflicts, forget (with expected_manifest), verify_audit
server.py400surfaceFastAPI 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.py245surfaceFastMCP 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.py392surfaceAdapter, mem0_adapter, zep_adapter, WrappedMemory, DeletionReceipt, wrap()
wrap_http.py120surfaceHTTPMemoryClient over stdlib urllib; http_adapter; UpstreamError
__init__.py95public exports; __version__ = 0.0.7
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

140 tests collected in 0.0.7; 138 pass with a local Postgres, and 2 skip unless mem0 or zep is installed. CI runs a pgvector service and gates every push on: the zero-dependency spike, the full pytest suite on all tiers, the LangChain tests, the built-in eval (6 of 6), a Postgres p95 latency gate under 200 ms at 50 subjects and 200 queries, and the audit-and-forget demo.

Measured numbers

MeasureValueSource
Postgres search p95about 1 msdocumented project benchmark; CI gate 200 ms
In-memory search p95, 200 subjects0.84 mseval.bench, run 4 Oct 2026
SQLite search p95, plaintext6.6 ms at 803 events · 32 ms at 4,003 · 98 ms at 12,003run 4 Oct 2026
SQLite search p95, with a KEK11.7 ms at 803 events · 59 ms at 4,003run 4 Oct 2026
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; 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 on 9 October 2026: ten items are still open, six of them now stated in the docs, and five are fixed.

Still open in 0.0.7

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

Two concurrent add() calls break single-valued supersession

Documented in 0.0.7

add() reads the live projection, then appends. Two writers for one subject both see the old value and both assert a new one. The 0.0.7 Postgres fix makes concurrent writes safe for the log and the projection tables, not for this check.

Evidence: Reproduced with two threads and a 200 ms extractor: Berlin and Paris were both live for lives_in.

Direction: Serialise writes per subject in the caller, as the README's deploy checklist now says. In the engine, re-check the live value inside the append lock.

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

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

The in-memory and SQLite tiers fold the entire log on every read and write. On Postgres, search is indexed, but add(), timeline, provenance and conflicts fold the log, and every write rebuilds both projection tables.

Evidence: SQLite search p95 was 98 ms at 12,003 events, about twice that with a KEK. On Postgres, add() took a median of 730 ms at about 600 facts on 0.0.6; batched inserts bring that to 367 ms in 0.0.7, and it still grows with the log.

Direction: Snapshots and incremental projection, both already named in ARCHITECTURE.md. On Postgres this should land before real traffic.

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.

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 on 9 October 2026. Diagrams are hand-placed SVG. The certificate, log and evidence bundle come from running the 0.0.7 engine on 9 October 2026, the measured numbers carry their own run dates, and the chain demo re-runs its verification logic in your browser.