Skip to main content

sysknife_daemon/
transactions.rs

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