Back to Browse

Attestor MCP Server

by Bolnet
Developer ToolsUse Caution4.2MCP RegistryLocal
Free

Server data from the Official MCP Registry

Self-hosted memory for agent teams. Bi-temporal replay, deterministic retrieval, audit log.

About

Self-hosted memory for agent teams. Bi-temporal replay, deterministic retrieval, audit log.

Security Report

4.2
Use Caution4.2High Risk

Attestor is a well-architected memory backend for multi-agent systems with strong security controls at the database layer (Postgres RLS, audit trails, GDPR compliance). However, several moderate-risk patterns warrant attention: the server eagerly imports all database backends (Postgres/Neo4j) at install time despite supporting optional backends, LLM calls in the extraction/conflict resolution path lack explicit input validation for prompt injection, and embeddings auto-detection could bypass security controls if local Ollama is unexpectedly present. The codebase is well-structured and permissions align with purpose, but these issues prevent a higher score. Supply chain analysis found 10 known vulnerabilities in dependencies (0 critical, 5 high severity). Package verification found 1 issue.

3 files analyzed · 18 issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

File System Read

Reads files on your machine. Normal for tools that analyze or process local data.

File System Write

Writes or modifies files on your machine. Check that this is expected for the tool.

HTTP Network Access

Connects to external APIs or services over the internet.

env_vars

Check that this permission is expected for this type of plugin.

database

Check that this permission is expected for this type of plugin.

process_spawn

Check that this permission is expected for this type of plugin.

What You'll Need

Set these up before or after installing:

Directory containing attestor's config.toml. Defaults to ~/.attestor.Optional

Environment variable: ATTESTOR_PATH

Set to 1 to skip the local Ollama embedder probe (e.g., on hosts without Ollama). Defaults to unset (Ollama is the default embedder).Optional

Environment variable: ATTESTOR_DISABLE_LOCAL_EMBED

OpenAI API key (used for the OpenAI text-embedding-3-large fallback embedder when Ollama is unavailable).Required

Environment variable: OPENAI_API_KEY

OpenRouter API key (used for the federated LLM extraction / consolidation paths and for the LongMemEval benchmark runner).Required

Environment variable: OPENROUTER_API_KEY

How to Install

Add this to your MCP configuration file:

{
  "mcpServers": {
    "io-github-bolnet-attestor": {
      "env": {
        "ATTESTOR_PATH": "your-attestor-path-here",
        "OPENAI_API_KEY": "your-openai-api-key-here",
        "OPENROUTER_API_KEY": "your-openrouter-api-key-here",
        "ATTESTOR_DISABLE_LOCAL_EMBED": "your-attestor-disable-local-embed-here"
      },
      "args": [
        "attestor"
      ],
      "command": "uvx"
    }
  }
}

Documentation

View on GitHub

From the project's GitHub README.

Attestor

Governed memory for multi-agent production meshes.

Attestor is no longer "memory for your coding assistant." Three things happened: the editor vendors shipped memory for free, the benchmark race became a race about which LLM answers the question, and every serious conversation about a memory layer was about something else entirely: when many agents share one memory, who is allowed to write, who is allowed to forget, and can you prove it afterwards. So that is what Attestor is now.

When many agents share one memory, recall is the easy part. The hard part is who may write, who may forget, what an answer was built from, and whether you can prove all of it later. Attestor is a self-hosted memory service that makes those properties enforceable in code rather than in policy documents: role-based access at the AgentContext layer, provenance on every memory (optionally Ed25519-signed), per-agent token budgets and write quotas, a deterministic ranking path with no LLM in it, temporal supersession you can query as of any past moment, hard tenant isolation across all three storage roles, an auditable, audit-first forget-user path with declarative retention, and governance jobs that run as durable Temporal workflows: retried by policy, resumed after a crash, visible in an operator UI, and never on the read path. It runs as a Python library, a REST sidecar, or an MCP server with the same API. Role enforcement is an AgentContext guarantee and runs in process. The REST and MCP surfaces call the store directly and rely on their own auth.

ctx = AgentContext(agent_id="reviewer-1", role=AgentRole.REVIEWER, namespace="acme-prod")
ctx.add_memory("...")                              # PermissionError: REVIEWER lacks 'write'
mem.recall("...", context=ctx, as_of=last_friday)  # what the mesh believed then — nothing is deleted

PyPI PyPI Downloads GitHub Stars Build Evals License: MIT

pip install attestor
Version4.1.11 (stable; greenfield rebuild — no v3 migration path)
PyPIattestor
Importattestor
Live sitehttps://attestor.dev/
Repohttps://github.com/bolnet/attestor
LicenseMIT

