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}