Skip to main content

sysknife_daemon/
transactions.rs

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