Designed and built by Surendra Singh — building auditable infrastructure for multi-agent AI, with fifteen years of production-systems discipline brought to the memory layer. Companion projects: claude-finance (Claude-powered financial analytics) · private-equity (PE × AI workshop). Reach out if you're hiring senior IC for AI infrastructure.


What it is

Attestor is the memory layer for an agent mesh that has to answer to someone — a security review, a regulator, a customer, or the engineer paged at 3 a.m. Every memory is stored once in Postgres (the source of truth) and derived into Pinecone (vectors) and Neo4j (graph). Every read and write goes through an AgentContext that carries identity, role, namespace, and budget, and the store keeps enough history that any past state can be reconstructed.

What it enforces

Each guarantee below is a property of the code, not a roadmap item. File references point at the enforcing implementation.

GuaranteeWhat the code doesWhere
Role-based access, six rolesORCHESTRATOR = read + write + forget; PLANNER / EXECUTOR / RESEARCHER = read + write; REVIEWER / MONITOR = read only. read_only=True is an independent kill switch that strips write + forget regardless of role. Violations raise PermissionError before any backend is touched.attestor/context.py — ROLE_PERMISSIONS, _require_permission
Provenance on every memoryEvery write carries agent_id, session_id, namespace, and role; child contexts accumulate an agent_trail, so "which agent said this, on whose behalf" is answerable. source_episode_id is set by the episode consolidation path, not by every write. Supersession never deletes. Rows leave only through an explicit forget_user() (audited first), a retention delete policy (audited after), or compact() (not audited). Optional Ed25519 signing of id ‖ agent_id ‖ t_created ‖ content_hash, verified with verify_memory().attestor/context.py, attestor/identity/signing.py
Per-agent budgets and write quotastoken_budget bounds what recall() packs for one AgentContext; it never limits writes. max_writes_per_agent is an in-memory counter on the context and resets with a new context. set_quota() sets a per-user daily write limit enforced on add(); it is per user, not per agent.attestor/context.py, attestor/core/quota_service.py, attestor/core/agent_memory.py
Deterministic read pathRanking is vector → BM25 → RRF → graph → MMR → budget fit, with no LLM in scoring by default. Same query, same order, unit-testable and replayable. Query-expansion lanes (HyDE, multi-query) are YAML flags that add candidates; they never touch scoring. An optional reranker (bge, cohere, or llm provider) can re-score candidates; it is off by default.attestor/retrieval/orchestrator/core.py, attestor/retrieval/scorer.py, attestor/retrieval/reranker.py
Temporal supersession, as_of replayContradictions supersede older facts; supersession never deletes. Every fact carries valid time (valid_from, valid_until) and transaction time (t_created, t_expired). recall(as_of=...) filters candidates on transaction time at document hydration and in the BM25 lane; the Pinecone lane ignores as_of and logs a warning.attestor/temporal/manager.py, attestor/store/schema.sql, attestor/store/pinecone_backend.py
Hard tenant isolation, all three rolesTenant is the user. Postgres: row-level security keyed to attestor.current_user_id, with namespace stamped into metadata. Pinecone: one index, partitioned by namespace= on every call. Neo4j: namespace property with a composite (key, namespace) constraint and namespace-scoped traversal; the PageRank projection is global and is a ranking signal only.attestor/store/schema.sql, attestor/store/pinecone_backend.py, attestor/store/neo4j_backend.py
Auditable forget-user and retentionforget_user() writes an RLS-exempt, append-only by convention forget_audit row before deleting, then walks document → state → vector → graph and reports per-backend counts so partial success is visible, never silent. Declarative retention policies (archive / delete, filtered by namespace, category, layer, tags, age) apply first and write a forget_audit row after; only forget_user() is audit-first. Full export for subject-access requests.attestor/compliance/retention.py, attestor/core/agent_memory.py — export_user, purge_user, deletion_audit_log
Durable governance jobsConsolidation, derived-state repair (attestor durable rebuild), the audit-first forget-user saga, and retention / session sweeps run as Temporal workflows with explicit retry policies and an operator UI. The forget saga writes the audit row, deletes document rows, then runs vector, graph, and state concurrently; it retries each backend independently and reports partial state as a failure. Opt-in extra (attestor[durable]); the read path and the Claude Code hooks never touch it.attestor/durable/, docs/INSTALL.md (Ch. 04)

How to evaluate it

The nearest neighbours are the temporal-knowledge-graph memory layers (Zep, Graphiti). For a production mesh the useful comparison is not benchmark accuracy — it is whether the memory layer itself enforces roles, budgets, tenancy, provenance, and auditable deletion, or leaves each of those to the calling application. The table above is Attestor's answer; hold any alternative to the same checklist, with file references.

