SPEC-0004: Persistence Layer
SPEC · SPEC-0004 · Status · implemented · Date · 2026-07-06 · Implements · ADR-0002
Overview
This capability defines switchboard's system of record: a single PostgreSQL database
accessed through a thin raw-SQL data-access layer over pgx (no ORM). It realizes
ADR-0002, which chose PostgreSQL
over SQLite, a dedicated broker, and Redis-as-ledger because switchboard is a self-hosted,
multi-tenant service whose hot path is a concurrent multi-worker job queue — not an
append-mostly log — with transactional dedup, competing consumers, and store-then-ack coupling to
ingestion.
The persistence layer owns the schema (humans, sessions, agents, endpoints, events, todos,
adapters, settings), the connection pool lifecycle, the embedded migration mechanism, the partial
and expression indexes that keep the hot queue scan cheap, the hybrid age + row-cap retention
policy, and the optional LISTEN/NOTIFY wakeups. Every other capability — including the todo
work-queue (SPEC-0003) — builds on the primitives this capability
provides. It is the datastore contract the rest of the system is written against; it does not itself
serve HTTP.
The reference implementation is internal/db/db.go (pool + migrations), internal/store/ (typed
data-access functions), and internal/db/migrations/0001_init.sql (schema DDL).
Requirements
Requirement: PostgreSQL as the Sole System of Record
Switchboard MUST use PostgreSQL as its single system of record for all durable state: humans, sessions, agents, vended endpoints, events, todos, adapters, and settings. It MUST NOT use SQLite, because single-writer serialization and a single-node file are the wrong shape for a multi-tenant, multi-worker queue. It MUST NOT use Redis as the ledger — Redis appears only as an ingestion transport, never as storage. A dedicated broker (Kafka/NATS/Temporal) MUST NOT be introduced unless PostgreSQL is demonstrably insufficient at scale; such an addition REQUIRES a new ADR.
All durable state SHALL live in one database so that a vended endpoint's scope, an agent's owner, and a todo's queue are joinable and enforceable within a single transaction.
Scenario: Multi-node app tier against one database
- WHEN more than one switchboard app node is deployed
- THEN every node MUST connect to the same PostgreSQL database, and availability/HA MUST be achievable through managed or streaming replication rather than being blocked by a single-host file
Scenario: Redis is transport, not storage
- WHEN a pull adapter consumes a message from a Redis stream
- THEN the durable record MUST be written to PostgreSQL, and Redis MUST NOT be treated as the authoritative store for that work
Requirement: Connection Pool Lifecycle and Timeouts
Switchboard MUST obtain database connections from a single pgx connection pool created at startup
from a DSN. The DSN MUST be injected via environment/deployment configuration (DATABASE_URL) and
MUST NOT be committed to source. Startup MUST fail fast with a wrapped error when the DSN is empty or
when connectivity cannot be verified. The pool MUST verify connectivity (a Ping) before the
service accepts work, and MUST be closed cleanly on shutdown. Every query MUST accept a
context.Context so that cancellation and timeouts propagate to the database driver.
Scenario: Empty or unreachable DSN fails fast
- WHEN
DATABASE_URLis empty, or the database cannot be reached at startup - THEN pool construction MUST return a wrapped error naming the failure (
db: empty DATABASE_URL,db: connect, ordb: ping) and the service MUST NOT start serving
Scenario: Context governs every query
- WHEN a caller passes a context that is cancelled or exceeds its deadline mid-query
- THEN the in-flight database operation MUST observe the cancellation and return promptly rather than blocking indefinitely
Requirement: Embedded, Ordered, Transactional Migrations
Schema evolution MUST be driven by plain .sql files embedded into the binary and applied on
startup in filename (lexical) order. Each migration MUST run inside its own transaction, and MUST be
rolled back atomically if either the DDL or its bookkeeping insert fails. Applied migrations MUST be
recorded in a schema_migrations table keyed by version, and a migration already recorded there
MUST be skipped on subsequent starts (idempotent, at-most-once application). No external migration
tool SHALL be required — switchboard MUST remain a single static binary.
Scenario: Migration applied exactly once
- WHEN the service starts and a migration file is not present in
schema_migrations - THEN its SQL MUST be executed in a transaction and, on success, its version recorded — and on every later start that same migration MUST be skipped
Scenario: Failed migration leaves no partial state
- WHEN a migration's DDL fails partway through
- THEN the enclosing transaction MUST be rolled back, the version MUST NOT be recorded, and startup MUST abort with a wrapped error naming the migration
Requirement: Schema, Partial Indexes, and Rich Column Types
The initial migration MUST create the core tables — humans, sessions, agents, endpoints,
events, todos, adapters, settings — with timestamptz timestamps and first-class rich
types (jsonb for payloads/config/results, text[] for scope and source-type arrays, inet for
source IPs, bytea for raw event bodies). It MUST create the hot-path partial and unique indexes:
idx_todos_pendingontodos (queue, created_at) WHERE state = 'pending'— the claim scan MUST touch only live work, never the terminal backlog.idx_todos_dedupeUNIQUE ontodos (queue, idempotency_key) WHERE idempotency_key IS NOT NULL AND state <> 'done' AND state <> 'failed'— at most one non-terminal todo per(queue, idempotency_key).idx_events_dedupeUNIQUE onevents (source, external_id) WHERE external_id IS NOT NULL— event-level delivery dedup.idx_events_source_timeonevents (source, received_at DESC)for read surfaces.
Switchboard-minted credentials MUST be stored hashed (e.g. endpoints.credential_hash); no signing
secret or plaintext credential SHALL be persisted in any row.
Scenario: Hot claim scan hits the partial index
- WHEN a worker asks for the next pending todo on a queue while many terminal rows exist
- THEN the query MUST be served by
idx_todos_pendingand MUST NOT scan terminal rows
Scenario: No plaintext secrets at rest
- WHEN a credential is minted for a vended endpoint
- THEN only its hash and a non-secret display prefix MUST be stored, and the plaintext MUST NOT be recoverable from the database
Requirement: Parameterized Queries Only (Database Operation Standards)
Every SQL statement MUST be issued with bound parameters ($1, $2, …); user- or
producer-supplied values MUST NOT be concatenated or interpolated into SQL text. The data-access
layer MUST be a thin, centralized set of typed functions (InsertEvent, CreateTodo, ClaimTodo,
CompleteTodo, ReapExpired, Prune, …) so the hot queries stay auditable. Multi-step atomic
mutations MUST run inside a single transaction. Every database function MUST accept and honor a
context.Context. Domain lookups that match no row MUST return a sentinel error (ErrNotFound), and
lost state-transition races MUST return a distinct sentinel (ErrConflict), rather than being
silently swallowed.
Scenario: Producer input is always bound, never interpolated
- WHEN any value derived from a webhook, queue message, or API caller reaches a query
- THEN it MUST be passed as a bound parameter and MUST NOT be spliced into the SQL string
Scenario: Missing row versus lost race are distinguishable
- WHEN a conditional update affects zero rows
- THEN the layer MUST return
ErrNotFoundwhen the row is absent andErrConflictwhen the row exists but was not in the expected state, never an ambiguous nil result
Requirement: In-Database Wakeups via LISTEN/NOTIFY
The persistence layer SHOULD support LISTEN/NOTIFY so that an idle worker or the web UI can be
nudged when new work arrives instead of polling continuously. A NOTIFY on the todo_ready channel
(carrying the queue name) MAY be emitted when a pending todo is created. Because the durable queue
remains the source of truth, a missed notification MUST only cost latency and MUST NOT cause lost
work; correctness MUST NOT depend on delivery of any notification.
Scenario: Missed notification costs only latency
- WHEN a worker is not currently listening and a
todo_readynotification is emitted and lost - THEN the pending todo MUST still be claimable on the next poll or claim scan, with no work lost
Requirement: Hybrid Retention and Bounded Growth
Switchboard MUST bound row growth with a hybrid retention policy: both a max-age limit and a
max-row cap. Defaults MUST be seeded into settings (retention_max_age_days = 30,
retention_max_rows = 500000). A periodic pruning task MUST delete events and terminal todos older
than the age limit and then trim the oldest rows beyond the row cap, performing the delete-then-trim
as one transaction. Non-terminal todos (pending/claimed) MUST NOT be pruned. Time-range partitioning
of the event log (turning retention into a cheap DROP PARTITION) is RECOMMENDED for high-volume
deployments and MAY be adopted without changing this contract.
Scenario: Prune bounds both age and size atomically
- WHEN the pruning task runs with rows exceeding the age limit and the total exceeding the row cap
- THEN it MUST delete over-age events and terminal todos and trim the oldest beyond the cap in a single transaction, leaving all pending and claimed todos intact