ADR-0028: A durable state store (amends ADR-0007's "no database in v1")¶
- Status: Accepted
- Date: 2026-10-06
- Deciders: Jian (product owner)
Context¶
ADR-0007 chose no database in v1: files only. That held for interactive, single-run use. The demos show it does not hold for workflows that outlive a request:
- incident-responder keeps an
openIncidentsmap keyed by alert fingerprint. A restart orphans the Slack message a human is about to click, and a laterresolvedalert cannot find itsfiringincident. - support-triage keeps a pending-ticket map while a human decides, and relies on the IMAP
\Seenflag as its only dedupe. - market-etl needs an overlap lock and idempotent writes.
- A poll trigger (F1) needs a persisted cursor or seen-set.
These are one need (a small durable keyed store), wider than F4's "durable run state". In the
hosted server, runs rows persist a trace, but a pending approval can be resumed only by an
in-memory owned.run, and kampong serve uses an in-memory RunManager map.
Decision¶
1. A StateStore interface¶
A small keyed store with: get, put (with optional TTL), exists, delete, putIfAbsent (for
dedupe and locks), and a list-by-prefix. Used by: run resume and pending-approval correlation,
poll cursors and dedupe, the once(key, ttl) and open/close incident steps, and the runner's
overlap lock.
2. Two implementations¶
- Local and self-host: a SQLite file under
.kampong/(alongsidelayout.json, ADR-0006). It is never a source of truth for the spec. The spec YAML remains the single source of truth (ADR-0002); the store holds runtime state only. - Hosted: Postgres, in the existing server schema, workspace-scoped under RLS like the other tables (ADR-0018, ADR-0019).
3. Pending approvals become resumable from persisted state¶
A paused run records enough to resume (step index, accumulated outputs, correlation key) in the store. Approval resolves by key, so a restart or a different process can complete a pending run. This also closes the hosted gap where only an in-memory run object can resume.
4. Boundaries¶
- No telemetry or network use (local-first). The SQLite file stays on the user's machine.
- Secrets are never written to the store; only references.
- State is scoped by workflow and, hosted, by workspace. The exporter vendors the store with the engine (ADR-0010) and the exported container mounts its volume.
- Record-mode approval (a decision recorded on already-finished work) and
on_decisioncontinuations are expressed on top of the store, not as a separate mechanism.
Alternatives considered¶
| Option | Why not |
|---|---|
| Stay in memory, document the limit | Loses pending approvals on every restart; unattended workflows are not credible. |
| Files (JSON) | Concurrent writers and atomic putIfAbsent are exactly what breaks; a poller and a webhook share state. |
| Postgres everywhere | Forces infrastructure on the local-first developer path. |
Rely on external systems for state (the IMAP \Seen flag, Alertmanager repeats) |
Source-specific, not general, and gone for sources without a flag. |
Consequences¶
- Amends ADR-0007's "Database: none in v1" for runtime state only. Spec and layout remain files.
- Adds a native dependency or WASM build for SQLite to the engine and exporter; the choice of binding is made in the slice and must work in the exported Dockerfile (ADR-0010, ADR-0022).
- Unblocks the unattended runner, poll and schedule triggers, and the incident and triage templates (V11 Phases 1 and 2).
- Revisit retention, TTL defaults and a vacuum policy when the first long-running deployment exists.