Designed for

  • Multi-agent products where many LLM-driven agents with different trust levels write to the same memory
  • Regulated systems that need point-in-time reconstruction and a defensible deletion story
  • Self-hosted deployments — your VPC, your Postgres, your Pinecone, your Neo4j

Not designed for

  • A personal memory for a single coding assistant — the Claude Code plugin below is an on-ramp demo of the same engine, not the product
  • A general-purpose vector database or a RAG framework with chunking / reranking / orchestration
  • An LLM agent runtime — Attestor is the memory backend; the agent loop is yours

Try it locally in one command. pipx install attestor && attestor quickstart brings up Postgres + Pinecone Local + Neo4j in Docker and wires the Claude Code MCP server + hooks so you can watch the governance layer work on a real project. Details in Quick start and Install for Claude Code below.


Quick start

1. Install

pip install attestor                 # or: pipx install attestor

Or pull the container (introspection-grade image, single layer over python:3.12-slim, currently linux/amd64):

docker pull ghcr.io/bolnet/attestor:latest      # recommended — anonymous pull, mirrored to all registries below

Same image is mirrored to:

RegistryPull address
GHCRghcr.io/bolnet/attestor:latest
Docker Hubbolnet2025/attestor:latest
Quayquay.io/bolnet/attestor:latest
AWS ECR Publicpublic.ecr.aws/m6h5j7o3/attestor:latest
GCP ARus-central1-docker.pkg.dev/coral-marker-452616-n4/attestor/attestor:latest

(An internal Azure ACR mirror exists at memwright.azurecr.io/attestor but is private — Azure customers should use az acr import from one of the public registries above.)

The image's default entrypoint is attestor mcp (MCP server over stdio). For full production use, point the container at an external Postgres + Neo4j via env vars (or compose them with attestor/infra/local/docker-compose.yml); override the entrypoint to run attestor doctor, attestor api, etc.

2. Stand up the local stack — one command, zero questions

attestor quickstart

attestor quickstart does the whole local install non-interactively and prints every step: it writes ~/.attestor/{config.toml,attestor.yaml,.env}, brings up the three-role local stack in Docker, uses a local Ollama bge-m3 embedder (no cloud key), wires the Claude Code MCP server (./.mcp.json) + lifecycle hooks, and runs attestor doctor.

Prerequisites: Docker running, and Ollama serving bge-m3 (ollama pull bge-m3). quickstart runs a preflight that scans the ports/tools and tells you if anything is missing — it never prompts.

ContainerRolePortPurpose
Postgres 16Document5432Source of truth — content, tags, entity, ts, provenance, RLS-isolated by user_id
Pinecone LocalVector5080-5089Dense embeddings, per-namespace isolation, plain gRPC (no HTTPS)
Neo4j 5 + GDSGraph7687Entity nodes + typed edges, PageRank / BFS / Leiden

To reverse it later: attestor teardown (zero-question; keeps your data volumes by default — --purge also wipes them, --dry-run previews).

In Claude Code, drive the same install conversationally: /plugin marketplace add bolnet/attestor → /plugin install attestor (then enable it), and run /attestor:install-attestor — it runs attestor quickstart for you. Cloud/managed backends (Neon / RDS / Cloud SQL, Pinecone Cloud, Neo4j AuraDB) and alternative embedders (Pinecone Inference llama-text-embed-v2, Voyage voyage-4, OpenAI text-embedding-3) are configured in ~/.attestor/attestor.yaml (the single source of truth) — see docs/INSTALL.md.

attestor doctor (run automatically at the end, or any time) checks all four subsystems: Document Store (Postgres), Vector Store (Pinecone), Graph Store (Neo4j), Retrieval Pipeline. The only hard dependency that cannot be down is the document store (Postgres); transient vector-probe failures are surfaced in the response trace rather than swallowed (retrieval/orchestrator/debug.py — vector_error field).

3. Use it

from attestor import AgentMemory, AgentContext, AgentRole

mem = AgentMemory()                  # picks up env / ~/.attestor.toml automatically

ctx = AgentContext(
    agent_id="researcher-1",
    role=AgentRole.RESEARCHER,
    namespace="acme-prod",
)

mem.add(
    content="Alice is the engineering manager",
    entity="alice",
    category="role",
    context=ctx,
)

results = mem.recall(query="who runs engineering?", context=ctx)
for r in results:
    print(r.score, r.memory.content)

SOLO mode (zero-config). In v4, AgentMemory().add('foo') auto-provisions a singleton local user, an Inbox project (metadata.is_inbox=true), and a daily session — so the snippet above works on a fresh database without configuring identity (core.py:179-209). For multi-tenant production use, pass an explicit AgentContext with a real namespace.

