Skip to main content

sysknife_daemon/
transactions.rs

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