Skip to main content

sysknife_daemon/
transactions.rs

1use crate::audit_chain::{
2    self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
3    VerifyOutcome, CURRENT_KEY_ID,
4};
5use crate::audit_watermark::emit_chain_tip_watermark;
6use crate::auth::CallerPrincipal;
7use rusqlite::{params, Connection, TransactionBehavior};
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use subtle::ConstantTimeEq;
12use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
13use uuid::Uuid;
14
15/// Lifetime of an approval receipt / preview, in minutes.
16///
17/// A transaction can be approved, its receipt claimed for execution, or it is
18/// swept as stale, only while `created_at` is within this window. It is the
19/// single source of truth for the TTL: both the SQLite (`julianday`) and the
20/// PostgreSQL (`INTERVAL`) backends interpolate this constant into their SQL,
21/// so the two engines can never disagree on the window. 15 minutes balances
22/// operator usability (time to run `sysknife approve` in a terminal) against
23/// the exposure window of a single-use bearer receipt. Prose in `SECURITY.md`
24/// cites the same "15-minute" value; keep them in sync if this changes.
25pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
26
27/// One structured audit-log row for the history IPC.
28///
29/// Unlike [`TransactionRecord`] (which crosses the proto boundary and omits the
30/// creation timestamp), this is a serde-only DTO carried over the JSON daemon
31/// wire. It exists so programmatic clients (the MCP `sysknife_history` tool)
32/// get typed `risk_level` and `created_at` instead of re-parsing formatted
33/// text. `created_at` is the ISO-8601 timestamp stored at insert time.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct JobHistoryEntry {
36    pub transaction_id: String,
37    pub action_name: String,
38    pub risk_level: RiskLevel,
39    pub status: JobState,
40    pub summary: String,
41    pub created_at: String,
42}
43
44/// Data provided by the caller when recording a new transaction.
45///
46/// The initial `status` is always `Queued` — it is not caller-controllable.
47/// Hardcoding this in the store prevents callers from bypassing the state
48/// machine by recording a transaction in a terminal state.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct NewTransaction {
51    pub request_id: String,
52    pub request_hash: String,
53    pub action_name: String,
54    pub risk_level: RiskLevel,
55    /// Human-readable description of the planned action.
56    ///
57    /// **Chain-hashed at INSERT; intentionally not in the mutable field set.**
58    ///
59    /// `summary` is captured in [`crate::audit_chain::ChainContent`] and
60    /// baked into `chain_hash = ed25519_sign(canonical(fields) || prev_hash, key)`
61    /// at the moment the row is written. After that point the stored signature
62    /// is a one-time commitment.
63    ///
64    /// **Do not add an `update_summary` API** (or any equivalent that modifies
65    /// this field in an existing row). Any such change will cause
66    /// `sysknife audit verify` to report `VerifyOutcome::Broken` for the
67    /// modified row, because the signature will no longer verify against the
68    /// stored `chain_hash`.
69    ///
70    /// If a correction is genuinely needed, use one of the two safe strategies
71    /// documented on [`crate::audit_chain::ChainContent`]:
72    /// 1. Insert a corrective row that references the original `transaction_id`.
73    /// 2. Extend the chain protocol with a dedicated amendment record type.
74    pub summary: String,
75    pub warnings: Vec<String>,
76    /// Role the daemon resolved for the connection that asked for this action.
77    ///
78    /// Chain-hashed at INSERT alongside `summary`. Before this field existed the
79    /// signed record could say what was authorised but not which privilege tier
80    /// asked; which *account* asked is `caller_principal` below. Not
81    /// caller-supplied over the wire: the dispatcher passes the role
82    /// it resolved from `SO_PEERCRED` (or the vsock token), never a value from
83    /// the request body.
84    pub caller_role: CallerRole,
85    /// Which account asked, as opposed to what its role permitted.
86    ///
87    /// Chain-hashed at INSERT alongside `caller_role`. The role alone cannot
88    /// separate two members of `sysknife-admin`, so a trail built only from it
89    /// answers "an Admin did this" and stops there. Like the role, this is
90    /// resolved by the dispatcher from the connection, never read from the
91    /// request body. See [`crate::auth::CallerPrincipal`].
92    pub caller_principal: CallerPrincipal,
93}
94
95/// One forward-only SQLite schema step, applied in `version` order and
96/// recorded in `schema_migrations`.
97///
98/// This mirrors the Postgres `MIGRATIONS` list deliberately. The SQLite path
99/// used to be a single `CREATE TABLE IF NOT EXISTS` batch plus a hardcoded
100/// `version > 1` guard, which had no way to express "add a column to an
101/// existing database" — `IF NOT EXISTS` skips a table that is already there,
102/// columns and all. Adding the caller-identity columns needed a real step.
103struct SqliteMigration {
104    version: i64,
105    name: &'static str,
106    sql: &'static str,
107}
108
109/// Schema history for the SQLite backend.
110///
111/// Migration 1 covers the append-tamper-evident hash chain (see
112/// `audit_chain.rs` for the full threat model — note that truncation of the
113/// tail is NOT detected by this chain alone; that requires the signed
114/// checkpoints anchored to an append-only sink (`checkpoint_sink`), with the
115/// journald watermark as a lighter best-effort complement):
116///   seq             — monotonic ordering, 1-indexed
117///   key_id          — identifies the key generation (forward-compatible with
118///                     epoch rotation in a follow-up issue)
119///   chain_hash      — ed25519_sign(ROW_DOMAIN || canonical(immutable_fields)
120///                     || prev_chain_hash, key)
121///   prev_chain_hash — chain_hash of the previous row, "" for the first row
122///
123/// `status` is intentionally absent from the chain content — it is mutable.
124/// The chain protects the *authorisation decision* captured at insert time,
125/// not the live execution state.
126const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
127    SqliteMigration {
128        version: 1,
129        name: "initial_audit_schema",
130        sql: r#"
131            CREATE TABLE IF NOT EXISTS transactions (
132                transaction_id TEXT PRIMARY KEY,
133                request_id TEXT NOT NULL,
134                request_hash TEXT NOT NULL,
135                action_name TEXT NOT NULL,
136                risk_level TEXT NOT NULL,
137                status TEXT NOT NULL,
138                approval_id TEXT,
139                summary TEXT NOT NULL,
140                warnings_json TEXT NOT NULL,
141                created_at TEXT NOT NULL DEFAULT (datetime('now')),
142                seq INTEGER NOT NULL UNIQUE,
143                key_id TEXT NOT NULL,
144                chain_hash TEXT NOT NULL,
145                prev_chain_hash TEXT NOT NULL DEFAULT ''
146            );
147
148            CREATE TABLE IF NOT EXISTS transaction_previews (
149                transaction_id TEXT PRIMARY KEY,
150                preview_json TEXT NOT NULL
151            );
152
153            CREATE TABLE IF NOT EXISTS transaction_approvals (
154                transaction_id TEXT PRIMARY KEY,
155                receipt_digest TEXT NOT NULL,
156                approved_at TEXT NOT NULL DEFAULT (datetime('now')),
157                consumed_at TEXT
158            );
159
160            CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
161        "#,
162    },
163    // Caller identity in the signed content, and the approval-event chain.
164    //
165    // The three new `transactions` columns are nullable with a legacy default
166    // on purpose: rows written by an earlier binary were signed over an
167    // encoding that has no such fields, and backfilling them with empty
168    // strings would change every historical message and report the whole chain
169    // as Broken. `chain_version` selects the encoding per row — see
170    // `audit_chain::ChainIdentity`.
171    SqliteMigration {
172        version: 2,
173        name: "caller_identity_and_approval_events",
174        sql: r#"
175            ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
176            ALTER TABLE transactions ADD COLUMN caller_role TEXT;
177            ALTER TABLE transactions ADD COLUMN event_tip TEXT;
178
179            CREATE TABLE IF NOT EXISTS audit_events (
180                seq INTEGER PRIMARY KEY,
181                key_id TEXT NOT NULL,
182                kind TEXT NOT NULL,
183                transaction_id TEXT NOT NULL,
184                receipt_digest TEXT NOT NULL,
185                created_at TEXT NOT NULL,
186                chain_hash TEXT NOT NULL,
187                prev_chain_hash TEXT NOT NULL DEFAULT ''
188            );
189
190            CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
191                ON audit_events(transaction_id);
192        "#,
193    },
194    // Nullable for the same reason the v2 columns are: rows written before this
195    // step were signed over an encoding with no principal field, so backfilling
196    // any value would rewrite their message and report the chain as Broken.
197    // `chain_version` keeps selecting the encoding per row.
198    SqliteMigration {
199        version: 3,
200        name: "caller_principal",
201        sql: r#"
202            ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
203        "#,
204    },
205];
206
207/// Column list for every `ChainRow` read, shared with the Postgres backend
208/// and declared once in `store.rs` (#397) — the mapper below reads columns
209/// positionally in exactly that order.
210use crate::store::CHAIN_ROW_COLUMNS;
211
212fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
213    Ok(ChainRow {
214        seq: row.get::<_, i64>(0)? as u64,
215        key_id: row.get(1)?,
216        transaction_id: row.get(2)?,
217        request_id: row.get(3)?,
218        request_hash: row.get(4)?,
219        action_name: row.get(5)?,
220        risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
221            rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
222        })?,
223        summary: row.get(7)?,
224        approval_id: row.get(8)?,
225        warnings_json: row.get(9)?,
226        created_at: row.get(10)?,
227        prev_chain_hash: row.get(11)?,
228        chain_hash: row.get(12)?,
229        chain_version: row.get::<_, i64>(13)? as u32,
230        caller_role: row.get(14)?,
231        event_tip: row.get(15)?,
232        caller_principal: row.get(16)?,
233    })
234}
235
236fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
237    Ok(EventRow {
238        seq: row.get::<_, i64>(0)? as u64,
239        key_id: row.get(1)?,
240        kind: row.get(2)?,
241        transaction_id: row.get(3)?,
242        receipt_digest: row.get(4)?,
243        created_at: row.get(5)?,
244        prev_chain_hash: row.get(6)?,
245        chain_hash: row.get(7)?,
246    })
247}
248
249/// Run `query_row`, treating "no row matched" as `Ok(None)` instead of an
250/// error. Three call sites used to spell out
251/// `.map(Some).or_else(|e| match e { QueryReturnedNoRows => Ok(None), other => Err(other) })`
252/// by hand; sharing this mapping means a future call site cannot drop the
253/// `QueryReturnedNoRows` arm and turn a routine "not found" into a hard error.
254fn query_optional<T>(
255    conn: &Connection,
256    sql: &str,
257    params: impl rusqlite::Params,
258    f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
259) -> rusqlite::Result<Option<T>> {
260    match conn.query_row(sql, params, f) {
261        Ok(v) => Ok(Some(v)),
262        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
263        Err(e) => Err(e),
264    }
265}
266
267#[derive(Clone, Debug)]
268pub struct TransactionStore {
269    path: PathBuf,
270    /// Ed25519 signing key used to compute the forward audit chain on insert.
271    /// `None` only for read-only callers that never write rows.
272    audit_key: Option<Arc<AuditKey>>,
273}
274
275#[derive(Clone, Debug, PartialEq)]
276pub struct RecordedPreviewedTransaction {
277    pub transaction: TransactionRecord,
278    pub preview: PreviewEnvelope,
279}
280
281struct InsertedTransaction {
282    record: TransactionRecord,
283    seq: u64,
284    chain_hash: String,
285}
286
287#[derive(Debug, thiserror::Error)]
288pub enum TransactionStoreError {
289    #[error("io error: {0}")]
290    Io(#[from] std::io::Error),
291
292    #[error("sqlite error: {0}")]
293    Sqlite(#[from] rusqlite::Error),
294
295    #[error("json error: {0}")]
296    Json(#[from] serde_json::Error),
297
298    #[error("database invariant violation: {0}")]
299    DatabaseInvariant(String),
300
301    #[error("transaction not found: {0}")]
302    NotFound(String),
303
304    #[error("invalid transition from {from:?} to {to:?}")]
305    InvalidTransition { from: JobState, to: JobState },
306
307    #[error("audit chain misconfiguration: {0}")]
308    AuditChainMissing(&'static str),
309
310    /// `update_status`'s compare-and-swap `UPDATE ... WHERE status = <observed>`
311    /// matched zero rows: another writer changed `status` between our read and
312    /// our write. This is a lost race, not a validation failure — the caller
313    /// (see `dispatcher::update_terminal_status`) already retries on any error
314    /// and re-reads the current status, so surfacing this distinctly (rather
315    /// than silently overwriting a transition we never validated) is safe to
316    /// retry.
317    #[error("transaction {0} status changed concurrently; retry")]
318    ConcurrentStatusChange(String),
319}
320
321impl TransactionStore {
322    /// Open the store with **no audit chain key**. Inserts will fail with
323    /// `AuditChainMissing` — only suitable for read-only callers (e.g.
324    /// `sysknife audit verify` which loads the key separately).
325    pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
326        let path = path.as_ref().to_path_buf();
327        if let Some(parent) = path.parent() {
328            ensure_private_dir(parent)?;
329        }
330
331        let store = Self {
332            path,
333            audit_key: None,
334        };
335        store.initialize()?;
336        Ok(store)
337    }
338
339    /// Open the store and bind it to an audit chain key. Every insert
340    /// computes a forward Ed25519-signed chain hash linked to the previous row.
341    ///
342    /// The key path defaults to `<db_dir>/audit-key` so dev/test runs with
343    /// per-tempdir databases are fully isolated. Production deployments
344    /// override with `SYSKNIFE_AUDIT_KEY_PATH=/etc/sysknife/audit-key`.
345    pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
346        let db_path = path.as_ref();
347        let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
348            .map(PathBuf::from)
349            .unwrap_or_else(|_| {
350                db_path
351                    .parent()
352                    .unwrap_or_else(|| Path::new("."))
353                    .join("audit-key")
354            });
355        let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
356            TransactionStoreError::Io(std::io::Error::new(
357                std::io::ErrorKind::InvalidData,
358                format!("audit key load failed: {e}"),
359            ))
360        })?;
361        Self::open_with_key(path, Arc::new(key))
362    }
363
364    /// Open the store with an explicit audit key. Used by tests and by
365    /// production code paths that want to inject a key from a specific path.
366    pub fn open_with_key(
367        path: impl AsRef<Path>,
368        audit_key: Arc<AuditKey>,
369    ) -> Result<Self, TransactionStoreError> {
370        let path = path.as_ref().to_path_buf();
371        if let Some(parent) = path.parent() {
372            ensure_private_dir(parent)?;
373        }
374
375        let store = Self {
376            path,
377            audit_key: Some(audit_key),
378        };
379        store.initialize()?;
380        restrict_db_file_mode(&store.path);
381        Ok(store)
382    }
383
384    /// Shared body of [`record`](Self::record) and
385    /// [`record_previewed`](Self::record_previewed): check for a writable
386    /// store, open the write transaction, insert the transaction row, run
387    /// `in_tx` inside that same transaction (so e.g. a preview insert commits
388    /// atomically with its transaction row), commit, and emit the chain-tip
389    /// watermark. `in_tx` runs between the insert and the commit — never
390    /// after — so nothing it does can land outside the transaction it needs
391    /// to be atomic with.
392    fn record_with(
393        &self,
394        transaction: NewTransaction,
395        in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
396    ) -> Result<InsertedTransaction, TransactionStoreError> {
397        let key = self
398            .audit_key
399            .as_ref()
400            .ok_or(TransactionStoreError::AuditChainMissing(
401                "this TransactionStore was opened read-only; cannot record",
402            ))?;
403        let mut conn = self.connection()?;
404        // IMMEDIATE acquires the write lock at BEGIN, so the read of
405        // `next_seq_and_prev_hash` is consistent with the eventual INSERT.
406        // Default DEFERRED would let two writers both read the same prev_hash
407        // and then race to INSERT — the loser hits SQLITE_BUSY.
408        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
409        let transaction_id = Uuid::new_v4().to_string();
410        let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
411        in_tx(&tx, &inserted)?;
412        tx.commit()?;
413        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
414        Ok(inserted)
415    }
416
417    pub fn record(
418        &self,
419        transaction: NewTransaction,
420    ) -> Result<TransactionRecord, TransactionStoreError> {
421        let inserted = self.record_with(transaction, |_, _| Ok(()))?;
422        Ok(inserted.record)
423    }
424
425    pub fn record_previewed(
426        &self,
427        transaction: NewTransaction,
428        preview: PreviewEnvelope,
429    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
430        let inserted = self.record_with(transaction, |conn, inserted| {
431            Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
432        })?;
433        Ok(RecordedPreviewedTransaction {
434            transaction: inserted.record,
435            preview,
436        })
437    }
438
439    pub fn get(
440        &self,
441        transaction_id: &str,
442    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
443        let conn = self.connection()?;
444        let mut stmt = conn.prepare(
445            "SELECT
446                transaction_id,
447                request_id,
448                request_hash,
449                action_name,
450                risk_level,
451                status,
452                approval_id,
453                summary,
454                warnings_json
455             FROM transactions
456             WHERE transaction_id = ?1",
457        )?;
458        let mut rows = stmt.query(params![transaction_id])?;
459        if let Some(row) = rows.next()? {
460            Ok(Some(row_to_record(row)?))
461        } else {
462            Ok(None)
463        }
464    }
465
466    pub fn get_preview(
467        &self,
468        transaction_id: &str,
469    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
470        let conn = self.connection()?;
471        let mut stmt = conn.prepare(
472            "SELECT preview_json
473             FROM transaction_previews
474             WHERE transaction_id = ?1",
475        )?;
476        let mut rows = stmt.query(params![transaction_id])?;
477        if let Some(row) = rows.next()? {
478            let preview_json: String = row.get(0)?;
479            Ok(Some(serde_json::from_str(&preview_json)?))
480        } else {
481            Ok(None)
482        }
483    }
484
485    pub fn update_status(
486        &self,
487        transaction_id: &str,
488        new_status: JobState,
489    ) -> Result<(), TransactionStoreError> {
490        let conn = self.connection()?;
491
492        // Read the current status so we can validate the transition.
493        let current_status: String = conn
494            .query_row(
495                "SELECT status FROM transactions WHERE transaction_id = ?1",
496                params![transaction_id],
497                |row| row.get(0),
498            )
499            .map_err(|e| match e {
500                rusqlite::Error::QueryReturnedNoRows => {
501                    TransactionStoreError::NotFound(transaction_id.to_string())
502                }
503                other => TransactionStoreError::Sqlite(other),
504            })?;
505
506        let current: JobState = deserialize_field(&current_status)?;
507        if !crate::jobs::allowed_transition(&current, &new_status) {
508            return Err(TransactionStoreError::InvalidTransition {
509                from: current,
510                to: new_status,
511            });
512        }
513
514        // Compare-and-swap: only write if `status` still equals the value we
515        // just validated the transition against. Without the `AND status =
516        // ?3` guard this was a check-then-act race — a concurrent writer
517        // (e.g. two calls racing to move the same job to two different
518        // terminal states) could flip `status` between our SELECT and this
519        // UPDATE, and the loser would silently report success for a
520        // transition it never actually validated against the row's real
521        // prior value. `rows_affected == 0` means we lost that race (rows are
522        // never deleted, so it cannot mean the row vanished); the caller
523        // (`dispatcher::update_terminal_status`) already retries on any error
524        // and re-checks the final status, so failing closed here is safe.
525        // The UPDATE and the event that records it go in one sqlite
526        // transaction. Written separately, a crash between them leaves the
527        // column saying one thing and the chain another, which is the state
528        // this whole check exists to detect: a status nobody can corroborate
529        // would then look like tampering.
530        let mut conn = conn;
531        let tx = conn.transaction()?;
532        let rows_affected = tx.execute(
533            "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
534            params![
535                serialize_field(&new_status)?,
536                transaction_id,
537                current_status
538            ],
539        )?;
540        if rows_affected == 0 {
541            return Err(TransactionStoreError::ConcurrentStatusChange(
542                transaction_id.to_string(),
543            ));
544        }
545        // `transactions.status` is a mutable column. Chaining the outcome means
546        // rewriting it no longer agrees with a signed record, which is what
547        // `status_matches_chain` compares. A read-only store has no key and
548        // never reaches a write, so the absence of one here is a bug rather
549        // than a case to pass over.
550        let Some(key) = self.audit_key.as_ref() else {
551            return Err(TransactionStoreError::AuditChainMissing(
552                "update_status has no signing key, so the outcome could not be chained",
553            ));
554        };
555        Self::append_event(
556            &tx,
557            key,
558            AuditEventKind::from(new_status),
559            transaction_id,
560            "",
561        )?;
562        tx.commit()?;
563        Ok(())
564    }
565
566    /// Report any transaction whose `status` column disagrees with the newest
567    /// status event chained for it, or `None` when every row agrees.
568    ///
569    /// `ChainContent` deliberately does not sign `status`: the chain protects
570    /// the authorisation decision captured at insert, not the live execution
571    /// state, and that exclusion is correct. The consequence was that an action
572    /// which ran to completion could be rewritten in the column as canceled,
573    /// `sysknife history` reported it canceled, and the chain still verified
574    /// Intact, because nothing in the chain had an opinion about the outcome.
575    /// Now the outcome is an event, and this is the comparison that uses it.
576    pub fn status_matches_chain(
577        &self,
578    ) -> Result<audit_chain::StatusOutcome, TransactionStoreError> {
579        let conn = self.connection()?;
580        let mut stmt = conn.prepare(
581            "SELECT t.transaction_id, t.status, ( \
582                 SELECT e.kind FROM audit_events e \
583                 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
584                 ORDER BY e.seq DESC LIMIT 1 \
585             ) FROM transactions t",
586        )?;
587        let mut disagreements = Vec::new();
588        let mut checked: u64 = 0;
589        let rows = stmt.query_map([], |row| {
590            Ok((
591                row.get::<_, String>(0)?,
592                row.get::<_, String>(1)?,
593                row.get::<_, Option<String>>(2)?,
594            ))
595        })?;
596        for row in rows {
597            let (id, status_json, newest) = row?;
598            checked += 1;
599            let state: JobState = deserialize_field(&status_json)?;
600            // A row that never moved off Queued has no status event, and that
601            // is not a disagreement: `record` chains the row itself.
602            let Some(newest) = newest else {
603                if state != JobState::Queued {
604                    disagreements.push(format!(
605                        "{id}: status is {state:?} and no status event was ever chained"
606                    ));
607                }
608                continue;
609            };
610            let expected = AuditEventKind::from(state).as_str();
611            if newest != expected {
612                disagreements.push(format!(
613                    "{id}: status column says {state:?} ({expected}) and the newest \
614                     chained status event is {newest}"
615                ));
616            }
617        }
618        Ok(if disagreements.is_empty() {
619            audit_chain::StatusOutcome::Agrees {
620                rows_checked: checked,
621            }
622        } else {
623            audit_chain::StatusOutcome::Disagrees {
624                detail: disagreements.join("; "),
625            }
626        })
627    }
628
629    /// Attach one immutable approval receipt digest to a fresh queued preview.
630    pub fn approve_transaction(
631        &self,
632        transaction_id: &str,
633    ) -> Result<Option<String>, TransactionStoreError> {
634        let key = self
635            .audit_key
636            .as_ref()
637            .ok_or(TransactionStoreError::AuditChainMissing(
638                "this TransactionStore was opened read-only; cannot approve",
639            ))?;
640        let Some(record) = self.get(transaction_id)? else {
641            return Ok(None);
642        };
643        let receipt = key.approval_receipt(transaction_id, &record.request_hash);
644        let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
645        let Some(committed_digest) = record.approval_id.as_deref() else {
646            return Err(TransactionStoreError::DatabaseInvariant(format!(
647                "transaction {transaction_id} has no signed approval commitment"
648            )));
649        };
650        if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
651            return Err(TransactionStoreError::DatabaseInvariant(format!(
652                "transaction {transaction_id} approval commitment does not match its signed preview"
653            )));
654        }
655
656        // The approval row and its chained event commit together: an approval
657        // that is not in the event chain is exactly the gap this chain closes.
658        let mut conn = self.connection()?;
659        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
660        let queued_json = serialize_field(&JobState::Queued)?;
661        let rows_affected = tx.execute(
662            &format!(
663                "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
664                 SELECT transaction_id, ?1 FROM transactions \
665                 WHERE transaction_id = ?2 \
666                   AND status = ?3 \
667                   AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
668                   AND NOT EXISTS ( \
669                       SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
670                   )"
671            ),
672            params![receipt_digest, transaction_id, queued_json],
673        )?;
674        if rows_affected > 0 {
675            Self::append_event(
676                &tx,
677                key,
678                AuditEventKind::ApprovalGranted,
679                transaction_id,
680                &receipt_digest,
681            )?;
682        }
683        tx.commit()?;
684        Ok((rows_affected > 0).then_some(receipt))
685    }
686
687    /// Remove an approval that was persisted but could not be delivered to the
688    /// caller. Consumed receipts are never revocable.
689    pub fn revoke_unconsumed_approval(
690        &self,
691        transaction_id: &str,
692    ) -> Result<bool, TransactionStoreError> {
693        let key = self
694            .audit_key
695            .as_ref()
696            .ok_or(TransactionStoreError::AuditChainMissing(
697                "this TransactionStore was opened read-only; cannot revoke",
698            ))?;
699        let mut conn = self.connection()?;
700        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
701        let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
702        tx.commit()?;
703        Ok(revoked)
704    }
705
706    fn revoke_unconsumed_approval_in_tx(
707        conn: &Connection,
708        key: &AuditKey,
709        transaction_id: &str,
710    ) -> Result<bool, TransactionStoreError> {
711        // Capture the digest before the DELETE: the event has to name which
712        // receipt was retracted, and after the delete there is nothing to name.
713        let digest: Option<String> = query_optional(
714            conn,
715            "SELECT receipt_digest FROM transaction_approvals \
716             WHERE transaction_id = ?1 AND consumed_at IS NULL",
717            params![transaction_id],
718            |row| row.get(0),
719        )?;
720        let rows_affected = conn.execute(
721            "DELETE FROM transaction_approvals \
722             WHERE transaction_id = ?1 AND consumed_at IS NULL",
723            params![transaction_id],
724        )?;
725        if rows_affected > 0 {
726            let digest = digest.ok_or_else(|| {
727                TransactionStoreError::DatabaseInvariant(format!(
728                    "revoked an approval for {transaction_id} that had no receipt digest"
729                ))
730            })?;
731            Self::append_event(
732                conn,
733                key,
734                AuditEventKind::ApprovalRevoked,
735                transaction_id,
736                &digest,
737            )?;
738        }
739        Ok(rows_affected > 0)
740    }
741
742    /// Atomically consume an approved receipt and transition Queued to Running.
743    pub fn claim_approved_for_execution(
744        &self,
745        transaction_id: &str,
746        receipt_digest: &str,
747    ) -> Result<bool, TransactionStoreError> {
748        let key = self
749            .audit_key
750            .as_ref()
751            .ok_or(TransactionStoreError::AuditChainMissing(
752                "this TransactionStore was opened read-only; cannot claim",
753            ))?;
754        let mut conn = self.connection()?;
755        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
756        let queued_json = serialize_field(&JobState::Queued)?;
757        let running_json = serialize_field(&JobState::Running)?;
758        let rows_affected = tx.execute(
759            &format!(
760                "UPDATE transactions SET status = ?1 \
761                 WHERE transaction_id = ?2 \
762                   AND status = ?3 \
763                   AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
764                   AND EXISTS ( \
765                       SELECT 1 FROM transaction_approvals \
766                       WHERE transaction_id = ?2 \
767                         AND receipt_digest = ?4 \
768                         AND consumed_at IS NULL \
769                   )"
770            ),
771            params![running_json, transaction_id, queued_json, receipt_digest],
772        )?;
773        if rows_affected > 0 {
774            tx.execute(
775                "UPDATE transaction_approvals \
776                 SET consumed_at = datetime('now') \
777                 WHERE transaction_id = ?1 AND consumed_at IS NULL",
778                params![transaction_id],
779            )?;
780            Self::append_event(
781                &tx,
782                key,
783                AuditEventKind::ApprovalConsumed,
784                transaction_id,
785                receipt_digest,
786            )?;
787        }
788        tx.commit()?;
789        Ok(rows_affected > 0)
790    }
791
792    /// Cancel all `Queued` transactions whose `created_at` timestamp is older
793    /// than the 15-minute TTL window.  Returns the number of rows affected.
794    ///
795    /// **State-machine safety:** the WHERE clause pins `status = Queued`
796    /// before applying `Queued → Canceled`, which is the only legal
797    /// transition reachable from `Queued` other than `Running`. A row that
798    /// has progressed to `Running` (or any terminal state) in between the
799    /// TTL match and our UPDATE is skipped because the predicate no longer
800    /// matches it. This makes the bulk SQL semantically equivalent to
801    /// fetching each candidate, building a `JobStateMachine`, and calling
802    /// `cancel()` on it — but in a single statement so we don't race ourselves
803    /// when many rows expire at once. The
804    /// `cleanup_stale_queued_does_not_clobber_running_rows` regression test
805    /// in `tests/coverage_gaps.rs` pins this guarantee.
806    pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
807        let key = self
808            .audit_key
809            .as_ref()
810            .ok_or(TransactionStoreError::AuditChainMissing(
811                "this TransactionStore was opened read-only; cannot clean up",
812            ))?;
813        let mut conn = self.connection()?;
814        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
815        let canceled_json = serialize_field(&JobState::Canceled)?;
816        let queued_json = serialize_field(&JobState::Queued)?;
817        let stale_ids: Vec<String> = {
818            let mut statement = tx.prepare(&format!(
819                "SELECT transaction_id FROM transactions \
820                 WHERE status = ?1 \
821                   AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
822            ))?;
823            let stale_ids = statement
824                .query_map(params![queued_json], |row| row.get(0))?
825                .collect::<Result<_, _>>()?;
826            stale_ids
827        };
828        let mut canceled = 0;
829        for transaction_id in stale_ids {
830            let rows_affected = tx.execute(
831                "UPDATE transactions SET status = ?1 \
832                 WHERE transaction_id = ?2 AND status = ?3",
833                params![canceled_json, transaction_id, queued_json],
834            )?;
835            if rows_affected > 0 {
836                Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?;
837                canceled += rows_affected;
838            }
839        }
840        tx.commit()?;
841        Ok(canceled as u64)
842    }
843
844    /// Cancel one still-`Queued` transaction (`Queued → Canceled`). Returns
845    /// `true` iff a queued row was transitioned.
846    ///
847    /// Option A semantics: the `status = ?3` (Queued) guard means a `Running`
848    /// transaction — one whose privileged action is already executing — is
849    /// never cancelled, so we never leave a half-applied root mutation behind
850    /// a `Canceled` record. Missing or already-terminal transactions return
851    /// `false`.
852    pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
853        let key = self
854            .audit_key
855            .as_ref()
856            .ok_or(TransactionStoreError::AuditChainMissing(
857                "this TransactionStore was opened read-only; cannot cancel",
858            ))?;
859        let mut conn = self.connection()?;
860        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
861        let canceled_json = serialize_field(&JobState::Canceled)?;
862        let queued_json = serialize_field(&JobState::Queued)?;
863        let rows_affected = tx.execute(
864            "UPDATE transactions SET status = ?1 \
865             WHERE transaction_id = ?2 AND status = ?3",
866            params![canceled_json, transaction_id, queued_json],
867        )?;
868        if rows_affected > 0 {
869            Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
870        }
871        tx.commit()?;
872        Ok(rows_affected > 0)
873    }
874
875    /// List transactions with optional filters, ordered by newest first.
876    ///
877    /// - `limit`: max number of rows (capped at 100)
878    /// - `status_filter`: if set, only return rows matching this status
879    ///   (must be a valid `JobState` variant: `"succeeded"`, `"failed"`,
880    ///   `"queued"`, `"running"`, `"canceled"`, `"rolled_back"`, `"needs_reboot"`)
881    /// - `action_filter`: if set, only return rows with this exact action name
882    /// - `since_hours`: if set, only return rows created within the last N hours
883    pub fn list_transactions(
884        &self,
885        limit: u32,
886        status_filter: Option<&str>,
887        action_filter: Option<&str>,
888        since_hours: Option<u32>,
889    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
890        let conn = self.connection()?;
891        let (filter_sql, param_values) =
892            Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
893        let sql = format!(
894            "SELECT transaction_id, request_id, request_hash, action_name, \
895             risk_level, status, approval_id, summary, warnings_json \
896             FROM transactions WHERE 1=1{filter_sql}"
897        );
898        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
899            param_values.iter().map(|b| b.as_ref()).collect();
900
901        let mut stmt = conn.prepare(&sql)?;
902        let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
903
904        let mut results = Vec::new();
905        for row in rows {
906            results.push(row??);
907        }
908        Ok(results)
909    }
910
911    /// Structured history for programmatic clients (the MCP `sysknife_history`
912    /// tool). Unlike [`list_transactions`](Self::list_transactions) it selects
913    /// `created_at` and returns [`JobHistoryEntry`] so `risk_level` and
914    /// `created_at` reach the caller typed, without text re-parsing.
915    pub fn list_history(
916        &self,
917        limit: u32,
918        status_filter: Option<&str>,
919        action_filter: Option<&str>,
920        since_hours: Option<u32>,
921    ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
922        let conn = self.connection()?;
923        let (filter_sql, param_values) =
924            Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
925        let sql = format!(
926            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
927             FROM transactions WHERE 1=1{filter_sql}"
928        );
929        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
930            param_values.iter().map(|b| b.as_ref()).collect();
931
932        let mut stmt = conn.prepare(&sql)?;
933        let rows = stmt.query_map(params_ref.as_slice(), |row| {
934            Ok((|| {
935                Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
936                    transaction_id: row.get(0)?,
937                    action_name: row.get(1)?,
938                    risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
939                    status: deserialize_field(&row.get::<_, String>(3)?)?,
940                    summary: row.get(4)?,
941                    created_at: row.get(5)?,
942                })
943            })())
944        })?;
945
946        let mut results = Vec::new();
947        for row in rows {
948            results.push(row??);
949        }
950        Ok(results)
951    }
952
953    /// Build the shared `WHERE`/`ORDER BY`/`LIMIT` suffix (after `WHERE 1=1`)
954    /// and its bound parameters for the history queries, so
955    /// [`list_transactions`](Self::list_transactions) and
956    /// [`list_history`](Self::list_history) cannot filter differently.
957    fn build_history_filter(
958        limit: u32,
959        status_filter: Option<&str>,
960        action_filter: Option<&str>,
961        since_hours: Option<u32>,
962    ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
963        let mut sql = String::new();
964        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
965
966        if let Some(status) = status_filter {
967            // Validate against known JobState variants to avoid silent empty
968            // results from typos (e.g. "success" instead of "succeeded").
969            // deserialize_field returns serde_json::Error → TransactionStoreError::Json.
970            let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
971            let status_json = serialize_field(&validated)?;
972            sql.push_str(" AND status = ?");
973            param_values.push(Box::new(status_json));
974        }
975
976        if let Some(action) = action_filter {
977            sql.push_str(" AND action_name = ?");
978            param_values.push(Box::new(action.to_string()));
979        }
980
981        if let Some(hours) = since_hours {
982            sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
983            param_values.push(Box::new(hours));
984        }
985
986        sql.push_str(" ORDER BY seq DESC LIMIT ?");
987        param_values.push(Box::new(limit.min(100)));
988
989        Ok((sql, param_values))
990    }
991
992    fn connection(&self) -> Result<Connection, TransactionStoreError> {
993        let conn = Connection::open(&self.path)?;
994        // Concurrency tuning :
995        //   - WAL journal mode lets readers proceed concurrently with writers.
996        //   - busy_timeout=5000ms makes a contending writer block instead of
997        //     immediately returning SQLITE_BUSY. Without it, two concurrent
998        //     `record()` calls (one of the two daemon use cases the dispatcher
999        //     supports) had a 100% second-writer failure rate.
1000        //   - synchronous=NORMAL is the WAL-recommended setting; FULL is
1001        //     overkill for an audit log that's already append-only by design,
1002        //     and OFF risks losing the latest transactions on a crash.
1003        //   - foreign_keys=ON for parity with future schema changes.
1004        conn.pragma_update(None, "journal_mode", "WAL")?;
1005        conn.pragma_update(None, "synchronous", "NORMAL")?;
1006        conn.pragma_update(None, "foreign_keys", "ON")?;
1007        conn.busy_timeout(std::time::Duration::from_secs(5))?;
1008        Ok(conn)
1009    }
1010
1011    fn initialize(&self) -> Result<(), TransactionStoreError> {
1012        let mut conn = self.connection()?;
1013        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
1014        tx.execute_batch(
1015            "CREATE TABLE IF NOT EXISTS schema_migrations (\
1016                 version INTEGER PRIMARY KEY,\
1017                 name TEXT NOT NULL,\
1018                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
1019             );",
1020        )?;
1021        let current: i64 = tx.query_row(
1022            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
1023            [],
1024            |row| row.get(0),
1025        )?;
1026        let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
1027        if current > latest {
1028            return Err(TransactionStoreError::DatabaseInvariant(format!(
1029                "sqlite schema version {current} is newer than this binary supports ({latest})"
1030            )));
1031        }
1032        for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
1033            tx.execute_batch(migration.sql)?;
1034            tx.execute(
1035                "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1036                params![migration.version, migration.name],
1037            )?;
1038        }
1039        tx.commit()?;
1040        Ok(())
1041    }
1042
1043    /// Return all rows in seq order with the chain fields needed for verify.
1044    pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
1045        let conn = self.connection()?;
1046        let mut stmt = conn.prepare(&format!(
1047            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
1048        ))?;
1049        let rows = stmt.query_map([], chain_row_from_sqlite)?;
1050        let mut out = Vec::new();
1051        for row in rows {
1052            out.push(row?);
1053        }
1054        Ok(out)
1055    }
1056
1057    /// Return every approval event in seq order.
1058    pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
1059        let conn = self.connection()?;
1060        let mut stmt = conn.prepare(
1061            "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1062                    created_at, prev_chain_hash, chain_hash \
1063             FROM audit_events ORDER BY seq ASC",
1064        )?;
1065        let rows = stmt.query_map([], event_row_from_sqlite)?;
1066        let mut out = Vec::new();
1067        for row in rows {
1068            out.push(row?);
1069        }
1070        Ok(out)
1071    }
1072
1073    /// Walk the audit chain with `key` and report integrity status.
1074    pub fn verify_audit_chain(
1075        &self,
1076        key: &AuditKey,
1077    ) -> Result<VerifyOutcome, TransactionStoreError> {
1078        let rows = self.fetch_chain_rows()?;
1079        Ok(audit_chain::verify_chain(key, &rows))
1080    }
1081
1082    /// Walk the approval-event chain with `key`.
1083    pub fn verify_event_chain(
1084        &self,
1085        key: &AuditKey,
1086    ) -> Result<VerifyOutcome, TransactionStoreError> {
1087        let rows = self.fetch_event_rows()?;
1088        Ok(audit_chain::verify_event_chain(key, &rows))
1089    }
1090
1091    /// Check that every event tip committed by a transaction row is still
1092    /// present in the event chain. See [`audit_chain::verify_event_binding`].
1093    pub fn verify_event_binding(
1094        &self,
1095    ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1096        let tx_rows = self.fetch_chain_rows()?;
1097        let event_rows = self.fetch_event_rows()?;
1098        Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1099    }
1100
1101    /// Verify the chain with only the hex-encoded Ed25519 **public** key. The
1102    /// auditor path: proves the chain without the private key and cannot forge.
1103    pub fn verify_audit_chain_with_pubkey(
1104        &self,
1105        verifying_key_hex: &str,
1106    ) -> Result<VerifyOutcome, TransactionStoreError> {
1107        let rows = self.fetch_chain_rows()?;
1108        Ok(audit_chain::verify_chain_with_pubkey(
1109            verifying_key_hex,
1110            &rows,
1111        ))
1112    }
1113
1114    /// Fetch a single row's chain metadata by `transaction_id`. Used by the
1115    /// audit-log forwarder to construct an `AuditEvent` after insert.
1116    pub fn fetch_chain_row(
1117        &self,
1118        transaction_id: &str,
1119    ) -> Result<Option<ChainRow>, TransactionStoreError> {
1120        let conn = self.connection()?;
1121        let mut stmt = conn.prepare(&format!(
1122            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1123        ))?;
1124        let mut rows = stmt.query(params![transaction_id])?;
1125        if let Some(row) = rows.next()? {
1126            Ok(Some(chain_row_from_sqlite(row)?))
1127        } else {
1128            Ok(None)
1129        }
1130    }
1131
1132    /// Allocate the next monotonic `seq` and fetch the previous row's
1133    /// `chain_hash`. Caller must hold a transaction so the (seq, prev_hash)
1134    /// pair is consistent against concurrent writers.
1135    fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1136        let prev: Option<(i64, String)> = query_optional(
1137            conn,
1138            "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1139            [],
1140            |row| Ok((row.get(0)?, row.get(1)?)),
1141        )?;
1142        Ok(match prev {
1143            Some((seq, hash)) => ((seq as u64) + 1, hash),
1144            None => (1, String::new()),
1145        })
1146    }
1147
1148    /// `chain_hash` of the last approval event, or `None` when no event has
1149    /// ever been recorded.
1150    fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1151        Ok(query_optional(
1152            conn,
1153            "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1154            [],
1155            |row| row.get(0),
1156        )?)
1157    }
1158
1159    /// Append one signed approval event. Must be called inside a DB
1160    /// transaction that also performs the state change being recorded: an
1161    /// event committed without its state change (or the reverse) would be a
1162    /// trail that disagrees with reality.
1163    fn append_event(
1164        conn: &Connection,
1165        key: &AuditKey,
1166        kind: AuditEventKind,
1167        transaction_id: &str,
1168        receipt_digest: &str,
1169    ) -> Result<(), TransactionStoreError> {
1170        let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1171        let seq: i64 = conn.query_row(
1172            "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1173            [],
1174            |row| row.get(0),
1175        )?;
1176        let created_at: String =
1177            conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1178                row.get(0)
1179            })?;
1180        let key_id = CURRENT_KEY_ID.to_string();
1181        let chain_hash = key.event_hash(
1182            &EventContent {
1183                seq: seq as u64,
1184                key_id: &key_id,
1185                kind,
1186                transaction_id,
1187                receipt_digest,
1188                created_at: &created_at,
1189            },
1190            &prev_chain_hash,
1191        );
1192        conn.execute(
1193            "INSERT INTO audit_events (
1194                seq, key_id, kind, transaction_id, receipt_digest,
1195                created_at, chain_hash, prev_chain_hash
1196            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1197            params![
1198                seq,
1199                key_id,
1200                kind.as_str(),
1201                transaction_id,
1202                receipt_digest,
1203                created_at,
1204                chain_hash,
1205                prev_chain_hash,
1206            ],
1207        )?;
1208        Ok(())
1209    }
1210
1211    fn insert_transaction(
1212        conn: &Connection,
1213        key: &AuditKey,
1214        transaction_id: &str,
1215        transaction: NewTransaction,
1216    ) -> Result<InsertedTransaction, TransactionStoreError> {
1217        let request_id = transaction.request_id;
1218        let request_hash = transaction.request_hash;
1219        let action_name = transaction.action_name;
1220        let risk_level = transaction.risk_level;
1221        // Initial status is always Queued — not caller-controllable.
1222        let status = JobState::Queued;
1223        // Always the store's own commitment over this transaction. It used to
1224        // accept a caller-supplied `Some(..)` and use it verbatim; production
1225        // always passed `None`, so the invariant held only because every call
1226        // site remembered to. The value is chain-hashed and then treated as
1227        // authoritative evidence that an approval happened, which is not
1228        // something a caller may hand us.
1229        let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1230        let summary = transaction.summary;
1231        let warnings = transaction.warnings;
1232        let warnings_json = serde_json::to_string(&warnings)?;
1233
1234        // Allocate the next seq + previous chain hash inside the same DB
1235        // transaction so concurrent writers can't race.
1236        let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1237
1238        // SQLite's `datetime('now')` (default for the column) is computed at
1239        // INSERT time, but we need the same value to compute the chain hash
1240        // before the row exists. Compute it ourselves and pin it.
1241        let created_at: String =
1242            conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1243                row.get(0)
1244            })?;
1245
1246        let key_id = CURRENT_KEY_ID.to_string();
1247        // Bind this row to the approval-event chain as it stands right now.
1248        // Read inside the caller's DB transaction so a concurrent event append
1249        // cannot land between the read and the insert.
1250        let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1251        let caller_role = transaction.caller_role.as_str();
1252        let caller_principal = transaction.caller_principal.as_signed_str();
1253        // Build the identity once and derive the stored version from it, so the
1254        // chain_version column is provably the version whose message was signed.
1255        // A constant here let the two drift the moment a new encoding landed.
1256        let identity = ChainIdentity::V3 {
1257            caller_role,
1258            event_tip: &event_tip,
1259            caller_principal: &caller_principal,
1260        };
1261        let chain_hash = key.chain_hash(
1262            &ChainContent {
1263                seq,
1264                key_id: &key_id,
1265                transaction_id,
1266                request_id: &request_id,
1267                request_hash: &request_hash,
1268                action_name: &action_name,
1269                risk_level,
1270                summary: &summary,
1271                approval_id: approval_id.as_deref(),
1272                warnings_json: &warnings_json,
1273                created_at: &created_at,
1274                identity,
1275            },
1276            &prev_chain_hash,
1277        );
1278
1279        let record = TransactionRecord {
1280            transaction_id: transaction_id.to_string(),
1281            request_id: request_id.clone(),
1282            request_hash: request_hash.clone(),
1283            action_name: action_name.clone(),
1284            risk_level,
1285            status,
1286            approval_id: approval_id.clone(),
1287            summary: summary.clone(),
1288            warnings: warnings.clone(),
1289        };
1290
1291        conn.execute(
1292            "INSERT INTO transactions (
1293                transaction_id,
1294                request_id,
1295                request_hash,
1296                action_name,
1297                risk_level,
1298                status,
1299                approval_id,
1300                summary,
1301                warnings_json,
1302                created_at,
1303                seq,
1304                key_id,
1305                chain_hash,
1306                prev_chain_hash,
1307                chain_version,
1308                caller_role,
1309                event_tip,
1310                caller_principal
1311            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1312            params![
1313                transaction_id,
1314                request_id,
1315                request_hash,
1316                action_name,
1317                serialize_field(&risk_level)?,
1318                serialize_field(&status)?,
1319                approval_id,
1320                summary,
1321                warnings_json,
1322                created_at,
1323                seq as i64,
1324                key_id,
1325                chain_hash,
1326                prev_chain_hash,
1327                identity.version() as i64,
1328                caller_role,
1329                event_tip,
1330                caller_principal,
1331            ],
1332        )?;
1333
1334        Ok(InsertedTransaction {
1335            record,
1336            seq,
1337            chain_hash,
1338        })
1339    }
1340
1341    fn insert_preview(
1342        conn: &Connection,
1343        transaction_id: &str,
1344        preview: &PreviewEnvelope,
1345    ) -> Result<(), TransactionStoreError> {
1346        conn.execute(
1347            "INSERT INTO transaction_previews (transaction_id, preview_json)
1348             VALUES (?1, ?2)",
1349            params![transaction_id, serde_json::to_string(preview)?],
1350        )?;
1351        Ok(())
1352    }
1353}
1354
1355/// Create `dir` and any missing parents with mode `0o700` (rwx owner only).
1356///
1357/// If the directory already exists, its mode is left untouched — the operator
1358/// or packaging spec (`sysknife-tmpfiles.conf`) owns existing-directory policy.
1359/// If the directory must be created here (e.g. dev contributor's first daemon
1360/// run), we never produce a world-readable audit-log directory.
1361fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1362    use std::os::unix::fs::DirBuilderExt;
1363    if dir.exists() {
1364        return Ok(());
1365    }
1366    std::fs::DirBuilder::new()
1367        .recursive(true)
1368        .mode(0o700)
1369        .create(dir)
1370}
1371
1372/// Narrow the audit database (and its WAL sidecars) to mode `0o600`.
1373///
1374/// SQLite creates its files `0o644 & ~umask`. The 0700 parent directory hides
1375/// them from other users today, but the file mode travels: a backup, a `cp`, or
1376/// an operator who widens the directory exposes every action name, parameter,
1377/// and approving principal the daemon has recorded. SQLite also derives the
1378/// `-wal`/`-shm` modes from the main file, so leaving the main file permissive
1379/// leaks the most recent (not-yet-checkpointed) transactions as well.
1380///
1381/// Tightening only, and never fatal: the directory is the primary control, and
1382/// a `chmod` that fails because the file belongs to another user must not stop
1383/// a daemon that previously started fine. It is loud, because a security
1384/// property that quietly stops holding is worse than one that never held.
1385fn restrict_db_file_mode(db_path: &Path) {
1386    use std::os::unix::fs::PermissionsExt;
1387
1388    // `-shm` is recreated per connection; `-wal` persists. Both inherit from the
1389    // main file at creation, so fixing the main file covers the common case and
1390    // these two cover a database that already exists with a wider mode.
1391    let sidecars = [
1392        db_path.to_path_buf(),
1393        with_suffix(db_path, "-wal"),
1394        with_suffix(db_path, "-shm"),
1395    ];
1396    for path in sidecars {
1397        let Ok(metadata) = std::fs::metadata(&path) else {
1398            continue; // Absent sidecar: nothing to narrow.
1399        };
1400        let mode = metadata.permissions().mode() & 0o777;
1401        if mode & 0o077 == 0 {
1402            continue; // Already owner-only or tighter.
1403        }
1404        if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1405            eprintln!(
1406                "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1407                 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1408                 it. The parent directory should be 0700 — check it.",
1409                path.display()
1410            );
1411        }
1412    }
1413}
1414
1415/// `path` with `suffix` appended to the file name (not the extension), matching
1416/// how SQLite names `daemon.sqlite-wal` beside `daemon.sqlite`.
1417fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1418    let mut name = path.as_os_str().to_os_string();
1419    name.push(suffix);
1420    PathBuf::from(name)
1421}
1422
1423fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1424    Ok(TransactionRecord {
1425        transaction_id: row.get(0)?,
1426        request_id: row.get(1)?,
1427        request_hash: row.get(2)?,
1428        action_name: row.get(3)?,
1429        risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1430        status: deserialize_field(&row.get::<_, String>(5)?)?,
1431        approval_id: row.get(6)?,
1432        summary: row.get(7)?,
1433        warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1434    })
1435}
1436
1437fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1438    serde_json::to_string(value)
1439}
1440
1441fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1442    serde_json::from_str(value)
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use super::*;
1448    use crate::audit_chain::CHAIN_VERSION_CURRENT;
1449    use crate::audit_chain::CHAIN_VERSION_LEGACY;
1450    use std::os::unix::fs::PermissionsExt;
1451    use tempfile::tempdir;
1452
1453    /// Open a TransactionStore with a deterministic test key. Avoids the
1454    /// XDG/`/etc` lookup in `TransactionStore::open` so tests don't share
1455    /// state with the dev environment.
1456    fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1457        let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1458        TransactionStore::open_with_key(path, key).unwrap()
1459    }
1460
1461    // ── Schema migration ─────────────────────────────────────────────────
1462
1463    /// Create a database at exactly schema version 1 — the shape a v0.2.12
1464    /// daemon left behind — and write one row using the encoding that binary
1465    /// signed. Building the fixture from `SQLITE_MIGRATIONS[0]` rather than a
1466    /// copied DDL string means it stays honest if migration 1 is ever edited.
1467    fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1468        let conn = Connection::open(path).unwrap();
1469        conn.execute_batch(
1470            "CREATE TABLE schema_migrations (
1471                 version INTEGER PRIMARY KEY,
1472                 name TEXT NOT NULL,
1473                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1474             );",
1475        )
1476        .unwrap();
1477        conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1478        conn.execute(
1479            "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1480            params![SQLITE_MIGRATIONS[0].name],
1481        )
1482        .unwrap();
1483
1484        let transaction_id = "tx-legacy";
1485        let created_at = "2026-04-24T12:00:00.000Z";
1486        let chain_hash = key.chain_hash(
1487            &ChainContent {
1488                seq: 1,
1489                key_id: CURRENT_KEY_ID,
1490                transaction_id,
1491                request_id: "req-legacy",
1492                request_hash: "hash-legacy",
1493                action_name: "UpdateSystem",
1494                risk_level: RiskLevel::High,
1495                summary: "Upgrade the system",
1496                approval_id: None,
1497                warnings_json: "[]",
1498                created_at,
1499                identity: ChainIdentity::LegacyV1,
1500            },
1501            "",
1502        );
1503        conn.execute(
1504            "INSERT INTO transactions (
1505                transaction_id, request_id, request_hash, action_name, risk_level,
1506                status, approval_id, summary, warnings_json, created_at,
1507                seq, key_id, chain_hash, prev_chain_hash
1508            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1509            params![
1510                transaction_id,
1511                "req-legacy",
1512                "hash-legacy",
1513                "UpdateSystem",
1514                serialize_field(&RiskLevel::High).unwrap(),
1515                serialize_field(&JobState::Queued).unwrap(),
1516                "Upgrade the system",
1517                "[]",
1518                created_at,
1519                CURRENT_KEY_ID,
1520                chain_hash,
1521            ],
1522        )
1523        .unwrap();
1524        transaction_id.to_string()
1525    }
1526
1527    /// A database at schema version 2 holding one v2 row: the shape every host
1528    /// running v0.2.13 through v0.2.16 has on disk right now.
1529    ///
1530    /// Built by replaying `SQLITE_MIGRATIONS[0..=1]` rather than hardcoded DDL,
1531    /// so editing those migrations cannot leave this fixture describing a schema
1532    /// that never existed.
1533    fn v2_database(path: &Path, key: &AuditKey) -> String {
1534        let conn = Connection::open(path).unwrap();
1535        conn.execute_batch(
1536            "CREATE TABLE schema_migrations (
1537                 version INTEGER PRIMARY KEY,
1538                 name TEXT NOT NULL,
1539                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1540             );",
1541        )
1542        .unwrap();
1543        for migration in &SQLITE_MIGRATIONS[0..=1] {
1544            conn.execute_batch(migration.sql).unwrap();
1545            conn.execute(
1546                "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1547                params![migration.version, migration.name],
1548            )
1549            .unwrap();
1550        }
1551
1552        let transaction_id = "tx-v2";
1553        let created_at = "2026-07-01T12:00:00.000Z";
1554        let chain_hash = key.chain_hash(
1555            &ChainContent {
1556                seq: 1,
1557                key_id: CURRENT_KEY_ID,
1558                transaction_id,
1559                request_id: "req-v2",
1560                request_hash: "hash-v2",
1561                action_name: "UpdateSystem",
1562                risk_level: RiskLevel::High,
1563                summary: "Upgrade the system",
1564                approval_id: None,
1565                warnings_json: "[]",
1566                created_at,
1567                identity: ChainIdentity::V2 {
1568                    caller_role: "admin",
1569                    event_tip: "",
1570                },
1571            },
1572            "",
1573        );
1574        conn.execute(
1575            "INSERT INTO transactions (
1576                transaction_id, request_id, request_hash, action_name, risk_level,
1577                status, approval_id, summary, warnings_json, created_at,
1578                seq, key_id, chain_hash, prev_chain_hash,
1579                chain_version, caller_role, event_tip
1580            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1581            params![
1582                transaction_id,
1583                "req-v2",
1584                "hash-v2",
1585                "UpdateSystem",
1586                serialize_field(&RiskLevel::High).unwrap(),
1587                serialize_field(&JobState::Queued).unwrap(),
1588                "Upgrade the system",
1589                "[]",
1590                created_at,
1591                CURRENT_KEY_ID,
1592                chain_hash,
1593            ],
1594        )
1595        .unwrap();
1596        transaction_id.to_string()
1597    }
1598
1599    /// The upgrade every deployed host actually performs: v2 on disk, then v3
1600    /// rows appended by the new daemon, in one chain.
1601    ///
1602    /// The v1 fixture already covers the older jump, but nothing covered this
1603    /// one, and it is the only one with installed users. If migration 3 ever
1604    /// backfilled a principal, or the v2 dispatch drifted, this is where a real
1605    /// operator's audit log would start reporting as tampered.
1606    #[test]
1607    fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1608        let dir = tempdir().unwrap();
1609        let db_path = dir.path().join("tx.db");
1610        let key = AuditKey::from_bytes(vec![0x42; 32]);
1611        let old_transaction = v2_database(&db_path, &key);
1612
1613        let store = test_store(&db_path);
1614
1615        let version: i64 = store
1616            .connection()
1617            .unwrap()
1618            .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1619                row.get(0)
1620            })
1621            .unwrap();
1622        assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1623
1624        let rows = store.fetch_chain_rows().unwrap();
1625        assert_eq!(rows.len(), 1);
1626        assert_eq!(rows[0].transaction_id, old_transaction);
1627        assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1628        assert_eq!(
1629            rows[0].caller_principal, None,
1630            "migration 3 must not invent a principal for a row signed without one"
1631        );
1632        assert!(matches!(
1633            store.verify_audit_chain(&key).unwrap(),
1634            audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1635        ));
1636
1637        // Append under the new encoding and re-verify the mixed chain.
1638        let mut fresh = queued_transaction();
1639        fresh.caller_principal = CallerPrincipal::Uid(4242);
1640        store.record(fresh).unwrap();
1641
1642        let rows = store.fetch_chain_rows().unwrap();
1643        assert_eq!(rows.len(), 2);
1644        assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1645        assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1646        assert_eq!(
1647            rows[1].prev_chain_hash, rows[0].chain_hash,
1648            "the v3 row must chain onto the v2 row, not start a new chain"
1649        );
1650        assert!(matches!(
1651            store.verify_audit_chain(&key).unwrap(),
1652            audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1653        ));
1654
1655        // The census, taken over rows that made a real round trip through SQLite
1656        // rather than hand-built fixtures. This is the only place that can catch
1657        // the SQL side going wrong: a `SELECT` that dropped `caller_principal`, or
1658        // a mapping that coerced `NULL` to `""`, would move every legacy row out
1659        // of "names nobody" and into "names an account", restoring the exact
1660        // misreport this census exists to prevent, with every in-memory test
1661        // still green.
1662        let census = audit_chain::AttributionCensus::of(&rows);
1663        assert_eq!(census.rows(), 2);
1664        assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1665        assert_eq!(
1666            census.not_recorded(),
1667            1,
1668            "the v2 row's encoding never signed a principal"
1669        );
1670        assert_eq!(census.attribution_failed(), 0);
1671        assert_eq!(census.unattested(), 0);
1672        assert_eq!(census.unnamed(), 1);
1673    }
1674
1675    #[test]
1676    fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1677        // The migration contract, end to end through the store. An operator
1678        // upgrading the daemon must not see their existing audit log start
1679        // reporting as tampered — that would make a real compromise
1680        // indistinguishable from a routine upgrade.
1681        let dir = tempdir().unwrap();
1682        let db_path = dir.path().join("tx.db");
1683        let key = AuditKey::from_bytes(vec![0x42; 32]);
1684        legacy_v1_database(&db_path, &key);
1685
1686        let store = test_store(&db_path);
1687        assert_eq!(
1688            store.verify_audit_chain(&key).unwrap(),
1689            VerifyOutcome::Intact { rows_checked: 1 }
1690        );
1691        let rows = store.fetch_chain_rows().unwrap();
1692        assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1693        assert_eq!(rows[0].caller_role, None);
1694    }
1695
1696    #[test]
1697    fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1698        // Mixed-generation chain written through the real store, not a
1699        // hand-built fixture: the new row's prev_chain_hash must link to the
1700        // legacy row, and both encodings must verify in one walk.
1701        let dir = tempdir().unwrap();
1702        let db_path = dir.path().join("tx.db");
1703        let key = AuditKey::from_bytes(vec![0x42; 32]);
1704        legacy_v1_database(&db_path, &key);
1705
1706        let store = test_store(&db_path);
1707        store.record(queued_transaction()).unwrap();
1708
1709        let rows = store.fetch_chain_rows().unwrap();
1710        assert_eq!(rows.len(), 2);
1711        assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1712        assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1713        assert_eq!(
1714            store.verify_audit_chain(&key).unwrap(),
1715            VerifyOutcome::Intact { rows_checked: 2 }
1716        );
1717    }
1718
1719    #[test]
1720    fn a_schema_newer_than_this_binary_is_refused() {
1721        let dir = tempdir().unwrap();
1722        let db_path = dir.path().join("tx.db");
1723        let key = AuditKey::from_bytes(vec![0x42; 32]);
1724        legacy_v1_database(&db_path, &key);
1725        let conn = Connection::open(&db_path).unwrap();
1726        conn.execute(
1727            "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1728            [],
1729        )
1730        .unwrap();
1731        drop(conn);
1732
1733        let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1734        assert!(
1735            matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1736            "expected a refusal naming the unsupported version, got {err:?}"
1737        );
1738    }
1739
1740    // ── caller identity + approval events ────────────────────────────────
1741
1742    #[test]
1743    fn the_signed_row_records_which_role_asked() {
1744        let dir = tempdir().unwrap();
1745        let store = test_store(dir.path().join("tx.db"));
1746        let mut tx = queued_transaction();
1747        tx.caller_role = CallerRole::Admin;
1748        store.record(tx).unwrap();
1749
1750        let rows = store.fetch_chain_rows().unwrap();
1751        assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1752    }
1753
1754    #[test]
1755    fn approving_consuming_and_revoking_each_append_a_chained_event() {
1756        let dir = tempdir().unwrap();
1757        let key = AuditKey::from_bytes(vec![0x42; 32]);
1758        let store = test_store(dir.path().join("tx.db"));
1759
1760        // Approve then consume.
1761        let a = store.record(queued_transaction()).unwrap();
1762        let receipt = store
1763            .approve_transaction(&a.transaction_id)
1764            .unwrap()
1765            .expect("queued transaction approves");
1766        let digest = audit_chain::approval_receipt_digest(&receipt);
1767        assert!(store
1768            .claim_approved_for_execution(&a.transaction_id, &digest)
1769            .unwrap());
1770
1771        // Approve then revoke.
1772        let b = store.record(queued_transaction()).unwrap();
1773        store.approve_transaction(&b.transaction_id).unwrap();
1774        assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1775
1776        let events = store.fetch_event_rows().unwrap();
1777        let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1778        assert_eq!(
1779            kinds,
1780            vec![
1781                "approval_granted",
1782                "approval_consumed",
1783                "approval_granted",
1784                "approval_revoked",
1785            ]
1786        );
1787        assert_eq!(
1788            store.verify_event_chain(&key).unwrap(),
1789            VerifyOutcome::Intact { rows_checked: 4 }
1790        );
1791    }
1792
1793    #[test]
1794    fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() {
1795        // The module documentation says status transitions ARE chained
1796        // separately, in audit_events. They were not: update_status appended
1797        // nothing, and audit_events recorded only the approval lifecycle. So a
1798        // job that ran to completion could be rewritten in the `status` column
1799        // as canceled, `sysknife history` reported it canceled, and
1800        // `audit verify` still reported the chain Intact. An investigator
1801        // reading that documentation would believe the outcome was protected.
1802        let dir = tempdir().unwrap();
1803        let key = AuditKey::from_bytes(vec![0x42; 32]);
1804        let store = test_store(dir.path().join("tx.db"));
1805
1806        let tx = store.record(queued_transaction()).unwrap();
1807        let receipt = store
1808            .approve_transaction(&tx.transaction_id)
1809            .unwrap()
1810            .expect("queued transaction approves");
1811        let digest = audit_chain::approval_receipt_digest(&receipt);
1812        assert!(store
1813            .claim_approved_for_execution(&tx.transaction_id, &digest)
1814            .unwrap());
1815        store
1816            .update_status(&tx.transaction_id, JobState::Succeeded)
1817            .unwrap();
1818
1819        // The outcome must be in the event chain, not only in a mutable column.
1820        let kinds: Vec<String> = store
1821            .fetch_event_rows()
1822            .unwrap()
1823            .iter()
1824            .map(|e| e.kind.clone())
1825            .collect();
1826        assert!(
1827            kinds.iter().any(|k| k.contains("succeeded")),
1828            "no event records the terminal outcome; the chain covers only {kinds:?}"
1829        );
1830        assert!(
1831            matches!(
1832                store.status_matches_chain().unwrap(),
1833                audit_chain::StatusOutcome::Agrees { .. }
1834            ),
1835            "an untampered store must report agreement, got {:?}",
1836            store.status_matches_chain().unwrap()
1837        );
1838
1839        // Now rewrite the column the way anyone with write access to the
1840        // database can, and the cross-check must notice.
1841        {
1842            let conn = rusqlite::Connection::open(dir.path().join("tx.db")).unwrap();
1843            let n = conn
1844                .execute(
1845                    "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
1846                    rusqlite::params!["\"canceled\"", tx.transaction_id],
1847                )
1848                .unwrap();
1849            assert_eq!(n, 1, "the fixture did not rewrite the status column");
1850        }
1851        let audit_chain::StatusOutcome::Disagrees { detail } =
1852            store.status_matches_chain().unwrap()
1853        else {
1854            panic!("a rewritten status must be reported, not passed over");
1855        };
1856        assert!(
1857            detail.contains(&tx.transaction_id),
1858            "the report must name the transaction: {detail}"
1859        );
1860        // The event chain itself is untouched by that UPDATE, which is exactly
1861        // why verifying it alone was not enough.
1862        assert!(
1863            matches!(
1864                store.verify_event_chain(&key).unwrap(),
1865                VerifyOutcome::Intact { .. }
1866            ),
1867            "event chain: {:?}, rows: {:?}",
1868            store.verify_event_chain(&key).unwrap(),
1869            store
1870                .fetch_event_rows()
1871                .unwrap()
1872                .iter()
1873                .map(|e| (e.seq, e.kind.clone()))
1874                .collect::<Vec<_>>()
1875        );
1876    }
1877
1878    #[test]
1879    fn a_failed_approval_appends_no_event() {
1880        // Only real state changes get chained. A no-op approve (already
1881        // approved) writing an event would make the trail claim something that
1882        // did not happen.
1883        let dir = tempdir().unwrap();
1884        let store = test_store(dir.path().join("tx.db"));
1885        let tx = store.record(queued_transaction()).unwrap();
1886        store.approve_transaction(&tx.transaction_id).unwrap();
1887        store.approve_transaction(&tx.transaction_id).unwrap();
1888        assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1889
1890        // Same for a revoke with nothing to revoke.
1891        assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1892        assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1893    }
1894
1895    #[test]
1896    fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1897        // The finding. Before the event chain, `transaction_approvals` was a
1898        // plain table: DELETE the row and `audit verify` still said Intact, so
1899        // the fact that a privileged action had been approved could be erased
1900        // without a trace. Now the deletion has to survive two independent
1901        // checks.
1902        let dir = tempdir().unwrap();
1903        let db_path = dir.path().join("tx.db");
1904        let key = AuditKey::from_bytes(vec![0x42; 32]);
1905        let store = test_store(&db_path);
1906
1907        let a = store.record(queued_transaction()).unwrap();
1908        store.approve_transaction(&a.transaction_id).unwrap();
1909        // A later transaction commits to the event tip, which is what carries
1910        // the binding into the checkpoint-anchored transaction chain.
1911        store.record(queued_transaction()).unwrap();
1912
1913        assert_eq!(
1914            store.verify_event_chain(&key).unwrap(),
1915            VerifyOutcome::Intact { rows_checked: 1 }
1916        );
1917        assert_eq!(
1918            store.verify_event_binding().unwrap(),
1919            audit_chain::BindingOutcome::Consistent {
1920                bindings_checked: 1
1921            }
1922        );
1923
1924        // Erase the approval and its event.
1925        let conn = Connection::open(&db_path).unwrap();
1926        conn.execute("DELETE FROM transaction_approvals", [])
1927            .unwrap();
1928        conn.execute("DELETE FROM audit_events", []).unwrap();
1929        drop(conn);
1930
1931        // The event chain alone is now empty and walks clean — deleting the
1932        // only event leaves nothing to contradict. The transaction row's
1933        // committed tip is what catches it.
1934        assert_eq!(
1935            store.verify_event_chain(&key).unwrap(),
1936            VerifyOutcome::Intact { rows_checked: 0 }
1937        );
1938        match store.verify_event_binding().unwrap() {
1939            audit_chain::BindingOutcome::MissingEvent {
1940                transaction_seq, ..
1941            } => assert_eq!(transaction_seq, 2),
1942            other => panic!("expected MissingEvent, got {other:?}"),
1943        }
1944    }
1945
1946    #[test]
1947    fn a_read_only_store_cannot_revoke_or_claim() {
1948        // Both paths now append to the event chain, so both need the signing
1949        // key. Refusing loudly beats silently skipping the event.
1950        let dir = tempdir().unwrap();
1951        let db_path = dir.path().join("tx.db");
1952        let store = test_store(&db_path);
1953        store.record(queued_transaction()).unwrap();
1954
1955        let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1956        assert!(matches!(
1957            read_only.revoke_unconsumed_approval("tx"),
1958            Err(TransactionStoreError::AuditChainMissing(_))
1959        ));
1960        assert!(matches!(
1961            read_only.claim_approved_for_execution("tx", "digest"),
1962            Err(TransactionStoreError::AuditChainMissing(_))
1963        ));
1964    }
1965
1966    // ── Audit chain integration tests ────────────────────────────────────
1967
1968    /// The store must persist the principal the dispatcher resolved, under the
1969    /// v3 tag, or the chain claims an encoding whose evidence is missing. Reads
1970    /// the columns directly rather than through the verifier, so a mismatch
1971    /// between what was signed and what was stored is visible.
1972    #[test]
1973    fn record_persists_the_caller_principal_under_the_current_encoding() {
1974        let dir = tempdir().unwrap();
1975        let store = test_store(dir.path().join("tx.db"));
1976        let mut tx = queued_transaction();
1977        tx.caller_principal = CallerPrincipal::Uid(4242);
1978        store.record(tx).unwrap();
1979
1980        let conn = store.connection().unwrap();
1981        let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1982            .query_row(
1983                "SELECT chain_version, caller_role, caller_principal FROM transactions",
1984                [],
1985                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1986            )
1987            .unwrap();
1988
1989        assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1990        assert_eq!(role.as_deref(), Some("dev"));
1991        assert_eq!(
1992            principal.as_deref(),
1993            Some("uid:4242"),
1994            "the row must name the account the dispatcher resolved"
1995        );
1996
1997        // And the signature must agree with those columns.
1998        let key = AuditKey::from_bytes(vec![0x42; 32]);
1999        assert!(matches!(
2000            store.verify_audit_chain(&key).unwrap(),
2001            audit_chain::VerifyOutcome::Intact { .. }
2002        ));
2003    }
2004
2005    #[test]
2006    fn record_writes_audit_chain_columns() {
2007        let dir = tempdir().unwrap();
2008        let store = test_store(dir.path().join("tx.db"));
2009        let _record = store.record(queued_transaction()).unwrap();
2010
2011        let conn = store.connection().unwrap();
2012        let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
2013            .query_row(
2014                "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
2015                [],
2016                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
2017            )
2018            .unwrap();
2019        assert_eq!(seq, 1, "first row gets seq=1");
2020        assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
2021        assert_eq!(prev, "", "first row has empty prev_chain_hash");
2022        assert_eq!(
2023            chain_hash.len(),
2024            audit_chain::HASH_HEX_LEN,
2025            "chain_hash is a hex-encoded Ed25519 signature"
2026        );
2027    }
2028
2029    #[test]
2030    fn sequential_records_produce_chained_hashes() {
2031        let dir = tempdir().unwrap();
2032        let store = test_store(dir.path().join("tx.db"));
2033        store.record(queued_transaction()).unwrap();
2034        store.record(queued_transaction()).unwrap();
2035        store.record(queued_transaction()).unwrap();
2036
2037        let rows = store.fetch_chain_rows().unwrap();
2038        assert_eq!(rows.len(), 3);
2039        assert_eq!(rows[0].seq, 1);
2040        assert_eq!(rows[1].seq, 2);
2041        assert_eq!(rows[2].seq, 3);
2042        // Each row's prev_chain_hash matches the previous row's chain_hash.
2043        assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
2044        assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
2045    }
2046
2047    /// T3 — concurrent `record()` keeps the chain intact and seqs contiguous.
2048    ///
2049    /// The store guarantees this via `BEGIN IMMEDIATE` on every record:
2050    /// the immediate write lock means `next_seq_and_prev_hash` is read
2051    /// inside the same SQLite transaction that does the INSERT, so two
2052    /// records cannot both observe `seq=N` and produce two rows with the
2053    /// same chain hash.  Drive 8 worker threads × 10 records each
2054    /// through the same store and assert (a) `verify_audit_chain` returns
2055    /// Intact { rows_checked: 80 } and (b) the seq column is contiguous
2056    /// 1..=80.  A regression that drops `BEGIN IMMEDIATE` or substitutes
2057    /// a non-locking read fails one of these on every run.
2058    #[test]
2059    fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
2060        const WORKERS: usize = 8;
2061        const RECORDS_PER_WORKER: usize = 10;
2062        const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
2063
2064        let dir = tempdir().unwrap();
2065        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2066
2067        let mut handles = Vec::with_capacity(WORKERS);
2068        for w in 0..WORKERS {
2069            let store = std::sync::Arc::clone(&store);
2070            handles.push(std::thread::spawn(move || {
2071                for r in 0..RECORDS_PER_WORKER {
2072                    let tx = NewTransaction {
2073                        request_id: format!("worker-{w}-record-{r}"),
2074                        request_hash: format!("hash-{w}-{r}"),
2075                        action_name: "GetSystemState".to_string(),
2076                        risk_level: RiskLevel::Low,
2077                        summary: format!("worker {w} record {r}"),
2078                        warnings: vec![],
2079                        caller_role: CallerRole::Dev,
2080                        // Distinct per worker: with every row claiming the same
2081                        // account, the chain could attribute a row to the wrong
2082                        // caller under contention and still verify.
2083                        caller_principal: CallerPrincipal::Uid(3000 + w as u32),
2084                    };
2085                    store
2086                        .record(tx)
2087                        .expect("record must succeed under contention");
2088                }
2089            }));
2090        }
2091        for h in handles {
2092            h.join().expect("worker thread did not panic");
2093        }
2094
2095        // (a) chain must be intact end-to-end.
2096        let key = AuditKey::from_bytes(vec![0x42; 32]);
2097        let outcome = store.verify_audit_chain(&key).unwrap();
2098        match outcome {
2099            VerifyOutcome::Intact { rows_checked } => {
2100                assert_eq!(
2101                    rows_checked, TOTAL as u64,
2102                    "expected {TOTAL} rows checked, got {rows_checked}"
2103                );
2104            }
2105            other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
2106        }
2107
2108        // (a2) every row must still name the worker that wrote it. The summary
2109        // carries the worker id independently of the principal, so a row whose
2110        // principal drifted onto another worker's insert is detectable.
2111        {
2112            let conn = store.connection().unwrap();
2113            let mut stmt = conn
2114                .prepare("SELECT summary, caller_principal FROM transactions")
2115                .unwrap();
2116            let pairs: Vec<(String, Option<String>)> = stmt
2117                .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
2118                .unwrap()
2119                .map(|r| r.unwrap())
2120                .collect();
2121            assert_eq!(pairs.len(), TOTAL);
2122            for (summary, principal) in pairs {
2123                let worker: u32 = summary
2124                    .split_whitespace()
2125                    .nth(1)
2126                    .and_then(|w| w.parse().ok())
2127                    .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
2128                assert_eq!(
2129                    principal.as_deref(),
2130                    Some(format!("uid:{}", 3000 + worker).as_str()),
2131                    "row {summary:?} was attributed to the wrong account"
2132                );
2133            }
2134        }
2135
2136        // (b) seq must be a contiguous run 1..=TOTAL with no gaps and no duplicates.
2137        let conn = store.connection().unwrap();
2138        let mut stmt = conn
2139            .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
2140            .unwrap();
2141        let seqs: Vec<i64> = stmt
2142            .query_map([], |row| row.get::<_, i64>(0))
2143            .unwrap()
2144            .map(|r| r.unwrap())
2145            .collect();
2146        assert_eq!(seqs.len(), TOTAL, "row count mismatch");
2147        for (i, s) in seqs.iter().enumerate() {
2148            assert_eq!(
2149                *s,
2150                (i as i64) + 1,
2151                "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
2152            );
2153        }
2154    }
2155
2156    #[test]
2157    fn verify_audit_chain_intact_after_inserts() {
2158        let dir = tempdir().unwrap();
2159        let store = test_store(dir.path().join("tx.db"));
2160        for _ in 0..3 {
2161            store.record(queued_transaction()).unwrap();
2162        }
2163        let key = AuditKey::from_bytes(vec![0x42; 32]);
2164        let outcome = store.verify_audit_chain(&key).unwrap();
2165        assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2166    }
2167
2168    #[test]
2169    fn verify_audit_chain_with_pubkey_intact_after_inserts() {
2170        let dir = tempdir().unwrap();
2171        let store = test_store(dir.path().join("tx.db"));
2172        for _ in 0..3 {
2173            store.record(queued_transaction()).unwrap();
2174        }
2175        // Auditor path: verify with ONLY the public key, no private key.
2176        let key = AuditKey::from_bytes(vec![0x42; 32]);
2177        let outcome = store
2178            .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2179            .unwrap();
2180        assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2181    }
2182
2183    #[test]
2184    fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2185        let dir = tempdir().unwrap();
2186        let store = test_store(dir.path().join("tx.db"));
2187        store.record(queued_transaction()).unwrap();
2188        // A different keypair's public key must not validate the chain.
2189        let other = AuditKey::from_bytes(vec![0x99; 32]);
2190        let outcome = store
2191            .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2192            .unwrap();
2193        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2194    }
2195
2196    #[test]
2197    fn verify_detects_tampered_summary() {
2198        let dir = tempdir().unwrap();
2199        let store = test_store(dir.path().join("tx.db"));
2200        let tx = store.record(queued_transaction()).unwrap();
2201
2202        // Tamper: modify the summary field directly via SQL — simulates an
2203        // attacker with database write access who skips the daemon code path.
2204        let conn = store.connection().unwrap();
2205        conn.execute(
2206            "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2207            params!["EVIL CHANGE", tx.transaction_id],
2208        )
2209        .unwrap();
2210
2211        let key = AuditKey::from_bytes(vec![0x42; 32]);
2212        let outcome = store.verify_audit_chain(&key).unwrap();
2213        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2214    }
2215
2216    #[test]
2217    fn status_update_does_not_break_chain() {
2218        // Status is mutable; the chain protects only immutable fields.
2219        let dir = tempdir().unwrap();
2220        let store = test_store(dir.path().join("tx.db"));
2221        let tx = store.record(queued_transaction()).unwrap();
2222        store
2223            .update_status(&tx.transaction_id, JobState::Running)
2224            .unwrap();
2225        store
2226            .update_status(&tx.transaction_id, JobState::Succeeded)
2227            .unwrap();
2228
2229        let key = AuditKey::from_bytes(vec![0x42; 32]);
2230        let outcome = store.verify_audit_chain(&key).unwrap();
2231        assert!(
2232            matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2233            "status mutation must not break the chain (status not in chain content): {outcome:?}"
2234        );
2235    }
2236
2237    #[test]
2238    fn open_read_only_rejects_record() {
2239        let dir = tempdir().unwrap();
2240        let key_path = dir.path().join("audit-key");
2241        std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2242        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2243
2244        let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2245        let result = store.record(queued_transaction());
2246        assert!(matches!(
2247            result,
2248            Err(TransactionStoreError::AuditChainMissing(_))
2249        ));
2250    }
2251
2252    #[test]
2253    fn ensure_private_dir_creates_with_0700_mode() {
2254        let root = tempdir().unwrap();
2255        let target = root.path().join("a/b/c");
2256        ensure_private_dir(&target).unwrap();
2257        assert!(target.is_dir());
2258        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2259        assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2260    }
2261
2262    /// The audit database is the record of every privileged action taken on the
2263    /// host: action names, parameters, and who approved them. SQLite creates it
2264    /// `0644 & ~umask`, which the 0700 parent hides today — but only today. A
2265    /// backup, a `cp`, or an operator who widens the directory carries the file
2266    /// mode with it, and SQLite derives the `-wal`/`-shm` sidecar modes from the
2267    /// main file, so a permissive main file leaks the most recent transactions
2268    /// through the WAL too.
2269    #[test]
2270    fn the_audit_database_is_created_owner_only() {
2271        let dir = tempdir().unwrap();
2272        let db = dir.path().join("daemon.sqlite");
2273        let store = TransactionStore::open(&db).unwrap();
2274        // Write a row so the WAL sidecar exists and can be checked as well.
2275        store
2276            .record(NewTransaction {
2277                request_id: "mode-probe".to_string(),
2278                request_hash: "hash-mode-probe".to_string(),
2279                action_name: "GetSystemState".to_string(),
2280                risk_level: RiskLevel::Low,
2281                summary: "probe".to_string(),
2282                warnings: vec![],
2283                caller_role: CallerRole::Dev,
2284                caller_principal: CallerPrincipal::Uid(3000),
2285            })
2286            .unwrap();
2287
2288        for path in [db.clone(), db.with_extension("sqlite-wal")] {
2289            if !path.exists() {
2290                continue;
2291            }
2292            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2293            assert_eq!(
2294                mode,
2295                0o600,
2296                "{} must be owner-only, got {mode:o}",
2297                path.display()
2298            );
2299        }
2300    }
2301
2302    #[test]
2303    fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2304        let root = tempdir().unwrap();
2305        let target = root.path().join("preexisting");
2306        std::fs::create_dir(&target).unwrap();
2307        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2308        ensure_private_dir(&target).unwrap();
2309        // Existing directory: we don't touch its mode.
2310        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2311        assert_eq!(mode, 0o755);
2312    }
2313
2314    #[test]
2315    fn open_creates_parent_with_private_mode() {
2316        let root = tempdir().unwrap();
2317        let db_path = root.path().join("nested/dirs/daemon.sqlite");
2318        let _store = test_store(&db_path);
2319        let parent = db_path.parent().unwrap();
2320        let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2321        assert_eq!(mode, 0o700);
2322    }
2323
2324    fn queued_transaction() -> NewTransaction {
2325        NewTransaction {
2326            request_id: "req-1".to_string(),
2327            request_hash: "hash-abc".to_string(),
2328            action_name: "UpdateSystem".to_string(),
2329            risk_level: RiskLevel::High,
2330            summary: "Upgrade the system".to_string(),
2331            warnings: vec![],
2332            caller_role: CallerRole::Dev,
2333            caller_principal: CallerPrincipal::Uid(1000),
2334        }
2335    }
2336
2337    #[test]
2338    fn update_status_transitions_queued_to_running() {
2339        let dir = tempdir().unwrap();
2340        let store = test_store(dir.path().join("tx.db"));
2341        let tx = store.record(queued_transaction()).unwrap();
2342
2343        store
2344            .update_status(&tx.transaction_id, JobState::Running)
2345            .unwrap();
2346
2347        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2348        assert_eq!(updated.status, JobState::Running);
2349    }
2350
2351    #[test]
2352    fn update_status_transitions_running_to_succeeded() {
2353        let dir = tempdir().unwrap();
2354        let store = test_store(dir.path().join("tx.db"));
2355        let tx = store.record(queued_transaction()).unwrap();
2356
2357        store
2358            .update_status(&tx.transaction_id, JobState::Running)
2359            .unwrap();
2360        store
2361            .update_status(&tx.transaction_id, JobState::Succeeded)
2362            .unwrap();
2363
2364        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2365        assert_eq!(updated.status, JobState::Succeeded);
2366    }
2367
2368    #[test]
2369    fn update_status_for_unknown_id_returns_not_found() {
2370        let dir = tempdir().unwrap();
2371        let store = test_store(dir.path().join("tx.db"));
2372
2373        let result = store.update_status("does-not-exist", JobState::Running);
2374        assert!(
2375            matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2376            "expected NotFound, got: {result:?}"
2377        );
2378    }
2379
2380    #[test]
2381    fn update_status_leaves_other_fields_intact() {
2382        let dir = tempdir().unwrap();
2383        let store = test_store(dir.path().join("tx.db"));
2384        let tx = store.record(queued_transaction()).unwrap();
2385
2386        store
2387            .update_status(&tx.transaction_id, JobState::Running)
2388            .unwrap();
2389        store
2390            .update_status(&tx.transaction_id, JobState::Failed)
2391            .unwrap();
2392
2393        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2394        assert_eq!(updated.action_name, "UpdateSystem");
2395        assert_eq!(updated.risk_level, RiskLevel::High);
2396        assert_eq!(updated.status, JobState::Failed);
2397    }
2398
2399    /// `update_status` must be a compare-and-swap, not a check-then-act race.
2400    /// Bring a transaction to `Running`, then fire two conflicting terminal
2401    /// transitions (`Succeeded` and `Failed`) from concurrent threads. Both
2402    /// read `Running` and both pass `allowed_transition`, but only one may
2403    /// actually apply — the loser must see an explicit error, never a silent
2404    /// double-write. Before the `AND status = ?<observed>` guard, both calls
2405    /// would unconditionally UPDATE and both would return `Ok(())`, hiding the
2406    /// fact that one of them clobbered a transition it never validated.
2407    #[test]
2408    fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2409        let dir = tempdir().unwrap();
2410        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2411        let tx = store.record(queued_transaction()).unwrap();
2412        store
2413            .update_status(&tx.transaction_id, JobState::Running)
2414            .unwrap();
2415
2416        let store_a = std::sync::Arc::clone(&store);
2417        let id_a = tx.transaction_id.clone();
2418        let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2419
2420        let store_b = std::sync::Arc::clone(&store);
2421        let id_b = tx.transaction_id.clone();
2422        let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2423
2424        let succeed_result = succeed.join().unwrap();
2425        let fail_result = fail.join().unwrap();
2426
2427        // Exactly one of the two conflicting transitions may win.
2428        let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2429            .into_iter()
2430            .filter(|ok| *ok)
2431            .count();
2432        assert_eq!(
2433            oks, 1,
2434            "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2435             fail={fail_result:?}"
2436        );
2437
2438        // The loser must be refused outright, not silently succeed and clobber
2439        // the winner. *How* it is refused depends on whether the two threads
2440        // actually overlapped, and both outcomes are correct:
2441        //
2442        //   ConcurrentStatusChange — they overlapped, so the loser read
2443        //       `Running` and its compare-and-swap found the row already moved.
2444        //   InvalidTransition      — they serialised, so the loser read the
2445        //       winner's committed terminal status and rejected the transition
2446        //       before reaching the CAS at all.
2447        //
2448        // Asserting only the first made this test depend on thread
2449        // interleaving; it passes on a many-core dev machine and fails on a
2450        // 2-core CI runner, where the first thread routinely finishes before
2451        // the second starts. The invariant under test is "exactly one wins and
2452        // the loser does not clobber it", which both errors satisfy.
2453        let loser = if succeed_result.is_ok() {
2454            &fail_result
2455        } else {
2456            &succeed_result
2457        };
2458        match loser {
2459            Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2460                assert_eq!(id, &tx.transaction_id);
2461            }
2462            Err(TransactionStoreError::InvalidTransition { from, to }) => {
2463                // The winner's terminal state must be what the loser saw.
2464                let winner = if succeed_result.is_ok() {
2465                    JobState::Succeeded
2466                } else {
2467                    JobState::Failed
2468                };
2469                assert_eq!(
2470                    *from, winner,
2471                    "the loser must have observed the winner's committed status"
2472                );
2473                assert_ne!(*to, winner, "the loser must be the other transition");
2474            }
2475            other => panic!(
2476                "the losing transition must be refused with either \
2477                 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2478            ),
2479        }
2480
2481        // The stored status must match whichever transition actually won —
2482        // never a mix, and never left at `Running`.
2483        let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2484        if succeed_result.is_ok() {
2485            assert_eq!(final_status, JobState::Succeeded);
2486        } else {
2487            assert_eq!(final_status, JobState::Failed);
2488        }
2489    }
2490
2491    #[test]
2492    fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2493        // Revocation is the operator's "actually, no" between approving and
2494        // executing. Nothing covered it at all.
2495        let dir = tempdir().unwrap();
2496        let store = test_store(dir.path().join("tx.db"));
2497        let tx = store.record(queued_transaction()).unwrap();
2498        let receipt = store
2499            .approve_transaction(&tx.transaction_id)
2500            .unwrap()
2501            .expect("approved");
2502        let digest = audit_chain::approval_receipt_digest(&receipt);
2503
2504        assert!(
2505            store
2506                .revoke_unconsumed_approval(&tx.transaction_id)
2507                .unwrap(),
2508            "an unconsumed approval must be revocable"
2509        );
2510        assert!(
2511            !store
2512                .claim_approved_for_execution(&tx.transaction_id, &digest)
2513                .unwrap(),
2514            "a revoked receipt must no longer be claimable"
2515        );
2516    }
2517
2518    #[test]
2519    fn revoke_cannot_retract_an_approval_that_was_already_used() {
2520        // The documented guarantee: once a receipt has been consumed, the
2521        // execution it authorised is a fact and revocation must not rewrite
2522        // history to say otherwise.
2523        let dir = tempdir().unwrap();
2524        let store = test_store(dir.path().join("tx.db"));
2525        let tx = store.record(queued_transaction()).unwrap();
2526        let receipt = store
2527            .approve_transaction(&tx.transaction_id)
2528            .unwrap()
2529            .expect("approved");
2530        let digest = audit_chain::approval_receipt_digest(&receipt);
2531        assert!(store
2532            .claim_approved_for_execution(&tx.transaction_id, &digest)
2533            .unwrap());
2534
2535        assert!(
2536            !store
2537                .revoke_unconsumed_approval(&tx.transaction_id)
2538                .unwrap(),
2539            "a consumed approval must not be revocable"
2540        );
2541        assert_eq!(
2542            store.get(&tx.transaction_id).unwrap().unwrap().status,
2543            JobState::Running,
2544            "a failed revocation must not disturb the running transaction"
2545        );
2546    }
2547
2548    #[test]
2549    fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2550        // Every existing approve test starts from a freshly-recorded Queued
2551        // row. Approving something already running (or finished) would mint a
2552        // receipt for work that is past the point of approval.
2553        let dir = tempdir().unwrap();
2554        let store = test_store(dir.path().join("tx.db"));
2555        let tx = store.record(queued_transaction()).unwrap();
2556        let receipt = store
2557            .approve_transaction(&tx.transaction_id)
2558            .unwrap()
2559            .expect("approved");
2560        let digest = audit_chain::approval_receipt_digest(&receipt);
2561        assert!(store
2562            .claim_approved_for_execution(&tx.transaction_id, &digest)
2563            .unwrap());
2564
2565        // Running.
2566        assert!(
2567            store
2568                .approve_transaction(&tx.transaction_id)
2569                .unwrap()
2570                .is_none(),
2571            "a Running transaction must not be approvable"
2572        );
2573
2574        // Terminal.
2575        store
2576            .update_status(&tx.transaction_id, JobState::Succeeded)
2577            .unwrap();
2578        assert!(
2579            store
2580                .approve_transaction(&tx.transaction_id)
2581                .unwrap()
2582                .is_none(),
2583            "a completed transaction must not be approvable"
2584        );
2585    }
2586
2587    #[test]
2588    fn only_one_of_two_concurrent_claims_can_execute() {
2589        // `claim_approved_for_execution` is the interlock that stops one
2590        // approval being spent twice. `update_status` has a concurrency test;
2591        // this path had none, and a double claim means the same approved
2592        // action runs twice.
2593        let dir = tempdir().unwrap();
2594        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2595        let tx = store.record(queued_transaction()).unwrap();
2596        let receipt = store
2597            .approve_transaction(&tx.transaction_id)
2598            .unwrap()
2599            .expect("approved");
2600        let digest = audit_chain::approval_receipt_digest(&receipt);
2601
2602        let mut handles = Vec::new();
2603        for _ in 0..2 {
2604            let store = std::sync::Arc::clone(&store);
2605            let id = tx.transaction_id.clone();
2606            let digest = digest.clone();
2607            handles.push(std::thread::spawn(move || {
2608                store.claim_approved_for_execution(&id, &digest)
2609            }));
2610        }
2611        let claims: Vec<bool> = handles
2612            .into_iter()
2613            .map(|h| h.join().unwrap().unwrap_or(false))
2614            .collect();
2615
2616        assert_eq!(
2617            claims.iter().filter(|c| **c).count(),
2618            1,
2619            "exactly one claim may win: {claims:?}"
2620        );
2621        assert_eq!(
2622            store.get(&tx.transaction_id).unwrap().unwrap().status,
2623            JobState::Running
2624        );
2625    }
2626
2627    #[test]
2628    fn cancel_queued_cancels_a_queued_transaction_once() {
2629        let dir = tempdir().unwrap();
2630        let store = test_store(dir.path().join("tx.db"));
2631        let tx = store.record(queued_transaction()).unwrap();
2632
2633        assert!(
2634            store.cancel_queued(&tx.transaction_id).unwrap(),
2635            "a queued transaction is cancelable"
2636        );
2637        assert_eq!(
2638            store.get(&tx.transaction_id).unwrap().unwrap().status,
2639            JobState::Canceled
2640        );
2641        assert!(
2642            !store.cancel_queued(&tx.transaction_id).unwrap(),
2643            "an already-canceled transaction is not cancelable again"
2644        );
2645        assert!(
2646            !store.cancel_queued("no-such-transaction").unwrap(),
2647            "a missing transaction is not cancelable"
2648        );
2649        assert!(
2650            store.fetch_event_rows().unwrap().is_empty(),
2651            "canceling an unapproved transaction must not append an approval event"
2652        );
2653    }
2654
2655    #[test]
2656    fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
2657        let dir = tempdir().unwrap();
2658        let store = test_store(dir.path().join("tx.db"));
2659        let tx = store.record(queued_transaction()).unwrap();
2660        let receipt = store
2661            .approve_transaction(&tx.transaction_id)
2662            .unwrap()
2663            .expect("queued transaction is approvable");
2664        let digest = audit_chain::approval_receipt_digest(&receipt);
2665
2666        assert!(store.cancel_queued(&tx.transaction_id).unwrap());
2667        assert_eq!(
2668            store.get(&tx.transaction_id).unwrap().unwrap().status,
2669            JobState::Canceled
2670        );
2671        assert!(
2672            !store
2673                .claim_approved_for_execution(&tx.transaction_id, &digest)
2674                .unwrap(),
2675            "canceling must revoke the unconsumed receipt"
2676        );
2677        assert_eq!(
2678            store
2679                .fetch_event_rows()
2680                .unwrap()
2681                .iter()
2682                .map(|event| event.kind.as_str())
2683                .collect::<Vec<_>>(),
2684            vec!["approval_granted", "approval_revoked"]
2685        );
2686    }
2687
2688    #[test]
2689    fn cancel_queued_refuses_a_running_transaction() {
2690        // Option A: never cancel an in-flight privileged action. Once a
2691        // transaction is claimed (Running), cancel must refuse and leave it.
2692        let dir = tempdir().unwrap();
2693        let store = test_store(dir.path().join("tx.db"));
2694        let tx = store.record(queued_transaction()).unwrap();
2695        let receipt = store
2696            .approve_transaction(&tx.transaction_id)
2697            .unwrap()
2698            .expect("approved");
2699        let digest = audit_chain::approval_receipt_digest(&receipt);
2700        assert!(store
2701            .claim_approved_for_execution(&tx.transaction_id, &digest)
2702            .unwrap());
2703
2704        assert!(
2705            !store.cancel_queued(&tx.transaction_id).unwrap(),
2706            "a running transaction must not be cancelable"
2707        );
2708        assert_eq!(
2709            store.get(&tx.transaction_id).unwrap().unwrap().status,
2710            JobState::Running,
2711            "cancel must not disturb a running transaction"
2712        );
2713    }
2714
2715    #[test]
2716    fn approved_receipt_is_required_and_consumed_once() {
2717        let dir = tempdir().unwrap();
2718        let store = test_store(dir.path().join("tx.db"));
2719        let tx = store.record(queued_transaction()).unwrap();
2720
2721        assert!(
2722            !store
2723                .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2724                .unwrap(),
2725            "an unapproved preview must not execute"
2726        );
2727        let receipt = store
2728            .approve_transaction(&tx.transaction_id)
2729            .unwrap()
2730            .expect("first approval must succeed");
2731        let digest = audit_chain::approval_receipt_digest(&receipt);
2732        assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2733        assert!(
2734            store
2735                .approve_transaction(&tx.transaction_id)
2736                .unwrap()
2737                .is_none(),
2738            "approval is immutable once issued"
2739        );
2740        assert!(
2741            !store
2742                .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2743                .unwrap(),
2744            "a forged receipt must not execute"
2745        );
2746        assert!(
2747            store
2748                .claim_approved_for_execution(&tx.transaction_id, &digest)
2749                .unwrap(),
2750            "the exact approved receipt must execute"
2751        );
2752        assert!(
2753            !store
2754                .claim_approved_for_execution(&tx.transaction_id, &digest)
2755                .unwrap(),
2756            "the receipt must be one-time"
2757        );
2758    }
2759
2760    #[test]
2761    fn approval_commitment_is_covered_by_the_signed_chain() {
2762        let dir = tempdir().unwrap();
2763        let store = test_store(dir.path().join("tx.db"));
2764        let tx = store.record(queued_transaction()).unwrap();
2765        assert!(tx.approval_id.is_some());
2766
2767        store
2768            .connection()
2769            .unwrap()
2770            .execute(
2771                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2772                params![tx.transaction_id],
2773            )
2774            .unwrap();
2775
2776        let key = AuditKey::from_bytes(vec![0x42; 32]);
2777        assert!(matches!(
2778            store.verify_audit_chain(&key).unwrap(),
2779            VerifyOutcome::Broken { .. }
2780        ));
2781    }
2782
2783    #[test]
2784    fn approve_rejects_a_forged_commitment_at_runtime() {
2785        // Defense-in-depth: even before the chain-verify pass runs, approving a
2786        // transaction whose stored commitment was tampered must fail closed via
2787        // the constant-time check in `approve_transaction`, not issue a receipt.
2788        let dir = tempdir().unwrap();
2789        let store = test_store(dir.path().join("tx.db"));
2790        let tx = store.record(queued_transaction()).unwrap();
2791
2792        store
2793            .connection()
2794            .unwrap()
2795            .execute(
2796                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2797                params![tx.transaction_id],
2798            )
2799            .unwrap();
2800
2801        let err = store
2802            .approve_transaction(&tx.transaction_id)
2803            .expect_err("a forged commitment must be rejected, not approved");
2804        assert!(
2805            matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2806            "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2807        );
2808    }
2809
2810    #[test]
2811    fn stale_iso_timestamp_cannot_be_approved() {
2812        let dir = tempdir().unwrap();
2813        let store = test_store(dir.path().join("tx.db"));
2814        let tx = store.record(queued_transaction()).unwrap();
2815        let conn = store.connection().unwrap();
2816        conn.execute(
2817            "UPDATE transactions \
2818             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2819             WHERE transaction_id = ?1",
2820            params![tx.transaction_id],
2821        )
2822        .unwrap();
2823
2824        assert!(
2825            store
2826                .approve_transaction(&tx.transaction_id)
2827                .unwrap()
2828                .is_none(),
2829            "a production-format timestamp outside the TTL must not be approved"
2830        );
2831    }
2832
2833    #[test]
2834    fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2835        // The TTL is enforced at *execute* (claim) time too, not only at approve
2836        // time. An approval that ages past the 15-minute window between approve
2837        // and execute must not be claimable — the predicate is duplicated at
2838        // both sites, so both need coverage.
2839        let dir = tempdir().unwrap();
2840        let store = test_store(dir.path().join("tx.db"));
2841        let tx = store.record(queued_transaction()).unwrap();
2842        let receipt = store
2843            .approve_transaction(&tx.transaction_id)
2844            .unwrap()
2845            .expect("a fresh approval succeeds");
2846        let digest = audit_chain::approval_receipt_digest(&receipt);
2847
2848        // Age the row past the TTL window *after* the approval was issued.
2849        let conn = store.connection().unwrap();
2850        conn.execute(
2851            "UPDATE transactions \
2852             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2853             WHERE transaction_id = ?1",
2854            params![tx.transaction_id],
2855        )
2856        .unwrap();
2857
2858        assert!(
2859            !store
2860                .claim_approved_for_execution(&tx.transaction_id, &digest)
2861                .unwrap(),
2862            "an approval aged past the TTL must not be claimable at execute time"
2863        );
2864        assert_eq!(
2865            store.get(&tx.transaction_id).unwrap().unwrap().status,
2866            JobState::Queued,
2867            "a TTL-expired claim must leave the transaction Queued, never Running"
2868        );
2869    }
2870
2871    #[test]
2872    fn cleanup_stale_queued_cancels_old_records() {
2873        let dir = tempdir().unwrap();
2874        let store = test_store(dir.path().join("tx.db"));
2875
2876        // Create two transactions: one fresh, one stale.
2877        let fresh = store.record(queued_transaction()).unwrap();
2878        let stale = store.record(queued_transaction()).unwrap();
2879
2880        // Backdate the stale one.
2881        let conn = store.connection().unwrap();
2882        conn.execute(
2883            "UPDATE transactions \
2884             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2885             WHERE transaction_id = ?1",
2886            params![stale.transaction_id],
2887        )
2888        .unwrap();
2889
2890        let canceled = store.cleanup_stale_queued().unwrap();
2891        assert_eq!(canceled, 1, "only the stale record should be canceled");
2892
2893        // The stale record should now be Canceled.
2894        let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2895        assert_eq!(stale_record.status, JobState::Canceled);
2896
2897        // The fresh record should still be Queued.
2898        let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2899        assert_eq!(fresh_record.status, JobState::Queued);
2900    }
2901
2902    #[test]
2903    fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
2904        let dir = tempdir().unwrap();
2905        let store = test_store(dir.path().join("tx.db"));
2906        let transactions: Vec<_> = (0..3)
2907            .map(|_| {
2908                let transaction = store.record(queued_transaction()).unwrap();
2909                store
2910                    .approve_transaction(&transaction.transaction_id)
2911                    .unwrap()
2912                    .expect("stale transaction is approvable");
2913                transaction
2914            })
2915            .collect();
2916
2917        let conn = store.connection().unwrap();
2918        for transaction in &transactions {
2919            conn.execute(
2920                "UPDATE transactions \
2921                 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2922                 WHERE transaction_id = ?1",
2923                params![transaction.transaction_id],
2924            )
2925            .unwrap();
2926        }
2927
2928        assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
2929        for transaction in &transactions {
2930            assert_eq!(
2931                store
2932                    .get(&transaction.transaction_id)
2933                    .unwrap()
2934                    .unwrap()
2935                    .status,
2936                JobState::Canceled
2937            );
2938            assert!(!store
2939                .revoke_unconsumed_approval(&transaction.transaction_id)
2940                .unwrap());
2941        }
2942        assert_eq!(
2943            store
2944                .fetch_event_rows()
2945                .unwrap()
2946                .iter()
2947                .map(|event| event.kind.as_str())
2948                .collect::<Vec<_>>(),
2949            vec![
2950                "approval_granted",
2951                "approval_granted",
2952                "approval_granted",
2953                "approval_revoked",
2954                "approval_revoked",
2955                "approval_revoked"
2956            ]
2957        );
2958    }
2959
2960    // ── State-machine validation tests ──────────────────────────────────────
2961
2962    #[test]
2963    fn update_status_rejects_queued_to_succeeded() {
2964        let dir = tempdir().unwrap();
2965        let store = test_store(dir.path().join("tx.db"));
2966        let tx = store.record(queued_transaction()).unwrap();
2967
2968        let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2969        assert!(
2970            matches!(
2971                result,
2972                Err(TransactionStoreError::InvalidTransition {
2973                    from: JobState::Queued,
2974                    to: JobState::Succeeded,
2975                })
2976            ),
2977            "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2978        );
2979    }
2980
2981    #[test]
2982    fn update_status_rejects_succeeded_to_running() {
2983        let dir = tempdir().unwrap();
2984        let store = test_store(dir.path().join("tx.db"));
2985        let tx = store.record(queued_transaction()).unwrap();
2986
2987        store
2988            .update_status(&tx.transaction_id, JobState::Running)
2989            .unwrap();
2990        store
2991            .update_status(&tx.transaction_id, JobState::Succeeded)
2992            .unwrap();
2993
2994        let result = store.update_status(&tx.transaction_id, JobState::Running);
2995        assert!(
2996            matches!(
2997                result,
2998                Err(TransactionStoreError::InvalidTransition {
2999                    from: JobState::Succeeded,
3000                    to: JobState::Running,
3001                })
3002            ),
3003            "Succeeded -> Running must be rejected (terminal state): {result:?}"
3004        );
3005    }
3006
3007    #[test]
3008    fn update_status_accepts_running_to_failed() {
3009        let dir = tempdir().unwrap();
3010        let store = test_store(dir.path().join("tx.db"));
3011        let tx = store.record(queued_transaction()).unwrap();
3012
3013        store
3014            .update_status(&tx.transaction_id, JobState::Running)
3015            .unwrap();
3016        store
3017            .update_status(&tx.transaction_id, JobState::Failed)
3018            .unwrap();
3019
3020        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3021        assert_eq!(updated.status, JobState::Failed);
3022    }
3023
3024    #[test]
3025    fn update_status_accepts_running_to_rolled_back() {
3026        let dir = tempdir().unwrap();
3027        let store = test_store(dir.path().join("tx.db"));
3028        let tx = store.record(queued_transaction()).unwrap();
3029
3030        store
3031            .update_status(&tx.transaction_id, JobState::Running)
3032            .unwrap();
3033        store
3034            .update_status(&tx.transaction_id, JobState::RolledBack)
3035            .unwrap();
3036
3037        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3038        assert_eq!(updated.status, JobState::RolledBack);
3039    }
3040
3041    // ── list_transactions tests ───────────────────────────────────────────
3042
3043    #[test]
3044    fn list_transactions_returns_empty_for_fresh_store() {
3045        let dir = tempdir().unwrap();
3046        let store = test_store(dir.path().join("tx.db"));
3047        let results = store.list_transactions(10, None, None, None).unwrap();
3048        assert!(results.is_empty());
3049    }
3050
3051    #[test]
3052    fn list_transactions_returns_all_records_ordered_by_newest_first() {
3053        let dir = tempdir().unwrap();
3054        let store = test_store(dir.path().join("tx.db"));
3055        store.record(queued_transaction()).unwrap();
3056
3057        let mut second = queued_transaction();
3058        second.action_name = "GetDiskUsage".to_string();
3059        second.risk_level = RiskLevel::Low;
3060        store.record(second).unwrap();
3061
3062        let results = store.list_transactions(10, None, None, None).unwrap();
3063        assert_eq!(results.len(), 2);
3064        // Most recent first (GetDiskUsage was recorded second).
3065        assert_eq!(results[0].action_name, "GetDiskUsage");
3066        assert_eq!(results[1].action_name, "UpdateSystem");
3067    }
3068
3069    #[test]
3070    fn list_history_populates_created_at_and_risk_level() {
3071        let dir = tempdir().unwrap();
3072        let store = test_store(dir.path().join("tx.db"));
3073        store.record(queued_transaction()).unwrap();
3074
3075        let entries = store.list_history(10, None, None, None).unwrap();
3076        assert_eq!(entries.len(), 1);
3077        let entry = &entries[0];
3078        assert_eq!(entry.action_name, "UpdateSystem");
3079        assert_eq!(entry.risk_level, RiskLevel::High);
3080        assert_eq!(entry.status, JobState::Queued);
3081        assert!(
3082            !entry.created_at.is_empty(),
3083            "created_at must be populated from the stored row, not left blank"
3084        );
3085    }
3086
3087    #[test]
3088    fn list_history_applies_the_same_filters_as_list_transactions() {
3089        let dir = tempdir().unwrap();
3090        let store = test_store(dir.path().join("tx.db"));
3091        store.record(queued_transaction()).unwrap();
3092        let mut low = queued_transaction();
3093        low.action_name = "GetDiskUsage".to_string();
3094        low.risk_level = RiskLevel::Low;
3095        store.record(low).unwrap();
3096
3097        let only = store
3098            .list_history(10, None, Some("GetDiskUsage"), None)
3099            .unwrap();
3100        assert_eq!(only.len(), 1);
3101        assert_eq!(only[0].action_name, "GetDiskUsage");
3102    }
3103
3104    #[test]
3105    fn list_transactions_respects_limit() {
3106        let dir = tempdir().unwrap();
3107        let store = test_store(dir.path().join("tx.db"));
3108        for _ in 0..5 {
3109            store.record(queued_transaction()).unwrap();
3110        }
3111        let results = store.list_transactions(3, None, None, None).unwrap();
3112        assert_eq!(results.len(), 3);
3113    }
3114
3115    #[test]
3116    fn list_transactions_filters_by_status() {
3117        let dir = tempdir().unwrap();
3118        let store = test_store(dir.path().join("tx.db"));
3119        let tx = store.record(queued_transaction()).unwrap();
3120        store
3121            .update_status(&tx.transaction_id, JobState::Running)
3122            .unwrap();
3123        store
3124            .update_status(&tx.transaction_id, JobState::Succeeded)
3125            .unwrap();
3126
3127        // Add another that stays Queued.
3128        store.record(queued_transaction()).unwrap();
3129
3130        let succeeded = store
3131            .list_transactions(10, Some("succeeded"), None, None)
3132            .unwrap();
3133        assert_eq!(succeeded.len(), 1);
3134        assert_eq!(succeeded[0].status, JobState::Succeeded);
3135
3136        let queued = store
3137            .list_transactions(10, Some("queued"), None, None)
3138            .unwrap();
3139        assert_eq!(queued.len(), 1);
3140        assert_eq!(queued[0].status, JobState::Queued);
3141    }
3142
3143    #[test]
3144    fn list_transactions_filters_by_action_name() {
3145        let dir = tempdir().unwrap();
3146        let store = test_store(dir.path().join("tx.db"));
3147        store.record(queued_transaction()).unwrap(); // UpdateSystem
3148
3149        let mut disk = queued_transaction();
3150        disk.action_name = "GetDiskUsage".to_string();
3151        store.record(disk).unwrap();
3152
3153        let results = store
3154            .list_transactions(10, None, Some("GetDiskUsage"), None)
3155            .unwrap();
3156        assert_eq!(results.len(), 1);
3157        assert_eq!(results[0].action_name, "GetDiskUsage");
3158    }
3159
3160    #[test]
3161    fn list_transactions_filters_by_since_hours() {
3162        let dir = tempdir().unwrap();
3163        let store = test_store(dir.path().join("tx.db"));
3164
3165        // Record a transaction and backdate it to 48 hours ago.
3166        let old = store.record(queued_transaction()).unwrap();
3167        let conn = store.connection().unwrap();
3168        conn.execute(
3169            "UPDATE transactions \
3170             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
3171             WHERE transaction_id = ?1",
3172            params![old.transaction_id],
3173        )
3174        .unwrap();
3175
3176        // Record a fresh transaction.
3177        store.record(queued_transaction()).unwrap();
3178
3179        // since_hours=24 should only return the fresh one.
3180        let results = store.list_transactions(10, None, None, Some(24)).unwrap();
3181        assert_eq!(results.len(), 1);
3182
3183        // since_hours=72 should return both.
3184        let results = store.list_transactions(10, None, None, Some(72)).unwrap();
3185        assert_eq!(results.len(), 2);
3186    }
3187
3188    #[test]
3189    fn list_transactions_rejects_invalid_status_filter() {
3190        let dir = tempdir().unwrap();
3191        let store = test_store(dir.path().join("tx.db"));
3192        store.record(queued_transaction()).unwrap();
3193        let result = store.list_transactions(10, Some("bogus"), None, None);
3194        assert!(result.is_err(), "invalid status filter should return error");
3195    }
3196
3197    // ── Audit watermark sink tests ────────────────────────────────────────
3198    //
3199    // Each test below installs a `WatermarkSink` via `install_test_sink`.
3200    // `cargo nextest` runs every test in its own process, so the `OnceLock`
3201    // that backs the sink is always unset at the start of each test.
3202
3203    /// W1 — `record()` emits exactly one watermark per chain entry.
3204    #[test]
3205    fn record_emits_one_watermark_per_entry() {
3206        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3207        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3208
3209        let dir = tempdir().unwrap();
3210        let store = test_store(dir.path().join("tx.db"));
3211        store.record(queued_transaction()).unwrap();
3212
3213        let calls = crate::audit_watermark::take_watermarks(&sink);
3214        assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3215    }
3216
3217    /// W2 — `record_previewed()` emits exactly one watermark.
3218    #[test]
3219    fn record_previewed_emits_one_watermark() {
3220        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3221        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3222
3223        let dir = tempdir().unwrap();
3224        let store = test_store(dir.path().join("tx.db"));
3225        let preview = PreviewEnvelope {
3226            summary: "Upgrade the system".to_string(),
3227            risk_level: RiskLevel::High,
3228            current_state: serde_json::Value::Null,
3229            proposed_change: serde_json::Value::Null,
3230            expected_side_effects: vec![],
3231            reboot_required: false,
3232            rollback_available: false,
3233            warnings: vec![],
3234            request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3235        };
3236        store
3237            .record_previewed(queued_transaction(), preview)
3238            .unwrap();
3239
3240        let calls = crate::audit_watermark::take_watermarks(&sink);
3241        assert_eq!(
3242            calls.len(),
3243            1,
3244            "expected exactly 1 watermark per record_previewed()"
3245        );
3246    }
3247
3248    /// W3 — watermark seq and chain_hash_hex match the stored chain row.
3249    #[test]
3250    fn watermark_seq_and_hash_match_chain_row() {
3251        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3252        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3253
3254        let dir = tempdir().unwrap();
3255        let store = test_store(dir.path().join("tx.db"));
3256        store.record(queued_transaction()).unwrap();
3257
3258        let rows = store.fetch_chain_rows().unwrap();
3259        assert_eq!(rows.len(), 1);
3260        let row = &rows[0];
3261
3262        let calls = crate::audit_watermark::take_watermarks(&sink);
3263        assert_eq!(calls.len(), 1);
3264        assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3265        assert_eq!(
3266            calls[0].chain_hash_hex, row.chain_hash,
3267            "watermark chain_hash_hex must match stored chain_hash"
3268        );
3269    }
3270
3271    /// W4 — N records produce N watermarks, one per entry, in seq order.
3272    #[test]
3273    fn multiple_records_produce_one_watermark_each() {
3274        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3275        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3276
3277        let dir = tempdir().unwrap();
3278        let store = test_store(dir.path().join("tx.db"));
3279        for _ in 0..3 {
3280            store.record(queued_transaction()).unwrap();
3281        }
3282
3283        let calls = crate::audit_watermark::take_watermarks(&sink);
3284        assert_eq!(calls.len(), 3, "one watermark per record call");
3285        assert_eq!(calls[0].seq, 1);
3286        assert_eq!(calls[1].seq, 2);
3287        assert_eq!(calls[2].seq, 3);
3288    }
3289
3290    /// W5 — a failed SQL INSERT (unique-constraint violation via a crafted
3291    /// duplicate seq) must NOT emit a watermark, because the row was never
3292    /// committed to the chain.
3293    ///
3294    /// We simulate this by calling `insert_transaction` directly on an already-
3295    /// committed connection with duplicate seq. In practice this cannot happen
3296    /// through the public API (BEGIN IMMEDIATE + seq allocation inside the same
3297    /// DB transaction prevents races), but the unit test validates the ordering
3298    /// invariant: the watermark is emitted AFTER `tx.commit()` succeeds, so a
3299    /// rolled-back transaction emits nothing.
3300    ///
3301    /// Strategy: install the sink, then verify that a store that has never had
3302    /// `record()` called on it emits zero watermarks.
3303    #[test]
3304    fn no_watermark_emitted_before_any_record() {
3305        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3306        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3307
3308        let dir = tempdir().unwrap();
3309        let _store = test_store(dir.path().join("tx.db"));
3310
3311        // No record() called — sink must be empty.
3312        let calls = crate::audit_watermark::take_watermarks(&sink);
3313        assert!(
3314            calls.is_empty(),
3315            "no watermark must be emitted without a record() call"
3316        );
3317    }
3318}