4. Run a smoke benchmark (optional)

Verify your install end-to-end against a tiny LongMemEval slice. Defaults come from configs/attestor.yaml: Pinecone Inference llama-text-embed-v2 (1024-D) embedder + Pinecone vector store, openai/gpt-5.5 answerer, dual judges (openai/gpt-5.5 + anthropic/claude-sonnet-4-6), parallel=2.

set -a && source .env && set +a   # OPENROUTER_API_KEY, PINECONE_API_KEY, NEO4J_PASSWORD
.venv/bin/python scripts/lme_smoke_local.py --n 2 --yes

Every model and parameter comes from YAML — see § Benchmarking below for the full bench harness.


Benchmarking

Every benchmark — smoke, single slice, full sweep, synthetic supersession — reads its knobs from two YAMLs:

FileWhat lives there
configs/attestor.yamlStack — embedder, models, retrieval features, DBs, registries, clouds
configs/bench.yamlBench-only — variants, category iteration order, target scores, output paths

The two files must have disjoint keys. The CI test tests/test_config_no_duplicate_keys.py enforces this; the bench loader (attestor.bench_config.get_bench) crashes on overlap. If you want a one-off override (different model for one bench run), use an env var or CLI flag — never duplicate the key in bench.yaml.

What LongMemEval is

