Skip to main content

sysknife_daemon/
transactions.rs

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