dwara_core/state/
migrations.rs

1//! Versioned, transactional SQLite schema migrations (DW-115).
2//!
3//! # Model
4//!
5//! All schema evolution is expressed as an ordered list of UP migrations
6//! ([`migrations`]) applied by `rusqlite_migration`, which records progress
7//! in `PRAGMA user_version` — the same pragma the DW-018 hand-rolled schema
8//! used. A database at `user_version = 1` with the DW-018 tables IS
9//! migration state 1: migration 001 below is that exact baseline DDL
10//! (kept idempotent via `IF NOT EXISTS`), so a fresh database runs 001 to
11//! reach state 1, while an existing DW-018 database already reports state 1
12//! and 001 is correctly treated as applied.
13//!
14//! # Forward-only
15//!
16//! There are NO down migrations. Rolling a gateway binary back onto a
17//! newer-schema data directory is refused (see
18//! [`StateStore`][crate::state::store::StateStore] open: `user_version` greater
19//! than the binary's latest is a hard error), and downgrading in place is
20//! unsupported. The documented rebuild path, since schema v1 content is
21//! entirely re-derivable:
22//!
23//! 1. Stop the gateway. Every migration takes a backup first — see
24//!    "Backup before migrate" in [`crate::state::store`] — so locate the newest
25//!    `<db>.bak-<version>-<timestamp>` file for the version you want.
26//! 2. Replace the live db file with that backup (it is a consistent
27//!    `VACUUM INTO` snapshot at the pre-migration version).
28//! 3. Restart. Consumers and credentials are also re-seedable from
29//!    config via [`sync_consumers_from_config`][crate::state::store::sync_consumers_from_config]
30//!    if no backup exists, so the honest v1 answer is: restore the backup,
31//!    or recreate the data dir and let config seeding repopulate it.
32//!
33//! # Adding a migration
34//!
35//! Append ONE `M::up` entry and bump [`LATEST_SCHEMA_VERSION`].
36//! Migrations must be additive (create table/index, add column with a
37//! default) so every historical version migrates forward with zero data
38//! loss — tests here open a hand-built v1 database through every version
39//! to HEAD. Never edit an existing migration: databases in the wild have
40//! already recorded it.
41
42use rusqlite_migration::{Migrations, M};
43
44/// Latest schema version this build knows how to produce. Equals the
45/// number of entries in [`migrations`]; asserted by test.
46pub const LATEST_SCHEMA_VERSION: u32 = 10;
47
48/// Migration 001: the DW-018 baseline schema, verbatim (idempotent).
49///
50/// Runs on fresh databases only: a database already at `user_version = 1`
51/// (the DW-018 hand-rolled schema) is recognized as migration state 1 and
52/// skips this entry.
53const MIGRATION_001_BASELINE: &str = "
54    CREATE TABLE IF NOT EXISTS consumers (
55        id INTEGER PRIMARY KEY AUTOINCREMENT,
56        name TEXT NOT NULL UNIQUE,
57        priority INTEGER,
58        created_at INTEGER NOT NULL
59    );
60    CREATE TABLE IF NOT EXISTS credentials (
61        id INTEGER PRIMARY KEY AUTOINCREMENT,
62        consumer_id INTEGER NOT NULL REFERENCES consumers(id),
63        kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
64        hash TEXT NOT NULL,
65        salt TEXT,
66        selector TEXT NOT NULL,
67        created_at INTEGER NOT NULL,
68        revoked_at INTEGER
69    );
70    CREATE INDEX IF NOT EXISTS idx_credentials_selector
71        ON credentials (selector);
72    CREATE TABLE IF NOT EXISTS quota_counters (
73        consumer_id INTEGER NOT NULL REFERENCES consumers(id),
74        counter_key TEXT NOT NULL,
75        window_start INTEGER NOT NULL,
76        used INTEGER NOT NULL DEFAULT 0,
77        PRIMARY KEY (consumer_id, counter_key, window_start)
78    );
79";
80
81/// Migration 002: index `quota_counters (window_start)`.
82///
83/// Additive (a CREATE INDEX over existing rows changes no data). The
84/// retention prune in [`StateStore::incr_quota`][crate::state::store::StateStore::incr_quota]
85/// runs `DELETE ... WHERE window_start < (SELECT MAX(window_start) ...)` on
86/// every quota write; without an index on `window_start` that subquery and
87/// delete scan the whole table, which grows with one row per (consumer,
88/// key, window). Chosen over an `issued_at` column on credentials because
89/// it removes a real per-write cost on the quota hot-ish path today,
90/// whereas nothing yet consumes a credential issue timestamp (DW-019's
91/// authenticator will decide that schema when it lands).
92const MIGRATION_002_QUOTA_WINDOW_INDEX: &str =
93    "CREATE INDEX IF NOT EXISTS idx_quota_counters_window ON quota_counters (window_start);";
94
95/// Migration 003 (#124): `consumers.groups` — a JSON array of group-name
96/// strings (`TEXT NOT NULL DEFAULT '[]'`), giving STORE-managed consumers
97/// the same group memberships config consumers declare, so group-based
98/// authorization (`allowed_groups` / `denied_groups` at any attachment
99/// level) applies to them. JSON (not a separator-joined string) because
100/// group names are free-form and may contain any separator; additive
101/// (existing rows default to `'[]'` = no groups, exactly the pre-003
102/// behavior where store consumers had none).
103const MIGRATION_003_CONSUMER_GROUPS: &str =
104    "ALTER TABLE consumers ADD COLUMN groups TEXT NOT NULL DEFAULT '[]';";
105
106/// Migration 004 (#46/DW-045): `credentials.source_ref` — nullable TEXT
107/// recording WHICH `${...}` config reference seeded a row. The seed
108/// path's fail-closed skip (a re-seed whose reference no longer
109/// resolves) uses it to revoke exactly the row a previous generation of
110/// that same reference seeded, so a store-backed registry fails closed
111/// (the OLD key stops authenticating) instead of serving it forever.
112/// NULL = every pre-004 row, inline-key rows, and operator-managed
113/// rows: credentials the reference lifecycle does not govern, which
114/// keep the documented upsert-only posture. Additive (ADD COLUMN;
115/// existing rows default NULL = the pre-004 behavior of no linkage).
116const MIGRATION_004_CREDENTIAL_SOURCE_REF: &str =
117    "ALTER TABLE credentials ADD COLUMN source_ref TEXT;";
118
119/// Migration 005 (DW-046): `credentials.retire_at` — nullable INTEGER,
120/// Unix epoch SECONDS at which an ACTIVE credential stops
121/// authenticating (scheduled retirement, the dual-validity window of
122/// key rotation: the new key is added, both validate, the old key's
123/// row carries the far edge of the window). NULL = never (the
124/// pre-005 posture). Distinct from `revoked_at` (immediate,
125/// operator-confirmed) on purpose: retirement is SCHEDULED forward at
126/// rotation time and enforced lazily (rows with a past `retire_at` are
127/// filtered at lookup), so no background task is required. Additive.
128const MIGRATION_005_CREDENTIAL_RETIRE_AT: &str =
129    "ALTER TABLE credentials ADD COLUMN retire_at INTEGER;";
130
131/// Migration 006 (DW-086): `prompt_overrides` — one row per prompt
132/// name whose active version has been overridden at runtime via the
133/// admin API (the config declares the default `active` version; the
134/// override table records the operator's runtime choice, which takes
135/// precedence). The table is keyed by prompt name (TEXT PRIMARY KEY);
136/// `version` is the overridden active version name, and `updated_at`
137/// is the Unix-epoch seconds of the override. A row is DELETED when
138/// the override is cleared (revert to config default). Additive (a
139/// new table; existing databases have no rows = every prompt uses its
140/// config-declared active version, exactly the pre-006 behavior).
141const MIGRATION_006_PROMPT_OVERRIDES: &str = "
142    CREATE TABLE IF NOT EXISTS prompt_overrides (
143        prompt_name TEXT PRIMARY KEY,
144        version TEXT NOT NULL,
145        updated_at INTEGER NOT NULL
146    );
147";
148
149/// Migration 007 (DW-087): `mcp_sessions` — one row per active MCP
150/// agent session. The `session_id` is a server-generated 128-bit
151/// random hex string (the PRIMARY KEY); `consumer` is the
152/// authenticated consumer name; `created_at`, `last_used_at`, and
153/// `expires_at` are Unix-epoch SECONDS; `client_info` is the optional
154/// client-provided info JSON. A session is deleted on `shutdown` or
155/// when it expires (the cleanup pass deletes rows whose `expires_at`
156/// is in the past). Additive (a new table; existing databases have no
157/// rows = no active sessions, exactly the pre-007 behavior).
158const MIGRATION_007_MCP_SESSIONS: &str = "
159    CREATE TABLE IF NOT EXISTS mcp_sessions (
160        session_id   TEXT PRIMARY KEY,
161        consumer     TEXT NOT NULL,
162        created_at   INTEGER NOT NULL,
163        last_used_at INTEGER NOT NULL,
164        expires_at   INTEGER NOT NULL,
165        client_info  TEXT
166    );
167";
168
169/// Migration 008 (SCALE-05, #184): workspace persistence — durable
170/// storage for the multi-tenant workspace/RBAC/audit model (DW-067).
171/// Four tables:
172/// - `workspaces`: one row per tenant namespace (name PK, description,
173///   active flag, created_at). The `default` workspace is seeded by the
174///   workspace manager at first open, not by this migration (the
175///   manager treats a missing `default` row as "seed it").
176/// - `rbac_roles`: one row per named role (name PK, permissions JSON,
177///   created_at). `permissions` is a JSON array of
178///   `{"action":"read|write|admin","workspace":"<name or *>"}`.
179/// - `rbac_principals`: one row per authenticated principal (identity
180///   PK, roles JSON array of role names, created_at).
181/// - `workspace_audit`: append-only audit log (seq PK AUTOINCREMENT,
182///   timestamp_ms, principal, action, workspace, before JSON, after
183///   JSON, request_id). Indexed on `workspace` for per-workspace
184///   queries and on `timestamp_ms` for time-bounded queries.
185///
186/// Additive (new tables; existing databases have no rows = the
187/// pre-008 in-memory behavior, which the manager seeds on first
188/// open).
189const MIGRATION_008_WORKSPACE_PERSISTENCE: &str = "
190    CREATE TABLE IF NOT EXISTS workspaces (
191        name        TEXT PRIMARY KEY,
192        description TEXT NOT NULL DEFAULT '',
193        active      INTEGER NOT NULL DEFAULT 1,
194        created_at  INTEGER NOT NULL
195    );
196    CREATE TABLE IF NOT EXISTS rbac_roles (
197        name        TEXT PRIMARY KEY,
198        permissions TEXT NOT NULL DEFAULT '[]',
199        created_at  INTEGER NOT NULL
200    );
201    CREATE TABLE IF NOT EXISTS rbac_principals (
202        identity    TEXT PRIMARY KEY,
203        roles       TEXT NOT NULL DEFAULT '[]',
204        created_at  INTEGER NOT NULL
205    );
206    CREATE TABLE IF NOT EXISTS workspace_audit (
207        seq          INTEGER PRIMARY KEY AUTOINCREMENT,
208        timestamp_ms INTEGER NOT NULL,
209        principal    TEXT NOT NULL,
210        action       TEXT NOT NULL,
211        workspace    TEXT NOT NULL,
212        before_state TEXT,
213        after_state  TEXT,
214        request_id   TEXT NOT NULL
215    );
216    CREATE INDEX IF NOT EXISTS idx_workspace_audit_workspace
217        ON workspace_audit (workspace);
218    CREATE INDEX IF NOT EXISTS idx_workspace_audit_timestamp
219        ON workspace_audit (timestamp_ms);
220";
221
222/// SCALE-06 (#185): leader election for CP/DP HA. A single-row table
223/// holds the current leader's instance ID, epoch, and lease expiry.
224/// The epoch increments on each leadership transition. The lease
225/// expiry is a Unix-epoch-millis timestamp; a stale lease (expiry in
226/// the past) allows a standby to acquire leadership.
227///
228/// Additive (new table; existing databases get an empty table = no
229/// leader, so the first controller to call `try_acquire_leader` wins).
230const MIGRATION_009_LEADER_ELECTION: &str = "
231    CREATE TABLE IF NOT EXISTS controller_leader (
232        key            TEXT PRIMARY KEY DEFAULT 'leader',
233        instance_id    TEXT NOT NULL,
234        epoch          INTEGER NOT NULL,
235        acquired_at_ms INTEGER NOT NULL,
236        expires_at_ms  INTEGER NOT NULL
237    );
238    CREATE TABLE IF NOT EXISTS generation_counter (
239        key            TEXT PRIMARY KEY DEFAULT 'cp',
240        generation     INTEGER NOT NULL,
241        updated_at_ms  INTEGER NOT NULL
242    );
243";
244
245/// REL-14 (#249): event durability WAL for critical events. Events
246/// are written to this table before emission to the in-memory bus; on
247/// restart, un-acked events are replayed. The `acked` column is 0
248/// (pending) or 1 (delivered). The `payload` column is the JSON-
249/// serialized `EventPayload`.
250const MIGRATION_010_EVENT_WAL: &str = "
251    CREATE TABLE IF NOT EXISTS event_wal (
252        id           TEXT PRIMARY KEY,
253        kind         TEXT NOT NULL,
254        gateway      TEXT NOT NULL,
255        timestamp_ms INTEGER NOT NULL,
256        payload      TEXT NOT NULL,
257        acked        INTEGER NOT NULL DEFAULT 0
258    );
259    CREATE INDEX IF NOT EXISTS idx_event_wal_acked
260        ON event_wal (acked, timestamp_ms);
261";
262
263/// The full forward migration set, in order. See the module docs for the
264/// baseline recognition rule and the forward-only policy.
265pub fn migrations() -> Migrations<'static> {
266    Migrations::new(vec![
267        M::up(MIGRATION_001_BASELINE),
268        M::up(MIGRATION_002_QUOTA_WINDOW_INDEX),
269        M::up(MIGRATION_003_CONSUMER_GROUPS),
270        M::up(MIGRATION_004_CREDENTIAL_SOURCE_REF),
271        M::up(MIGRATION_005_CREDENTIAL_RETIRE_AT),
272        M::up(MIGRATION_006_PROMPT_OVERRIDES),
273        M::up(MIGRATION_007_MCP_SESSIONS),
274        M::up(MIGRATION_008_WORKSPACE_PERSISTENCE),
275        M::up(MIGRATION_009_LEADER_ELECTION),
276        M::up(MIGRATION_010_EVENT_WAL),
277    ])
278}
279
280/// Current vs latest schema version, for the admin surface (DW-022 will
281/// expose this via the admin API; `current < latest` after open should be
282/// impossible because [`StateStore::open`][crate::state::store::StateStore::open]
283/// migrates automatically — the seam exists for reporting, not gating).
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct SchemaInfo {
286    /// `PRAGMA user_version` of the live database.
287    pub current: u32,
288    /// Highest version this build can migrate to ([`LATEST_SCHEMA_VERSION`]).
289    pub latest: u32,
290}