Authoritative reference for write ordering, crash safety, and reconciliation in MCP Agent Mail. Updated 2026-02-08 as part of br-15dv.6.7.
- Primary: SQLite database (transactional, immediate consistency).
- Secondary: Git archive (human-auditable, append-only, eventually consistent).
- Rationale: All tool calls return success/failure based on DB state. The archive is a best-effort mirror that can lag under load or disk pressure. The DB is always rebuildable from archive if corruption occurs, and the archive is always reconstructible from DB if files are lost.
The original design aspired to archive-first writes, but the implementation evolved to DB-first for these reasons:
- Atomicity: SQLite transactions give all-or-nothing semantics. Git commits are multi-step (stage → tree → commit) and cannot be rolled back atomically.
- Performance: DB writes are synchronous and sub-millisecond. Archive writes go through a Write-Behind Queue (WBQ) + Commit Coalescer, adding 50-200ms latency. Blocking on archive would make every tool call 100x slower.
- Concurrency: At 1000+ agents, git index.lock contention makes synchronous archive writes impractical. The coalescer batches commits across agents.
- Failure isolation: Archive failures (disk full, git corruption, lock storms) must not prevent agents from communicating. DB remains available.
These tools modify only DB state and return success iff the DB write commits:
| Tool | DB Operation | Idempotent |
|---|---|---|
mark_message_read |
UPDATE read_ts via COALESCE |
Yes |
acknowledge_message |
UPDATE read_ts + ack_ts via COALESCE |
Yes |
release_file_reservations |
UPDATE released_ts WHERE NULL |
Yes |
renew_file_reservations |
UPDATE expires_ts |
Yes |
set_contact_policy |
UPDATE agent policy | Yes |
These tools write to DB first, then enqueue archive writes to the WBQ:
| Tool | DB Write | Archive Write | Idempotent |
|---|---|---|---|
ensure_project |
INSERT/SELECT project | ensure_archive dir | Yes |
register_agent |
INSERT/UPDATE agent | WBQ: profile.json | Yes |
create_agent_identity |
INSERT agent (reject dup) | WBQ: profile.json | No |
send_message |
INSERT message + recipients | WBQ: canonical + inbox/outbox | No |
reply_message |
INSERT message + recipients | WBQ: canonical + inbox/outbox | No |
file_reservation_paths |
INSERT per-path reservations | WBQ: JSON artifacts | No |
force_release_file_reservation |
UPDATE released_ts + INSERT notify | N/A | Partial |
request_contact |
INSERT/UPDATE contact link | WBQ: intro message | Partial |
respond_contact |
UPDATE contact link | N/A | Yes |
install_precommit_guard |
N/A (filesystem only) | Write hook script | Yes |
For all eventual-consistency tools:
1. DB transaction commits (BEGIN IMMEDIATE → COMMIT)
2. Tool returns success to caller
3. Archive write enqueued to WBQ (non-blocking)
4. WBQ drain thread writes files to disk (atomic: temp + rename)
5. Commit Coalescer batches git commits (50ms window, 4 shards)
Invariant: A tool NEVER returns success if the DB write failed. Archive write failure does NOT cause tool failure.
- Tool returns error to caller.
- No archive write attempted.
- Caller can safely retry.
- No orphaned state.
Causes: disk pressure (Critical level), WBQ channel disconnected, WBQ full.
- Tool returns success (DB is source of truth).
- A reconciliation marker is recorded in
needs_reindexmetrics. - On next server restart, the consistency probe detects any divergence.
- Manual recovery:
am reconcileCLI command rebuilds archive from DB.
Causes: index.lock contention after retries, git corruption.
- Files are on disk (atomic write via temp+rename).
- Commit coalescer retries with exponential backoff (5-7 attempts).
- Last resort: stale index.lock removed after 60s.
- If all retries fail: files discoverable on disk, next commit picks them up.
- Metric:
commit_errors_totalincremented.
- DB: SQLite WAL journal ensures either full commit or full rollback.
- Archive files: Atomic write (temp+rename) means either complete file or no file.
- Git commits: Partial staging is cleaned up by git on next operation.
- On restart: integrity probe runs PRAGMA quick_check; archive lock healing
removes stale
.archive.lockand.git/index.lockfiles.
- Startup probe detects via
PRAGMA quick_check. - Server refuses to start with remediation guidance.
- Recovery: VACUUM INTO to salvage data, then rebuild from archive.
| Category | Guarantee | Mechanism |
|---|---|---|
| Read/ack operations | Fully idempotent | COALESCE(existing, new) prevents overwrite |
| Release/renew | Fully idempotent | WHERE released_ts IS NULL / expires_ts check |
| register_agent | Idempotent (upsert) | SELECT-then-INSERT/UPDATE on (project, name) |
| ensure_project | Idempotent | UNIQUE(slug) + retry-on-conflict |
| send_message | NOT idempotent | Each call creates new message with fresh ID |
| reply_message | NOT idempotent | Each call creates new message with fresh ID |
| file_reservation_paths | NOT idempotent | Each call creates new reservations |
| create_agent_identity | NOT idempotent | Rejects duplicate names |
For non-idempotent tools, callers must implement their own deduplication (e.g., checking if a message with the same content was recently sent).
- DB: Pool with configurable min/max connections (default 25/100).
BEGIN IMMEDIATEserializes writes; reads can proceed concurrently (WAL mode). - Archive locks: Per-project
.archive.lockfor file writes. Per-project.commit.lockfor git operations. - WBQ: Single mpsc channel (capacity 8192) with one drain thread.
- Commit coalescer: 4 shards, each with independent worker thread. Repo root hashed to shard for locality.
| Metric | What it tells you |
|---|---|
wbq_depth |
Current WBQ backlog; high = archive lagging DB |
wbq_errors_total |
Archive write failures; rising = investigate disk/git |
commit_errors_total |
Git commit failures; rising = index.lock contention |
commit_sync_fallbacks_total |
Coalescer worker died; synchronous fallback used |
needs_reindex_total |
DB rows without corresponding archive files |
pool_acquire_latency_us (p95) |
DB contention; Yellow > 10ms, Red > 50ms |
On server startup (after integrity check), a lightweight probe samples recent DB messages and verifies corresponding archive files exist. This catches persistent archive-DB divergence.
- Checks last N messages (configurable, default 100).
- For each, verifies canonical archive path exists on disk.
- Reports count of missing archive files as
needs_reindex_total. - Does NOT block startup; logs warnings for operator visibility.
# Check archive-DB consistency
am reconcile --check
# Rebuild missing archive files from DB
am reconcile --rebuild
# Rebuild DB index from archive files
am reconcile --reindex