dwara_core/state/
store.rs

1//! SQLite-backed state store with an in-memory hot cache (DW-018).
2//!
3//! # Purpose
4//!
5//! Durable state for the gateway's control identity layer: consumers,
6//! credential records (pre-hashed secrets), and quota counters. The store
7//! is the OSS state layer (single-process SQLite); multi-instance
8//! deployments would swap the backend behind the same API surface — the
9//! schema and method semantics here are deliberately backend-neutral.
10//!
11//! # Threading model
12//!
13//! `rusqlite` is synchronous. [`StateStore`] owns ONE writer connection
14//! behind a [`std::sync::Mutex`]; every method is a short, non-blocking
15//! critical section (SQLite ops here are all primary-key/index lookups or
16//! small transactions). Callers on an async runtime should wrap
17//! cold-path operations (seeding, quota writes, revocation) in
18//! `tokio::task::spawn_blocking`; the HOT path —
19//! [`StateStore::lookup_credential`] — takes only an `RwLock` read on the
20//! in-memory cache and touches no SQLite after warmup, so it is safe to
21//! call inline on any thread.
22//!
23//! # Cache coherence
24//!
25//! The in-memory cache is coherent for writes made THROUGH the same
26//! [`StateStore`] handle (writes invalidate the affected cache entries).
27//! A separate process, or a second handle to the same file, will NOT
28//! invalidate this cache: single-process coherence only. Multi-instance
29//! deployments need an external shared cache/invalidation layer — that is
30//! an edition boundary concern, not a schema concern.
31//!
32//! # Schema and migrations
33//!
34//! The schema is created and evolved by the versioned, transactional
35//! migration set in [`crate::state::migrations`] (DW-115): opening a store
36//! applies all pending migrations automatically, taking a file backup
37//! first (see [`StateStore::open`]). A database whose `user_version` is
38//! NEWER than this build supports is refused. All writes are
39//! transactional; WAL journal mode and foreign-key enforcement are on.
40//!
41//! The store NEVER sees plaintext secrets: credential values arrive
42//! pre-hashed with a lookup `selector` (a hash for API keys/Basic since
43//! DW-019, a JWT kid/issuer, or an mTLS subject/fingerprint match value
44//! for bindings). Hashing is the authenticator's job (DW-019); the
45//! stored API-key/Basic hash format is owned by
46//! [`config::credentials`](crate::config::credentials) — peppered
47//! `hmac-sha256:<hex>` when the deployment configures a pepper (#124,
48//! resolved above this layer), legacy `sha256:<hex>` otherwise, and
49//! legacy rows upgrade in place on successful verification (see
50//! [`StateStore::rehash_credential`]). Config-seeded keys are hashed at
51//! seed time (see [`sync_consumers_from_config`]).
52
53use std::collections::HashMap;
54use std::fmt;
55use std::path::Path;
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::{Arc, Mutex, RwLock};
58
59use rusqlite::{params, Connection, OptionalExtension};
60
61use crate::config::{Credential, Gateway};
62use crate::state::migrations::{migrations, SchemaInfo, LATEST_SCHEMA_VERSION};
63
64/// Quota-counter retention horizon in seconds: rows whose `window_start`
65/// is older than the newest window in the table by more than this are
66/// pruned opportunistically on quota writes. Fixed at one year, which
67/// comfortably exceeds any realistic rate-limit window (hourly/daily
68/// rollover counters turn over thousands of times per year).
69const QUOTA_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
70
71/// Kind of credential record.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum CredentialKind {
74    ApiKey,
75    Jwt,
76    Mtls,
77    /// HMAC request-signing credential (DW-036). Resolves to a
78    /// CONFIG-declared key only: the store's hash-at-rest contract
79    /// cannot hold MAC-verifiable material (recomputing an HMAC needs
80    /// the raw key bytes), so no row of this kind is ever written —
81    /// the kind exists so the resolved identity can name its family.
82    Hmac,
83    /// OIDC introspection credential (DW-034): a Bearer token validated
84    /// by RFC 7662 token introspection against an external IdP. Like
85    /// `Hmac`, no row of this kind is ever written to the store — the
86    /// kind exists so the resolved identity can name its family.
87    Oidc,
88}
89
90impl CredentialKind {
91    /// The stable wire/storage spelling (public: the admin surface's
92    /// list view names the kind; the value carries no secret).
93    pub fn as_str(self) -> &'static str {
94        match self {
95            CredentialKind::ApiKey => "api_key",
96            CredentialKind::Jwt => "jwt",
97            CredentialKind::Mtls => "mtls",
98            CredentialKind::Hmac => "hmac",
99            CredentialKind::Oidc => "oidc",
100        }
101    }
102
103    fn from_str(s: &str) -> Option<Self> {
104        match s {
105            "api_key" => Some(CredentialKind::ApiKey),
106            "jwt" => Some(CredentialKind::Jwt),
107            "mtls" => Some(CredentialKind::Mtls),
108            "hmac" => Some(CredentialKind::Hmac),
109            "oidc" => Some(CredentialKind::Oidc),
110            _ => None,
111        }
112    }
113}
114
115/// One consumer row.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct ConsumerRecord {
118    pub id: i64,
119    pub name: String,
120    pub priority: Option<u8>,
121    /// Group memberships (#124), the store-side twin of the config
122    /// consumer's `groups` field: group-based authorization applies to
123    /// store-managed consumers exactly as to config ones. The group
124    /// NAMESPACE is shared; config-time validation only sees config
125    /// consumers (see `Consumer::groups` docs).
126    pub groups: Vec<String>,
127    /// Unix epoch seconds.
128    pub created_at: i64,
129}
130
131/// One credential row (already hashed; never contains a plaintext secret).
132///
133/// Manual `Debug`: `hash`, `salt`, and `selector` are credential
134/// material (the selector alone narrows an attacker's search space and
135/// the placeholder hash embeds the config value pre-DW-019), so they are
136/// redacted from debug output. Use field access when the values are
137/// genuinely needed.
138#[derive(Clone, PartialEq, Eq)]
139pub struct CredentialRecord {
140    pub id: i64,
141    pub consumer_id: i64,
142    /// Denormalized consumer name for hot-path convenience.
143    pub consumer_name: String,
144    pub kind: CredentialKind,
145    pub hash: String,
146    pub salt: Option<String>,
147    /// Lookup key (key id/prefix, JWT issuer/kid, cert fingerprint).
148    pub selector: String,
149    /// Unix epoch seconds.
150    pub created_at: i64,
151    /// Unix epoch seconds of revocation, if revoked.
152    pub revoked_at: Option<i64>,
153    /// Unix epoch seconds of SCHEDULED retirement (DW-046), if set: the
154    /// row keeps authenticating until this instant, then stops — the
155    /// dual-validity window's far edge. Enforced lazily at lookup; NULL
156    /// = never.
157    pub retire_at: Option<i64>,
158}
159
160impl fmt::Debug for CredentialRecord {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.debug_struct("CredentialRecord")
163            .field("id", &self.id)
164            .field("consumer_id", &self.consumer_id)
165            .field("consumer_name", &self.consumer_name)
166            .field("kind", &self.kind)
167            .field("hash", &"[redacted]")
168            .field("salt", &self.salt.as_ref().map(|_| "[redacted]"))
169            .field("selector", &"[redacted]")
170            .field("created_at", &self.created_at)
171            .field("revoked_at", &self.revoked_at)
172            .finish()
173    }
174}
175
176/// Typed store error.
177#[derive(Debug)]
178pub enum StoreError {
179    /// Underlying SQLite failure (message string; never carries row data).
180    Sqlite(String),
181    /// A quota increment would exceed the limit; nothing was written.
182    QuotaExceeded {
183        used: u64,
184        limit: u64,
185        requested: u64,
186    },
187    /// Referenced consumer does not exist (FK-class violation surfaced as
188    /// a typed error before SQLite's own FK error).
189    UnknownConsumer(String),
190}
191
192impl std::fmt::Display for StoreError {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        match self {
195            StoreError::Sqlite(m) => write!(f, "state store error: {m}"),
196            StoreError::QuotaExceeded {
197                used,
198                limit,
199                requested,
200            } => write!(
201                f,
202                "quota exceeded: used {used} + requested {requested} > limit {limit}"
203            ),
204            StoreError::UnknownConsumer(name) => {
205                write!(f, "unknown consumer: {name}")
206            }
207        }
208    }
209}
210
211impl std::error::Error for StoreError {}
212
213impl From<rusqlite::Error> for StoreError {
214    fn from(e: rusqlite::Error) -> Self {
215        StoreError::Sqlite(e.to_string())
216    }
217}
218
219impl From<std::io::Error> for StoreError {
220    fn from(e: std::io::Error) -> Self {
221        StoreError::Sqlite(format!("state store io error: {e}"))
222    }
223}
224
225type Result<T> = std::result::Result<T, StoreError>;
226
227/// In-memory hot cache: selectors and consumer names to shared records.
228///
229/// Credential entries cache the FULL active-credential list for a selector
230/// (an empty list is a cached negative result, so unknown selectors also
231/// stop touching disk after their first lookup). Entries are `Arc` so a
232/// cache hit clones no records.
233#[derive(Default)]
234struct HotCache {
235    credentials: RwLock<HashMap<String, Arc<Vec<Arc<CredentialRecord>>>>>,
236    consumers: RwLock<HashMap<String, Arc<ConsumerRecord>>>,
237}
238
239/// SQLite state store with an in-memory hot cache.
240///
241/// Hot path: [`StateStore::lookup_credential`] reads the cache under an `RwLock` read
242/// and performs ZERO SQLite work after warmup (provable via
243/// [`disk_reads`][StateStore::disk_reads]). Cold paths (writes, quota,
244/// list) take the connection mutex; see the module docs for the
245/// threading model.
246pub struct StateStore {
247    conn: Mutex<Connection>,
248    cache: HotCache,
249    disk_reads: AtomicU64,
250    cache_hits: AtomicU64,
251}
252
253/// Manual `Debug`: the connection and cache have no meaningful debug
254/// output; tests use it only for `unwrap_err` on open (error-side Debug).
255impl fmt::Debug for StateStore {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.debug_struct("StateStore").finish_non_exhaustive()
258    }
259}
260
261impl StateStore {
262    /// Open (or create) a store at `path`, applying pending migrations.
263    ///
264    /// ## Backup before migrate (DW-115)
265    ///
266    /// If the database's schema version is behind this build, a consistent
267    /// snapshot is taken via `VACUUM INTO` to
268    /// `<path>.bak-<old-version>-<unix-seconds>-<millis>` (millisecond
269    /// component disambiguates same-second migrations) BEFORE any
270    /// migration runs. Failure to create the backup ABORTS the open: no migration
271    /// proceeds without a safety copy. (A brand-new, empty database and
272    /// in-memory stores skip this — there is no data to lose.)
273    ///
274    /// ## Refusal rules
275    ///
276    /// A database whose `user_version` is NEWER than this build supports
277    /// is refused with a clear error (downgrade attempts; the migration
278    /// set is forward-only — see [`crate::state::migrations`] for the rebuild
279    /// path).
280    ///
281    /// ## Permissions
282    ///
283    /// On unix the database file is tightened to mode 0600 after create:
284    /// it stores credential hashes/selectors, so group/other read access
285    /// granted by the process umask default (0644) is removed. Existing
286    /// files are also tightened; a failure to set permissions is a hard
287    /// error rather than silently shipping a world-readable secrets file.
288    pub fn open(path: &Path) -> Result<Self> {
289        let conn = Connection::open(path)?;
290        #[cfg(unix)]
291        {
292            use std::os::unix::fs::PermissionsExt;
293            let mode = std::fs::metadata(path)?.permissions().mode();
294            if mode & 0o077 != 0 {
295                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
296            }
297        }
298        Self::init(conn, Some(path))
299    }
300
301    /// Open an in-memory store (tests and tooling).
302    pub fn open_in_memory() -> Result<Self> {
303        let conn = Connection::open_in_memory()?;
304        Self::init(conn, None)
305    }
306
307    fn init(conn: Connection, path: Option<&Path>) -> Result<Self> {
308        conn.pragma_update(None, "journal_mode", "WAL")?;
309        conn.pragma_update(None, "foreign_keys", "ON")?;
310        // Contended writers wait up to 5 s instead of failing immediately
311        // with SQLITE_BUSY (this store is single-writer, but a second
312        // handle/process on the same file is possible in tooling).
313        conn.busy_timeout(std::time::Duration::from_secs(5))?;
314        let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
315        if version > i64::from(LATEST_SCHEMA_VERSION) {
316            return Err(StoreError::Sqlite(format!(
317                "state db schema version {version} is newer than this build supports ({LATEST_SCHEMA_VERSION})"
318            )));
319        }
320        let mut conn = conn;
321        if version < i64::from(LATEST_SCHEMA_VERSION) {
322            // version 0 = brand-new (or empty) database: migration 001
323            // builds the base schema and there is no data to lose, so no
324            // backup is taken. Any database that already HAS a schema
325            // (version >= 1) gets a backup before the first change.
326            if version > 0 {
327                if let Some(path) = path {
328                    backup_before_migrate(&conn, path, version)?;
329                }
330            }
331            migrations()
332                .to_latest(&mut conn)
333                .map_err(|e| StoreError::Sqlite(format!("migration failed: {e}")))?;
334        }
335        Ok(Self {
336            conn: Mutex::new(conn),
337            cache: HotCache::default(),
338            disk_reads: AtomicU64::new(0),
339            cache_hits: AtomicU64::new(0),
340        })
341    }
342
343    /// Current schema version of the open database (`PRAGMA user_version`).
344    /// After [`Self::open`] this always equals
345    /// [`crate::state::migrations::LATEST_SCHEMA_VERSION`].
346    pub fn schema_version(&self) -> Result<u32> {
347        let conn = self.conn.lock().expect("store connection poisoned");
348        let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
349        u32::try_from(v)
350            .map_err(|_| StoreError::Sqlite(format!("schema version {v} out of u32 range")))
351    }
352
353    /// Current-vs-latest schema info. Seam for the admin API (DW-022):
354    /// `current` is read live, `latest` is what this build can migrate to.
355    pub fn schema_info(&self) -> Result<SchemaInfo> {
356        Ok(SchemaInfo {
357            current: self.schema_version()?,
358            latest: LATEST_SCHEMA_VERSION,
359        })
360    }
361
362    /// Number of disk (SQLite) reads performed by cache-miss lookups.
363    /// Test/ops observability for the "no disk on the hot path" contract.
364    pub fn disk_reads(&self) -> u64 {
365        self.disk_reads.load(Ordering::Relaxed)
366    }
367
368    /// Number of cache hits served without touching SQLite.
369    pub fn cache_hits(&self) -> u64 {
370        self.cache_hits.load(Ordering::Relaxed)
371    }
372
373    /// Insert or update a consumer by name; returns the resulting record.
374    /// Updating preserves the row id and creation time; `priority` and
375    /// `groups` change (#124). The consumer cache entry is refreshed.
376    pub fn upsert_consumer(
377        &self,
378        name: &str,
379        priority: Option<u8>,
380        groups: &[String],
381    ) -> Result<ConsumerRecord> {
382        let now = now_secs();
383        let groups_json = encode_groups(groups);
384        let conn = self.conn.lock().expect("store connection poisoned");
385        let tx = conn.unchecked_transaction()?;
386        let existing: Option<i64> = tx
387            .query_row(
388                "SELECT id FROM consumers WHERE name = ?1",
389                params![name],
390                |r| r.get(0),
391            )
392            .optional()?;
393        let id = match existing {
394            Some(id) => {
395                tx.execute(
396                    "UPDATE consumers SET priority = ?2, groups = ?3 WHERE id = ?1",
397                    params![id, priority, groups_json],
398                )?;
399                id
400            }
401            None => {
402                tx.execute(
403                    "INSERT INTO consumers (name, priority, groups, created_at)
404                     VALUES (?1, ?2, ?3, ?4)",
405                    params![name, priority, groups_json, now],
406                )?;
407                tx.last_insert_rowid()
408            }
409        };
410        tx.commit()?;
411        // Re-read the actual row rather than fabricating one: on update
412        // the DB keeps the ORIGINAL created_at, and the returned/cached
413        // record must match disk exactly.
414        let record = conn.query_row(
415            "SELECT id, name, priority, groups, created_at FROM consumers WHERE id = ?1",
416            params![id],
417            row_to_consumer,
418        )?;
419        drop(conn);
420        self.cache
421            .consumers
422            .write()
423            .expect("consumer cache poisoned")
424            .insert(name.to_string(), Arc::new(record.clone()));
425        Ok(record)
426    }
427
428    /// List all consumers (disk read; cold path).
429    pub fn list_consumers(&self) -> Result<Vec<ConsumerRecord>> {
430        let conn = self.conn.lock().expect("store connection poisoned");
431        let mut stmt = conn.prepare(
432            "SELECT id, name, priority, groups, created_at FROM consumers ORDER BY name",
433        )?;
434        let rows = stmt.query_map([], row_to_consumer)?;
435        rows.collect::<std::result::Result<Vec<_>, _>>()
436            .map_err(Into::into)
437    }
438
439    /// Add a credential with a PRE-HASHED value. The store never sees
440    /// plaintext secrets: hashing and selector derivation are the
441    /// authenticator's job (DW-019). The selector's cache entry is
442    /// invalidated so the next lookup reflects the new credential.
443    pub fn add_credential(
444        &self,
445        consumer_id: i64,
446        kind: CredentialKind,
447        hash: String,
448        salt: Option<String>,
449        selector: String,
450    ) -> Result<CredentialRecord> {
451        self.insert_credential(consumer_id, kind, hash, salt, selector, None)
452    }
453
454    /// [`Self::add_credential`] for a config credential seeded from a
455    /// `${...}` REFERENCE (DW-045, #46): records the reference text in
456    /// `credentials.source_ref` (schema v4) — the linkage
457    /// [`Self::revoke_credentials_by_source`] uses to retire THIS row
458    /// when a later seed of the same reference can no longer resolve
459    /// it. The reference text is config, not secret bytes (the
460    /// resolved value is hashed before it ever reaches the store).
461    pub fn add_credential_from_reference(
462        &self,
463        consumer_id: i64,
464        kind: CredentialKind,
465        hash: String,
466        selector: String,
467        source_ref: &str,
468    ) -> Result<CredentialRecord> {
469        self.insert_credential(consumer_id, kind, hash, None, selector, Some(source_ref))
470    }
471
472    /// Shared INSERT behind [`Self::add_credential`] and
473    /// [`Self::add_credential_from_reference`]; `source_ref` is the
474    /// config `${...}` reference text when the row was seeded from one
475    /// (NULL otherwise, including every pre-v4 row).
476    fn insert_credential(
477        &self,
478        consumer_id: i64,
479        kind: CredentialKind,
480        hash: String,
481        salt: Option<String>,
482        selector: String,
483        source_ref: Option<&str>,
484    ) -> Result<CredentialRecord> {
485        let now = now_secs();
486        let conn = self.conn.lock().expect("store connection poisoned");
487        let consumer_name: Option<String> = conn
488            .query_row(
489                "SELECT name FROM consumers WHERE id = ?1",
490                params![consumer_id],
491                |r| r.get(0),
492            )
493            .optional()?;
494        let Some(consumer_name) = consumer_name else {
495            return Err(StoreError::UnknownConsumer(format!("id {consumer_id}")));
496        };
497        conn.execute(
498            "INSERT INTO credentials
499                 (consumer_id, kind, hash, salt, selector, created_at, source_ref)
500             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
501            params![
502                consumer_id,
503                kind.as_str(),
504                hash,
505                salt,
506                selector,
507                now,
508                source_ref
509            ],
510        )?;
511        let id = conn.last_insert_rowid();
512        drop(conn);
513        self.invalidate(&selector);
514        Ok(CredentialRecord {
515            id,
516            consumer_id,
517            consumer_name,
518            kind,
519            hash,
520            salt,
521            selector: selector.clone(),
522            created_at: now,
523            revoked_at: None,
524            retire_at: None,
525        })
526    }
527
528    /// Revoke a credential by id; returns whether a row was revoked.
529    /// Idempotent (revoking a revoked credential returns false). The
530    /// selector's cache entry is invalidated so the next lookup reflects
531    /// the revocation.
532    pub fn revoke_credential(&self, credential_id: i64) -> Result<bool> {
533        let now = now_secs();
534        let conn = self.conn.lock().expect("store connection poisoned");
535        let selector: Option<String> = conn
536            .query_row(
537                "SELECT selector FROM credentials WHERE id = ?1 AND revoked_at IS NULL",
538                params![credential_id],
539                |r| r.get(0),
540            )
541            .optional()?;
542        let Some(selector) = selector else {
543            return Ok(false);
544        };
545        conn.execute(
546            "UPDATE credentials SET revoked_at = ?2 WHERE id = ?1",
547            params![credential_id, now],
548        )?;
549        drop(conn);
550        self.invalidate(&selector);
551        Ok(true)
552    }
553
554    /// Schedule a credential's RETIREMENT (DW-046): the row keeps
555    /// authenticating until `at_epoch_secs`, then stops — the
556    /// dual-validity window of key rotation. `at` in the past or now is
557    /// an immediate retirement (equivalent to revocation for lookup
558    /// purposes, but recorded as a scheduled stop, not an operator
559    /// revocation). Returns whether an active row was updated.
560    /// Idempotent: re-retiring moves an existing future retirement
561    /// EARLIER only (never later — a scheduled stop cannot be
562    /// postponed through this call; issue a new credential instead).
563    pub fn retire_credential(&self, credential_id: i64, at_epoch_secs: i64) -> Result<bool> {
564        let conn = self.conn.lock().expect("store connection poisoned");
565        let selector: Option<String> = conn
566            .query_row(
567                "SELECT selector FROM credentials
568                 WHERE id = ?1 AND revoked_at IS NULL
569                   AND (retire_at IS NULL OR retire_at > ?2)",
570                params![credential_id, at_epoch_secs],
571                |r| r.get(0),
572            )
573            .optional()?;
574        let Some(selector) = selector else {
575            return Ok(false);
576        };
577        conn.execute(
578            "UPDATE credentials SET retire_at = ?2 WHERE id = ?1",
579            params![credential_id, at_epoch_secs],
580        )?;
581        drop(conn);
582        // The cached list still carries the row; the registry filters
583        // expired retirements at lookup time (lazy enforcement), but
584        // invalidating here makes an IMMEDIATE retirement effective on
585        // the very next request without waiting for the filter.
586        self.invalidate(&selector);
587        Ok(true)
588    }
589
590    /// Every credential row of a named consumer (including revoked and
591    /// retired ones), newest first — the admin rotation surface's list
592    /// view. Secret material (hash/salt/selector) never leaves this
593    /// module; the admin layer projects only the safe fields.
594    pub fn list_credentials_for_consumer(
595        &self,
596        consumer_name: &str,
597    ) -> Result<Vec<CredentialRecord>> {
598        let conn = self.conn.lock().expect("store connection poisoned");
599        let mut stmt = conn.prepare(
600            "SELECT c.id, c.consumer_id, k.name, c.kind, c.hash, c.salt,
601                    c.selector, c.created_at, c.retire_at, c.revoked_at
602             FROM credentials c JOIN consumers k ON k.id = c.consumer_id
603             WHERE k.name = ?1
604             ORDER BY c.id DESC",
605        )?;
606        let rows = stmt.query_map(params![consumer_name], row_to_credential_full)?;
607        rows.collect::<std::result::Result<Vec<_>, _>>()
608            .map_err(Into::into)
609    }
610
611    /// Revoke every ACTIVE credential of `consumer_id`/`kind` seeded
612    /// from the config reference `source_ref` (#46): the seed path's
613    /// fail-closed skip — a re-seed whose reference no longer resolves
614    /// — calls this so the row a PREVIOUS generation of the same
615    /// reference seeded stops authenticating through
616    /// `CredentialRegistry::Store` (the store-backed twin of the
617    /// config-registry build, which simply does not install the
618    /// credential). Uses the store's own revocation semantics
619    /// (`revoked_at`, per-selector cache invalidation), so a re-seed
620    /// after the source heals inserts a fresh row — revocation never
621    /// blocks config re-seeding. Rows WITHOUT the linkage (pre-v4
622    /// databases, inline keys, operator-managed rows) are untouched:
623    /// they keep the documented upsert-only posture. Returns the number
624    /// of rows revoked.
625    pub fn revoke_credentials_by_source(
626        &self,
627        consumer_id: i64,
628        kind: CredentialKind,
629        source_ref: &str,
630    ) -> Result<u64> {
631        let now = now_secs();
632        let conn = self.conn.lock().expect("store connection poisoned");
633        let selectors: Vec<String> = {
634            let mut stmt = conn.prepare(
635                "SELECT selector FROM credentials
636                 WHERE consumer_id = ?1 AND kind = ?2 AND source_ref = ?3
637                   AND revoked_at IS NULL",
638            )?;
639            let rows = stmt.query_map(params![consumer_id, kind.as_str(), source_ref], |r| {
640                r.get(0)
641            })?;
642            rows.collect::<std::result::Result<_, _>>()?
643        };
644        if selectors.is_empty() {
645            return Ok(0);
646        }
647        let revoked = u64::try_from(conn.execute(
648            "UPDATE credentials SET revoked_at = ?4
649             WHERE consumer_id = ?1 AND kind = ?2 AND source_ref = ?3
650               AND revoked_at IS NULL",
651            params![consumer_id, kind.as_str(), source_ref, now],
652        )?)
653        .unwrap_or(u64::MAX);
654        drop(conn);
655        for selector in selectors {
656            self.invalidate(&selector);
657        }
658        Ok(revoked)
659    }
660
661    /// Replace a credential's stored hash in place (#124 pepper
662    /// transition): used by the authenticator after a SUCCESSFUL legacy
663    /// (`sha256:`) verification when a pepper is configured, upgrading
664    /// the row to `hmac-sha256:<hex>` so the transition completes lazily
665    /// — no credential re-issue, and the next lookup of the same
666    /// selector serves the peppered hash. The UPDATE is guarded by BOTH
667    /// the row id and the selector the credential was looked up under,
668    /// so a row rebound to a different selector between the lookup and
669    /// this write is left alone instead of being re-hashed. The
670    /// selector's cache entry is invalidated; nothing else about the row
671    /// (kind, revocation, timestamps) changes.
672    pub fn rehash_credential(&self, credential_id: i64, selector: &str, hash: &str) -> Result<()> {
673        let conn = self.conn.lock().expect("store connection poisoned");
674        conn.execute(
675            "UPDATE credentials SET hash = ?3 WHERE id = ?1 AND selector = ?2",
676            params![credential_id, selector, hash],
677        )?;
678        drop(conn);
679        self.invalidate(selector);
680        Ok(())
681    }
682
683    /// Disk lookup: all ACTIVE credentials for a selector (revoked rows
684    /// are ignored). Cold path; feeds the cache.
685    pub fn lookup_credentials_by_selector(&self, selector: &str) -> Result<Vec<CredentialRecord>> {
686        let conn = self.conn.lock().expect("store connection poisoned");
687        let mut stmt = conn.prepare(
688            "SELECT c.id, c.consumer_id, k.name, c.kind, c.hash, c.salt,
689                    c.selector, c.created_at, c.retire_at
690             FROM credentials c JOIN consumers k ON k.id = c.consumer_id
691             WHERE c.selector = ?1 AND c.revoked_at IS NULL
692               AND (c.retire_at IS NULL OR c.retire_at > ?2)
693             ORDER BY c.id",
694        )?;
695        let rows = stmt.query_map(params![selector, now_secs()], row_to_credential)?;
696        rows.collect::<std::result::Result<Vec<_>, _>>()
697            .map_err(Into::into)
698    }
699
700    /// HOT PATH: look up the active credentials for a selector. Cache hit
701    /// performs ZERO SQLite work (an `RwLock` read and an `Arc` clone).
702    /// Miss reads disk once and fills the cache, including a cached
703    /// NEGATIVE result (empty list) for unknown selectors.
704    pub fn lookup_credential(
705        &self,
706        selector: &str,
707    ) -> Result<Option<Arc<Vec<Arc<CredentialRecord>>>>> {
708        {
709            let cache = self.cache.credentials.read().expect("cache poisoned");
710            if let Some(entry) = cache.get(selector).map(Arc::clone) {
711                self.cache_hits.fetch_add(1, Ordering::Relaxed);
712                return Ok(Some(entry));
713            }
714        }
715        self.disk_reads.fetch_add(1, Ordering::Relaxed);
716        let records = self.lookup_credentials_by_selector(selector)?;
717        let entry = Arc::new(
718            records
719                .into_iter()
720                .map(Arc::new)
721                .collect::<Vec<Arc<CredentialRecord>>>(),
722        );
723        self.cache
724            .credentials
725            .write()
726            .expect("cache poisoned")
727            .insert(selector.to_string(), Arc::clone(&entry));
728        Ok(Some(entry))
729    }
730
731    /// Consumer lookup by name. Cached on hit (same counter rules as
732    /// [`Self::lookup_credential`]); unlike credentials there is no negative
733    /// caching — consumer-by-name lookups are a control-plane path, so an
734    /// unknown name re-reads disk.
735    pub fn lookup_consumer(&self, name: &str) -> Result<Option<Arc<ConsumerRecord>>> {
736        {
737            let cache = self.cache.consumers.read().expect("cache poisoned");
738            if let Some(entry) = cache.get(name).map(Arc::clone) {
739                self.cache_hits.fetch_add(1, Ordering::Relaxed);
740                return Ok(Some(entry));
741            }
742        }
743        self.disk_reads.fetch_add(1, Ordering::Relaxed);
744        let conn = self.conn.lock().expect("store connection poisoned");
745        let row = conn
746            .query_row(
747                "SELECT id, name, priority, groups, created_at FROM consumers WHERE name = ?1",
748                params![name],
749                row_to_consumer,
750            )
751            .optional()?;
752        drop(conn);
753        let record = row.map(Arc::new);
754        if let Some(record) = &record {
755            self.cache
756                .consumers
757                .write()
758                .expect("cache poisoned")
759                .insert(name.to_string(), Arc::clone(record));
760        }
761        Ok(record)
762    }
763
764    /// Legacy-cleanup rule (DW-019 review): databases seeded by the
765    /// pre-DW-019 build hold api_key rows whose SELECTOR is the plaintext
766    /// config key and whose hash is the placeholder
767    /// `config:api_key:<key>` — both embed the secret verbatim, and
768    /// without this cleanup they would sit in the store forever (the
769    /// post-DW-019 seeding writes a sha256 selector/hash and never
770    /// matches them, so the idempotence check skips them). This build
771    /// never writes that format, so every matching row is transitional
772    /// and is deleted here. A schema migration is overkill for one
773    /// build's placeholder rows: a code-level cleanup at sync time is
774    /// sufficient, runs everywhere sync runs (deployments with or
775    /// without a state DB), and needs no version bump. JWT/mTLS binding
776    /// rows (`config:jwt:` / `config:mtls:`) are NOT deleted — those are
777    /// the current, non-secret binding-marker format. Returns the number
778    /// of rows deleted (0 on a clean or fresh database).
779    pub fn delete_legacy_config_placeholder_credentials(&self) -> Result<u64> {
780        let conn = self.conn.lock().expect("store connection poisoned");
781        let selectors: Vec<String> = {
782            let mut stmt = conn
783                .prepare("SELECT selector FROM credentials WHERE hash LIKE 'config:api_key:%'")?;
784            let rows = stmt.query_map([], |r| r.get(0))?;
785            rows.collect::<std::result::Result<_, _>>()?
786        };
787        if selectors.is_empty() {
788            return Ok(0);
789        }
790        let deleted = u64::try_from(conn.execute(
791            "DELETE FROM credentials WHERE hash LIKE 'config:api_key:%'",
792            [],
793        )?)
794        .unwrap_or(u64::MAX);
795        drop(conn);
796        for selector in selectors {
797            self.invalidate(&selector);
798        }
799        Ok(deleted)
800    }
801
802    /// Invalidate one selector's cache entry (next lookup re-reads disk).
803    pub fn invalidate(&self, selector: &str) {
804        self.cache
805            .credentials
806            .write()
807            .expect("cache poisoned")
808            .remove(selector);
809    }
810
811    /// Invalidate every cache entry (next lookup of anything re-reads disk).
812    pub fn invalidate_all(&self) {
813        self.cache
814            .credentials
815            .write()
816            .expect("cache poisoned")
817            .clear();
818        self.cache
819            .consumers
820            .write()
821            .expect("cache poisoned")
822            .clear();
823    }
824
825    /// Atomically add `amount` to a quota counter, refusing the increment
826    /// (writing nothing) when it would exceed `limit`. Returns the
827    /// used-after value. Window semantics are caller-driven: a new
828    /// `window_start` is a NEW counter (rollover = call with the next
829    /// window's start).
830    pub fn incr_quota(
831        &self,
832        consumer_id: i64,
833        counter_key: &str,
834        window_start: i64,
835        amount: u64,
836        limit: Option<u64>,
837    ) -> Result<u64> {
838        let conn = self.conn.lock().expect("store connection poisoned");
839        // Pre-check the consumer so an unknown id surfaces as a typed
840        // error instead of a raw SQLite FK violation on insert below.
841        let exists: Option<()> = conn
842            .query_row(
843                "SELECT 1 FROM consumers WHERE id = ?1",
844                params![consumer_id],
845                |_| Ok(()),
846            )
847            .optional()?;
848        if exists.is_none() {
849            return Err(StoreError::UnknownConsumer(format!("id {consumer_id}")));
850        }
851        let tx = conn.unchecked_transaction()?;
852        tx.execute(
853            "INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
854             VALUES (?1, ?2, ?3, 0)
855             ON CONFLICT (consumer_id, counter_key, window_start) DO NOTHING",
856            params![consumer_id, counter_key, window_start],
857        )?;
858        // Opportunistic retention: counter rows accumulate one per
859        // (consumer, key, window) forever without pruning. Rows older
860        // than the newest window by more than QUOTA_RETENTION_SECS
861        // (1 year) can no longer be part of any live window and are
862        // deleted here. Pruning against the newest window in the table
863        // (not "now") keeps the horizon independent of clock skew and
864        // caller window scales; worst case with hourly windows the
865        // table holds ~8760 rows per counter key.
866        tx.execute(
867            "DELETE FROM quota_counters
868             WHERE window_start < (SELECT MAX(window_start) FROM quota_counters) - ?1",
869            params![QUOTA_RETENTION_SECS],
870        )?;
871        let used: u64 = tx.query_row(
872            "SELECT used FROM quota_counters
873             WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
874            params![consumer_id, counter_key, window_start],
875            sql_u64_row,
876        )?;
877        if let Some(limit) = limit {
878            let Some(next) = used.checked_add(amount) else {
879                return Err(StoreError::QuotaExceeded {
880                    used,
881                    limit,
882                    requested: amount,
883                });
884            };
885            if next > limit {
886                return Err(StoreError::QuotaExceeded {
887                    used,
888                    limit,
889                    requested: amount,
890                });
891            }
892        }
893        tx.execute(
894            "UPDATE quota_counters SET used = used + ?4
895             WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
896            params![
897                consumer_id,
898                counter_key,
899                window_start,
900                i64::try_from(amount).unwrap_or(i64::MAX)
901            ],
902        )?;
903        let used_after: u64 = tx.query_row(
904            "SELECT used FROM quota_counters
905             WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
906            params![consumer_id, counter_key, window_start],
907            sql_u64_row,
908        )?;
909        tx.commit()?;
910        Ok(used_after)
911    }
912
913    /// Read the current usage of a quota counter (0 when absent).
914    pub fn get_quota(&self, consumer_id: i64, counter_key: &str, window_start: i64) -> Result<u64> {
915        let conn = self.conn.lock().expect("store connection poisoned");
916        let used: Option<u64> = conn
917            .query_row(
918                "SELECT used FROM quota_counters
919                 WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
920                params![consumer_id, counter_key, window_start],
921                sql_u64_row,
922            )
923            .optional()?;
924        Ok(used.unwrap_or(0))
925    }
926
927    // -----------------------------------------------------------------
928    // DW-086: prompt-version overrides (runtime active-version
929    // management). The config declares the default `active` version
930    // for each prompt; the admin API can override it at runtime, and
931    // the override is persisted here so it survives reloads. A
932    // reload that drops the prompt from config silently drops the
933    // override too (the prompt no longer exists).
934    // -----------------------------------------------------------------
935
936    /// Set (or replace) the active-version override for a prompt
937    /// (DW-086). Idempotent: re-setting the same prompt moves the
938    /// override to the new version. The caller validates that the
939    /// prompt name and version exist in the CURRENT config before
940    /// calling (the store does not know the config).
941    pub fn set_prompt_override(&self, prompt_name: &str, version: &str) -> Result<()> {
942        let now = now_secs();
943        let conn = self.conn.lock().expect("store connection poisoned");
944        conn.execute(
945            "INSERT INTO prompt_overrides (prompt_name, version, updated_at)
946             VALUES (?1, ?2, ?3)
947             ON CONFLICT (prompt_name) DO UPDATE SET version = ?2, updated_at = ?3",
948            params![prompt_name, version, now],
949        )?;
950        Ok(())
951    }
952
953    /// Clear the active-version override for a prompt (DW-086):
954    /// revert to the config-declared `active` version. Idempotent
955    /// (clearing a prompt with no override is a no-op).
956    pub fn clear_prompt_override(&self, prompt_name: &str) -> Result<()> {
957        let conn = self.conn.lock().expect("store connection poisoned");
958        conn.execute(
959            "DELETE FROM prompt_overrides WHERE prompt_name = ?1",
960            params![prompt_name],
961        )?;
962        Ok(())
963    }
964
965    /// Read the active-version override for a prompt (DW-086):
966    /// `Some(version)` when an override exists, `None` when the
967    /// config-declared `active` version governs.
968    pub fn get_prompt_override(&self, prompt_name: &str) -> Result<Option<String>> {
969        let conn = self.conn.lock().expect("store connection poisoned");
970        let version: Option<String> = conn
971            .query_row(
972                "SELECT version FROM prompt_overrides WHERE prompt_name = ?1",
973                params![prompt_name],
974                |r| r.get(0),
975            )
976            .optional()?;
977        Ok(version)
978    }
979
980    /// Every prompt override row (prompt_name, version), for the
981    /// admin surface's list view and for the compile-time override
982    /// merge (DW-086).
983    pub fn list_prompt_overrides(&self) -> Result<Vec<(String, String)>> {
984        let conn = self.conn.lock().expect("store connection poisoned");
985        let mut stmt =
986            conn.prepare("SELECT prompt_name, version FROM prompt_overrides ORDER BY prompt_name")?;
987        let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
988        rows.collect::<std::result::Result<Vec<_>, _>>()
989            .map_err(Into::into)
990    }
991
992    // --- MCP sessions (DW-087) ---------------------------------------
993
994    /// Create a new MCP agent session (DW-087). Inserts a row with
995    /// the given session id, consumer, TTL, and optional client info
996    /// JSON. `created_at`, `last_used_at`, and `expires_at` are
997    /// Unix-epoch SECONDS.
998    pub fn create_mcp_session(
999        &self,
1000        session_id: &str,
1001        consumer: &str,
1002        ttl_secs: u64,
1003        client_info: Option<&str>,
1004    ) -> Result<()> {
1005        let conn = self.conn.lock().expect("store connection poisoned");
1006        let now = std::time::SystemTime::now()
1007            .duration_since(std::time::UNIX_EPOCH)
1008            .map(|d| d.as_secs() as i64)
1009            .unwrap_or(0);
1010        let expires = now + ttl_secs as i64;
1011        conn.execute(
1012            "INSERT INTO mcp_sessions \
1013             (session_id, consumer, created_at, last_used_at, expires_at, client_info) \
1014             VALUES (?1, ?2, ?3, ?3, ?4, ?5)",
1015            params![session_id, consumer, now, expires, client_info],
1016        )?;
1017        Ok(())
1018    }
1019
1020    /// Touch an MCP session: update `last_used_at` to now. Returns
1021    /// `true` if the session exists and is not expired, `false`
1022    /// otherwise. An expired session is deleted on touch.
1023    pub fn touch_mcp_session(&self, session_id: &str) -> Result<bool> {
1024        let conn = self.conn.lock().expect("store connection poisoned");
1025        let now = std::time::SystemTime::now()
1026            .duration_since(std::time::UNIX_EPOCH)
1027            .map(|d| d.as_secs() as i64)
1028            .unwrap_or(0);
1029        let expires_at: Option<i64> = conn
1030            .query_row(
1031                "SELECT expires_at FROM mcp_sessions WHERE session_id = ?1",
1032                params![session_id],
1033                |r| r.get(0),
1034            )
1035            .optional()?;
1036        match expires_at {
1037            Some(exp) if exp > now => {
1038                conn.execute(
1039                    "UPDATE mcp_sessions SET last_used_at = ?1 WHERE session_id = ?2",
1040                    params![now, session_id],
1041                )?;
1042                Ok(true)
1043            }
1044            Some(_) => {
1045                // Expired: delete it.
1046                let _ = conn.execute(
1047                    "DELETE FROM mcp_sessions WHERE session_id = ?1",
1048                    params![session_id],
1049                );
1050                Ok(false)
1051            }
1052            None => Ok(false),
1053        }
1054    }
1055
1056    /// Delete an MCP session (DW-087 `shutdown`). Returns Ok
1057    /// regardless of whether a row was deleted.
1058    pub fn delete_mcp_session(&self, session_id: &str) -> Result<()> {
1059        let conn = self.conn.lock().expect("store connection poisoned");
1060        conn.execute(
1061            "DELETE FROM mcp_sessions WHERE session_id = ?1",
1062            params![session_id],
1063        )?;
1064        Ok(())
1065    }
1066
1067    /// Count active (non-expired) MCP sessions (DW-087).
1068    pub fn count_active_mcp_sessions(&self) -> Result<usize> {
1069        let conn = self.conn.lock().expect("store connection poisoned");
1070        let now = std::time::SystemTime::now()
1071            .duration_since(std::time::UNIX_EPOCH)
1072            .map(|d| d.as_secs() as i64)
1073            .unwrap_or(0);
1074        let count: i64 = conn.query_row(
1075            "SELECT COUNT(*) FROM mcp_sessions WHERE expires_at > ?1",
1076            params![now],
1077            |r| r.get(0),
1078        )?;
1079        Ok(count as usize)
1080    }
1081
1082    /// Delete expired MCP sessions (DW-087). Returns the number of
1083    /// rows deleted.
1084    pub fn cleanup_expired_mcp_sessions(&self) -> Result<usize> {
1085        let conn = self.conn.lock().expect("store connection poisoned");
1086        let now = std::time::SystemTime::now()
1087            .duration_since(std::time::UNIX_EPOCH)
1088            .map(|d| d.as_secs() as i64)
1089            .unwrap_or(0);
1090        let n = conn.execute(
1091            "DELETE FROM mcp_sessions WHERE expires_at <= ?1",
1092            params![now],
1093        )?;
1094        Ok(n)
1095    }
1096
1097    /// List active MCP sessions (DW-087): the admin surface's
1098    /// `GET /mcp/sessions` view. Returns (session_id, consumer,
1099    /// created_at, last_used_at, expires_at, client_info) for
1100    /// non-expired sessions, ordered by created_at descending.
1101    #[allow(clippy::type_complexity)]
1102    pub fn list_active_mcp_sessions(
1103        &self,
1104    ) -> Result<Vec<(String, String, i64, i64, i64, Option<String>)>> {
1105        let conn = self.conn.lock().expect("store connection poisoned");
1106        let now = std::time::SystemTime::now()
1107            .duration_since(std::time::UNIX_EPOCH)
1108            .map(|d| d.as_secs() as i64)
1109            .unwrap_or(0);
1110        let mut stmt = conn.prepare(
1111            "SELECT session_id, consumer, created_at, last_used_at, expires_at, client_info \
1112             FROM mcp_sessions WHERE expires_at > ?1 \
1113             ORDER BY created_at DESC",
1114        )?;
1115        let rows = stmt.query_map(params![now], |r| {
1116            Ok((
1117                r.get::<_, String>(0)?,
1118                r.get::<_, String>(1)?,
1119                r.get::<_, i64>(2)?,
1120                r.get::<_, i64>(3)?,
1121                r.get::<_, i64>(4)?,
1122                r.get::<_, Option<String>>(5)?,
1123            ))
1124        })?;
1125        rows.collect::<std::result::Result<Vec<_>, _>>()
1126            .map_err(Into::into)
1127    }
1128
1129    // ----------------------------------------------------------------------
1130    // SCALE-05 (#184): workspace persistence — workspaces, RBAC roles,
1131    // principals, and the append-only audit log. The SQL schema lives
1132    // in migration 008; these methods are the store's typed CRUD over
1133    // it. The workspace manager owns the domain types and calls these
1134    // methods; the store stays backend-neutral (no upward import of
1135    // workspace types — the record structs here are plain data,
1136    // serialized to/from JSON at the boundary).
1137    // ----------------------------------------------------------------------
1138
1139    /// Insert or replace a workspace row. The `default` workspace is
1140    /// seeded by the manager on first open; this method is the
1141    /// manager's persistence path for create/update.
1142    pub fn upsert_workspace(&self, name: &str, description: &str, active: bool) -> Result<()> {
1143        let conn = self.conn.lock().expect("store connection poisoned");
1144        conn.execute(
1145            "INSERT INTO workspaces (name, description, active, created_at) \
1146             VALUES (?1, ?2, ?3, ?4) \
1147             ON CONFLICT (name) DO UPDATE SET \
1148             description = excluded.description, \
1149             active = excluded.active",
1150            params![name, description, active as i64, now_secs()],
1151        )?;
1152        Ok(())
1153    }
1154
1155    /// Delete a workspace row. Returns true if a row was deleted.
1156    pub fn delete_workspace_row(&self, name: &str) -> Result<bool> {
1157        let conn = self.conn.lock().expect("store connection poisoned");
1158        let n = conn.execute("DELETE FROM workspaces WHERE name = ?1", params![name])?;
1159        Ok(n > 0)
1160    }
1161
1162    /// List all workspace rows.
1163    pub fn list_workspace_rows(&self) -> Result<Vec<WorkspaceRow>> {
1164        let conn = self.conn.lock().expect("store connection poisoned");
1165        let mut stmt = conn.prepare(
1166            "SELECT name, description, active, created_at FROM workspaces ORDER BY name",
1167        )?;
1168        let rows = stmt.query_map([], |r| {
1169            Ok(WorkspaceRow {
1170                name: r.get(0)?,
1171                description: r.get(1)?,
1172                active: r.get::<_, i64>(2)? != 0,
1173                created_at: r.get(3)?,
1174            })
1175        })?;
1176        rows.collect::<std::result::Result<Vec<_>, _>>()
1177            .map_err(Into::into)
1178    }
1179
1180    /// Get a single workspace row by name.
1181    pub fn get_workspace_row(&self, name: &str) -> Result<Option<WorkspaceRow>> {
1182        let conn = self.conn.lock().expect("store connection poisoned");
1183        let mut stmt = conn.prepare(
1184            "SELECT name, description, active, created_at FROM workspaces WHERE name = ?1",
1185        )?;
1186        let row = stmt
1187            .query_row(params![name], |r| {
1188                Ok(WorkspaceRow {
1189                    name: r.get(0)?,
1190                    description: r.get(1)?,
1191                    active: r.get::<_, i64>(2)? != 0,
1192                    created_at: r.get(3)?,
1193                })
1194            })
1195            .optional()?;
1196        Ok(row)
1197    }
1198
1199    /// Insert or replace an RBAC role row. The permissions are stored
1200    /// as a JSON array of `{"action":"...","workspace":"..."}`.
1201    pub fn upsert_role(&self, name: &str, permissions_json: &str) -> Result<()> {
1202        let conn = self.conn.lock().expect("store connection poisoned");
1203        conn.execute(
1204            "INSERT INTO rbac_roles (name, permissions, created_at) \
1205             VALUES (?1, ?2, ?3) \
1206             ON CONFLICT (name) DO UPDATE SET permissions = excluded.permissions",
1207            params![name, permissions_json, now_secs()],
1208        )?;
1209        Ok(())
1210    }
1211
1212    /// Delete an RBAC role row. Returns true if a row was deleted.
1213    pub fn delete_role_row(&self, name: &str) -> Result<bool> {
1214        let conn = self.conn.lock().expect("store connection poisoned");
1215        let n = conn.execute("DELETE FROM rbac_roles WHERE name = ?1", params![name])?;
1216        Ok(n > 0)
1217    }
1218
1219    /// List all RBAC role rows.
1220    pub fn list_role_rows(&self) -> Result<Vec<RoleRow>> {
1221        let conn = self.conn.lock().expect("store connection poisoned");
1222        let mut stmt =
1223            conn.prepare("SELECT name, permissions, created_at FROM rbac_roles ORDER BY name")?;
1224        let rows = stmt.query_map([], |r| {
1225            Ok(RoleRow {
1226                name: r.get(0)?,
1227                permissions_json: r.get(1)?,
1228                created_at: r.get(2)?,
1229            })
1230        })?;
1231        rows.collect::<std::result::Result<Vec<_>, _>>()
1232            .map_err(Into::into)
1233    }
1234
1235    /// Insert or replace a principal row. The roles are stored as a
1236    /// JSON array of role-name strings.
1237    pub fn upsert_principal(&self, identity: &str, roles_json: &str) -> Result<()> {
1238        let conn = self.conn.lock().expect("store connection poisoned");
1239        conn.execute(
1240            "INSERT INTO rbac_principals (identity, roles, created_at) \
1241             VALUES (?1, ?2, ?3) \
1242             ON CONFLICT (identity) DO UPDATE SET roles = excluded.roles",
1243            params![identity, roles_json, now_secs()],
1244        )?;
1245        Ok(())
1246    }
1247
1248    /// Get a single principal row by identity.
1249    pub fn get_principal_row(&self, identity: &str) -> Result<Option<PrincipalRow>> {
1250        let conn = self.conn.lock().expect("store connection poisoned");
1251        let row = conn
1252            .query_row(
1253                "SELECT identity, roles, created_at FROM rbac_principals WHERE identity = ?1",
1254                params![identity],
1255                |r| {
1256                    Ok(PrincipalRow {
1257                        identity: r.get(0)?,
1258                        roles_json: r.get(1)?,
1259                        created_at: r.get(2)?,
1260                    })
1261                },
1262            )
1263            .optional()?;
1264        Ok(row)
1265    }
1266
1267    /// List all principal rows.
1268    pub fn list_principal_rows(&self) -> Result<Vec<PrincipalRow>> {
1269        let conn = self.conn.lock().expect("store connection poisoned");
1270        let mut stmt = conn
1271            .prepare("SELECT identity, roles, created_at FROM rbac_principals ORDER BY identity")?;
1272        let rows = stmt.query_map([], |r| {
1273            Ok(PrincipalRow {
1274                identity: r.get(0)?,
1275                roles_json: r.get(1)?,
1276                created_at: r.get(2)?,
1277            })
1278        })?;
1279        rows.collect::<std::result::Result<Vec<_>, _>>()
1280            .map_err(Into::into)
1281    }
1282
1283    /// Append an audit entry. Returns the assigned sequence number.
1284    #[allow(clippy::too_many_arguments)]
1285    pub fn append_workspace_audit(
1286        &self,
1287        timestamp_ms: i64,
1288        principal: &str,
1289        action: &str,
1290        workspace: &str,
1291        before: Option<&str>,
1292        after: Option<&str>,
1293        request_id: &str,
1294    ) -> Result<i64> {
1295        let conn = self.conn.lock().expect("store connection poisoned");
1296        conn.execute(
1297            "INSERT INTO workspace_audit \
1298             (timestamp_ms, principal, action, workspace, before_state, after_state, request_id) \
1299             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1300            params![
1301                timestamp_ms,
1302                principal,
1303                action,
1304                workspace,
1305                before,
1306                after,
1307                request_id
1308            ],
1309        )?;
1310        Ok(conn.last_insert_rowid())
1311    }
1312
1313    /// Query audit entries, optionally filtered by workspace and
1314    /// bounded by a time range. Returns entries ordered by seq
1315    /// descending (newest first), capped at `limit`.
1316    pub fn query_workspace_audit(
1317        &self,
1318        workspace: Option<&str>,
1319        since_ms: Option<i64>,
1320        until_ms: Option<i64>,
1321        limit: i64,
1322    ) -> Result<Vec<AuditRow>> {
1323        let conn = self.conn.lock().expect("store connection poisoned");
1324        let mut sql = String::from(
1325            "SELECT seq, timestamp_ms, principal, action, workspace, before_state, after_state, request_id \
1326             FROM workspace_audit WHERE 1=1",
1327        );
1328        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1329        if let Some(ws) = workspace {
1330            sql.push_str(" AND workspace = ?");
1331            params_vec.push(Box::new(ws.to_string()));
1332        }
1333        if let Some(since) = since_ms {
1334            sql.push_str(" AND timestamp_ms >= ?");
1335            params_vec.push(Box::new(since));
1336        }
1337        if let Some(until) = until_ms {
1338            sql.push_str(" AND timestamp_ms <= ?");
1339            params_vec.push(Box::new(until));
1340        }
1341        sql.push_str(" ORDER BY seq DESC LIMIT ?");
1342        params_vec.push(Box::new(limit));
1343        let mut stmt = conn.prepare(&sql)?;
1344        let params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1345        let rows = stmt.query_map(params_ref.as_slice(), |r| {
1346            Ok(AuditRow {
1347                seq: r.get(0)?,
1348                timestamp_ms: r.get(1)?,
1349                principal: r.get(2)?,
1350                action: r.get(3)?,
1351                workspace: r.get(4)?,
1352                before_state: r.get(5)?,
1353                after_state: r.get(6)?,
1354                request_id: r.get(7)?,
1355            })
1356        })?;
1357        rows.collect::<std::result::Result<Vec<_>, _>>()
1358            .map_err(Into::into)
1359    }
1360
1361    // ------------------------------------------------------------------
1362    // SCALE-06 (#185): leader election for CP/DP HA. The
1363    // controller_leader table (migration 009) holds a single row
1364    // with the current leader's instance ID, epoch, and lease expiry.
1365    // ------------------------------------------------------------------
1366
1367    /// Try to acquire leadership. Returns `Ok(LeaderRow)` if this
1368    /// instance now holds the lease; returns `Err(String)` with
1369    /// "instance_id:expires_at_ms" of the current holder if another
1370    /// instance holds a valid (non-expired) lease.
1371    pub fn try_acquire_leader(&self, instance_id: &str, ttl_ms: i64) -> Result<LeaderRow> {
1372        let conn = self.conn.lock().expect("store connection poisoned");
1373        let now = now_unix_ms();
1374        let expires_at = now + ttl_ms;
1375
1376        // Check if there's an existing valid lease.
1377        let existing: Option<(String, i64, i64)> = conn
1378            .query_row(
1379                "SELECT instance_id, epoch, expires_at_ms \
1380                 FROM controller_leader WHERE key = 'leader'",
1381                [],
1382                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1383            )
1384            .ok();
1385
1386        match existing {
1387            Some((id, _epoch, expires)) if expires > now && id != instance_id => {
1388                // Another instance holds a valid lease.
1389                Err(StoreError::Sqlite(format!("{id}:{expires}")))
1390            }
1391            Some((id, epoch, _)) if id == instance_id => {
1392                // We already hold the lease; renew it.
1393                let new_epoch = epoch;
1394                conn.execute(
1395                    "UPDATE controller_leader \
1396                     SET expires_at_ms = ?1 \
1397                     WHERE key = 'leader' AND instance_id = ?2",
1398                    params![expires_at, instance_id],
1399                )?;
1400                Ok(LeaderRow {
1401                    instance_id: instance_id.to_string(),
1402                    epoch: new_epoch as u64,
1403                    acquired_at_ms: now,
1404                    expires_at_ms: expires_at,
1405                })
1406            }
1407            _ => {
1408                // No lease or expired lease: acquire it.
1409                let epoch = existing.map(|(_, e, _)| e + 1).unwrap_or(1);
1410                conn.execute(
1411                    "INSERT OR REPLACE INTO controller_leader \
1412                     (key, instance_id, epoch, acquired_at_ms, expires_at_ms) \
1413                     VALUES ('leader', ?1, ?2, ?3, ?4)",
1414                    params![instance_id, epoch, now, expires_at],
1415                )?;
1416                Ok(LeaderRow {
1417                    instance_id: instance_id.to_string(),
1418                    epoch: epoch as u64,
1419                    acquired_at_ms: now,
1420                    expires_at_ms: expires_at,
1421                })
1422            }
1423        }
1424    }
1425
1426    /// Renew the leader lease. Returns `Ok(LeaderRow)` with the new
1427    /// expiry if this instance still holds the lease; `Err` if the
1428    /// lease was lost (expired or stolen).
1429    pub fn renew_leader_lease(&self, instance_id: &str, ttl_ms: i64) -> Result<LeaderRow> {
1430        let conn = self.conn.lock().expect("store connection poisoned");
1431        let now = now_unix_ms();
1432        let expires_at = now + ttl_ms;
1433
1434        let updated = conn.execute(
1435            "UPDATE controller_leader \
1436             SET expires_at_ms = ?1 \
1437             WHERE key = 'leader' AND instance_id = ?2 AND expires_at_ms > ?3",
1438            params![expires_at, instance_id, now],
1439        )?;
1440
1441        if updated == 0 {
1442            // Either no row, or the lease was stolen/expired.
1443            return Err(StoreError::Sqlite(
1444                "lease lost: no valid lease held by this instance".to_string(),
1445            ));
1446        }
1447
1448        // Read back the full row.
1449        let row = conn.query_row(
1450            "SELECT instance_id, epoch, acquired_at_ms, expires_at_ms \
1451             FROM controller_leader WHERE key = 'leader'",
1452            [],
1453            |r| {
1454                Ok(LeaderRow {
1455                    instance_id: r.get(0)?,
1456                    epoch: r.get::<_, i64>(1)? as u64,
1457                    acquired_at_ms: r.get(2)?,
1458                    expires_at_ms: r.get(3)?,
1459                })
1460            },
1461        )?;
1462        Ok(row)
1463    }
1464
1465    /// Voluntarily release the lease (graceful shutdown). Returns
1466    /// `Ok(())` if the lease was released, `Err` if this instance
1467    /// did not hold the lease.
1468    pub fn step_down_leader(&self, instance_id: &str) -> Result<()> {
1469        let conn = self.conn.lock().expect("store connection poisoned");
1470        let deleted = conn.execute(
1471            "DELETE FROM controller_leader \
1472             WHERE key = 'leader' AND instance_id = ?1",
1473            params![instance_id],
1474        )?;
1475        if deleted == 0 {
1476            return Err(StoreError::Sqlite(
1477                "step_down failed: this instance does not hold the lease".to_string(),
1478            ));
1479        }
1480        Ok(())
1481    }
1482
1483    /// Get the current leader (if any), or `None` if no valid
1484    /// (non-expired) lease exists.
1485    pub fn current_leader(&self) -> Result<Option<LeaderRow>> {
1486        let conn = self.conn.lock().expect("store connection poisoned");
1487        let now = now_unix_ms();
1488        let row = conn
1489            .query_row(
1490                "SELECT instance_id, epoch, acquired_at_ms, expires_at_ms \
1491                 FROM controller_leader WHERE key = 'leader' AND expires_at_ms > ?1",
1492                params![now],
1493                |r| {
1494                    Ok(LeaderRow {
1495                        instance_id: r.get(0)?,
1496                        epoch: r.get::<_, i64>(1)? as u64,
1497                        acquired_at_ms: r.get(2)?,
1498                        expires_at_ms: r.get(3)?,
1499                    })
1500                },
1501            )
1502            .ok();
1503        // Distinguish "no row" from "query error".
1504        match row {
1505            Some(r) => Ok(Some(r)),
1506            None => {
1507                // Check if a row exists at all (expired or not).
1508                let count: i64 = conn
1509                    .query_row(
1510                        "SELECT COUNT(*) FROM controller_leader WHERE key = 'leader'",
1511                        [],
1512                        |r| r.get(0),
1513                    )
1514                    .unwrap_or(0);
1515                if count == 0 {
1516                    Ok(None)
1517                } else {
1518                    // Row exists but expired.
1519                    Ok(None)
1520                }
1521            }
1522        }
1523    }
1524
1525    /// Persist the current config generation counter (SCALE-06, #185).
1526    /// This survives controller restarts so the generation counter
1527    /// does not reset to 1.
1528    pub fn save_generation_counter(&self, generation: u64) -> Result<()> {
1529        let conn = self.conn.lock().expect("store connection poisoned");
1530        let now = now_unix_ms();
1531        conn.execute(
1532            "INSERT OR REPLACE INTO generation_counter (key, generation, updated_at_ms) \
1533             VALUES ('cp', ?1, ?2)",
1534            params![generation as i64, now],
1535        )?;
1536        Ok(())
1537    }
1538
1539    /// Load the persisted config generation counter (SCALE-06, #185).
1540    /// Returns `None` if no counter has been persisted (fresh store).
1541    pub fn load_generation_counter(&self) -> Result<Option<u64>> {
1542        let conn = self.conn.lock().expect("store connection poisoned");
1543        let row = conn
1544            .query_row(
1545                "SELECT generation FROM generation_counter WHERE key = 'cp'",
1546                [],
1547                |r| r.get::<_, i64>(0),
1548            )
1549            .ok();
1550        Ok(row.map(|v| v as u64))
1551    }
1552
1553    // -----------------------------------------------------------------------
1554    // REL-14 (#249): Event durability WAL.
1555    // -----------------------------------------------------------------------
1556
1557    /// Append a critical event to the WAL before emission. The event
1558    /// is persisted as un-acked (`acked = 0`); the deliverer marks it
1559    /// acked after dispatch. `payload_json` is the JSON-serialized
1560    /// `EventPayload`.
1561    pub fn append_event_wal(
1562        &self,
1563        id: &str,
1564        kind: &str,
1565        gateway: &str,
1566        timestamp_ms: u64,
1567        payload_json: &str,
1568    ) -> Result<()> {
1569        let conn = self.conn.lock().expect("store connection poisoned");
1570        conn.execute(
1571            "INSERT OR REPLACE INTO event_wal (id, kind, gateway, timestamp_ms, payload, acked) \
1572             VALUES (?1, ?2, ?3, ?4, ?5, 0)",
1573            params![id, kind, gateway, timestamp_ms as i64, payload_json],
1574        )?;
1575        Ok(())
1576    }
1577
1578    /// Mark an event as acked (delivered). Called by the webhook
1579    /// deliverer after the event has been dispatched to matching
1580    /// targets. Returns the number of rows updated (0 if the event was
1581    /// already acked or doesn't exist).
1582    pub fn ack_event_wal(&self, id: &str) -> Result<usize> {
1583        let conn = self.conn.lock().expect("store connection poisoned");
1584        let n = conn.execute(
1585            "UPDATE event_wal SET acked = 1 WHERE id = ?1 AND acked = 0",
1586            params![id],
1587        )?;
1588        Ok(n)
1589    }
1590
1591    /// Load all un-acked events from the WAL, ordered by timestamp
1592    /// (oldest first). Called on startup to replay events that were
1593    /// not delivered before the previous process exited.
1594    pub fn unacked_events_wal(&self) -> Result<Vec<EventWalRow>> {
1595        let conn = self.conn.lock().expect("store connection poisoned");
1596        let mut stmt = conn.prepare(
1597            "SELECT id, kind, gateway, timestamp_ms, payload \
1598             FROM event_wal WHERE acked = 0 ORDER BY timestamp_ms ASC",
1599        )?;
1600        let rows = stmt.query_map([], |r| {
1601            Ok(EventWalRow {
1602                id: r.get(0)?,
1603                kind: r.get(1)?,
1604                gateway: r.get(2)?,
1605                timestamp_ms: r.get(3)?,
1606                payload: r.get(4)?,
1607            })
1608        })?;
1609        let mut result = Vec::new();
1610        for row in rows {
1611            result.push(row?);
1612        }
1613        Ok(result)
1614    }
1615
1616    /// Purge acked events older than the given timestamp. Called
1617    /// periodically to keep the WAL from growing unbounded. Returns
1618    /// the number of rows purged.
1619    pub fn purge_acked_events_wal(&self, older_than_ms: u64) -> Result<usize> {
1620        let conn = self.conn.lock().expect("store connection poisoned");
1621        let n = conn.execute(
1622            "DELETE FROM event_wal WHERE acked = 1 AND timestamp_ms < ?1",
1623            params![older_than_ms as i64],
1624        )?;
1625        Ok(n)
1626    }
1627}
1628
1629/// One workspace row (SCALE-05, #184). Plain data — the workspace
1630/// domain types live in [`crate::workspace`]; the store serializes
1631/// at the SQL boundary so it never imports upward.
1632#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct WorkspaceRow {
1634    pub name: String,
1635    pub description: String,
1636    pub active: bool,
1637    pub created_at: i64,
1638}
1639
1640/// One RBAC role row (SCALE-05, #184). `permissions_json` is a JSON
1641/// array of `{"action":"read|write|admin","workspace":"<name or *>"}`.
1642#[derive(Debug, Clone, PartialEq, Eq)]
1643pub struct RoleRow {
1644    pub name: String,
1645    pub permissions_json: String,
1646    pub created_at: i64,
1647}
1648
1649/// One principal row (SCALE-05, #184). `roles_json` is a JSON array
1650/// of role-name strings.
1651#[derive(Debug, Clone, PartialEq, Eq)]
1652pub struct PrincipalRow {
1653    pub identity: String,
1654    pub roles_json: String,
1655    pub created_at: i64,
1656}
1657
1658/// One audit log row (SCALE-05, #184). `before_state` and
1659/// `after_state` are optional JSON strings (None for create/delete).
1660#[derive(Debug, Clone, PartialEq, Eq)]
1661pub struct AuditRow {
1662    pub seq: i64,
1663    pub timestamp_ms: i64,
1664    pub principal: String,
1665    pub action: String,
1666    pub workspace: String,
1667    pub before_state: Option<String>,
1668    pub after_state: Option<String>,
1669    pub request_id: String,
1670}
1671
1672/// One leader row (SCALE-06, #185). The current leader's instance ID,
1673/// epoch, and lease expiry. Plain data — the leader election module
1674/// owns the domain types; the store stays backend-neutral.
1675#[derive(Debug, Clone, PartialEq, Eq)]
1676pub struct LeaderRow {
1677    pub instance_id: String,
1678    pub epoch: u64,
1679    pub acquired_at_ms: i64,
1680    pub expires_at_ms: i64,
1681}
1682
1683/// One event WAL row (REL-14, #249). Plain data — the events module
1684/// owns the domain types; the store stays backend-neutral. The
1685/// `payload` is the JSON-serialized `EventPayload`.
1686#[derive(Debug, Clone, PartialEq, Eq)]
1687pub struct EventWalRow {
1688    pub id: String,
1689    pub kind: String,
1690    pub gateway: String,
1691    pub timestamp_ms: i64,
1692    pub payload: String,
1693}
1694
1695/// Row mapper for `used` counters (SQLite INTEGER -> u64). rusqlite 0.38
1696/// dropped the u64 FromSql impl, so quota counters convert at the SQL
1697/// boundary; negative values cannot occur (counters only increment from 0).
1698fn sql_u64_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<u64> {
1699    let v: i64 = r.get(0)?;
1700    u64::try_from(v).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, v))
1701}
1702
1703/// Backup file name for a pre-migration snapshot: `<file>.bak-<version>-<unix-seconds>-<millis>`.
1704/// The millisecond component disambiguates backups taken within the same
1705/// second (a seconds-only name made a same-second retry collide with the
1706/// previous attempt's file, making `VACUUM INTO` fail on the existing
1707/// target and the retry look like a persistent fault).
1708fn backup_file_name(path: &Path, version: i64, now: std::time::SystemTime) -> String {
1709    let dur = now
1710        .duration_since(std::time::UNIX_EPOCH)
1711        .unwrap_or_default();
1712    format!(
1713        "{}.bak-{version}-{}-{}",
1714        path.file_name()
1715            .map(std::ffi::OsStr::to_string_lossy)
1716            .unwrap_or_else(|| "state.db".into()),
1717        dur.as_secs(),
1718        dur.subsec_millis()
1719    )
1720}
1721
1722/// Take a consistent pre-migration snapshot of the database at `path`
1723/// (schema `version`) into the name built by [`backup_file_name`] using
1724/// `VACUUM INTO`, which the SQLite backup API backs and which includes
1725/// all committed WAL content at the moment it runs. Any failure is
1726/// returned (and any PARTIAL target removed — a truncated snapshot on
1727/// disk is silent-corruption bait for a restore): the caller aborts the
1728/// open rather than migrating unbacked.
1729fn backup_before_migrate(conn: &Connection, path: &Path, version: i64) -> Result<()> {
1730    let file_name = backup_file_name(path, version, std::time::SystemTime::now());
1731    let backup_path = path.with_file_name(file_name);
1732    // Single-quote escaping for the SQL string literal.
1733    let escaped = backup_path.to_string_lossy().replace('\'', "''");
1734    conn.execute_batch(&format!("VACUUM INTO '{escaped}';"))
1735        .map_err(|e| {
1736            // Best-effort cleanup: VACUUM INTO may leave a partial file
1737            // when it fails mid-way; restoring a truncated snapshot would
1738            // silently corrupt the database, so remove it.
1739            let _ = std::fs::remove_file(&backup_path);
1740            StoreError::Sqlite(format!(
1741                "refusing to migrate: backup to {} failed: {e}",
1742                backup_path.display()
1743            ))
1744        })?;
1745    Ok(())
1746}
1747
1748fn now_secs() -> i64 {
1749    std::time::SystemTime::now()
1750        .duration_since(std::time::UNIX_EPOCH)
1751        .map(|d| d.as_secs() as i64)
1752        .unwrap_or(0)
1753}
1754
1755/// Wall-clock Unix milliseconds (SCALE-06, #185).
1756fn now_unix_ms() -> i64 {
1757    std::time::SystemTime::now()
1758        .duration_since(std::time::UNIX_EPOCH)
1759        .map(|d| d.as_millis() as i64)
1760        .unwrap_or(0)
1761}
1762
1763fn row_to_consumer(r: &rusqlite::Row<'_>) -> rusqlite::Result<ConsumerRecord> {
1764    let groups_raw: Option<String> = r.get(3)?;
1765    Ok(ConsumerRecord {
1766        id: r.get(0)?,
1767        name: r.get(1)?,
1768        priority: r.get(2)?,
1769        groups: decode_groups(groups_raw.as_deref()),
1770        created_at: r.get(4)?,
1771    })
1772}
1773
1774/// Encode consumer groups as the `consumers.groups` JSON array (#124).
1775/// `Vec<String>` serialization cannot fail; the fallback keeps the
1776/// signature infallible and the column always valid JSON.
1777fn encode_groups(groups: &[String]) -> String {
1778    serde_json::to_string(groups).unwrap_or_else(|_| "[]".to_string())
1779}
1780
1781/// Decode the `consumers.groups` JSON array (#124). A malformed value
1782/// (a hand-edited database) degrades to NO groups — fail-closed for
1783/// group-based authorization (an `allowed_groups` rule then denies) —
1784/// with a server-side warning; group names inside a valid-but-odd JSON
1785/// array are used verbatim.
1786fn decode_groups(raw: Option<&str>) -> Vec<String> {
1787    match raw {
1788        None => Vec::new(),
1789        Some(s) => serde_json::from_str(s).unwrap_or_else(|_| {
1790            tracing::warn!(
1791                code = "consumer_groups_unparseable",
1792                "consumers.groups is not a JSON array of strings; treating as no groups"
1793            );
1794            Vec::new()
1795        }),
1796    }
1797}
1798
1799fn row_to_credential(r: &rusqlite::Row<'_>) -> rusqlite::Result<CredentialRecord> {
1800    let kind_str: String = r.get(3)?;
1801    Ok(CredentialRecord {
1802        id: r.get(0)?,
1803        consumer_id: r.get(1)?,
1804        consumer_name: r.get(2)?,
1805        kind: CredentialKind::from_str(&kind_str).ok_or_else(|| {
1806            rusqlite::Error::FromSqlConversionFailure(
1807                3,
1808                rusqlite::types::Type::Text,
1809                format!("unknown credential kind {kind_str}").into(),
1810            )
1811        })?,
1812        hash: r.get(4)?,
1813        salt: r.get(5)?,
1814        selector: r.get(6)?,
1815        created_at: r.get(7)?,
1816        revoked_at: None,
1817        retire_at: r.get(8)?,
1818    })
1819}
1820
1821/// The admin list view's mapper: the FULL row (revocation and
1822/// retirement included), column order per
1823/// [`StateStore::list_credentials_for_consumer`]'s SELECT.
1824fn row_to_credential_full(r: &rusqlite::Row<'_>) -> rusqlite::Result<CredentialRecord> {
1825    let hot = row_to_credential(r)?;
1826    Ok(CredentialRecord {
1827        revoked_at: r.get(9)?,
1828        ..hot
1829    })
1830}
1831
1832/// Bootstrap the store from the gateway config (DW-018 seeding, hashing
1833/// per DW-019, peppered per #124).
1834///
1835/// Creates/updates every consumer found in the config (including its
1836/// `groups`, #124) and inserts a credential row for each config
1837/// credential that is not already present (matched by consumer +
1838/// selector, so re-syncing is idempotent).
1839///
1840/// Hashing: config API keys are hashed at seed time — the selector is
1841/// `hex(sha256(key))` (never the plaintext key; this closes the DW-018
1842/// finding that seeded selectors were raw config values) and the stored
1843/// hash is `hmac-sha256:<hex>` when a `pepper` is configured (#124;
1844/// resolved by the CALLER through the SecretSource seam — this store
1845/// never touches the extension) or legacy `sha256:<hex>` otherwise.
1846/// Rows seeded by a pre-pepper sync keep verifying (legacy format) and
1847/// upgrade lazily on successful verification. JWT and mTLS config
1848/// credentials are BINDINGS, not secrets (tokens are verified
1849/// cryptographically; client certificates by subject/fingerprint), so
1850/// their rows keep a binding-marker hash and the issuer / match-value
1851/// selector.
1852///
1853/// Fail-closed skip (#46): a `${...}` reference that no longer resolves
1854/// at seed time is skipped loudly, AND the row a previous generation of
1855/// the SAME reference seeded is revoked
1856/// ([`StateStore::revoke_credentials_by_source`], linkage via
1857/// `credentials.source_ref`, schema v4) — with the store live the
1858/// registry serves rows rather than config, so a skip that left the old
1859/// row active would keep the OLD key authenticating, the opposite of
1860/// fail-closed. Rows without the linkage (pre-v4 databases, inline
1861/// keys, operator-managed rows) keep the upsert-only posture below.
1862///
1863/// Legacy cleanup: rows seeded by the pre-DW-019 build (plaintext api-key
1864/// selector + `config:api_key:` placeholder hash) are deleted on every
1865/// sync — see
1866/// [`StateStore::delete_legacy_config_placeholder_credentials`].
1867pub fn sync_consumers_from_config(
1868    store: &StateStore,
1869    gateway: &Gateway,
1870    pepper: Option<&[u8]>,
1871) -> Result<()> {
1872    store.delete_legacy_config_placeholder_credentials()?;
1873    for consumer in &gateway.consumers {
1874        let record = store.upsert_consumer(&consumer.name, consumer.priority, &consumer.groups)?;
1875        for credential in &consumer.credentials {
1876            // DW-036: HMAC credentials are CONFIG-served only. The store
1877            // never sees plaintext secrets and an HMAC cannot be verified
1878            // from a hash — the authenticator holds the resolved raw key
1879            // in memory — so there is no row to seed or retire. The
1880            // consumer record itself is still upserted above (groups and
1881            // priority apply to HMAC consumers like any other).
1882            if matches!(credential, Credential::Hmac { .. }) {
1883                continue;
1884            }
1885            // DW-045: resolve ${...} references before hashing; an error is
1886            // the validate-vs-seed microsecond race (validation already
1887            // rejected unresolvable references for this generation). Fail
1888            // CLOSED — skip the row (that key stops authenticating) with a
1889            // loud log naming the reference, never the value — AND retire
1890            // what a previous generation of this reference seeded (see the
1891            // fail-closed-skip note above). The next successful publish
1892            // re-seeds. `source_ref` links the config's reference text to
1893            // its row; a well-formed reference that RESOLVES records it on
1894            // the seeded row so a later skip can find that row.
1895            let source_ref = match credential {
1896                Credential::ApiKey { key } => {
1897                    match crate::config::credentials::parse_secret_reference(key) {
1898                        Some(Ok(_)) => Some(key.clone()),
1899                        _ => None,
1900                    }
1901                }
1902                _ => None,
1903            };
1904            let (kind, selector, hash) = match credential_parts(credential, pepper) {
1905                Ok(parts) => parts,
1906                Err(err) => {
1907                    tracing::error!(
1908                        code = "config_api_key_unresolvable",
1909                        consumer = %consumer.name,
1910                        "skipping credential seed (secret reference unresolvable): {err}"
1911                    );
1912                    if let Some(source) = &source_ref {
1913                        let retired = store.revoke_credentials_by_source(
1914                            record.id,
1915                            CredentialKind::ApiKey,
1916                            source,
1917                        )?;
1918                        if retired > 0 {
1919                            tracing::warn!(
1920                                code = "config_api_key_previous_generation_revoked",
1921                                consumer = %consumer.name,
1922                                "revoked {retired} previous-generation credential row(s) \
1923                                 seeded from the now-unresolvable reference (the old key \
1924                                 stops authenticating)"
1925                            );
1926                        }
1927                    }
1928                    continue;
1929                }
1930            };
1931            let existing = store.lookup_credentials_by_selector(&selector)?;
1932            if existing
1933                .iter()
1934                .any(|c| c.consumer_id == record.id && c.kind == kind)
1935            {
1936                continue;
1937            }
1938            match &source_ref {
1939                Some(source) => {
1940                    store.add_credential_from_reference(
1941                        record.id,
1942                        kind,
1943                        hash,
1944                        selector.clone(),
1945                        source,
1946                    )?;
1947                }
1948                None => {
1949                    store.add_credential(record.id, kind, hash, None, selector.clone())?;
1950                }
1951            }
1952        }
1953    }
1954    Ok(())
1955}
1956
1957/// Hash one config credential into (kind, selector, stored-hash) parts.
1958/// DW-045: `${...}` api-key references are RESOLVED here (env/file, the
1959/// config grammar) and the resolved bytes hashed — the plaintext never
1960/// reaches the store. `Err` names the reference, never a value.
1961fn credential_parts(
1962    credential: &Credential,
1963    pepper: Option<&[u8]>,
1964) -> std::result::Result<(CredentialKind, String, String), String> {
1965    match credential {
1966        Credential::ApiKey { key } => {
1967            let key = crate::config::credentials::resolve_configured_secret(key)?;
1968            let selector = crate::config::credentials::credential_selector(&key);
1969            let hash = match pepper {
1970                Some(pepper) => crate::config::credentials::hmac_stored_hash(pepper, &key),
1971                None => crate::config::credentials::sha256_stored_hash(&key),
1972            };
1973            Ok((CredentialKind::ApiKey, selector, hash))
1974        }
1975        Credential::Jwt { issuer, .. } => Ok((
1976            CredentialKind::Jwt,
1977            issuer.clone(),
1978            format!("config:jwt:{issuer}"),
1979        )),
1980        // DW-036: unreachable through every caller — the sync loop skips
1981        // hmac credentials before hashing (raw key material cannot be
1982        // store-served; see the module note there). The arm exists only
1983        // to keep this match exhaustive, and fails closed if a future
1984        // caller forgets the skip.
1985        Credential::Hmac { .. } => {
1986            Err("hmac credentials are config-served only (no store row)".to_string())
1987        }
1988        // The mTLS selector is the credential's MATCH VALUE (#124): the
1989        // subject CN when `subject` is set, else the certificate
1990        // fingerprint. The authenticator looks up a presented certificate
1991        // by BOTH its subject CN and its fingerprint, so either shape
1992        // resolves. An unvalidated both-empty config yields an empty
1993        // selector that no presented certificate can ever match.
1994        Credential::Mtls {
1995            subject,
1996            fingerprint,
1997        } => {
1998            let match_value = subject
1999                .clone()
2000                .or_else(|| fingerprint.clone())
2001                .unwrap_or_default();
2002            Ok((
2003                CredentialKind::Mtls,
2004                match_value.clone(),
2005                format!("config:mtls:{match_value}"),
2006            ))
2007        }
2008    }
2009}
2010
2011// White-box tests staying in src/ per AGENTS.md: these tests perform
2012// raw-SQL introspection of the store's connection and exercise private
2013// helpers that are not reachable through the public API.
2014#[cfg(test)]
2015mod tests {
2016    use super::*;
2017    fn seeded_store() -> StateStore {
2018        let store = StateStore::open_in_memory().unwrap();
2019        store.upsert_consumer("acme", Some(7), &[]).unwrap();
2020        store
2021    }
2022    #[test]
2023    fn quota_prunes_rows_older_than_retention_horizon() {
2024        let store = seeded_store();
2025        let consumer = store.lookup_consumer("acme").unwrap().unwrap();
2026        let now = now_secs();
2027        // Seed a stale row (older than the newest window by > 1 year) and
2028        // a recent one; the next quota write prunes only the stale row.
2029        {
2030            let conn = store.conn.lock().unwrap();
2031            conn.execute(
2032                "INSERT INTO quota_counters VALUES (?1, 'rpm', ?2, 42)",
2033                params![consumer.id, now - 2 * QUOTA_RETENTION_SECS],
2034            )
2035            .unwrap();
2036            conn.execute(
2037                "INSERT INTO quota_counters VALUES (?1, 'rpm', ?2, 0)
2038                 ON CONFLICT (consumer_id, counter_key, window_start) DO NOTHING",
2039                params![consumer.id, now],
2040            )
2041            .unwrap();
2042        }
2043        assert_eq!(
2044            store
2045                .get_quota(consumer.id, "rpm", now - 2 * QUOTA_RETENTION_SECS)
2046                .unwrap(),
2047            42
2048        );
2049        store.incr_quota(consumer.id, "rpm", now, 1, None).unwrap();
2050        assert_eq!(
2051            store
2052                .get_quota(consumer.id, "rpm", now - 2 * QUOTA_RETENTION_SECS)
2053                .unwrap(),
2054            0
2055        );
2056        assert_eq!(store.get_quota(consumer.id, "rpm", now).unwrap(), 1);
2057    }
2058
2059    /// Build a database exactly as DW-018 did: the hand-rolled v1 DDL,
2060    /// `PRAGMA user_version = 1`, and rows in every table (including
2061    /// quota data). This is the "v1 gateway data dir" fixture for the
2062    /// migration tests below.
2063    fn build_v1_db(path: &Path) {
2064        let conn = Connection::open(path).unwrap();
2065        conn.execute_batch(
2066            "BEGIN;
2067             CREATE TABLE consumers (
2068                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2069                 name TEXT NOT NULL UNIQUE,
2070                 priority INTEGER,
2071                 created_at INTEGER NOT NULL
2072             );
2073             CREATE TABLE credentials (
2074                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2075                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2076                 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2077                 hash TEXT NOT NULL,
2078                 salt TEXT,
2079                 selector TEXT NOT NULL,
2080                 created_at INTEGER NOT NULL,
2081                 revoked_at INTEGER
2082             );
2083             CREATE INDEX idx_credentials_selector ON credentials (selector);
2084             CREATE TABLE quota_counters (
2085                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2086                 counter_key TEXT NOT NULL,
2087                 window_start INTEGER NOT NULL,
2088                 used INTEGER NOT NULL DEFAULT 0,
2089                 PRIMARY KEY (consumer_id, counter_key, window_start)
2090             );
2091             INSERT INTO consumers (name, priority, created_at) VALUES ('acme', 5, 1000);
2092             INSERT INTO credentials
2093                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2094             VALUES (1, 'api_key', 'h1', NULL, 'key-1', 1001, NULL);
2095             INSERT INTO credentials
2096                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2097             VALUES (1, 'jwt', 'h2', 's2', 'key-2', 1002, 5555);
2098             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2099             VALUES (1, 'rpm', 3600, 42);
2100             PRAGMA user_version = 1;
2101             COMMIT;",
2102        )
2103        .unwrap();
2104    }
2105    #[test]
2106    fn v1_db_migrates_to_latest_with_zero_data_loss() {
2107        let dir = tempfile::tempdir().unwrap();
2108        let path = dir.path().join("state.db");
2109        build_v1_db(&path);
2110
2111        // Opening migrates automatically...
2112        let store = StateStore::open(&path).unwrap();
2113        assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2114        // ...with every v1 row intact: consumer (and its priority),
2115        let consumers = store.list_consumers().unwrap();
2116        assert_eq!(consumers.len(), 1);
2117        assert_eq!(consumers[0].name, "acme");
2118        assert_eq!(consumers[0].priority, Some(5));
2119        assert_eq!(consumers[0].created_at, 1000);
2120        // The 003 groups column defaults to no groups for v1 rows.
2121        assert_eq!(consumers[0].groups, Vec::<String>::new());
2122        // active credential,
2123        let active = store.lookup_credentials_by_selector("key-1").unwrap();
2124        assert_eq!(active.len(), 1);
2125        assert_eq!(active[0].hash, "h1");
2126        assert_eq!(active[0].kind, CredentialKind::ApiKey);
2127        // quota data,
2128        assert_eq!(store.get_quota(1, "rpm", 3600).unwrap(), 42);
2129        // and the 002 index exists.
2130        {
2131            let conn = store.conn.lock().unwrap();
2132            let n: i64 = conn
2133                .query_row(
2134                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index'
2135                     AND name = 'idx_quota_counters_window'",
2136                    [],
2137                    |r| r.get(0),
2138                )
2139                .unwrap();
2140            assert_eq!(n, 1);
2141        }
2142        // The store remains writable post-migration (idempotent upsert).
2143        store
2144            .upsert_consumer("acme", Some(6), &["partners".to_string()])
2145            .unwrap();
2146        assert_eq!(store.list_consumers().unwrap()[0].priority, Some(6));
2147        assert_eq!(
2148            store.list_consumers().unwrap()[0].groups,
2149            vec!["partners".to_string()]
2150        );
2151    }
2152    /// Build a database exactly as the v2 build left it (#124 fixture):
2153    /// the v1 DDL plus migration 002's quota index, rows present, and
2154    /// `PRAGMA user_version = 2` — a database that has NEVER seen the
2155    /// `groups` column, so opening it must apply ONLY migration 003.
2156    fn build_v2_db(path: &Path) {
2157        let conn = Connection::open(path).unwrap();
2158        conn.execute_batch(
2159            "BEGIN;
2160             CREATE TABLE consumers (
2161                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2162                 name TEXT NOT NULL UNIQUE,
2163                 priority INTEGER,
2164                 created_at INTEGER NOT NULL
2165             );
2166             CREATE TABLE credentials (
2167                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2168                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2169                 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2170                 hash TEXT NOT NULL,
2171                 salt TEXT,
2172                 selector TEXT NOT NULL,
2173                 created_at INTEGER NOT NULL,
2174                 revoked_at INTEGER
2175             );
2176             CREATE INDEX idx_credentials_selector ON credentials (selector);
2177             CREATE TABLE quota_counters (
2178                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2179                 counter_key TEXT NOT NULL,
2180                 window_start INTEGER NOT NULL,
2181                 used INTEGER NOT NULL DEFAULT 0,
2182                 PRIMARY KEY (consumer_id, counter_key, window_start)
2183             );
2184             CREATE INDEX idx_quota_counters_window ON quota_counters (window_start);
2185             INSERT INTO consumers (name, priority, created_at) VALUES ('globex', 3, 2000);
2186             INSERT INTO credentials
2187                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2188             VALUES (1, 'api_key', 'h1', NULL, 'key-1', 2001, NULL);
2189             PRAGMA user_version = 2;
2190             COMMIT;",
2191        )
2192        .unwrap();
2193    }
2194    #[test]
2195    fn v2_db_migrates_to_latest_adding_groups_in_place() {
2196        // Migration fidelity from the V2 schema state (#124): only 003
2197        // is pending, and it must add `groups` with the no-groups
2198        // default while leaving every existing row byte-identical.
2199        let dir = tempfile::tempdir().unwrap();
2200        let path = dir.path().join("state.db");
2201        build_v2_db(&path);
2202
2203        let store = StateStore::open(&path).unwrap();
2204        assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2205        // Every v2 row survives untouched...
2206        let consumers = store.list_consumers().unwrap();
2207        assert_eq!(consumers.len(), 1);
2208        assert_eq!(consumers[0].name, "globex");
2209        assert_eq!(consumers[0].priority, Some(3));
2210        assert_eq!(consumers[0].created_at, 2000);
2211        // ...and the new column reads as the default no-groups value.
2212        assert_eq!(consumers[0].groups, Vec::<String>::new());
2213        let active = store.lookup_credentials_by_selector("key-1").unwrap();
2214        assert_eq!(active.len(), 1);
2215        assert_eq!(active[0].hash, "h1");
2216        // The migrated store accepts group writes (idempotent upsert).
2217        store
2218            .upsert_consumer("globex", None, &["partners".to_string()])
2219            .unwrap();
2220        assert_eq!(
2221            store.lookup_consumer("globex").unwrap().unwrap().groups,
2222            vec!["partners".to_string()]
2223        );
2224    }
2225    #[test]
2226    fn malformed_groups_json_degrades_to_no_groups() {
2227        // A hand-edited `consumers.groups` value that is not a JSON array
2228        // of strings must DEGRADE to no groups (#124: fail-closed for
2229        // group authorization — an allowed_groups rule then denies —
2230        // never error the lookup, never invent groups). Fresh store
2231        // handles per value so the hot consumer cache cannot mask the
2232        // disk decode path a restarted deployment would take.
2233        let dir = tempfile::tempdir().unwrap();
2234        let path = dir.path().join("state.db");
2235        {
2236            let store = StateStore::open(&path).unwrap();
2237            store
2238                .upsert_consumer("acme", None, &["partners".to_string()])
2239                .unwrap();
2240        }
2241        for bad in ["not-json", "null", "\"partners\"", "[1,2]", "{\"a\":1}"] {
2242            let conn = Connection::open(&path).unwrap();
2243            conn.execute("UPDATE consumers SET groups = ?1", params![bad])
2244                .unwrap();
2245            drop(conn);
2246            let store = StateStore::open(&path).unwrap();
2247            assert_eq!(
2248                store.list_consumers().unwrap()[0].groups,
2249                Vec::<String>::new(),
2250                "malformed groups {bad:?} must read back as no groups"
2251            );
2252            assert_eq!(
2253                store.lookup_consumer("acme").unwrap().unwrap().groups,
2254                Vec::<String>::new(),
2255                "malformed groups {bad:?} must read back as no groups"
2256            );
2257        }
2258    }
2259    // ---- tester coverage for DW-115 (chain, backup integrity, deep
2260    // fidelity, rapid reopen, foreign version-0 databases, pragmas) ----
2261
2262    /// Dump every row of `table` as formatted text, row order pinned by
2263    /// rowid, so pre/post/backup databases can be compared column for
2264    /// column ("byte-identical" at the value level). `cols` restricts the
2265    /// projection: migration tests compare the columns the OLDER schema
2266    /// also had (#124: `consumers.groups` exists only at HEAD).
2267    fn dump_table(conn: &Connection, table: &str, cols: &str) -> Vec<Vec<String>> {
2268        let mut stmt = conn
2269            .prepare(&format!("SELECT {cols} FROM {table} ORDER BY rowid"))
2270            .unwrap();
2271        let n = stmt.column_count();
2272        let mut rows = stmt.query([]).unwrap();
2273        let mut out = Vec::new();
2274        while let Some(row) = rows.next().unwrap() {
2275            let mut cells = Vec::with_capacity(n);
2276            for i in 0..n {
2277                let cell = match row.get_ref(i).unwrap() {
2278                    rusqlite::types::ValueRef::Null => "NULL".to_string(),
2279                    rusqlite::types::ValueRef::Integer(v) => v.to_string(),
2280                    rusqlite::types::ValueRef::Real(v) => v.to_string(),
2281                    rusqlite::types::ValueRef::Text(v) => {
2282                        format!("'{}'", String::from_utf8_lossy(v))
2283                    }
2284                    rusqlite::types::ValueRef::Blob(v) => format!("{v:?}"),
2285                };
2286                cells.push(cell);
2287            }
2288            out.push(cells);
2289        }
2290        out
2291    }
2292
2293    /// Richer v1 fixture than [`build_v1_db`]: 2 consumers, 3 credentials
2294    /// (one revoked), and 5 quota rows spanning consumers, keys, and
2295    /// windows. Returns nothing; callers compare state before/after.
2296    fn build_v1_db_rich(path: &Path) {
2297        let conn = Connection::open(path).unwrap();
2298        conn.execute_batch(
2299            "BEGIN;
2300             CREATE TABLE consumers (
2301                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2302                 name TEXT NOT NULL UNIQUE,
2303                 priority INTEGER,
2304                 created_at INTEGER NOT NULL
2305             );
2306             CREATE TABLE credentials (
2307                 id INTEGER PRIMARY KEY AUTOINCREMENT,
2308                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2309                 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2310                 hash TEXT NOT NULL,
2311                 salt TEXT,
2312                 selector TEXT NOT NULL,
2313                 created_at INTEGER NOT NULL,
2314                 revoked_at INTEGER
2315             );
2316             CREATE INDEX idx_credentials_selector ON credentials (selector);
2317             CREATE TABLE quota_counters (
2318                 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2319                 counter_key TEXT NOT NULL,
2320                 window_start INTEGER NOT NULL,
2321                 used INTEGER NOT NULL DEFAULT 0,
2322                 PRIMARY KEY (consumer_id, counter_key, window_start)
2323             );
2324             INSERT INTO consumers (name, priority, created_at) VALUES ('acme', 5, 1000);
2325             INSERT INTO consumers (name, priority, created_at) VALUES ('globex', NULL, 1001);
2326             INSERT INTO credentials
2327                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2328             VALUES (1, 'api_key', 'h1', NULL, 'key-1', 1001, NULL);
2329             INSERT INTO credentials
2330                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2331             VALUES (1, 'jwt', 'h2', 's2', 'key-2', 1002, 5555);
2332             INSERT INTO credentials
2333                 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2334             VALUES (2, 'mtls', 'h3', 's3', 'key-3', 1003, NULL);
2335             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2336             VALUES (1, 'rpm', 3600, 42);
2337             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2338             VALUES (1, 'rpm', 7200, 7);
2339             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2340             VALUES (1, 'rpd', 86400, 900);
2341             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2342             VALUES (2, 'rpm', 3600, 1);
2343             INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2344             VALUES (2, 'monthly', 2678400, 123456);
2345             PRAGMA user_version = 1;
2346             COMMIT;",
2347        )
2348        .unwrap();
2349    }
2350
2351    fn all_backups(dir: &Path) -> Vec<String> {
2352        std::fs::read_dir(dir)
2353            .unwrap()
2354            .filter_map(|e| {
2355                let name = e.unwrap().file_name().to_string_lossy().into_owned();
2356                name.contains(".bak-").then_some(name)
2357            })
2358            .collect()
2359    }
2360    #[test]
2361    fn migration_preserves_every_row_and_the_hot_path_still_works() {
2362        // DATA FIDELITY (deep): every row of a rich v1 database is
2363        // value-identical after migration, revoked credentials stay
2364        // revoked (invisible to lookups), quota counters read back exact,
2365        // and the migrated store's hot cache behaves: one disk read to
2366        // warm, then zero-disk hits.
2367        let dir = tempfile::tempdir().unwrap();
2368        let path = dir.path().join("state.db");
2369        build_v1_db_rich(&path);
2370        let before = {
2371            let conn = Connection::open(&path).unwrap();
2372            (
2373                dump_table(&conn, "consumers", "id, name, priority, created_at"),
2374                dump_table(&conn, "credentials", "*"),
2375                dump_table(&conn, "quota_counters", "*"),
2376            )
2377        };
2378
2379        let store = StateStore::open(&path).unwrap();
2380        assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2381        {
2382            let conn = store.conn.lock().expect("store connection poisoned");
2383            assert_eq!(
2384                dump_table(&conn, "consumers", "id, name, priority, created_at"),
2385                before.0
2386            );
2387            // 004 added `credentials.source_ref`; compare the columns the
2388            // v1 schema already had, then the new column separately.
2389            assert_eq!(
2390                dump_table(
2391                    &conn,
2392                    "credentials",
2393                    "id, consumer_id, kind, hash, salt, selector, created_at, revoked_at"
2394                ),
2395                before.1
2396            );
2397            assert_eq!(dump_table(&conn, "quota_counters", "*"), before.2);
2398            // 003 added `consumers.groups` with the no-groups default:
2399            // every migrated v1 row reads back empty groups.
2400            let groups = dump_table(&conn, "consumers", "groups");
2401            assert_eq!(groups, vec![vec!["'[]'".to_string()]; 2]);
2402            // 004 added `credentials.source_ref` with no linkage default:
2403            // every migrated v1 row reads back NULL (pre-v4 rows keep the
2404            // upsert-only posture; only rows seeded from a ${...}
2405            // reference carry the linkage).
2406            let sources = dump_table(&conn, "credentials", "source_ref");
2407            assert_eq!(sources, vec![vec!["NULL".to_string()]; 3]);
2408        }
2409
2410        // Both consumers present with their exact fields.
2411        let consumers = store.list_consumers().unwrap();
2412        assert_eq!(consumers.len(), 2);
2413        let globex = store.lookup_consumer("globex").unwrap().unwrap();
2414        assert_eq!(globex.priority, None);
2415        assert_eq!(globex.created_at, 1001);
2416
2417        // Active selectors resolve; the REVOKED selector resolves to
2418        // nothing (revoked_at 5555 survived and keeps excluding it).
2419        assert_eq!(
2420            store.lookup_credentials_by_selector("key-1").unwrap().len(),
2421            1
2422        );
2423        assert_eq!(
2424            store.lookup_credentials_by_selector("key-3").unwrap().len(),
2425            1
2426        );
2427        assert!(
2428            store
2429                .lookup_credentials_by_selector("key-2")
2430                .unwrap()
2431                .is_empty(),
2432            "revoked credential must stay revoked after migration"
2433        );
2434
2435        // Every quota counter reads back its exact value.
2436        assert_eq!(store.get_quota(1, "rpm", 3600).unwrap(), 42);
2437        assert_eq!(store.get_quota(1, "rpm", 7200).unwrap(), 7);
2438        assert_eq!(store.get_quota(1, "rpd", 86400).unwrap(), 900);
2439        assert_eq!(store.get_quota(2, "rpm", 3600).unwrap(), 1);
2440        assert_eq!(store.get_quota(2, "monthly", 2678400).unwrap(), 123456);
2441
2442        // Hot path: first lookup costs one disk read, subsequent ones zero.
2443        let reads_before = store.disk_reads();
2444        let entry = store.lookup_credential("key-1").unwrap().unwrap();
2445        assert_eq!(entry.len(), 1);
2446        assert_eq!(store.disk_reads(), reads_before + 1);
2447        for _ in 0..3 {
2448            let again = store.lookup_credential("key-1").unwrap().unwrap();
2449            assert_eq!(again.len(), 1);
2450        }
2451        assert_eq!(
2452            store.disk_reads(),
2453            reads_before + 1,
2454            "warm cache must be disk-free post-migration"
2455        );
2456    }
2457
2458    #[test]
2459    fn rapid_reopen_and_two_live_handles_on_one_migrated_file() {
2460        // Single-process concurrency doc: after migration, an immediate
2461        // second open sees the latest schema without error, and two LIVE
2462        // handles on the same file coexist (WAL + busy_timeout): a write
2463        // through one is visible to the other, no corruption.
2464        let dir = tempfile::tempdir().unwrap();
2465        let path = dir.path().join("state.db");
2466        build_v1_db(&path);
2467
2468        let a = StateStore::open(&path).unwrap();
2469        assert_eq!(a.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2470        drop(a); // close, then IMMEDIATELY reopen the same file
2471        let b = StateStore::open(&path).unwrap();
2472        assert_eq!(b.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2473        assert_eq!(b.get_quota(1, "rpm", 3600).unwrap(), 42);
2474
2475        // Two handles alive at once: write through the second...
2476        let c = StateStore::open(&path).unwrap();
2477        c.upsert_consumer("globex", Some(9), &[]).unwrap();
2478        c.add_credential(1, CredentialKind::ApiKey, "hx".into(), None, "key-x".into())
2479            .unwrap();
2480        // ...read committed state through the first.
2481        let names: Vec<String> = b
2482            .list_consumers()
2483            .unwrap()
2484            .into_iter()
2485            .map(|r| r.name)
2486            .collect();
2487        assert!(names.contains(&"globex".to_string()), "names: {names:?}");
2488        assert_eq!(b.lookup_credentials_by_selector("key-x").unwrap().len(), 1);
2489        // And the file still passes SQLite's own consistency check.
2490        let check: String = c
2491            .conn
2492            .lock()
2493            .unwrap()
2494            .query_row("PRAGMA integrity_check", [], |r| r.get(0))
2495            .unwrap();
2496        assert_eq!(check, "ok");
2497    }
2498
2499    #[test]
2500    fn version_zero_database_with_foreign_tables_migrates_without_harming_them() {
2501        // EDGE: user_version 0 but NOT empty (an older unrelated app left
2502        // tables). Pinned behavior: 001's IF NOT EXISTS builds our schema
2503        // alongside the foreign tables (which survive untouched), the
2504        // database lands at latest, and no backup is taken because
2505        // version 0 means "no recognized schema to lose".
2506        let dir = tempfile::tempdir().unwrap();
2507        let path = dir.path().join("state.db");
2508        {
2509            let conn = Connection::open(&path).unwrap();
2510            conn.execute_batch(
2511                "CREATE TABLE legacy_widgets (
2512                     id INTEGER PRIMARY KEY,
2513                     label TEXT NOT NULL
2514                 );
2515                 INSERT INTO legacy_widgets (label) VALUES ('keep-me');",
2516            )
2517            .unwrap();
2518        }
2519
2520        let store = StateStore::open(&path).unwrap();
2521        assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2522        // The foreign table and its row survived verbatim.
2523        let label: String = store
2524            .conn
2525            .lock()
2526            .unwrap()
2527            .query_row("SELECT label FROM legacy_widgets WHERE id = 1", [], |r| {
2528                r.get(0)
2529            })
2530            .unwrap();
2531        assert_eq!(label, "keep-me");
2532        // Our schema is fully usable next to it.
2533        store.upsert_consumer("acme", None, &[]).unwrap();
2534        assert_eq!(store.list_consumers().unwrap().len(), 1);
2535        // version 0 -> no backup (nothing recognized to back up).
2536        assert!(
2537            all_backups(dir.path()).is_empty(),
2538            "version-0 open must not take a backup"
2539        );
2540    }
2541
2542    #[test]
2543    fn backup_names_are_unique_within_the_same_second() {
2544        // The name generator carries a millisecond component, so two
2545        // snapshots in the same wall-clock second get distinct files (a
2546        // seconds-only name made a same-second retry collide with the
2547        // previous attempt's file, and VACUUM INTO refuses an existing
2548        // target).
2549        let path = Path::new("/data/state.db");
2550        let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_millis(1_700_000_000_123);
2551        let a = backup_file_name(path, 1, t0);
2552        let b = backup_file_name(path, 1, t0 + std::time::Duration::from_millis(1));
2553        assert_eq!(a, "state.db.bak-1-1700000000-123");
2554        assert_eq!(b, "state.db.bak-1-1700000000-124");
2555        assert_ne!(a, b, "same-second backups must not collide");
2556    }
2557
2558    #[test]
2559    fn post_migration_pragmas_stay_configured() {
2560        // PRAGMA state after a migration open: WAL journaling, FK
2561        // enforcement, and the 5 s busy_timeout must all still be set
2562        // (they are applied before migrations and must survive them).
2563        let dir = tempfile::tempdir().unwrap();
2564        let path = dir.path().join("state.db");
2565        build_v1_db(&path);
2566        let store = StateStore::open(&path).unwrap();
2567        let conn = store.conn.lock().expect("store connection poisoned");
2568        let mode: String = conn
2569            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2570            .unwrap();
2571        assert_eq!(mode, "wal");
2572        let fk: i64 = conn
2573            .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
2574            .unwrap();
2575        assert_eq!(fk, 1, "foreign_keys must be ON post-migration");
2576        let timeout_ms: i64 = conn
2577            .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
2578            .unwrap();
2579        assert_eq!(timeout_ms, 5000, "busy_timeout must be 5 s post-migration");
2580    }
2581}