LongMemEval (Wu et al., 2024 — published at ICLR '25) is the canonical benchmark for memory-augmented chat assistants. It measures whether an AI system can correctly answer questions that require recalling facts from long, multi-session conversation histories — the exact scenario Attestor is built for.

500 questions, 6 reasoning categories, 3 haystack sizes. Same questions across all three sizes; only the noise around the answer-bearing session changes:

VariantTokens / QSessionsWhat it measures
oracle~3-15k1-3 goldReasoning ceiling — what the answerer can do with perfect retrieval. If you score low here, your prompt or LLM is broken (retrieval can't help).
s (Standard / Small)~115k~50Public leaderboard — the canonical comparison. Fits in a single Claude/GPT context window, so Attestor's retrieval is benchmarked against the "just stuff everything into long context" baseline.
m (Plus / Medium)~1M+~500Pure retrieval — too big for any context window. Memory layer is forced; no long-context shortcut available.

LME-S is the headline number to beat. A memory layer that scores within 5% of a long-context baseline at 30× lower token cost is the marketing pitch.

The 6 reasoning categories (cleaned LME-S, 500 questions total — note: no abstention slice in the cleaned split, which the synthetic supersession suite covers):

CategoryNWhat it tests
multi-session133Fact spans across multiple sessions — must track an entity over time
temporal-reasoning133Date arithmetic ("two weeks ago", "before X") — Attestor's bi-temporal layer is built for this slice
knowledge-update78Supersession — newer fact must beat older fact when both exist
single-session-user70One session, fact stated by the user
single-session-assistant56One session, fact stated by the assistant
single-session-preference30One session, user preference

Why this benchmark for Attestor: the temporal-reasoning and knowledge-update slices directly exercise features that distinguish Attestor from a vanilla RAG: bi-temporal recall, supersession-on-contradiction, event-time vs transaction-time disambiguation. A high score on those slices is the regulated-AI / audit / compliance pitch.

For the published Attestor numbers, see docs/bench/ — bench artifacts persist as lme-{variant}-{category}-{date}.{report,summary}.json. The Reporting section below shows how to render them as a table.

Download the LongMemEval dataset (one-time, before any bench run)

All lme_*.sh scripts use the cleaned LongMemEval split published on HuggingFace by xiaowu0162/longmemeval-cleaned. It auto-downloads on first use, but you'll want to know what's happening.

Cache location (created on first call):

~/.cache/attestor/longmemeval/

(Or $XDG_CACHE_HOME/attestor/longmemeval/ if you set XDG_CACHE_HOME.)

Variants and on-disk sizes:

VariantFilenameSizeTokens / QUse
oraclelongmemeval_oracle.json~5 MB~3-15kReasoning ceiling — cheapest smoke
slongmemeval_s_cleaned.json~250 MB~115kPublic leaderboard (canonical)
mlongmemeval_m_cleaned.json~2 GB~1M+Forces retrieval (no long-context shortcut)
Option A — auto-download (recommended)

Just run any bench command. The first call downloads and caches; every subsequent call reads from disk:

# Will download longmemeval_oracle.json (~5 MB) the first time
.venv/bin/python scripts/lme_smoke_local.py --n 2 --yes --variant oracle

# Will download longmemeval_s_cleaned.json (~250 MB) the first time
scripts/bench/lme_run.sh knowledge-update

You only pay the download cost once per variant. Internet flake during the first run? Delete the partial file in the cache dir and rerun.

Option B — pre-warm the cache (offline / CI)

Pre-fetch every variant you plan to use before the bench day:

.venv/bin/python -c "
from attestor.longmemeval import load_or_download
for v in ('oracle', 's', 'm'):
    samples = load_or_download(variant=v)
    print(f'{v}: {len(samples)} samples')
"

Expected output:

oracle: 500 samples
s: 500 samples
m: 500 samples
Option C — manual download (firewalled environments)

If your runner can't reach huggingface.co, fetch the files on a connected machine and drop them into the cache dir manually:

mkdir -p ~/.cache/attestor/longmemeval
cd ~/.cache/attestor/longmemeval

# pick the variants you need
curl -L -o longmemeval_oracle.json \
    https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_oracle.json

curl -L -o longmemeval_s_cleaned.json \
    https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json

curl -L -o longmemeval_m_cleaned.json \
    https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_m_cleaned.json

The bench harness checks for these filenames exactly — don't rename them.

Verify the dataset is loadable

After download (auto or manual), confirm the loader picks it up cleanly:

.venv/bin/python -c "
from attestor.longmemeval import load_or_download
from collections import Counter
samples = load_or_download(variant='s')
cnt = Counter(s.question_type for s in samples)
print(f'Loaded {len(samples)} samples')
for cat, n in sorted(cnt.items(), key=lambda x: -x[1]):
    print(f'  {cat}: {n}')
"

Expected for the cleaned s variant (500 questions, 6 categories — note: no abstention slice in the cleaned split):

Loaded 500 samples
  multi-session: 133
  temporal-reasoning: 133
  knowledge-update: 78
  single-session-user: 70
  single-session-assistant: 56
  single-session-preference: 30

If counts don't match, the file is truncated — re-download.

Quick smoke (≤ 1 minute, ≤ $0.10)

Confirm the pipeline runs end-to-end before committing or running anything bigger:

.venv/bin/python scripts/lme_smoke_local.py --n 2 --yes --variant oracle

oracle is the cheapest variant (gold sessions only, no distractor haystack). Schema is reapplied automatically; pass --skip-schema if you want to keep a populated DB between runs.

Single category — scripts/bench/lme_run.sh

# all 6 categories, current variant from bench.yaml (default: s)
scripts/bench/lme_run.sh

# one slice — full
scripts/bench/lme_run.sh knowledge-update

# one slice — capped at N samples (smoke)
scripts/bench/lme_run.sh knowledge-update 10

# one slice on a different variant (oracle = cheapest, m = ~1M tokens)
scripts/bench/lme_run.sh knowledge-update "" oracle

Valid --category values: single-session-user, single-session-assistant, single-session-preference, multi-session, temporal-reasoning, knowledge-update. See What LongMemEval is above for sample counts and what each category tests.

Each run persists two files:

docs/bench/lme-{variant}-{category}-{YYYYMMDD}.report.json   # full LMERunReport
docs/bench/lme-{variant}-{category}-{YYYYMMDD}.summary.json  # BenchmarkSummary

Full sweep — scripts/bench/lme_all.sh

Iterates bench.yaml's lme.categories list in order. Adding/removing slices is a YAML edit, not a script edit:

# All 6 slices, current variant
scripts/bench/lme_all.sh

# All 6 slices, capped at 10 samples each (smoke)
scripts/bench/lme_all.sh 10

# All 6 slices on Oracle variant
scripts/bench/lme_all.sh "" oracle

If one slice fails, the script logs it and moves on to the next.

Reporting — scripts/bench/lme_report.py

Aggregates every docs/bench/lme-*.summary.json into one markdown table; picks the most-recent file per (variant, category):

.venv/bin/python scripts/bench/lme_report.py                       # latest-per-slice
.venv/bin/python scripts/bench/lme_report.py --variant s           # filter to LME-S
.venv/bin/python scripts/bench/lme_report.py \
    --markdown-out docs/bench/LME-S.md                             # also write file
.venv/bin/python scripts/bench/lme_report.py --trend               # progression over time

Default mode (latest-per-slice):

| Variant | Category | Score | N | Date | Answer | Judges |
| ------- | -------- | -----:| -:| ---- | ------ | ------ |
| s | knowledge-update | 87.5% | 78 | 20260429 | openai/gpt-5.4-mini | openai/gpt-5.5, anthropic/claude-sonnet-4-6 |

Trend mode (--trend) reads docs/bench/trend.csv — one row appended per bench run (auto-populated by lme_run.sh) — and shows progression with a Δ column:

| Variant | Category | Date | N | Score | Δ | SHA | Features | Run |
| ------- | -------- | ---- | -:| -----:| -:| --- | -------- | --- |
| s | knowledge-update | 20260429 | 78 | 80.0% |       | a126e7a |               | bench |
| s | knowledge-update | 20260430 | 78 | 88.0% | +8.0  | badcf1b | multi_query   | bench |
| s | knowledge-update | 20260501 | 78 | 91.5% | +3.5  | xxxxxxx | multi_query,hyde | bench |

The Features column records exactly which retrieval/answerer flags were enabled per run, so you can see at a glance which knob produced which lift.

Retrieval + answerer feature flags

Five orthogonal features land via configs/attestor.yaml boolean flips. All disabled by default — pick one per bench run, measure the lift, decide which to ship enabled.

FlagWhat it doesLiftCost overhead
retrieval.multi_queryrewrite question into N paraphrases, RRF-merge N+1 vector lanes+6-10% (lit.); regressed −10pp on LME-S temporal smoke1 small LLM call + N extra vector searches per recall
retrieval.hydeevent-descriptive hypothetical-document embedding (temperature=0) — embed it as a parallel vector lane+10pp measured on LME-S temporal-reasoning (30q smoke, 70%→80%→96.7% with BM25 hybrid)1 small LLM call + 1 extra vector search per recall
retrieval.temporal_prefilterregex-detect "two weeks ago" etc; narrow event-time window before vector+1.5% (lit.); 0pp on LME-S interrogative-anchor questionsFree (regex-only, no LLM)
self_consistencyanswerer draws K=5 samples at temperature, elects consensus+3-6% (lit.)5× answerer cost
critique_reviseanswer → critique → conditional revise+3-5% (lit.)~3× answerer worst case

multi_query and hyde are mutually exclusive in this release (multi_query wins if both flags are on with a logged warning). self_consistency and critique_revise are similarly mutually exclusive on the answerer side. Combinations across the two sides (e.g. hyde + self_consistency) are fine.

HyDE v2 prompt (attestor/retrieval/hyde.py) — generates an event-descriptive snippet rather than an answer-shape response, so the embedding lands close to source-shape conversation turns instead of question-shape queries. This is the lever that produced the +10pp measured lift on LME-S temporal-reasoning. temperature=0 is pinned so re-runs are deterministic.

Honest negative results documented above — multi_query and temporal_prefilter did NOT generalize from their literature numbers on the LME-S temporal-reasoning slice. multi_query paraphrases stay in question-shape and RRF dilutes marginal hits; temporal_prefilter heuristic anchors don't help interrogative-form questions ("how many days ago…"). HyDE was the right tool. Per-feature methodology + diagnostic artifacts in docs/bench/pinecone-lme-temporal-diagnostic-{baseline,mq3,hyde,hyde-bm25}-20260429.json.

Cross-vector-DB diagnostic harness — experiments/pinecone_lme_temporal_diagnostic.py runs retrieval-only LME-S diagnostics against Pinecone Local with --baseline / --multi-query / --hyde / --bm25-hybrid / --temporal-prefilter / --category flags. No answerer, no judge — pure recall@K ceiling. --skip-ingest reuses populated namespaces for fast retrieval-flag iteration (~60s for 30q vs ~50min with fresh ingest).

To benchmark a single feature: flip its enabled: true in configs/attestor.yaml, run the bench slice, compare against a same-day baseline run with everything off. The trend table will show the delta in the Δ column.

Synthetic supersession suite — python -m evals.knowledge_updates

50 hand-curated cases, 10 contradiction categories × 5 each (numeric, categorical, temporal, preference, entity, locational, intent, relational, count, status_binary). Each case ingests two sessions (Session 1 states a fact, Session 5 contradicts it) and asks a question that should resolve to the newer fact. Metric: % of cases where retrieval surfaces the new fact as top-1.

# All 50 cases — ~5 min, ~$0.50 worth of embedding calls
.venv/bin/python -m evals.knowledge_updates

# Smoke — first 5 cases
.venv/bin/python -m evals.knowledge_updates --limit 5

# Custom fixtures
.venv/bin/python -m evals.knowledge_updates --fixtures my_cases.json

Outputs:

docs/bench/knowledge-updates-{YYYYMMDD}.report.json   # per-case verdicts (new_wins | stale_wins | miss | ambiguous)
docs/bench/knowledge-updates-{YYYYMMDD}.summary.json  # aggregate score + per-category breakdown

Target score (configurable in bench.yaml): 92% new_wins. Below that, the supersession-confidence-decay weight in attestor/retrieval/scorer.py needs tuning.

Cost & runtime guide

Approximate, at reasoning_effort=high for answerer + judge, parallel=2, OpenRouter pricing:

RunNWall timeCost
Quick smoke2 oracle~1 min< $0.10
knowledge-update slice78~30-60 min~$3-5
temporal-reasoning slice133~50-100 min~$5-8
Full LME-S 500q500~75-180 min~$20-30
Synthetic supersession50~5 min~$0.50 (embeddings only)

To cut costs, edit configs/attestor.yaml's models.reasoning_effort.{answerer,judge} from high → medium or low.

Configuration cheat sheet — configs/bench.yaml

bench:
  lme:
    variant: s                    # s | m | oracle
    cache_dir: ~/.cache/attestor/lme
    output_dir: docs/bench
    sample_limit: null            # null = full dataset; int = truncate
    category: null                # null = all 7; or single slice name
    categories: [...]             # iteration order for lme_all.sh
    variants_to_run: [...]        # for full size matrix

  knowledge_updates:
    fixtures_path: evals/knowledge_updates/fixtures.json
    n_cases: 50
    target_score: 0.92
    categories: [numeric, categorical, ...]

  report:
    headline_slice: abstention
    trend_csv: docs/bench/trend.csv
    markdown_path: docs/bench/LME-S.md

Architecture

Bi-temporal — replay any past state

Every memory carries two time axes:

AxisColumnsMeaning
Event timevalid_from, valid_untilWhen the fact is true in the world
Transaction timet_created, t_expiredWhen the row landed in the store

Plus a superseded_by chain. Old facts are never deleted — they remain queryable forever (attestor/temporal/manager.py:30-66).

# What did we believe on March 1?
mem.recall(query="who runs engineering?", as_of="2026-03-01T00:00:00Z", context=ctx)

# Show me everything we knew about Alice between Feb and Apr
mem.recall(query="alice", time_window=("2026-02-01", "2026-04-01"), context=ctx)

as_of and time_window propagate end-to-end through the orchestrator and document store. Auto-supersession on write is wired into core.py:add() (core.py:762, 784-785): on every add, the temporal manager finds active rows with the same (entity, category, namespace) and different content, marks them superseded, sets valid_until=now, and links superseded_by=<new_id>. Detection is rule-based string equality today.

Tenant isolation — Postgres Row-Level Security

Every tenant table (users, projects, sessions, episodes, memories, user_quotas, deletion_audit) carries a tenant_isolation_* policy keyed off the attestor.current_user_id session variable. An empty / unset value fails closed — no rows visible (attestor/store/schema.sql:311-327).

Honest disclosure. Enforcement lives in Postgres, not Python. The AgentRole enum in attestor/context.py:49-56 is metadata that flows onto memories for provenance; it does not gate operations in Python. RLS is what actually controls access. This is correct architecture for a memory backend, but worth knowing if you read the Python alone.

The retrieval pipeline — semantic-first, six steps

attestor/retrieval/orchestrator/core.py runs the same six steps for every query:

  1. Vector top-K — Pinecone cosine, k=50 (pgvector remains as opt-in fallback for self-contained deploys)
  2. Graph narrow — Neo4j BFS depth ≤ 2 from each candidate's entity to the question entities; affinity bonus per hop (0-hop=+0.30, 1-hop=+0.20, 2-hop=+0.10; unreachable=−0.05). Discrete, not "soft".
  3. Triples inject — typed-edge facts (uses, authored-by, supersedes) injected as synthetic memories
  4. MMR rerank — λ=0.7
  5. Confidence decay + temporal boost — recency lifts; stale, low-confidence rows fall
  6. Budget fit — greedy monotonic-by-score pack into the caller's token budget

Every call writes a JSONL trace to logs/attestor_trace.jsonl (disable via ATTESTOR_TRACE=0).

Async retrieval — lower latency without weakening audit

Independent recall steps run concurrently via asyncio.gather, but none of the eight audit invariants are relaxed. You don't trade trust for speed — you get both.

Async stepLatency winAudit invariant preserved
HyDE LLM call ‖ original-question vector embed−33 % on HyDE-enabled recalls (~600 ms → ~400 ms in the simulated unit-test)A7 — generator pins temperature=0.0, same prompt + same model = same hypothetical = same RRF order. Async amplifies non-determinism risk if T > 0; we explicitly pin T=0.
Per-lane vector searches in parallel (HyDE / multi-query)proportional to N (≈ N × per-lane → max-per-lane)RRF over the lanes is deterministic given identical inputs — gather order does not corrupt rank positions (test_multi_query_async_preserves_RRF_order).
Self-consistency K-fanout (answerer side)5× on K=5 samplingVote consensus is order-independent; answerer-side change, doesn't touch the document store.
Vector ‖ BM25 ‖ graph candidate-fetch−20 % on baseline recallsA2 recall_started_at ceiling — every cross-store read carries the same monotonic timestamp captured at recall start. Concurrent writes that land mid-recall are simply not visible.
Graph BFS ‖ Postgres doc-fetch−50 ms typicalSame ceiling.

Write-side stays sync. All add(), update(), supersession writes are explicitly non-goals for the async refactor — the audit chain depends on serial write ordering and the bi-temporal t_created order must be linearizable per row. Async is read-side only.

Trace stays reconstructable. Every event carries recall_id + monotonic seq + optional parent_event_id, so the audit dashboard renders concurrent recalls as a tree of events rather than a stream — (recall_id, seq) reconstructs causal order from the JSONL log.

Same recall(as_of=X) replay guarantee. A past recall remains byte-for-byte reproducible from the bi-temporal columns + deletion_audit + the trace JSONL — async parallelism doesn't change what gets read, only when. The load-bearing test (tests/test_as_of_replay.py) is in the regression gate of every async PR.

Full design + audit-invariant matrix: docs/plans/async-retrieval/PLAN.md. Convention: every async PR ships with an audit-preservation argument and the matching invariant test (tests/async_retrieval/test_audit_invariants_under_async.py) GREEN before merge.

Three storage roles

RolePurposeDefaultAlternatives
DocumentSource of truth (content, tags, entity, ts, provenance, confidence)Postgres 16AlloyDB, ArangoDB, DynamoDB, Cosmos DB
VectorDense embedding per memoryPinecone (Local Docker / Cloud)pgvector, AlloyDB ScaNN, ArangoDB, OpenSearch Serverless, Cosmos DiskANN
GraphEntity nodes + typed edgesNeo4j 5 + GDSApache AGE on AlloyDB, ArangoDB, Neptune, NetworkX (Azure)

Postgres is the source of truth. Pinecone vectors and Neo4j graph are derived state, both rebuildable from Postgres — but both are required for the canonical install: vector cosine is step 1 of the retrieval pipeline, graph expansion is step 2, and conversation ingest writes typed edges. The only role that cannot be down is the document store; the orchestrator records transient vector-probe failures in the response trace (vector_error) instead of swallowing them.

Durable governance jobs — the read path never waits, the jobs never quit

Governance work that used to be a hand-rolled queue, a docstring promising a sweeper, or a warning-only write runs as Temporal workflows (attestor/durable/). Opt-in: durable.enabled: false by default; with it off, or the server unreachable, every job falls back to the in-process path it replaced.

TriggerWorkflowWhat it guarantees
On every writeDeriveMemoryVector and graph derivation is idempotent and retried, so derived state cannot silently drift from Postgres.
On each episodeConsolidateEpisodeReplaces the SKIP LOCKED queue and lease; a stuck or failed episode is visible, not lost.
On a scheduleRetentionSweep, SessionSweepDeclarative retention and idle-session cleanup actually run, on a Temporal Schedule you apply once.
On demandRebuildDerived, ForgetUserRebuild Pinecone and Neo4j from Postgres. Forget as a saga: audit row first, then each backend with its own retry and result.

Two hard rules, enforced by tests/test_durable_isolation.py: recall never touches Temporal and hooks never wait on it (nothing under attestor/retrieval/** or attestor/hooks/** may import attestor.durable or temporalio), and workflow payloads carry ids, never memory content, so Postgres stays the only source of truth and Temporal holds job state only. Driver exceptions are sanitized before they reach workflow history.

attestor quickstart --durable      # Temporal server + UI (:8233) via compose profile `durable`
attestor worker                    # the governance worker; needs durable.enabled: true
attestor durable status            # probe the server, list schedules, describe a workflow
attestor durable rebuild           # rebuild derived state from Postgres
attestor durable schedules apply   # idempotent create-or-update of the retention / session schedules

Optional BM25 / FTS lane

A trigger-maintained content_tsv tsvector + GIN index lifts queries that embeddings under-recall (acronyms, IDs, rare proper nouns). Enabled when v4 schema is detected; fuses with the vector lane via Reciprocal Rank Fusion (RRF, k=60). Graceful no-op on backends without the column (core.py:122-130).


Conversation ingest

The heavyweight write path that turns conversation turns into auditable memories. core.py:ingest_round(turn) orchestrates four passes:

turn  →  extract_user_facts(user_turn)        ┐
        extract_agent_facts(assistant_turn)   ┘  → resolve_conflicts → apply

Two-pass speaker-locked extraction

attestor/extraction/round_extractor.py:216, 258 — separate prompts for user vs assistant turns. The user-turn extractor only emits facts attributable to the user; the assistant-turn extractor only emits facts the assistant introduced. Stops cross-attribution. The "+53.6 over Mem0" delta in our LongMemEval scores comes from this split.

Four-decision conflict resolver

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!