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