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