Skip to content

Guardian state store (bun:sqlite): ingress/client registry + optional restart persistence #433

Description

@itlackey

Guardian state store (bun:sqlite): ingress/client registry + optional restart persistence

As the guardian grows from "HMAC channels only" to multiple ingress surfaces (HMAC channels + OpenCode-proxy #429 + MCP + A2A #432), its per-ingress configuration and runtime state outgrow env vars + in-memory Maps. Add a small guardian-local SQLite DB (bun:sqlitebuilt into Bun, no new dependency) for the things that genuinely benefit from a table or from surviving a restart. Keep ephemeral hot-path state in memory.

Motivation

  • Growing ingress surface → growing config. Each ingress needs a token, an enabled flag, a bind policy, a rate policy, and (with Configurable personas per channel (over OpenCode agents) #22) a persona binding. Today these are env vars (CHANNEL_*_SECRET_FILE, OP_GUARDIAN_*_TOKEN, …). That surface balloons as ingress options multiply — the opposite of Collapse port + bind-address pairs (introduce OP_BIND_ADDRESS global) #395's env-reduction goal. A small registry table scales better and is queryable by the admin UI.
  • Restart resilience. Ownership maps and the A2A task store are in-memory; a guardian restart orphans sessions (today mitigated by broadcasting a synthetic session.error). A durable store removes the orphan window.

What goes in SQLite (and what does NOT)

State Today Move to SQLite? Why
Ingress/client registry (per channel/client: token hash, enabled protocols, persona binding, rate policy) env vars yes Scales the growing config surface; queryable; complements #395
Session/permission ownership (ownership.ts) in-mem Map, 50k cap, 15m TTL ⚖️ optional Survives restart → no orphaned sessions; low write volume
A2A task store (#432) InMemoryTaskStore ⚖️ optional Durable tasks/get across restart; v1 can stay in-memory
Audit log (audit.ts) JSONL file ⚖️ optional Queryable/rotatable; file is fine for v1
Nonce/replay store (replay.ts) in-mem Map, 50k cap, 5-min window no Ephemeral by design; a 5-min window needs no persistence; per-request disk I/O for zero security gain
Rate-limit buckets (rate-limit.ts) in-mem Map, 10k cap no Ephemeral counters; same reasoning

Principle: persist what benefits from durability or scales as a table (registry, ownership, tasks, audit). Keep short-window ephemeral state (nonce, rate) in memory — moving it to disk adds hot-path I/O without benefit.

Design

  • bun:sqlite (import { Database } from "bun:sqlite"), WAL mode, one DB file under the guardian's data dir (data/guardian/state.db), 0600. No new dependency (Bun built-in — satisfies the no-dep-duplicates-a-Bun-built-in rule).
  • A thin core/guardian/src/store.ts with typed accessors (no ORM, no abstraction layer — per the zero-abstraction policy). Schema migrations are a simple PRAGMA user_version step on boot.
  • Phase 1 — ingress/client registry only. One ingress table (id, kind, token_hash, enabled, protocols, persona, rate_policy, created_at). The admin UI manages rows; the guardian reads them at request time (cached, short TTL). This alone delivers the "growing ingress list" value and trims env vars.
  • Phase 2 (optional) — ownership + A2A task persistence. Back ownership.ts and the A2A task store with SQLite tables, keeping the same TTL/eviction discipline (now durable). Gated on whether the orphan-on-restart window actually bites in practice.
  • Never SQLite-back the nonce or rate-limit stores.

Security invariants

  • DB is guardian-private (data/guardian/, 0600), never mounted elsewhere; tokens stored hashed (never plaintext), consistent with the secret-file model.
  • No change to ingress posture: registry rows only describe ingress; they don't grant network exposure (still loopback-default + OP_BIND_ADDRESS opt-in).
  • Reads on the request hot path must be cached (short TTL) so the registry lookup isn't a per-request disk hit.

Acceptance criteria

  • bun:sqlite-backed core/guardian/src/store.ts; no new npm dependency added to the guardian.
  • Phase 1: an ingress registry table + admin CRUD; per-ingress token (hashed), enabled flag, protocols, persona, rate policy read from the DB (cached) instead of new env vars.
  • Nonce + rate-limit stores remain in-memory (explicitly NOT migrated).
  • DB file is data/guardian/state.db, 0600, guardian-private; tokens hashed at rest.
  • cd core/guardian && bun test passes (store accessors + cache + hot-path-no-disk-on-nonce covered).

Relationships


🤝 Spun out of the gateway/ingress evaluation (2026-06-05): bun:sqlite is zero-dep; persist the registry (+ optionally ownership/tasks), keep ephemeral nonce/rate in memory. Effort: S (Phase 1) / M (with Phase 2 persistence).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions