Skip to main content

sysknife_daemon/
transactions.rs

1use crate::audit_chain::{
2    self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent,
3    EventIdentity, EventRow, 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    // Approver identity in the signed event encoding (#249).
206    //
207    // Nullable/defaulted for the same reason the v2 transaction columns are:
208    // every event row already on disk was signed over the six-field encoding,
209    // and backfilling either column would rewrite its message and report the
210    // whole event chain as Broken. `chain_version DEFAULT 1` makes every
211    // historical row LegacyV1 without touching it; new approval events are
212    // written at version 2 with the acting account signed in.
213    SqliteMigration {
214        version: 4,
215        name: "event_approver_identity",
216        sql: r#"
217            ALTER TABLE audit_events ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
218            ALTER TABLE audit_events ADD COLUMN caller_principal TEXT;
219        "#,
220    },
221];
222
223/// Column list for every `ChainRow` read, shared with the Postgres backend
224/// and declared once in `store.rs` (#397) — the mapper below reads columns
225/// positionally in exactly that order.
226use crate::store::CHAIN_ROW_COLUMNS;
227
228fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
229    Ok(ChainRow {
230        seq: row.get::<_, i64>(0)? as u64,
231        key_id: row.get(1)?,
232        transaction_id: row.get(2)?,
233        request_id: row.get(3)?,
234        request_hash: row.get(4)?,
235        action_name: row.get(5)?,
236        risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
237            rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
238        })?,
239        summary: row.get(7)?,
240        approval_id: row.get(8)?,
241        warnings_json: row.get(9)?,
242        created_at: row.get(10)?,
243        prev_chain_hash: row.get(11)?,
244        chain_hash: row.get(12)?,
245        chain_version: row.get::<_, i64>(13)? as u32,
246        caller_role: row.get(14)?,
247        event_tip: row.get(15)?,
248        caller_principal: row.get(16)?,
249    })
250}
251
252fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
253    Ok(EventRow {
254        seq: row.get::<_, i64>(0)? as u64,
255        key_id: row.get(1)?,
256        kind: row.get(2)?,
257        transaction_id: row.get(3)?,
258        receipt_digest: row.get(4)?,
259        created_at: row.get(5)?,
260        prev_chain_hash: row.get(6)?,
261        chain_hash: row.get(7)?,
262        chain_version: row.get::<_, i64>(8)? as u32,
263        caller_principal: row.get(9)?,
264    })
265}
266
267/// Run `query_row`, treating "no row matched" as `Ok(None)` instead of an
268/// error. Three call sites used to spell out
269/// `.map(Some).or_else(|e| match e { QueryReturnedNoRows => Ok(None), other => Err(other) })`
270/// by hand; sharing this mapping means a future call site cannot drop the
271/// `QueryReturnedNoRows` arm and turn a routine "not found" into a hard error.
272fn query_optional<T>(
273    conn: &Connection,
274    sql: &str,
275    params: impl rusqlite::Params,
276    f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
277) -> rusqlite::Result<Option<T>> {
278    match conn.query_row(sql, params, f) {
279        Ok(v) => Ok(Some(v)),
280        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
281        Err(e) => Err(e),
282    }
283}
284
285#[derive(Clone, Debug)]
286pub struct TransactionStore {
287    path: PathBuf,
288    /// Ed25519 signing key used to compute the forward audit chain on insert.
289    /// `None` only for read-only callers that never write rows.
290    audit_key: Option<Arc<AuditKey>>,
291}
292
293#[derive(Clone, Debug, PartialEq)]
294pub struct RecordedPreviewedTransaction {
295    pub transaction: TransactionRecord,
296    pub preview: PreviewEnvelope,
297}
298
299struct InsertedTransaction {
300    record: TransactionRecord,
301    seq: u64,
302    chain_hash: String,
303}
304
305#[derive(Debug, thiserror::Error)]
306pub enum TransactionStoreError {
307    #[error("io error: {0}")]
308    Io(#[from] std::io::Error),
309
310    #[error("sqlite error: {0}")]
311    Sqlite(#[from] rusqlite::Error),
312
313    #[error("json error: {0}")]
314    Json(#[from] serde_json::Error),
315
316    #[error("database invariant violation: {0}")]
317    DatabaseInvariant(String),
318
319    #[error("transaction not found: {0}")]
320    NotFound(String),
321
322    #[error("invalid transition from {from:?} to {to:?}")]
323    InvalidTransition { from: JobState, to: JobState },
324
325    #[error("audit chain misconfiguration: {0}")]
326    AuditChainMissing(&'static str),
327
328    /// `update_status`'s compare-and-swap `UPDATE ... WHERE status = <observed>`
329    /// matched zero rows: another writer changed `status` between our read and
330    /// our write. This is a lost race, not a validation failure — the caller
331    /// (see `dispatcher::update_terminal_status`) already retries on any error
332    /// and re-reads the current status, so surfacing this distinctly (rather
333    /// than silently overwriting a transition we never validated) is safe to
334    /// retry.
335    #[error("transaction {0} status changed concurrently; retry")]
336    ConcurrentStatusChange(String),
337}
338
339impl TransactionStore {
340    /// Open the store with **no audit chain key**. Inserts will fail with
341    /// `AuditChainMissing` — only suitable for read-only callers (e.g.
342    /// `sysknife audit verify` which loads the key separately).
343    pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
344        let path = path.as_ref().to_path_buf();
345        if let Some(parent) = path.parent() {
346            ensure_private_dir(parent)?;
347        }
348
349        let store = Self {
350            path,
351            audit_key: None,
352        };
353        store.initialize()?;
354        Ok(store)
355    }
356
357    /// Open the store and bind it to an audit chain key. Every insert
358    /// computes a forward Ed25519-signed chain hash linked to the previous row.
359    ///
360    /// The key path defaults to `<db_dir>/audit-key` so dev/test runs with
361    /// per-tempdir databases are fully isolated. Production deployments
362    /// override with `SYSKNIFE_AUDIT_KEY_PATH=/etc/sysknife/audit-key`.
363    pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
364        let db_path = path.as_ref();
365        let key_path = crate::audit_chain::resolve_audit_key_path(db_path);
366        let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
367            TransactionStoreError::Io(std::io::Error::new(
368                std::io::ErrorKind::InvalidData,
369                format!("audit key load failed: {e}"),
370            ))
371        })?;
372        Self::open_with_key(path, Arc::new(key))
373    }
374
375    /// Open the store with an explicit audit key. Used by tests and by
376    /// production code paths that want to inject a key from a specific path.
377    pub fn open_with_key(
378        path: impl AsRef<Path>,
379        audit_key: Arc<AuditKey>,
380    ) -> Result<Self, TransactionStoreError> {
381        let path = path.as_ref().to_path_buf();
382        if let Some(parent) = path.parent() {
383            ensure_private_dir(parent)?;
384        }
385
386        let store = Self {
387            path,
388            audit_key: Some(audit_key),
389        };
390        store.initialize()?;
391        restrict_db_file_mode(&store.path);
392        Ok(store)
393    }
394
395    /// Shared body of [`record`](Self::record) and
396    /// [`record_previewed`](Self::record_previewed): check for a writable
397    /// store, open the write transaction, insert the transaction row, run
398    /// `in_tx` inside that same transaction (so e.g. a preview insert commits
399    /// atomically with its transaction row), commit, and emit the chain-tip
400    /// watermark. `in_tx` runs between the insert and the commit — never
401    /// after — so nothing it does can land outside the transaction it needs
402    /// to be atomic with.
403    fn record_with(
404        &self,
405        transaction: NewTransaction,
406        in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
407    ) -> Result<InsertedTransaction, TransactionStoreError> {
408        let key = self
409            .audit_key
410            .as_ref()
411            .ok_or(TransactionStoreError::AuditChainMissing(
412                "this TransactionStore was opened read-only; cannot record",
413            ))?;
414        let mut conn = self.connection()?;
415        // IMMEDIATE acquires the write lock at BEGIN, so the read of
416        // `next_seq_and_prev_hash` is consistent with the eventual INSERT.
417        // Default DEFERRED would let two writers both read the same prev_hash
418        // and then race to INSERT — the loser hits SQLITE_BUSY.
419        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
420        let transaction_id = Uuid::new_v4().to_string();
421        let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
422        in_tx(&tx, &inserted)?;
423        tx.commit()?;
424        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
425        Ok(inserted)
426    }
427
428    pub fn record(
429        &self,
430        transaction: NewTransaction,
431    ) -> Result<TransactionRecord, TransactionStoreError> {
432        let inserted = self.record_with(transaction, |_, _| Ok(()))?;
433        Ok(inserted.record)
434    }
435
436    pub fn record_previewed(
437        &self,
438        transaction: NewTransaction,
439        preview: PreviewEnvelope,
440    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
441        let inserted = self.record_with(transaction, |conn, inserted| {
442            Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
443        })?;
444        Ok(RecordedPreviewedTransaction {
445            transaction: inserted.record,
446            preview,
447        })
448    }
449
450    pub fn get(
451        &self,
452        transaction_id: &str,
453    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
454        let conn = self.connection()?;
455        let mut stmt = conn.prepare(
456            "SELECT
457                transaction_id,
458                request_id,
459                request_hash,
460                action_name,
461                risk_level,
462                status,
463                approval_id,
464                summary,
465                warnings_json
466             FROM transactions
467             WHERE transaction_id = ?1",
468        )?;
469        let mut rows = stmt.query(params![transaction_id])?;
470        if let Some(row) = rows.next()? {
471            Ok(Some(row_to_record(row)?))
472        } else {
473            Ok(None)
474        }
475    }
476
477    pub fn get_preview(
478        &self,
479        transaction_id: &str,
480    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
481        let conn = self.connection()?;
482        let mut stmt = conn.prepare(
483            "SELECT preview_json
484             FROM transaction_previews
485             WHERE transaction_id = ?1",
486        )?;
487        let mut rows = stmt.query(params![transaction_id])?;
488        if let Some(row) = rows.next()? {
489            let preview_json: String = row.get(0)?;
490            Ok(Some(serde_json::from_str(&preview_json)?))
491        } else {
492            Ok(None)
493        }
494    }
495
496    pub fn update_status(
497        &self,
498        transaction_id: &str,
499        new_status: JobState,
500    ) -> Result<(), TransactionStoreError> {
501        let conn = self.connection()?;
502
503        // Read the current status so we can validate the transition.
504        let current_status: String = conn
505            .query_row(
506                "SELECT status FROM transactions WHERE transaction_id = ?1",
507                params![transaction_id],
508                |row| row.get(0),
509            )
510            .map_err(|e| match e {
511                rusqlite::Error::QueryReturnedNoRows => {
512                    TransactionStoreError::NotFound(transaction_id.to_string())
513                }
514                other => TransactionStoreError::Sqlite(other),
515            })?;
516
517        let current: JobState = deserialize_field(&current_status)?;
518        if !crate::jobs::allowed_transition(&current, &new_status) {
519            return Err(TransactionStoreError::InvalidTransition {
520                from: current,
521                to: new_status,
522            });
523        }
524
525        // Compare-and-swap: only write if `status` still equals the value we
526        // just validated the transition against. Without the `AND status =
527        // ?3` guard this was a check-then-act race — a concurrent writer
528        // (e.g. two calls racing to move the same job to two different
529        // terminal states) could flip `status` between our SELECT and this
530        // UPDATE, and the loser would silently report success for a
531        // transition it never actually validated against the row's real
532        // prior value. `rows_affected == 0` means we lost that race (rows are
533        // never deleted, so it cannot mean the row vanished); the caller
534        // (`dispatcher::update_terminal_status`) already retries on any error
535        // and re-checks the final status, so failing closed here is safe.
536        // The UPDATE and the event that records it go in one sqlite
537        // transaction. Written separately, a crash between them leaves the
538        // column saying one thing and the chain another, which is the state
539        // this whole check exists to detect: a status nobody can corroborate
540        // would then look like tampering.
541        let mut conn = conn;
542        let tx = conn.transaction()?;
543        let rows_affected = tx.execute(
544            "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
545            params![
546                serialize_field(&new_status)?,
547                transaction_id,
548                current_status
549            ],
550        )?;
551        if rows_affected == 0 {
552            return Err(TransactionStoreError::ConcurrentStatusChange(
553                transaction_id.to_string(),
554            ));
555        }
556        // `transactions.status` is a mutable column. Chaining the outcome means
557        // rewriting it no longer agrees with a signed record, which is what
558        // `status_matches_chain` compares. A read-only store has no key and
559        // never reaches a write, so the absence of one here is a bug rather
560        // than a case to pass over.
561        let Some(key) = self.audit_key.as_ref() else {
562            return Err(TransactionStoreError::AuditChainMissing(
563                "update_status has no signing key, so the outcome could not be chained",
564            ));
565        };
566        Self::append_event(
567            &tx,
568            key,
569            AuditEventKind::from(new_status),
570            transaction_id,
571            "",
572            // Status events name no account, deliberately: they are written
573            // from spawned execution tasks with no caller attribution in
574            // scope (#249). They stay on the legacy six-field encoding.
575            EventIdentity::LegacyV1,
576        )?;
577        tx.commit()?;
578        Ok(())
579    }
580
581    /// Compare live statuses with their newest chained status events. Signed
582    /// contradictions are broken; historical Running or Canceled statuses
583    /// without proof are inconclusive. Terminal states always had signed events,
584    /// so an unsigned terminal state remains a contradiction.
585    ///
586    /// `ChainContent` deliberately does not sign `status`: the chain protects
587    /// the authorisation decision captured at insert, not the live execution
588    /// state, and that exclusion is correct. The consequence was that an action
589    /// which ran to completion could be rewritten in the column as canceled,
590    /// `sysknife history` reported it canceled, and the chain still verified
591    /// Intact, because nothing in the chain had an opinion about the outcome.
592    /// Now the outcome is an event, and this is the comparison that uses it.
593    pub fn status_matches_chain(
594        &self,
595    ) -> Result<audit_chain::StatusOutcome, TransactionStoreError> {
596        let conn = self.connection()?;
597        let mut stmt = conn.prepare(
598            "SELECT t.transaction_id, t.status, ( \
599                 SELECT e.kind FROM audit_events e \
600                 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
601                 ORDER BY e.seq DESC LIMIT 1 \
602             ), EXISTS ( \
603                 SELECT 1 FROM audit_events e \
604                 WHERE e.transaction_id = t.transaction_id AND e.kind = 'approval_consumed' \
605             ) FROM transactions t",
606        )?;
607        let mut disagreements = Vec::new();
608        let mut unproven = Vec::new();
609        let mut checked: u64 = 0;
610        let rows = stmt.query_map([], |row| {
611            Ok((
612                row.get::<_, String>(0)?,
613                row.get::<_, String>(1)?,
614                row.get::<_, Option<String>>(2)?,
615                row.get::<_, bool>(3)?,
616            ))
617        })?;
618        for row in rows {
619            let (id, status_json, newest, consumed) = row?;
620            checked += 1;
621            let state: JobState = deserialize_field(&status_json)?;
622            // Pre-fix claims and cancellations did not chain a status event.
623            // Their exact current outcome cannot be inferred from approval
624            // lifecycle events: revocation also legitimately leaves Queued,
625            // and consumption says nothing about eventual completion.
626            let Some(newest) = newest else {
627                if state == JobState::Queued && consumed {
628                    disagreements.push(format!(
629                        "{id}: status is Queued but a chained approval_consumed event proves it left Queued"
630                    ));
631                } else if matches!(state, JobState::Running | JobState::Canceled) {
632                    unproven.push(format!(
633                        "{id}: status is {state:?} and no status event was ever chained; \
634                         historical writes cannot establish the execution outcome"
635                    ));
636                } else if state != JobState::Queued {
637                    disagreements.push(format!(
638                        "{id}: status is {state:?} but no status event was ever chained; \
639                         terminal outcomes require a signed status event"
640                    ));
641                }
642                continue;
643            };
644            let expected = AuditEventKind::from(state).as_str();
645            if newest != expected {
646                disagreements.push(format!(
647                    "{id}: status column says {state:?} ({expected}) and the newest \
648                     chained status event is {newest}"
649                ));
650            }
651        }
652        Ok(if !disagreements.is_empty() {
653            audit_chain::StatusOutcome::Disagrees {
654                detail: disagreements.join("; "),
655            }
656        } else if !unproven.is_empty() {
657            audit_chain::StatusOutcome::CannotVerify {
658                detail: unproven.join("; "),
659            }
660        } else {
661            audit_chain::StatusOutcome::Agrees {
662                rows_checked: checked,
663            }
664        })
665    }
666
667    /// Attach one immutable approval receipt digest to a fresh queued preview.
668    ///
669    /// `approver` is the account performing this approval — signed into the
670    /// event so a verified chain names who approved, not only who asked (#249).
671    pub fn approve_transaction(
672        &self,
673        transaction_id: &str,
674        approver: CallerPrincipal,
675    ) -> Result<Option<String>, TransactionStoreError> {
676        let key = self
677            .audit_key
678            .as_ref()
679            .ok_or(TransactionStoreError::AuditChainMissing(
680                "this TransactionStore was opened read-only; cannot approve",
681            ))?;
682        // Rendered once, before any early return that follows, so the signed
683        // string and the stored column provably come from the same value.
684        let approver_signed = approver.as_signed_str();
685        let Some(record) = self.get(transaction_id)? else {
686            return Ok(None);
687        };
688        let receipt = key.approval_receipt(transaction_id, &record.request_hash);
689        let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
690        let Some(committed_digest) = record.approval_id.as_deref() else {
691            return Err(TransactionStoreError::DatabaseInvariant(format!(
692                "transaction {transaction_id} has no signed approval commitment"
693            )));
694        };
695        if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
696            return Err(TransactionStoreError::DatabaseInvariant(format!(
697                "transaction {transaction_id} approval commitment does not match its signed preview"
698            )));
699        }
700
701        // The approval row and its chained event commit together: an approval
702        // that is not in the event chain is exactly the gap this chain closes.
703        let mut conn = self.connection()?;
704        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
705        let queued_json = serialize_field(&JobState::Queued)?;
706        let rows_affected = tx.execute(
707            &format!(
708                "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
709                 SELECT transaction_id, ?1 FROM transactions \
710                 WHERE transaction_id = ?2 \
711                   AND status = ?3 \
712                   AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
713                   AND NOT EXISTS ( \
714                       SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
715                   )"
716            ),
717            params![receipt_digest, transaction_id, queued_json],
718        )?;
719        if rows_affected > 0 {
720            Self::append_event(
721                &tx,
722                key,
723                AuditEventKind::ApprovalGranted,
724                transaction_id,
725                &receipt_digest,
726                EventIdentity::V2 {
727                    caller_principal: &approver_signed,
728                },
729            )?;
730        }
731        tx.commit()?;
732        Ok((rows_affected > 0).then_some(receipt))
733    }
734
735    /// Remove an approval that was persisted but could not be delivered to the
736    /// caller. Consumed receipts are never revocable.
737    ///
738    /// `revoker` is signed into the revocation event: the chain should name
739    /// the account that retracted a receipt (#249).
740    pub fn revoke_unconsumed_approval(
741        &self,
742        transaction_id: &str,
743        revoker: CallerPrincipal,
744    ) -> Result<bool, TransactionStoreError> {
745        let key = self
746            .audit_key
747            .as_ref()
748            .ok_or(TransactionStoreError::AuditChainMissing(
749                "this TransactionStore was opened read-only; cannot revoke",
750            ))?;
751        let mut conn = self.connection()?;
752        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
753        let revoked = Self::revoke_unconsumed_approval_in_tx(
754            &tx,
755            key,
756            transaction_id,
757            &revoker.as_signed_str(),
758        )?;
759        tx.commit()?;
760        Ok(revoked)
761    }
762
763    fn revoke_unconsumed_approval_in_tx(
764        conn: &Connection,
765        key: &AuditKey,
766        transaction_id: &str,
767        revoker_signed: &str,
768    ) -> Result<bool, TransactionStoreError> {
769        // Capture the digest before the DELETE: the event has to name which
770        // receipt was retracted, and after the delete there is nothing to name.
771        let digest: Option<String> = query_optional(
772            conn,
773            "SELECT receipt_digest FROM transaction_approvals \
774             WHERE transaction_id = ?1 AND consumed_at IS NULL",
775            params![transaction_id],
776            |row| row.get(0),
777        )?;
778        let rows_affected = conn.execute(
779            "DELETE FROM transaction_approvals \
780             WHERE transaction_id = ?1 AND consumed_at IS NULL",
781            params![transaction_id],
782        )?;
783        if rows_affected > 0 {
784            let digest = digest.ok_or_else(|| {
785                TransactionStoreError::DatabaseInvariant(format!(
786                    "revoked an approval for {transaction_id} that had no receipt digest"
787                ))
788            })?;
789            Self::append_event(
790                conn,
791                key,
792                AuditEventKind::ApprovalRevoked,
793                transaction_id,
794                &digest,
795                EventIdentity::V2 {
796                    caller_principal: revoker_signed,
797                },
798            )?;
799        }
800        Ok(rows_affected > 0)
801    }
802
803    /// Atomically consume an approved receipt and transition Queued to Running.
804    ///
805    /// `executor` is signed into the consume event: on a host where the account
806    /// that spends a receipt could ever differ from the one that granted it,
807    /// the chain records both (#249).
808    pub fn claim_approved_for_execution(
809        &self,
810        transaction_id: &str,
811        receipt_digest: &str,
812        executor: CallerPrincipal,
813    ) -> Result<bool, TransactionStoreError> {
814        let key = self
815            .audit_key
816            .as_ref()
817            .ok_or(TransactionStoreError::AuditChainMissing(
818                "this TransactionStore was opened read-only; cannot claim",
819            ))?;
820        let mut conn = self.connection()?;
821        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
822        let queued_json = serialize_field(&JobState::Queued)?;
823        let running_json = serialize_field(&JobState::Running)?;
824        let rows_affected = tx.execute(
825            &format!(
826                "UPDATE transactions SET status = ?1 \
827                 WHERE transaction_id = ?2 \
828                   AND status = ?3 \
829                   AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
830                   AND EXISTS ( \
831                       SELECT 1 FROM transaction_approvals \
832                       WHERE transaction_id = ?2 \
833                         AND receipt_digest = ?4 \
834                         AND consumed_at IS NULL \
835                   )"
836            ),
837            params![running_json, transaction_id, queued_json, receipt_digest],
838        )?;
839        if rows_affected > 0 {
840            tx.execute(
841                "UPDATE transaction_approvals \
842                 SET consumed_at = datetime('now') \
843                 WHERE transaction_id = ?1 AND consumed_at IS NULL",
844                params![transaction_id],
845            )?;
846            Self::append_event(
847                &tx,
848                key,
849                AuditEventKind::ApprovalConsumed,
850                transaction_id,
851                receipt_digest,
852                EventIdentity::V2 {
853                    caller_principal: &executor.as_signed_str(),
854                },
855            )?;
856            Self::append_event(
857                &tx,
858                key,
859                AuditEventKind::StatusRunning,
860                transaction_id,
861                "",
862                EventIdentity::LegacyV1,
863            )?;
864        }
865        tx.commit()?;
866        Ok(rows_affected > 0)
867    }
868
869    /// Cancel all `Queued` transactions whose `created_at` timestamp is older
870    /// than the 15-minute TTL window.  Returns the number of rows affected.
871    ///
872    /// **State-machine safety:** the WHERE clause pins `status = Queued`
873    /// before applying `Queued → Canceled`, which is the only legal
874    /// transition reachable from `Queued` other than `Running`. A row that
875    /// has progressed to `Running` (or any terminal state) in between the
876    /// TTL match and our UPDATE is skipped because the predicate no longer
877    /// matches it. This makes the bulk SQL semantically equivalent to
878    /// fetching each candidate, building a `JobStateMachine`, and calling
879    /// `cancel()` on it — but in a single statement so we don't race ourselves
880    /// when many rows expire at once. The
881    /// `cleanup_stale_queued_does_not_clobber_running_rows` regression test
882    /// in `tests/coverage_gaps.rs` pins this guarantee.
883    pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
884        let key = self
885            .audit_key
886            .as_ref()
887            .ok_or(TransactionStoreError::AuditChainMissing(
888                "this TransactionStore was opened read-only; cannot clean up",
889            ))?;
890        let mut conn = self.connection()?;
891        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
892        let canceled_json = serialize_field(&JobState::Canceled)?;
893        let queued_json = serialize_field(&JobState::Queued)?;
894        let stale_ids: Vec<String> = {
895            let mut statement = tx.prepare(&format!(
896                "SELECT transaction_id FROM transactions \
897                 WHERE status = ?1 \
898                   AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
899            ))?;
900            let stale_ids = statement
901                .query_map(params![queued_json], |row| row.get(0))?
902                .collect::<Result<_, _>>()?;
903            stale_ids
904        };
905        let mut canceled = 0;
906        for transaction_id in stale_ids {
907            let rows_affected = tx.execute(
908                "UPDATE transactions SET status = ?1 \
909                 WHERE transaction_id = ?2 AND status = ?3",
910                params![canceled_json, transaction_id, queued_json],
911            )?;
912            if rows_affected > 0 {
913                // Daemon-initiated revocation: the stale sweep has no caller
914                // connection to attribute, and `Unattributed` records exactly
915                // that rather than inventing an account (#249).
916                Self::revoke_unconsumed_approval_in_tx(
917                    &tx,
918                    key,
919                    &transaction_id,
920                    &CallerPrincipal::Unattributed.as_signed_str(),
921                )?;
922                Self::append_event(
923                    &tx,
924                    key,
925                    AuditEventKind::StatusCanceled,
926                    &transaction_id,
927                    "",
928                    EventIdentity::LegacyV1,
929                )?;
930                canceled += rows_affected;
931            }
932        }
933        tx.commit()?;
934        Ok(canceled as u64)
935    }
936
937    /// Cancel one still-`Queued` transaction (`Queued → Canceled`). Returns
938    /// `true` iff a queued row was transitioned.
939    ///
940    /// Option A semantics: the `status = ?3` (Queued) guard means a `Running`
941    /// transaction — one whose privileged action is already executing — is
942    /// never cancelled, so we never leave a half-applied root mutation behind
943    /// a `Canceled` record. Missing or already-terminal transactions return
944    /// `false`.
945    pub fn cancel_queued(
946        &self,
947        transaction_id: &str,
948        canceller: CallerPrincipal,
949    ) -> Result<bool, TransactionStoreError> {
950        let key = self
951            .audit_key
952            .as_ref()
953            .ok_or(TransactionStoreError::AuditChainMissing(
954                "this TransactionStore was opened read-only; cannot cancel",
955            ))?;
956        let mut conn = self.connection()?;
957        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
958        let canceled_json = serialize_field(&JobState::Canceled)?;
959        let queued_json = serialize_field(&JobState::Queued)?;
960        let rows_affected = tx.execute(
961            "UPDATE transactions SET status = ?1 \
962             WHERE transaction_id = ?2 AND status = ?3",
963            params![canceled_json, transaction_id, queued_json],
964        )?;
965        if rows_affected > 0 {
966            // The cancel came from a caller connection (`handle_cancel`), so
967            // the revocation event names the account that cancelled. This is
968            // NOT the daemon-sweep path: recording `Unattributed` here would
969            // sign away an identity the code actually holds (#249).
970            Self::revoke_unconsumed_approval_in_tx(
971                &tx,
972                key,
973                transaction_id,
974                &canceller.as_signed_str(),
975            )?;
976            Self::append_event(
977                &tx,
978                key,
979                AuditEventKind::StatusCanceled,
980                transaction_id,
981                "",
982                EventIdentity::LegacyV1,
983            )?;
984        }
985        tx.commit()?;
986        Ok(rows_affected > 0)
987    }
988
989    /// List transactions with optional filters, ordered by newest first.
990    ///
991    /// - `limit`: max number of rows (capped at 100)
992    /// - `status_filter`: if set, only return rows matching this status
993    ///   (must be a valid `JobState` variant: `"succeeded"`, `"failed"`,
994    ///   `"queued"`, `"running"`, `"canceled"`, `"rolled_back"`, `"needs_reboot"`)
995    /// - `action_filter`: if set, only return rows with this exact action name
996    /// - `since_hours`: if set, only return rows created within the last N hours
997    pub fn list_transactions(
998        &self,
999        limit: u32,
1000        status_filter: Option<&str>,
1001        action_filter: Option<&str>,
1002        since_hours: Option<u32>,
1003    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
1004        let conn = self.connection()?;
1005        let (filter_sql, param_values) =
1006            Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
1007        let sql = format!(
1008            "SELECT transaction_id, request_id, request_hash, action_name, \
1009             risk_level, status, approval_id, summary, warnings_json \
1010             FROM transactions WHERE 1=1{filter_sql}"
1011        );
1012        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1013            param_values.iter().map(|b| b.as_ref()).collect();
1014
1015        let mut stmt = conn.prepare(&sql)?;
1016        let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
1017
1018        let mut results = Vec::new();
1019        for row in rows {
1020            results.push(row??);
1021        }
1022        Ok(results)
1023    }
1024
1025    /// Structured history for programmatic clients (the MCP `sysknife_history`
1026    /// tool). Unlike [`list_transactions`](Self::list_transactions) it selects
1027    /// `created_at` and returns [`JobHistoryEntry`] so `risk_level` and
1028    /// `created_at` reach the caller typed, without text re-parsing.
1029    pub fn list_history(
1030        &self,
1031        limit: u32,
1032        status_filter: Option<&str>,
1033        action_filter: Option<&str>,
1034        since_hours: Option<u32>,
1035    ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
1036        let conn = self.connection()?;
1037        let (filter_sql, param_values) =
1038            Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
1039        let sql = format!(
1040            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
1041             FROM transactions WHERE 1=1{filter_sql}"
1042        );
1043        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1044            param_values.iter().map(|b| b.as_ref()).collect();
1045
1046        let mut stmt = conn.prepare(&sql)?;
1047        let rows = stmt.query_map(params_ref.as_slice(), |row| {
1048            Ok((|| {
1049                Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
1050                    transaction_id: row.get(0)?,
1051                    action_name: row.get(1)?,
1052                    risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
1053                    status: deserialize_field(&row.get::<_, String>(3)?)?,
1054                    summary: row.get(4)?,
1055                    created_at: row.get(5)?,
1056                })
1057            })())
1058        })?;
1059
1060        let mut results = Vec::new();
1061        for row in rows {
1062            results.push(row??);
1063        }
1064        Ok(results)
1065    }
1066
1067    /// Build the shared `WHERE`/`ORDER BY`/`LIMIT` suffix (after `WHERE 1=1`)
1068    /// and its bound parameters for the history queries, so
1069    /// [`list_transactions`](Self::list_transactions) and
1070    /// [`list_history`](Self::list_history) cannot filter differently.
1071    fn build_history_filter(
1072        limit: u32,
1073        status_filter: Option<&str>,
1074        action_filter: Option<&str>,
1075        since_hours: Option<u32>,
1076    ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
1077        let mut sql = String::new();
1078        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1079
1080        if let Some(status) = status_filter {
1081            // Validate against known JobState variants to avoid silent empty
1082            // results from typos (e.g. "success" instead of "succeeded").
1083            // deserialize_field returns serde_json::Error → TransactionStoreError::Json.
1084            let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
1085            let status_json = serialize_field(&validated)?;
1086            sql.push_str(" AND status = ?");
1087            param_values.push(Box::new(status_json));
1088        }
1089
1090        if let Some(action) = action_filter {
1091            sql.push_str(" AND action_name = ?");
1092            param_values.push(Box::new(action.to_string()));
1093        }
1094
1095        if let Some(hours) = since_hours {
1096            sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
1097            param_values.push(Box::new(hours));
1098        }
1099
1100        sql.push_str(" ORDER BY seq DESC LIMIT ?");
1101        param_values.push(Box::new(limit.min(100)));
1102
1103        Ok((sql, param_values))
1104    }
1105
1106    fn connection(&self) -> Result<Connection, TransactionStoreError> {
1107        let conn = Connection::open(&self.path)?;
1108        // Concurrency tuning :
1109        //   - WAL journal mode lets readers proceed concurrently with writers.
1110        //   - busy_timeout=5000ms makes a contending writer block instead of
1111        //     immediately returning SQLITE_BUSY. Without it, two concurrent
1112        //     `record()` calls (one of the two daemon use cases the dispatcher
1113        //     supports) had a 100% second-writer failure rate.
1114        //   - synchronous=NORMAL is the WAL-recommended setting; FULL is
1115        //     overkill for an audit log that's already append-only by design,
1116        //     and OFF risks losing the latest transactions on a crash.
1117        //   - foreign_keys=ON for parity with future schema changes.
1118        conn.pragma_update(None, "journal_mode", "WAL")?;
1119        conn.pragma_update(None, "synchronous", "NORMAL")?;
1120        conn.pragma_update(None, "foreign_keys", "ON")?;
1121        conn.busy_timeout(std::time::Duration::from_secs(5))?;
1122        Ok(conn)
1123    }
1124
1125    fn initialize(&self) -> Result<(), TransactionStoreError> {
1126        let mut conn = self.connection()?;
1127        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
1128        tx.execute_batch(
1129            "CREATE TABLE IF NOT EXISTS schema_migrations (\
1130                 version INTEGER PRIMARY KEY,\
1131                 name TEXT NOT NULL,\
1132                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
1133             );",
1134        )?;
1135        let current: i64 = tx.query_row(
1136            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
1137            [],
1138            |row| row.get(0),
1139        )?;
1140        let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
1141        if current > latest {
1142            return Err(TransactionStoreError::DatabaseInvariant(format!(
1143                "sqlite schema version {current} is newer than this binary supports ({latest})"
1144            )));
1145        }
1146        for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
1147            tx.execute_batch(migration.sql)?;
1148            tx.execute(
1149                "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1150                params![migration.version, migration.name],
1151            )?;
1152        }
1153        tx.commit()?;
1154        Ok(())
1155    }
1156
1157    /// Return all rows in seq order with the chain fields needed for verify.
1158    pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
1159        let conn = self.connection()?;
1160        let mut stmt = conn.prepare(&format!(
1161            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
1162        ))?;
1163        let rows = stmt.query_map([], chain_row_from_sqlite)?;
1164        let mut out = Vec::new();
1165        for row in rows {
1166            out.push(row?);
1167        }
1168        Ok(out)
1169    }
1170
1171    /// Return every approval event in seq order.
1172    pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
1173        let conn = self.connection()?;
1174        let mut stmt = conn.prepare(
1175            "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1176                    created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1177             FROM audit_events ORDER BY seq ASC",
1178        )?;
1179        let rows = stmt.query_map([], event_row_from_sqlite)?;
1180        let mut out = Vec::new();
1181        for row in rows {
1182            out.push(row?);
1183        }
1184        Ok(out)
1185    }
1186
1187    /// Walk the audit chain with `key` and report integrity status.
1188    pub fn verify_audit_chain(
1189        &self,
1190        key: &AuditKey,
1191    ) -> Result<VerifyOutcome, TransactionStoreError> {
1192        let rows = self.fetch_chain_rows()?;
1193        Ok(audit_chain::verify_chain(key, &rows))
1194    }
1195
1196    /// Walk the approval-event chain with `key`.
1197    pub fn verify_event_chain(
1198        &self,
1199        key: &AuditKey,
1200    ) -> Result<VerifyOutcome, TransactionStoreError> {
1201        let rows = self.fetch_event_rows()?;
1202        Ok(audit_chain::verify_event_chain(key, &rows))
1203    }
1204
1205    /// Check that every event tip committed by a transaction row is still
1206    /// present in the event chain. See [`audit_chain::verify_event_binding`].
1207    pub fn verify_event_binding(
1208        &self,
1209    ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1210        let tx_rows = self.fetch_chain_rows()?;
1211        let event_rows = self.fetch_event_rows()?;
1212        Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1213    }
1214
1215    /// Verify the chain with only the hex-encoded Ed25519 **public** key. The
1216    /// auditor path: proves the chain without the private key and cannot forge.
1217    pub fn verify_audit_chain_with_pubkey(
1218        &self,
1219        verifying_key_hex: &str,
1220    ) -> Result<VerifyOutcome, TransactionStoreError> {
1221        let rows = self.fetch_chain_rows()?;
1222        Ok(audit_chain::verify_chain_with_pubkey(
1223            verifying_key_hex,
1224            &rows,
1225        ))
1226    }
1227
1228    /// Fetch a single row's chain metadata by `transaction_id`. Used by the
1229    /// audit-log forwarder to construct an `AuditEvent` after insert.
1230    pub fn fetch_chain_row(
1231        &self,
1232        transaction_id: &str,
1233    ) -> Result<Option<ChainRow>, TransactionStoreError> {
1234        let conn = self.connection()?;
1235        let mut stmt = conn.prepare(&format!(
1236            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1237        ))?;
1238        let mut rows = stmt.query(params![transaction_id])?;
1239        if let Some(row) = rows.next()? {
1240            Ok(Some(chain_row_from_sqlite(row)?))
1241        } else {
1242            Ok(None)
1243        }
1244    }
1245
1246    /// Allocate the next monotonic `seq` and fetch the previous row's
1247    /// `chain_hash`. Caller must hold a transaction so the (seq, prev_hash)
1248    /// pair is consistent against concurrent writers.
1249    fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1250        let prev: Option<(i64, String)> = query_optional(
1251            conn,
1252            "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1253            [],
1254            |row| Ok((row.get(0)?, row.get(1)?)),
1255        )?;
1256        Ok(match prev {
1257            Some((seq, hash)) => ((seq as u64) + 1, hash),
1258            None => (1, String::new()),
1259        })
1260    }
1261
1262    /// `chain_hash` of the last approval event, or `None` when no event has
1263    /// ever been recorded.
1264    fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1265        Ok(query_optional(
1266            conn,
1267            "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1268            [],
1269            |row| row.get(0),
1270        )?)
1271    }
1272
1273    /// Append one signed approval event. Must be called inside a DB
1274    /// transaction that also performs the state change being recorded: an
1275    /// event committed without its state change (or the reverse) would be a
1276    /// trail that disagrees with reality.
1277    fn append_event(
1278        conn: &Connection,
1279        key: &AuditKey,
1280        kind: AuditEventKind,
1281        transaction_id: &str,
1282        receipt_digest: &str,
1283        identity: EventIdentity<'_>,
1284    ) -> Result<(), TransactionStoreError> {
1285        let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1286        let seq: i64 = conn.query_row(
1287            "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1288            [],
1289            |row| row.get(0),
1290        )?;
1291        let created_at: String =
1292            conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1293                row.get(0)
1294            })?;
1295        let key_id = CURRENT_KEY_ID.to_string();
1296        // Build the content once and derive the stored version from its
1297        // identity, so the chain_version column is provably the version whose
1298        // message was signed — the same discipline the transaction insert
1299        // follows.
1300        let content = EventContent {
1301            seq: seq as u64,
1302            key_id: &key_id,
1303            kind,
1304            transaction_id,
1305            receipt_digest,
1306            created_at: &created_at,
1307            identity,
1308        };
1309        let chain_hash = key.event_hash(&content, &prev_chain_hash);
1310        let stored_principal = match identity {
1311            EventIdentity::V2 { caller_principal } => Some(caller_principal),
1312            EventIdentity::LegacyV1 => None,
1313        };
1314        conn.execute(
1315            "INSERT INTO audit_events (\
1316                seq, key_id, kind, transaction_id, receipt_digest, \
1317                created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1318            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1319            params![
1320                seq,
1321                key_id,
1322                kind.as_str(),
1323                transaction_id,
1324                receipt_digest,
1325                created_at,
1326                chain_hash,
1327                prev_chain_hash,
1328                identity.version() as i64,
1329                stored_principal,
1330            ],
1331        )?;
1332        Ok(())
1333    }
1334
1335    fn insert_transaction(
1336        conn: &Connection,
1337        key: &AuditKey,
1338        transaction_id: &str,
1339        transaction: NewTransaction,
1340    ) -> Result<InsertedTransaction, TransactionStoreError> {
1341        let request_id = transaction.request_id;
1342        let request_hash = transaction.request_hash;
1343        let action_name = transaction.action_name;
1344        let risk_level = transaction.risk_level;
1345        // Initial status is always Queued — not caller-controllable.
1346        let status = JobState::Queued;
1347        // Always the store's own commitment over this transaction. It used to
1348        // accept a caller-supplied `Some(..)` and use it verbatim; production
1349        // always passed `None`, so the invariant held only because every call
1350        // site remembered to. The value is chain-hashed and then treated as
1351        // authoritative evidence that an approval happened, which is not
1352        // something a caller may hand us.
1353        let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1354        let summary = transaction.summary;
1355        let warnings = transaction.warnings;
1356        let warnings_json = serde_json::to_string(&warnings)?;
1357
1358        // Allocate the next seq + previous chain hash inside the same DB
1359        // transaction so concurrent writers can't race.
1360        let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1361
1362        // SQLite's `datetime('now')` (default for the column) is computed at
1363        // INSERT time, but we need the same value to compute the chain hash
1364        // before the row exists. Compute it ourselves and pin it.
1365        let created_at: String =
1366            conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1367                row.get(0)
1368            })?;
1369
1370        let key_id = CURRENT_KEY_ID.to_string();
1371        // Bind this row to the approval-event chain as it stands right now.
1372        // Read inside the caller's DB transaction so a concurrent event append
1373        // cannot land between the read and the insert.
1374        let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1375        let caller_role = transaction.caller_role.as_str();
1376        let caller_principal = transaction.caller_principal.as_signed_str();
1377        // Build the identity once and derive the stored version from it, so the
1378        // chain_version column is provably the version whose message was signed.
1379        // A constant here let the two drift the moment a new encoding landed.
1380        let identity = ChainIdentity::V3 {
1381            caller_role,
1382            event_tip: &event_tip,
1383            caller_principal: &caller_principal,
1384        };
1385        let chain_hash = key.chain_hash(
1386            &ChainContent {
1387                seq,
1388                key_id: &key_id,
1389                transaction_id,
1390                request_id: &request_id,
1391                request_hash: &request_hash,
1392                action_name: &action_name,
1393                risk_level,
1394                summary: &summary,
1395                approval_id: approval_id.as_deref(),
1396                warnings_json: &warnings_json,
1397                created_at: &created_at,
1398                identity,
1399            },
1400            &prev_chain_hash,
1401        );
1402
1403        let record = TransactionRecord {
1404            transaction_id: transaction_id.to_string(),
1405            request_id: request_id.clone(),
1406            request_hash: request_hash.clone(),
1407            action_name: action_name.clone(),
1408            risk_level,
1409            status,
1410            approval_id: approval_id.clone(),
1411            summary: summary.clone(),
1412            warnings: warnings.clone(),
1413        };
1414
1415        conn.execute(
1416            "INSERT INTO transactions (
1417                transaction_id,
1418                request_id,
1419                request_hash,
1420                action_name,
1421                risk_level,
1422                status,
1423                approval_id,
1424                summary,
1425                warnings_json,
1426                created_at,
1427                seq,
1428                key_id,
1429                chain_hash,
1430                prev_chain_hash,
1431                chain_version,
1432                caller_role,
1433                event_tip,
1434                caller_principal
1435            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1436            params![
1437                transaction_id,
1438                request_id,
1439                request_hash,
1440                action_name,
1441                serialize_field(&risk_level)?,
1442                serialize_field(&status)?,
1443                approval_id,
1444                summary,
1445                warnings_json,
1446                created_at,
1447                seq as i64,
1448                key_id,
1449                chain_hash,
1450                prev_chain_hash,
1451                identity.version() as i64,
1452                caller_role,
1453                event_tip,
1454                caller_principal,
1455            ],
1456        )?;
1457
1458        Ok(InsertedTransaction {
1459            record,
1460            seq,
1461            chain_hash,
1462        })
1463    }
1464
1465    fn insert_preview(
1466        conn: &Connection,
1467        transaction_id: &str,
1468        preview: &PreviewEnvelope,
1469    ) -> Result<(), TransactionStoreError> {
1470        conn.execute(
1471            "INSERT INTO transaction_previews (transaction_id, preview_json)
1472             VALUES (?1, ?2)",
1473            params![transaction_id, serde_json::to_string(preview)?],
1474        )?;
1475        Ok(())
1476    }
1477}
1478
1479/// Create `dir` and any missing parents with mode `0o700` (rwx owner only).
1480///
1481/// If the directory already exists, its mode is left untouched — the operator
1482/// or packaging spec (`sysknife-tmpfiles.conf`) owns existing-directory policy.
1483/// If the directory must be created here (e.g. dev contributor's first daemon
1484/// run), we never produce a world-readable audit-log directory.
1485fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1486    use std::os::unix::fs::DirBuilderExt;
1487    if dir.exists() {
1488        return Ok(());
1489    }
1490    std::fs::DirBuilder::new()
1491        .recursive(true)
1492        .mode(0o700)
1493        .create(dir)
1494}
1495
1496/// Narrow the audit database (and its WAL sidecars) to mode `0o600`.
1497///
1498/// SQLite creates its files `0o644 & ~umask`. The 0700 parent directory hides
1499/// them from other users today, but the file mode travels: a backup, a `cp`, or
1500/// an operator who widens the directory exposes every action name, parameter,
1501/// and approving principal the daemon has recorded. SQLite also derives the
1502/// `-wal`/`-shm` modes from the main file, so leaving the main file permissive
1503/// leaks the most recent (not-yet-checkpointed) transactions as well.
1504///
1505/// Tightening only, and never fatal: the directory is the primary control, and
1506/// a `chmod` that fails because the file belongs to another user must not stop
1507/// a daemon that previously started fine. It is loud, because a security
1508/// property that quietly stops holding is worse than one that never held.
1509fn restrict_db_file_mode(db_path: &Path) {
1510    use std::os::unix::fs::PermissionsExt;
1511
1512    // `-shm` is recreated per connection; `-wal` persists. Both inherit from the
1513    // main file at creation, so fixing the main file covers the common case and
1514    // these two cover a database that already exists with a wider mode.
1515    let sidecars = [
1516        db_path.to_path_buf(),
1517        with_suffix(db_path, "-wal"),
1518        with_suffix(db_path, "-shm"),
1519    ];
1520    for path in sidecars {
1521        let Ok(metadata) = std::fs::metadata(&path) else {
1522            continue; // Absent sidecar: nothing to narrow.
1523        };
1524        let mode = metadata.permissions().mode() & 0o777;
1525        if mode & 0o077 == 0 {
1526            continue; // Already owner-only or tighter.
1527        }
1528        if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1529            eprintln!(
1530                "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1531                 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1532                 it. The parent directory should be 0700 — check it.",
1533                path.display()
1534            );
1535        }
1536    }
1537}
1538
1539/// `path` with `suffix` appended to the file name (not the extension), matching
1540/// how SQLite names `daemon.sqlite-wal` beside `daemon.sqlite`.
1541fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1542    let mut name = path.as_os_str().to_os_string();
1543    name.push(suffix);
1544    PathBuf::from(name)
1545}
1546
1547fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1548    Ok(TransactionRecord {
1549        transaction_id: row.get(0)?,
1550        request_id: row.get(1)?,
1551        request_hash: row.get(2)?,
1552        action_name: row.get(3)?,
1553        risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1554        status: deserialize_field(&row.get::<_, String>(5)?)?,
1555        approval_id: row.get(6)?,
1556        summary: row.get(7)?,
1557        warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1558    })
1559}
1560
1561fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1562    serde_json::to_string(value)
1563}
1564
1565fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1566    serde_json::from_str(value)
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571    use super::*;
1572    use crate::audit_chain::CHAIN_VERSION_CURRENT;
1573    use crate::audit_chain::CHAIN_VERSION_LEGACY;
1574    use crate::audit_chain::EVENT_VERSION_CURRENT;
1575    use crate::audit_chain::EVENT_VERSION_LEGACY;
1576    use std::os::unix::fs::PermissionsExt;
1577    use tempfile::tempdir;
1578
1579    /// Open a TransactionStore with a deterministic test key. Avoids the
1580    /// XDG/`/etc` lookup in `TransactionStore::open` so tests don't share
1581    /// state with the dev environment.
1582    fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1583        let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1584        TransactionStore::open_with_key(path, key).unwrap()
1585    }
1586
1587    // ── Schema migration ─────────────────────────────────────────────────
1588
1589    /// Create a database at exactly schema version 1 — the shape a v0.2.12
1590    /// daemon left behind — and write one row using the encoding that binary
1591    /// signed. Building the fixture from `SQLITE_MIGRATIONS[0]` rather than a
1592    /// copied DDL string means it stays honest if migration 1 is ever edited.
1593    fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1594        let conn = Connection::open(path).unwrap();
1595        conn.execute_batch(
1596            "CREATE TABLE schema_migrations (
1597                 version INTEGER PRIMARY KEY,
1598                 name TEXT NOT NULL,
1599                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1600             );",
1601        )
1602        .unwrap();
1603        conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1604        conn.execute(
1605            "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1606            params![SQLITE_MIGRATIONS[0].name],
1607        )
1608        .unwrap();
1609
1610        let transaction_id = "tx-legacy";
1611        let created_at = "2026-04-24T12:00:00.000Z";
1612        let chain_hash = key.chain_hash(
1613            &ChainContent {
1614                seq: 1,
1615                key_id: CURRENT_KEY_ID,
1616                transaction_id,
1617                request_id: "req-legacy",
1618                request_hash: "hash-legacy",
1619                action_name: "UpdateSystem",
1620                risk_level: RiskLevel::High,
1621                summary: "Upgrade the system",
1622                approval_id: None,
1623                warnings_json: "[]",
1624                created_at,
1625                identity: ChainIdentity::LegacyV1,
1626            },
1627            "",
1628        );
1629        conn.execute(
1630            "INSERT INTO transactions (
1631                transaction_id, request_id, request_hash, action_name, risk_level,
1632                status, approval_id, summary, warnings_json, created_at,
1633                seq, key_id, chain_hash, prev_chain_hash
1634            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1635            params![
1636                transaction_id,
1637                "req-legacy",
1638                "hash-legacy",
1639                "UpdateSystem",
1640                serialize_field(&RiskLevel::High).unwrap(),
1641                serialize_field(&JobState::Queued).unwrap(),
1642                "Upgrade the system",
1643                "[]",
1644                created_at,
1645                CURRENT_KEY_ID,
1646                chain_hash,
1647            ],
1648        )
1649        .unwrap();
1650        transaction_id.to_string()
1651    }
1652
1653    /// A database at schema version 2 holding one v2 row: the shape every host
1654    /// running v0.2.13 through v0.2.16 has on disk right now.
1655    ///
1656    /// Built by replaying `SQLITE_MIGRATIONS[0..=1]` rather than hardcoded DDL,
1657    /// so editing those migrations cannot leave this fixture describing a schema
1658    /// that never existed.
1659    fn v2_database(path: &Path, key: &AuditKey) -> String {
1660        let conn = Connection::open(path).unwrap();
1661        conn.execute_batch(
1662            "CREATE TABLE schema_migrations (
1663                 version INTEGER PRIMARY KEY,
1664                 name TEXT NOT NULL,
1665                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1666             );",
1667        )
1668        .unwrap();
1669        for migration in &SQLITE_MIGRATIONS[0..=1] {
1670            conn.execute_batch(migration.sql).unwrap();
1671            conn.execute(
1672                "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1673                params![migration.version, migration.name],
1674            )
1675            .unwrap();
1676        }
1677
1678        let transaction_id = "tx-v2";
1679        let created_at = "2026-07-01T12:00:00.000Z";
1680        let chain_hash = key.chain_hash(
1681            &ChainContent {
1682                seq: 1,
1683                key_id: CURRENT_KEY_ID,
1684                transaction_id,
1685                request_id: "req-v2",
1686                request_hash: "hash-v2",
1687                action_name: "UpdateSystem",
1688                risk_level: RiskLevel::High,
1689                summary: "Upgrade the system",
1690                approval_id: None,
1691                warnings_json: "[]",
1692                created_at,
1693                identity: ChainIdentity::V2 {
1694                    caller_role: "admin",
1695                    event_tip: "",
1696                },
1697            },
1698            "",
1699        );
1700        conn.execute(
1701            "INSERT INTO transactions (
1702                transaction_id, request_id, request_hash, action_name, risk_level,
1703                status, approval_id, summary, warnings_json, created_at,
1704                seq, key_id, chain_hash, prev_chain_hash,
1705                chain_version, caller_role, event_tip
1706            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1707            params![
1708                transaction_id,
1709                "req-v2",
1710                "hash-v2",
1711                "UpdateSystem",
1712                serialize_field(&RiskLevel::High).unwrap(),
1713                serialize_field(&JobState::Queued).unwrap(),
1714                "Upgrade the system",
1715                "[]",
1716                created_at,
1717                CURRENT_KEY_ID,
1718                chain_hash,
1719            ],
1720        )
1721        .unwrap();
1722        transaction_id.to_string()
1723    }
1724
1725    /// The upgrade every deployed host actually performs: v2 on disk, then v3
1726    /// rows appended by the new daemon, in one chain.
1727    ///
1728    /// The v1 fixture already covers the older jump, but nothing covered this
1729    /// one, and it is the only one with installed users. If migration 3 ever
1730    /// backfilled a principal, or the v2 dispatch drifted, this is where a real
1731    /// operator's audit log would start reporting as tampered.
1732    #[test]
1733    fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1734        let dir = tempdir().unwrap();
1735        let db_path = dir.path().join("tx.db");
1736        let key = AuditKey::from_bytes(vec![0x42; 32]);
1737        let old_transaction = v2_database(&db_path, &key);
1738
1739        let store = test_store(&db_path);
1740
1741        let version: i64 = store
1742            .connection()
1743            .unwrap()
1744            .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1745                row.get(0)
1746            })
1747            .unwrap();
1748        assert_eq!(
1749            version, 4,
1750            "opening a v2 database must apply every migration up to 4"
1751        );
1752
1753        let rows = store.fetch_chain_rows().unwrap();
1754        assert_eq!(rows.len(), 1);
1755        assert_eq!(rows[0].transaction_id, old_transaction);
1756        assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1757        assert_eq!(
1758            rows[0].caller_principal, None,
1759            "migration 3 must not invent a principal for a row signed without one"
1760        );
1761        assert!(matches!(
1762            store.verify_audit_chain(&key).unwrap(),
1763            audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1764        ));
1765
1766        // Append under the new encoding and re-verify the mixed chain.
1767        let mut fresh = queued_transaction();
1768        fresh.caller_principal = CallerPrincipal::Uid(4242);
1769        store.record(fresh).unwrap();
1770
1771        let rows = store.fetch_chain_rows().unwrap();
1772        assert_eq!(rows.len(), 2);
1773        assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1774        assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1775        assert_eq!(
1776            rows[1].prev_chain_hash, rows[0].chain_hash,
1777            "the v3 row must chain onto the v2 row, not start a new chain"
1778        );
1779        assert!(matches!(
1780            store.verify_audit_chain(&key).unwrap(),
1781            audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1782        ));
1783
1784        // The census, taken over rows that made a real round trip through SQLite
1785        // rather than hand-built fixtures. This is the only place that can catch
1786        // the SQL side going wrong: a `SELECT` that dropped `caller_principal`, or
1787        // a mapping that coerced `NULL` to `""`, would move every legacy row out
1788        // of "names nobody" and into "names an account", restoring the exact
1789        // misreport this census exists to prevent, with every in-memory test
1790        // still green.
1791        let census = audit_chain::AttributionCensus::of(&rows);
1792        assert_eq!(census.rows(), 2);
1793        assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1794        assert_eq!(
1795            census.not_recorded(),
1796            1,
1797            "the v2 row's encoding never signed a principal"
1798        );
1799        assert_eq!(census.attribution_failed(), 0);
1800        assert_eq!(census.unattested(), 0);
1801        assert_eq!(census.unnamed(), 1);
1802    }
1803
1804    #[test]
1805    fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1806        // The migration contract, end to end through the store. An operator
1807        // upgrading the daemon must not see their existing audit log start
1808        // reporting as tampered — that would make a real compromise
1809        // indistinguishable from a routine upgrade.
1810        let dir = tempdir().unwrap();
1811        let db_path = dir.path().join("tx.db");
1812        let key = AuditKey::from_bytes(vec![0x42; 32]);
1813        legacy_v1_database(&db_path, &key);
1814
1815        let store = test_store(&db_path);
1816        assert_eq!(
1817            store.verify_audit_chain(&key).unwrap(),
1818            VerifyOutcome::Intact { rows_checked: 1 }
1819        );
1820        let rows = store.fetch_chain_rows().unwrap();
1821        assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1822        assert_eq!(rows[0].caller_role, None);
1823    }
1824
1825    #[test]
1826    fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1827        // Mixed-generation chain written through the real store, not a
1828        // hand-built fixture: the new row's prev_chain_hash must link to the
1829        // legacy row, and both encodings must verify in one walk.
1830        let dir = tempdir().unwrap();
1831        let db_path = dir.path().join("tx.db");
1832        let key = AuditKey::from_bytes(vec![0x42; 32]);
1833        legacy_v1_database(&db_path, &key);
1834
1835        let store = test_store(&db_path);
1836        store.record(queued_transaction()).unwrap();
1837
1838        let rows = store.fetch_chain_rows().unwrap();
1839        assert_eq!(rows.len(), 2);
1840        assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1841        assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1842        assert_eq!(
1843            store.verify_audit_chain(&key).unwrap(),
1844            VerifyOutcome::Intact { rows_checked: 2 }
1845        );
1846    }
1847
1848    #[test]
1849    fn a_schema_newer_than_this_binary_is_refused() {
1850        let dir = tempdir().unwrap();
1851        let db_path = dir.path().join("tx.db");
1852        let key = AuditKey::from_bytes(vec![0x42; 32]);
1853        legacy_v1_database(&db_path, &key);
1854        let conn = Connection::open(&db_path).unwrap();
1855        conn.execute(
1856            "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1857            [],
1858        )
1859        .unwrap();
1860        drop(conn);
1861
1862        let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1863        assert!(
1864            matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1865            "expected a refusal naming the unsupported version, got {err:?}"
1866        );
1867    }
1868
1869    // ── caller identity + approval events ────────────────────────────────
1870
1871    #[test]
1872    fn the_signed_row_records_which_role_asked() {
1873        let dir = tempdir().unwrap();
1874        let store = test_store(dir.path().join("tx.db"));
1875        let mut tx = queued_transaction();
1876        tx.caller_role = CallerRole::Admin;
1877        store.record(tx).unwrap();
1878
1879        let rows = store.fetch_chain_rows().unwrap();
1880        assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1881    }
1882
1883    #[test]
1884    fn approving_consuming_and_revoking_each_append_a_chained_event() {
1885        let dir = tempdir().unwrap();
1886        let key = AuditKey::from_bytes(vec![0x42; 32]);
1887        let store = test_store(dir.path().join("tx.db"));
1888
1889        // Approve then consume.
1890        let a = store.record(queued_transaction()).unwrap();
1891        let receipt = store
1892            .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
1893            .unwrap()
1894            .expect("queued transaction approves");
1895        let digest = audit_chain::approval_receipt_digest(&receipt);
1896        assert!(store
1897            .claim_approved_for_execution(&a.transaction_id, &digest, CallerPrincipal::Uid(1000))
1898            .unwrap());
1899
1900        // Approve then revoke.
1901        let b = store.record(queued_transaction()).unwrap();
1902        store
1903            .approve_transaction(&b.transaction_id, CallerPrincipal::Uid(1000))
1904            .unwrap();
1905        assert!(store
1906            .revoke_unconsumed_approval(&b.transaction_id, CallerPrincipal::Uid(1000))
1907            .unwrap());
1908
1909        let events = store.fetch_event_rows().unwrap();
1910        let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1911        assert_eq!(
1912            kinds,
1913            vec![
1914                "approval_granted",
1915                "approval_consumed",
1916                "status_running",
1917                "approval_granted",
1918                "approval_revoked",
1919            ]
1920        );
1921        assert_eq!(
1922            store.verify_event_chain(&key).unwrap(),
1923            VerifyOutcome::Intact { rows_checked: 5 }
1924        );
1925    }
1926
1927    #[test]
1928    fn a_stored_approval_event_names_the_account_that_acted() {
1929        // End-to-end over the real store, not an in-memory fixture: the
1930        // principal threaded through `approve_transaction` /
1931        // `claim_approved_for_execution` must survive the round-trip into the
1932        // persisted event rows, so a verified chain names who approved and who
1933        // executed rather than leaving it to inference (#249). Two different
1934        // uids leave two distinguishable signed records.
1935        let dir = tempdir().unwrap();
1936        let key = AuditKey::from_bytes(vec![0x42; 32]);
1937        let store = test_store(dir.path().join("tx.db"));
1938
1939        let tx = store.record(queued_transaction()).unwrap();
1940        // alice (uid:1000) approves ...
1941        let receipt = store
1942            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
1943            .unwrap()
1944            .expect("queued transaction approves");
1945        let digest = audit_chain::approval_receipt_digest(&receipt);
1946        // ... bob (uid:1001) executes.
1947        assert!(store
1948            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1001))
1949            .unwrap());
1950
1951        let events = store.fetch_event_rows().unwrap();
1952        assert_eq!(events.len(), 3);
1953        let granted = events
1954            .iter()
1955            .find(|e| e.kind == "approval_granted")
1956            .expect("a grant event");
1957        let consumed = events
1958            .iter()
1959            .find(|e| e.kind == "approval_consumed")
1960            .expect("a consume event");
1961
1962        // Both rows carry the new encoding and the acting account.
1963        assert_eq!(granted.chain_version, EVENT_VERSION_CURRENT);
1964        assert_eq!(consumed.chain_version, EVENT_VERSION_CURRENT);
1965        assert_eq!(
1966            granted.caller_principal.as_deref(),
1967            Some("uid:1000"),
1968            "the grant must name the approver"
1969        );
1970        assert_eq!(
1971            consumed.caller_principal.as_deref(),
1972            Some("uid:1001"),
1973            "the consume must name the executor"
1974        );
1975        assert_ne!(
1976            granted.caller_principal, consumed.caller_principal,
1977            "a grant and a consume by different accounts must be distinguishable"
1978        );
1979
1980        // The approval rows and the running status event verify as a mixed chain.
1981        assert_eq!(
1982            store.verify_event_chain(&key).unwrap(),
1983            VerifyOutcome::Intact { rows_checked: 3 }
1984        );
1985    }
1986
1987    #[test]
1988    fn a_stored_status_event_carries_no_principal_and_stays_legacy() {
1989        // The complement of the test above: status events are written from
1990        // spawned execution tasks with no caller attribution, so they stay on
1991        // the six-field legacy encoding rather than signing an account the
1992        // code cannot see (#249). The mixed chain still verifies.
1993        let dir = tempdir().unwrap();
1994        let key = AuditKey::from_bytes(vec![0x42; 32]);
1995        let store = test_store(dir.path().join("tx.db"));
1996
1997        let tx = store.record(queued_transaction()).unwrap();
1998        let receipt = store
1999            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2000            .unwrap()
2001            .expect("queued transaction approves");
2002        let digest = audit_chain::approval_receipt_digest(&receipt);
2003        assert!(store
2004            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2005            .unwrap());
2006        store
2007            .update_status(&tx.transaction_id, JobState::Succeeded)
2008            .unwrap();
2009
2010        let events = store.fetch_event_rows().unwrap();
2011        let status = events
2012            .iter()
2013            .find(|e| e.kind == "status_succeeded")
2014            .expect("a status event");
2015        assert_eq!(status.chain_version, EVENT_VERSION_LEGACY);
2016        assert_eq!(
2017            status.caller_principal, None,
2018            "a status event names no account"
2019        );
2020        // grant + consume (V2) + running + terminal status (legacy) verify together.
2021        assert_eq!(
2022            store.verify_event_chain(&key).unwrap(),
2023            VerifyOutcome::Intact { rows_checked: 4 }
2024        );
2025    }
2026
2027    #[test]
2028    fn audit_status_event_failure_rolls_back_the_entire_transition() {
2029        for operation in ["claim", "cancel", "sweep"] {
2030            let dir = tempdir().unwrap();
2031            let store = test_store(dir.path().join("tx.db"));
2032            let tx = store.record(queued_transaction()).unwrap();
2033            let receipt = store
2034                .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2035                .unwrap()
2036                .unwrap();
2037            let digest = audit_chain::approval_receipt_digest(&receipt);
2038            let conn = store.connection().unwrap();
2039            if operation == "sweep" {
2040                conn.execute(
2041                    "UPDATE transactions SET created_at = datetime('now', '-20 minutes')",
2042                    [],
2043                )
2044                .unwrap();
2045            }
2046            conn.execute_batch("CREATE TRIGGER reject_status_event BEFORE INSERT ON audit_events WHEN NEW.kind LIKE 'status_%' BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;").unwrap();
2047            let result = match operation {
2048                "claim" => store.claim_approved_for_execution(
2049                    &tx.transaction_id,
2050                    &digest,
2051                    CallerPrincipal::Uid(1000),
2052                ),
2053                "cancel" => store.cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)),
2054                _ => store.cleanup_stale_queued().map(|count| count > 0),
2055            };
2056            assert!(
2057                result.is_err(),
2058                "{operation} must fail when its event cannot be written"
2059            );
2060            assert_eq!(
2061                store.get(&tx.transaction_id).unwrap().unwrap().status,
2062                JobState::Queued
2063            );
2064            let unconsumed: i64 = conn.query_row("SELECT COUNT(*) FROM transaction_approvals WHERE transaction_id = ?1 AND consumed_at IS NULL", params![tx.transaction_id], |row| row.get(0)).unwrap();
2065            assert_eq!(
2066                unconsumed, 1,
2067                "{operation} must roll back approval changes too"
2068            );
2069            assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2070        }
2071    }
2072
2073    #[test]
2074    fn audit_status_legacy_consumption_cannot_be_rewritten_as_queued() {
2075        let dir = tempdir().unwrap();
2076        let store = test_store(dir.path().join("tx.db"));
2077        let tx = store.record(queued_transaction()).unwrap();
2078        let receipt = store
2079            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2080            .unwrap()
2081            .unwrap();
2082        let digest = audit_chain::approval_receipt_digest(&receipt);
2083        store
2084            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2085            .unwrap();
2086        let conn = store.connection().unwrap();
2087        // Reproduce a pre-fix claim (grant + consume, no status event).
2088        conn.execute("DELETE FROM audit_events WHERE kind LIKE 'status_%'", [])
2089            .unwrap();
2090        let outcome = store.status_matches_chain().unwrap();
2091        assert_eq!(
2092            serde_json::to_value(&outcome).unwrap()["state"],
2093            "cannot_verify"
2094        );
2095        conn.execute(
2096            "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2097            params![
2098                serialize_field(&JobState::Queued).unwrap(),
2099                tx.transaction_id
2100            ],
2101        )
2102        .unwrap();
2103        assert!(matches!(
2104            store.status_matches_chain().unwrap(),
2105            audit_chain::StatusOutcome::Disagrees { .. }
2106        ));
2107    }
2108
2109    #[test]
2110    fn audit_status_tracks_an_in_flight_claim() {
2111        let dir = tempdir().unwrap();
2112        let store = test_store(dir.path().join("tx.db"));
2113        let tx = store.record(queued_transaction()).unwrap();
2114        let receipt = store
2115            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2116            .unwrap()
2117            .unwrap();
2118        let digest = audit_chain::approval_receipt_digest(&receipt);
2119        assert!(store
2120            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2121            .unwrap());
2122        assert_eq!(
2123            store.status_matches_chain().unwrap(),
2124            audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2125        );
2126        assert_eq!(
2127            store.fetch_event_rows().unwrap().last().unwrap().kind,
2128            "status_running"
2129        );
2130        assert_eq!(
2131            store
2132                .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2133                .unwrap(),
2134            VerifyOutcome::Intact { rows_checked: 3 }
2135        );
2136    }
2137
2138    #[test]
2139    fn audit_status_tracks_cancellation_with_and_without_approval() {
2140        for approved in [false, true] {
2141            let dir = tempdir().unwrap();
2142            let store = test_store(dir.path().join("tx.db"));
2143            let tx = store.record(queued_transaction()).unwrap();
2144            if approved {
2145                store
2146                    .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2147                    .unwrap();
2148            }
2149            assert!(store
2150                .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2151                .unwrap());
2152            assert_eq!(
2153                store.status_matches_chain().unwrap(),
2154                audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2155            );
2156            assert_eq!(
2157                store.fetch_event_rows().unwrap().last().unwrap().kind,
2158                "status_canceled"
2159            );
2160            assert!(matches!(
2161                store
2162                    .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2163                    .unwrap(),
2164                VerifyOutcome::Intact { .. }
2165            ));
2166            let count = store.fetch_event_rows().unwrap().len();
2167            assert!(!store
2168                .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2169                .unwrap());
2170            assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2171        }
2172    }
2173
2174    #[test]
2175    fn audit_status_tracks_stale_sweep_with_and_without_approval() {
2176        for approved in [false, true] {
2177            let dir = tempdir().unwrap();
2178            let store = test_store(dir.path().join("tx.db"));
2179            let tx = store.record(queued_transaction()).unwrap();
2180            if approved {
2181                store
2182                    .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2183                    .unwrap();
2184            }
2185            store.connection().unwrap().execute(
2186                "UPDATE transactions SET created_at = datetime('now', '-20 minutes') WHERE transaction_id = ?1",
2187                params![tx.transaction_id],
2188            ).unwrap();
2189            assert_eq!(store.cleanup_stale_queued().unwrap(), 1);
2190            assert_eq!(
2191                store.status_matches_chain().unwrap(),
2192                audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2193            );
2194            assert_eq!(
2195                store.fetch_event_rows().unwrap().last().unwrap().kind,
2196                "status_canceled"
2197            );
2198            let count = store.fetch_event_rows().unwrap().len();
2199            assert_eq!(store.cleanup_stale_queued().unwrap(), 0);
2200            assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2201        }
2202    }
2203
2204    #[test]
2205    fn audit_status_only_historical_claims_and_cancellations_are_inconclusive() {
2206        let dir = tempdir().unwrap();
2207        let store = test_store(dir.path().join("tx.db"));
2208        let tx = store.record(queued_transaction()).unwrap();
2209        // Historical cancel/sweep writes had no status event. An approval
2210        // revocation is not proof of cancellation: it can also leave Queued.
2211        store
2212            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2213            .unwrap();
2214        store
2215            .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
2216            .unwrap();
2217        assert_eq!(
2218            store.status_matches_chain().unwrap(),
2219            audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2220        );
2221        for state in [
2222            JobState::Canceled,
2223            JobState::Running,
2224            JobState::Succeeded,
2225            JobState::Failed,
2226            JobState::RolledBack,
2227            JobState::NeedsReboot,
2228        ] {
2229            store
2230                .connection()
2231                .unwrap()
2232                .execute(
2233                    "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2234                    params![serialize_field(&state).unwrap(), tx.transaction_id],
2235                )
2236                .unwrap();
2237            let outcome = store.status_matches_chain().unwrap();
2238            let historical = matches!(state, JobState::Running | JobState::Canceled);
2239            assert_eq!(
2240                serde_json::to_value(&outcome).unwrap()["state"],
2241                if historical {
2242                    "cannot_verify"
2243                } else {
2244                    "disagrees"
2245                },
2246                "unsigned state {state:?}"
2247            );
2248            assert_eq!(
2249                audit_chain::status_outcome_to_exit_code(&outcome),
2250                if historical { 2 } else { 1 },
2251                "unsigned state {state:?}"
2252            );
2253        }
2254    }
2255
2256    #[test]
2257    fn audit_status_new_event_contradictions_remain_broken() {
2258        let dir = tempdir().unwrap();
2259        let store = test_store(dir.path().join("tx.db"));
2260        let tx = store.record(queued_transaction()).unwrap();
2261        store
2262            .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2263            .unwrap();
2264        store
2265            .connection()
2266            .unwrap()
2267            .execute(
2268                "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2269                params![
2270                    serialize_field(&JobState::Queued).unwrap(),
2271                    tx.transaction_id
2272                ],
2273            )
2274            .unwrap();
2275        // Missing historical evidence must not downgrade a signed contradiction
2276        // on a different row from broken to inconclusive.
2277        let legacy = store.record(queued_transaction()).unwrap();
2278        store
2279            .connection()
2280            .unwrap()
2281            .execute(
2282                "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2283                params![
2284                    serialize_field(&JobState::Canceled).unwrap(),
2285                    legacy.transaction_id
2286                ],
2287            )
2288            .unwrap();
2289        assert!(matches!(
2290            store.status_matches_chain().unwrap(),
2291            audit_chain::StatusOutcome::Disagrees { .. }
2292        ));
2293    }
2294
2295    #[test]
2296    fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() {
2297        // The module documentation says status transitions ARE chained
2298        // separately, in audit_events. They were not: update_status appended
2299        // nothing, and audit_events recorded only the approval lifecycle. So a
2300        // job that ran to completion could be rewritten in the `status` column
2301        // as canceled, `sysknife history` reported it canceled, and
2302        // `audit verify` still reported the chain Intact. An investigator
2303        // reading that documentation would believe the outcome was protected.
2304        let dir = tempdir().unwrap();
2305        let key = AuditKey::from_bytes(vec![0x42; 32]);
2306        let store = test_store(dir.path().join("tx.db"));
2307
2308        let tx = store.record(queued_transaction()).unwrap();
2309        let receipt = store
2310            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2311            .unwrap()
2312            .expect("queued transaction approves");
2313        let digest = audit_chain::approval_receipt_digest(&receipt);
2314        assert!(store
2315            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2316            .unwrap());
2317        store
2318            .update_status(&tx.transaction_id, JobState::Succeeded)
2319            .unwrap();
2320
2321        // The outcome must be in the event chain, not only in a mutable column.
2322        let kinds: Vec<String> = store
2323            .fetch_event_rows()
2324            .unwrap()
2325            .iter()
2326            .map(|e| e.kind.clone())
2327            .collect();
2328        assert!(
2329            kinds.iter().any(|k| k.contains("succeeded")),
2330            "no event records the terminal outcome; the chain covers only {kinds:?}"
2331        );
2332        assert!(
2333            matches!(
2334                store.status_matches_chain().unwrap(),
2335                audit_chain::StatusOutcome::Agrees { .. }
2336            ),
2337            "an untampered store must report agreement, got {:?}",
2338            store.status_matches_chain().unwrap()
2339        );
2340
2341        // Now rewrite the column the way anyone with write access to the
2342        // database can, and the cross-check must notice.
2343        {
2344            let conn = rusqlite::Connection::open(dir.path().join("tx.db")).unwrap();
2345            let n = conn
2346                .execute(
2347                    "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2348                    rusqlite::params!["\"canceled\"", tx.transaction_id],
2349                )
2350                .unwrap();
2351            assert_eq!(n, 1, "the fixture did not rewrite the status column");
2352        }
2353        let audit_chain::StatusOutcome::Disagrees { detail } =
2354            store.status_matches_chain().unwrap()
2355        else {
2356            panic!("a rewritten status must be reported, not passed over");
2357        };
2358        assert!(
2359            detail.contains(&tx.transaction_id),
2360            "the report must name the transaction: {detail}"
2361        );
2362        // The event chain itself is untouched by that UPDATE, which is exactly
2363        // why verifying it alone was not enough.
2364        assert!(
2365            matches!(
2366                store.verify_event_chain(&key).unwrap(),
2367                VerifyOutcome::Intact { .. }
2368            ),
2369            "event chain: {:?}, rows: {:?}",
2370            store.verify_event_chain(&key).unwrap(),
2371            store
2372                .fetch_event_rows()
2373                .unwrap()
2374                .iter()
2375                .map(|e| (e.seq, e.kind.clone()))
2376                .collect::<Vec<_>>()
2377        );
2378    }
2379
2380    #[test]
2381    fn a_failed_approval_appends_no_event() {
2382        // Only real state changes get chained. A no-op approve (already
2383        // approved) writing an event would make the trail claim something that
2384        // did not happen.
2385        let dir = tempdir().unwrap();
2386        let store = test_store(dir.path().join("tx.db"));
2387        let tx = store.record(queued_transaction()).unwrap();
2388        store
2389            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2390            .unwrap();
2391        store
2392            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2393            .unwrap();
2394        assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2395
2396        // Same for a revoke with nothing to revoke.
2397        assert!(!store
2398            .revoke_unconsumed_approval("no-such-tx", CallerPrincipal::Uid(1000))
2399            .unwrap());
2400        assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2401    }
2402
2403    #[test]
2404    fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
2405        // The finding. Before the event chain, `transaction_approvals` was a
2406        // plain table: DELETE the row and `audit verify` still said Intact, so
2407        // the fact that a privileged action had been approved could be erased
2408        // without a trace. Now the deletion has to survive two independent
2409        // checks.
2410        let dir = tempdir().unwrap();
2411        let db_path = dir.path().join("tx.db");
2412        let key = AuditKey::from_bytes(vec![0x42; 32]);
2413        let store = test_store(&db_path);
2414
2415        let a = store.record(queued_transaction()).unwrap();
2416        store
2417            .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
2418            .unwrap();
2419        // A later transaction commits to the event tip, which is what carries
2420        // the binding into the checkpoint-anchored transaction chain.
2421        store.record(queued_transaction()).unwrap();
2422
2423        assert_eq!(
2424            store.verify_event_chain(&key).unwrap(),
2425            VerifyOutcome::Intact { rows_checked: 1 }
2426        );
2427        assert_eq!(
2428            store.verify_event_binding().unwrap(),
2429            audit_chain::BindingOutcome::Consistent {
2430                bindings_checked: 1
2431            }
2432        );
2433
2434        // Erase the approval and its event.
2435        let conn = Connection::open(&db_path).unwrap();
2436        conn.execute("DELETE FROM transaction_approvals", [])
2437            .unwrap();
2438        conn.execute("DELETE FROM audit_events", []).unwrap();
2439        drop(conn);
2440
2441        // The event chain alone is now empty and walks clean — deleting the
2442        // only event leaves nothing to contradict. The transaction row's
2443        // committed tip is what catches it.
2444        assert_eq!(
2445            store.verify_event_chain(&key).unwrap(),
2446            VerifyOutcome::Intact { rows_checked: 0 }
2447        );
2448        match store.verify_event_binding().unwrap() {
2449            audit_chain::BindingOutcome::MissingEvent {
2450                transaction_seq, ..
2451            } => assert_eq!(transaction_seq, 2),
2452            other => panic!("expected MissingEvent, got {other:?}"),
2453        }
2454    }
2455
2456    #[test]
2457    fn a_read_only_store_cannot_revoke_or_claim() {
2458        // Both paths now append to the event chain, so both need the signing
2459        // key. Refusing loudly beats silently skipping the event.
2460        let dir = tempdir().unwrap();
2461        let db_path = dir.path().join("tx.db");
2462        let store = test_store(&db_path);
2463        store.record(queued_transaction()).unwrap();
2464
2465        let read_only = TransactionStore::open_read_only(&db_path).unwrap();
2466        assert!(matches!(
2467            read_only.revoke_unconsumed_approval("tx", CallerPrincipal::Uid(1000)),
2468            Err(TransactionStoreError::AuditChainMissing(_))
2469        ));
2470        assert!(matches!(
2471            read_only.claim_approved_for_execution("tx", "digest", CallerPrincipal::Uid(1000)),
2472            Err(TransactionStoreError::AuditChainMissing(_))
2473        ));
2474    }
2475
2476    // ── Audit chain integration tests ────────────────────────────────────
2477
2478    /// The store must persist the principal the dispatcher resolved, under the
2479    /// v3 tag, or the chain claims an encoding whose evidence is missing. Reads
2480    /// the columns directly rather than through the verifier, so a mismatch
2481    /// between what was signed and what was stored is visible.
2482    #[test]
2483    fn record_persists_the_caller_principal_under_the_current_encoding() {
2484        let dir = tempdir().unwrap();
2485        let store = test_store(dir.path().join("tx.db"));
2486        let mut tx = queued_transaction();
2487        tx.caller_principal = CallerPrincipal::Uid(4242);
2488        store.record(tx).unwrap();
2489
2490        let conn = store.connection().unwrap();
2491        let (version, role, principal): (i64, Option<String>, Option<String>) = conn
2492            .query_row(
2493                "SELECT chain_version, caller_role, caller_principal FROM transactions",
2494                [],
2495                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2496            )
2497            .unwrap();
2498
2499        assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
2500        assert_eq!(role.as_deref(), Some("dev"));
2501        assert_eq!(
2502            principal.as_deref(),
2503            Some("uid:4242"),
2504            "the row must name the account the dispatcher resolved"
2505        );
2506
2507        // And the signature must agree with those columns.
2508        let key = AuditKey::from_bytes(vec![0x42; 32]);
2509        assert!(matches!(
2510            store.verify_audit_chain(&key).unwrap(),
2511            audit_chain::VerifyOutcome::Intact { .. }
2512        ));
2513    }
2514
2515    #[test]
2516    fn record_writes_audit_chain_columns() {
2517        let dir = tempdir().unwrap();
2518        let store = test_store(dir.path().join("tx.db"));
2519        let _record = store.record(queued_transaction()).unwrap();
2520
2521        let conn = store.connection().unwrap();
2522        let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
2523            .query_row(
2524                "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
2525                [],
2526                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
2527            )
2528            .unwrap();
2529        assert_eq!(seq, 1, "first row gets seq=1");
2530        assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
2531        assert_eq!(prev, "", "first row has empty prev_chain_hash");
2532        assert_eq!(
2533            chain_hash.len(),
2534            audit_chain::HASH_HEX_LEN,
2535            "chain_hash is a hex-encoded Ed25519 signature"
2536        );
2537    }
2538
2539    #[test]
2540    fn sequential_records_produce_chained_hashes() {
2541        let dir = tempdir().unwrap();
2542        let store = test_store(dir.path().join("tx.db"));
2543        store.record(queued_transaction()).unwrap();
2544        store.record(queued_transaction()).unwrap();
2545        store.record(queued_transaction()).unwrap();
2546
2547        let rows = store.fetch_chain_rows().unwrap();
2548        assert_eq!(rows.len(), 3);
2549        assert_eq!(rows[0].seq, 1);
2550        assert_eq!(rows[1].seq, 2);
2551        assert_eq!(rows[2].seq, 3);
2552        // Each row's prev_chain_hash matches the previous row's chain_hash.
2553        assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
2554        assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
2555    }
2556
2557    /// T3 — concurrent `record()` keeps the chain intact and seqs contiguous.
2558    ///
2559    /// The store guarantees this via `BEGIN IMMEDIATE` on every record:
2560    /// the immediate write lock means `next_seq_and_prev_hash` is read
2561    /// inside the same SQLite transaction that does the INSERT, so two
2562    /// records cannot both observe `seq=N` and produce two rows with the
2563    /// same chain hash.  Drive 8 worker threads × 10 records each
2564    /// through the same store and assert (a) `verify_audit_chain` returns
2565    /// Intact { rows_checked: 80 } and (b) the seq column is contiguous
2566    /// 1..=80.  A regression that drops `BEGIN IMMEDIATE` or substitutes
2567    /// a non-locking read fails one of these on every run.
2568    #[test]
2569    fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
2570        const WORKERS: usize = 8;
2571        const RECORDS_PER_WORKER: usize = 10;
2572        const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
2573
2574        let dir = tempdir().unwrap();
2575        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2576
2577        let mut handles = Vec::with_capacity(WORKERS);
2578        for w in 0..WORKERS {
2579            let store = std::sync::Arc::clone(&store);
2580            handles.push(std::thread::spawn(move || {
2581                for r in 0..RECORDS_PER_WORKER {
2582                    let tx = NewTransaction {
2583                        request_id: format!("worker-{w}-record-{r}"),
2584                        request_hash: format!("hash-{w}-{r}"),
2585                        action_name: "GetSystemState".to_string(),
2586                        risk_level: RiskLevel::Low,
2587                        summary: format!("worker {w} record {r}"),
2588                        warnings: vec![],
2589                        caller_role: CallerRole::Dev,
2590                        // Distinct per worker: with every row claiming the same
2591                        // account, the chain could attribute a row to the wrong
2592                        // caller under contention and still verify.
2593                        caller_principal: CallerPrincipal::Uid(3000 + w as u32),
2594                    };
2595                    store
2596                        .record(tx)
2597                        .expect("record must succeed under contention");
2598                }
2599            }));
2600        }
2601        for h in handles {
2602            h.join().expect("worker thread did not panic");
2603        }
2604
2605        // (a) chain must be intact end-to-end.
2606        let key = AuditKey::from_bytes(vec![0x42; 32]);
2607        let outcome = store.verify_audit_chain(&key).unwrap();
2608        match outcome {
2609            VerifyOutcome::Intact { rows_checked } => {
2610                assert_eq!(
2611                    rows_checked, TOTAL as u64,
2612                    "expected {TOTAL} rows checked, got {rows_checked}"
2613                );
2614            }
2615            other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
2616        }
2617
2618        // (a2) every row must still name the worker that wrote it. The summary
2619        // carries the worker id independently of the principal, so a row whose
2620        // principal drifted onto another worker's insert is detectable.
2621        {
2622            let conn = store.connection().unwrap();
2623            let mut stmt = conn
2624                .prepare("SELECT summary, caller_principal FROM transactions")
2625                .unwrap();
2626            let pairs: Vec<(String, Option<String>)> = stmt
2627                .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
2628                .unwrap()
2629                .map(|r| r.unwrap())
2630                .collect();
2631            assert_eq!(pairs.len(), TOTAL);
2632            for (summary, principal) in pairs {
2633                let worker: u32 = summary
2634                    .split_whitespace()
2635                    .nth(1)
2636                    .and_then(|w| w.parse().ok())
2637                    .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
2638                assert_eq!(
2639                    principal.as_deref(),
2640                    Some(format!("uid:{}", 3000 + worker).as_str()),
2641                    "row {summary:?} was attributed to the wrong account"
2642                );
2643            }
2644        }
2645
2646        // (b) seq must be a contiguous run 1..=TOTAL with no gaps and no duplicates.
2647        let conn = store.connection().unwrap();
2648        let mut stmt = conn
2649            .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
2650            .unwrap();
2651        let seqs: Vec<i64> = stmt
2652            .query_map([], |row| row.get::<_, i64>(0))
2653            .unwrap()
2654            .map(|r| r.unwrap())
2655            .collect();
2656        assert_eq!(seqs.len(), TOTAL, "row count mismatch");
2657        for (i, s) in seqs.iter().enumerate() {
2658            assert_eq!(
2659                *s,
2660                (i as i64) + 1,
2661                "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn verify_audit_chain_intact_after_inserts() {
2668        let dir = tempdir().unwrap();
2669        let store = test_store(dir.path().join("tx.db"));
2670        for _ in 0..3 {
2671            store.record(queued_transaction()).unwrap();
2672        }
2673        let key = AuditKey::from_bytes(vec![0x42; 32]);
2674        let outcome = store.verify_audit_chain(&key).unwrap();
2675        assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2676    }
2677
2678    #[test]
2679    fn verify_audit_chain_with_pubkey_intact_after_inserts() {
2680        let dir = tempdir().unwrap();
2681        let store = test_store(dir.path().join("tx.db"));
2682        for _ in 0..3 {
2683            store.record(queued_transaction()).unwrap();
2684        }
2685        // Auditor path: verify with ONLY the public key, no private key.
2686        let key = AuditKey::from_bytes(vec![0x42; 32]);
2687        let outcome = store
2688            .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2689            .unwrap();
2690        assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2691    }
2692
2693    #[test]
2694    fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2695        let dir = tempdir().unwrap();
2696        let store = test_store(dir.path().join("tx.db"));
2697        store.record(queued_transaction()).unwrap();
2698        // A different keypair's public key must not validate the chain.
2699        let other = AuditKey::from_bytes(vec![0x99; 32]);
2700        let outcome = store
2701            .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2702            .unwrap();
2703        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2704    }
2705
2706    #[test]
2707    fn verify_detects_tampered_summary() {
2708        let dir = tempdir().unwrap();
2709        let store = test_store(dir.path().join("tx.db"));
2710        let tx = store.record(queued_transaction()).unwrap();
2711
2712        // Tamper: modify the summary field directly via SQL — simulates an
2713        // attacker with database write access who skips the daemon code path.
2714        let conn = store.connection().unwrap();
2715        conn.execute(
2716            "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2717            params!["EVIL CHANGE", tx.transaction_id],
2718        )
2719        .unwrap();
2720
2721        let key = AuditKey::from_bytes(vec![0x42; 32]);
2722        let outcome = store.verify_audit_chain(&key).unwrap();
2723        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2724    }
2725
2726    #[test]
2727    fn status_update_does_not_break_chain() {
2728        // Status is mutable; the chain protects only immutable fields.
2729        let dir = tempdir().unwrap();
2730        let store = test_store(dir.path().join("tx.db"));
2731        let tx = store.record(queued_transaction()).unwrap();
2732        store
2733            .update_status(&tx.transaction_id, JobState::Running)
2734            .unwrap();
2735        store
2736            .update_status(&tx.transaction_id, JobState::Succeeded)
2737            .unwrap();
2738
2739        let key = AuditKey::from_bytes(vec![0x42; 32]);
2740        let outcome = store.verify_audit_chain(&key).unwrap();
2741        assert!(
2742            matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2743            "status mutation must not break the chain (status not in chain content): {outcome:?}"
2744        );
2745    }
2746
2747    #[test]
2748    fn open_read_only_rejects_record() {
2749        let dir = tempdir().unwrap();
2750        let key_path = dir.path().join("audit-key");
2751        std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2752        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2753
2754        let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2755        let result = store.record(queued_transaction());
2756        assert!(matches!(
2757            result,
2758            Err(TransactionStoreError::AuditChainMissing(_))
2759        ));
2760    }
2761
2762    #[test]
2763    fn ensure_private_dir_creates_with_0700_mode() {
2764        let root = tempdir().unwrap();
2765        let target = root.path().join("a/b/c");
2766        ensure_private_dir(&target).unwrap();
2767        assert!(target.is_dir());
2768        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2769        assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2770    }
2771
2772    /// The audit database is the record of every privileged action taken on the
2773    /// host: action names, parameters, and who approved them. SQLite creates it
2774    /// `0644 & ~umask`, which the 0700 parent hides today — but only today. A
2775    /// backup, a `cp`, or an operator who widens the directory carries the file
2776    /// mode with it, and SQLite derives the `-wal`/`-shm` sidecar modes from the
2777    /// main file, so a permissive main file leaks the most recent transactions
2778    /// through the WAL too.
2779    #[test]
2780    fn the_audit_database_is_created_owner_only() {
2781        let dir = tempdir().unwrap();
2782        let db = dir.path().join("daemon.sqlite");
2783        let store = TransactionStore::open(&db).unwrap();
2784        // Write a row so the WAL sidecar exists and can be checked as well.
2785        store
2786            .record(NewTransaction {
2787                request_id: "mode-probe".to_string(),
2788                request_hash: "hash-mode-probe".to_string(),
2789                action_name: "GetSystemState".to_string(),
2790                risk_level: RiskLevel::Low,
2791                summary: "probe".to_string(),
2792                warnings: vec![],
2793                caller_role: CallerRole::Dev,
2794                caller_principal: CallerPrincipal::Uid(3000),
2795            })
2796            .unwrap();
2797
2798        for path in [db.clone(), db.with_extension("sqlite-wal")] {
2799            if !path.exists() {
2800                continue;
2801            }
2802            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2803            assert_eq!(
2804                mode,
2805                0o600,
2806                "{} must be owner-only, got {mode:o}",
2807                path.display()
2808            );
2809        }
2810    }
2811
2812    #[test]
2813    fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2814        let root = tempdir().unwrap();
2815        let target = root.path().join("preexisting");
2816        std::fs::create_dir(&target).unwrap();
2817        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2818        ensure_private_dir(&target).unwrap();
2819        // Existing directory: we don't touch its mode.
2820        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2821        assert_eq!(mode, 0o755);
2822    }
2823
2824    #[test]
2825    fn open_creates_parent_with_private_mode() {
2826        let root = tempdir().unwrap();
2827        let db_path = root.path().join("nested/dirs/daemon.sqlite");
2828        let _store = test_store(&db_path);
2829        let parent = db_path.parent().unwrap();
2830        let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2831        assert_eq!(mode, 0o700);
2832    }
2833
2834    fn queued_transaction() -> NewTransaction {
2835        NewTransaction {
2836            request_id: "req-1".to_string(),
2837            request_hash: "hash-abc".to_string(),
2838            action_name: "UpdateSystem".to_string(),
2839            risk_level: RiskLevel::High,
2840            summary: "Upgrade the system".to_string(),
2841            warnings: vec![],
2842            caller_role: CallerRole::Dev,
2843            caller_principal: CallerPrincipal::Uid(1000),
2844        }
2845    }
2846
2847    #[test]
2848    fn update_status_transitions_queued_to_running() {
2849        let dir = tempdir().unwrap();
2850        let store = test_store(dir.path().join("tx.db"));
2851        let tx = store.record(queued_transaction()).unwrap();
2852
2853        store
2854            .update_status(&tx.transaction_id, JobState::Running)
2855            .unwrap();
2856
2857        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2858        assert_eq!(updated.status, JobState::Running);
2859    }
2860
2861    #[test]
2862    fn update_status_transitions_running_to_succeeded() {
2863        let dir = tempdir().unwrap();
2864        let store = test_store(dir.path().join("tx.db"));
2865        let tx = store.record(queued_transaction()).unwrap();
2866
2867        store
2868            .update_status(&tx.transaction_id, JobState::Running)
2869            .unwrap();
2870        store
2871            .update_status(&tx.transaction_id, JobState::Succeeded)
2872            .unwrap();
2873
2874        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2875        assert_eq!(updated.status, JobState::Succeeded);
2876    }
2877
2878    #[test]
2879    fn update_status_for_unknown_id_returns_not_found() {
2880        let dir = tempdir().unwrap();
2881        let store = test_store(dir.path().join("tx.db"));
2882
2883        let result = store.update_status("does-not-exist", JobState::Running);
2884        assert!(
2885            matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2886            "expected NotFound, got: {result:?}"
2887        );
2888    }
2889
2890    #[test]
2891    fn update_status_leaves_other_fields_intact() {
2892        let dir = tempdir().unwrap();
2893        let store = test_store(dir.path().join("tx.db"));
2894        let tx = store.record(queued_transaction()).unwrap();
2895
2896        store
2897            .update_status(&tx.transaction_id, JobState::Running)
2898            .unwrap();
2899        store
2900            .update_status(&tx.transaction_id, JobState::Failed)
2901            .unwrap();
2902
2903        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2904        assert_eq!(updated.action_name, "UpdateSystem");
2905        assert_eq!(updated.risk_level, RiskLevel::High);
2906        assert_eq!(updated.status, JobState::Failed);
2907    }
2908
2909    /// `update_status` must be a compare-and-swap, not a check-then-act race.
2910    /// Bring a transaction to `Running`, then fire two conflicting terminal
2911    /// transitions (`Succeeded` and `Failed`) from concurrent threads. Both
2912    /// read `Running` and both pass `allowed_transition`, but only one may
2913    /// actually apply — the loser must see an explicit error, never a silent
2914    /// double-write. Before the `AND status = ?<observed>` guard, both calls
2915    /// would unconditionally UPDATE and both would return `Ok(())`, hiding the
2916    /// fact that one of them clobbered a transition it never validated.
2917    #[test]
2918    fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2919        let dir = tempdir().unwrap();
2920        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2921        let tx = store.record(queued_transaction()).unwrap();
2922        store
2923            .update_status(&tx.transaction_id, JobState::Running)
2924            .unwrap();
2925
2926        let store_a = std::sync::Arc::clone(&store);
2927        let id_a = tx.transaction_id.clone();
2928        let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2929
2930        let store_b = std::sync::Arc::clone(&store);
2931        let id_b = tx.transaction_id.clone();
2932        let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2933
2934        let succeed_result = succeed.join().unwrap();
2935        let fail_result = fail.join().unwrap();
2936
2937        // Exactly one of the two conflicting transitions may win.
2938        let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2939            .into_iter()
2940            .filter(|ok| *ok)
2941            .count();
2942        assert_eq!(
2943            oks, 1,
2944            "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2945             fail={fail_result:?}"
2946        );
2947
2948        // The loser must be refused outright, not silently succeed and clobber
2949        // the winner. *How* it is refused depends on whether the two threads
2950        // actually overlapped, and both outcomes are correct:
2951        //
2952        //   ConcurrentStatusChange — they overlapped, so the loser read
2953        //       `Running` and its compare-and-swap found the row already moved.
2954        //   InvalidTransition      — they serialised, so the loser read the
2955        //       winner's committed terminal status and rejected the transition
2956        //       before reaching the CAS at all.
2957        //
2958        // Asserting only the first made this test depend on thread
2959        // interleaving; it passes on a many-core dev machine and fails on a
2960        // 2-core CI runner, where the first thread routinely finishes before
2961        // the second starts. The invariant under test is "exactly one wins and
2962        // the loser does not clobber it", which both errors satisfy.
2963        let loser = if succeed_result.is_ok() {
2964            &fail_result
2965        } else {
2966            &succeed_result
2967        };
2968        match loser {
2969            Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2970                assert_eq!(id, &tx.transaction_id);
2971            }
2972            Err(TransactionStoreError::InvalidTransition { from, to }) => {
2973                // The winner's terminal state must be what the loser saw.
2974                let winner = if succeed_result.is_ok() {
2975                    JobState::Succeeded
2976                } else {
2977                    JobState::Failed
2978                };
2979                assert_eq!(
2980                    *from, winner,
2981                    "the loser must have observed the winner's committed status"
2982                );
2983                assert_ne!(*to, winner, "the loser must be the other transition");
2984            }
2985            other => panic!(
2986                "the losing transition must be refused with either \
2987                 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2988            ),
2989        }
2990
2991        // The stored status must match whichever transition actually won —
2992        // never a mix, and never left at `Running`.
2993        let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2994        if succeed_result.is_ok() {
2995            assert_eq!(final_status, JobState::Succeeded);
2996        } else {
2997            assert_eq!(final_status, JobState::Failed);
2998        }
2999    }
3000
3001    #[test]
3002    fn revoke_unconsumed_approval_removes_an_unused_receipt() {
3003        // Revocation is the operator's "actually, no" between approving and
3004        // executing. Nothing covered it at all.
3005        let dir = tempdir().unwrap();
3006        let store = test_store(dir.path().join("tx.db"));
3007        let tx = store.record(queued_transaction()).unwrap();
3008        let receipt = store
3009            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3010            .unwrap()
3011            .expect("approved");
3012        let digest = audit_chain::approval_receipt_digest(&receipt);
3013
3014        assert!(
3015            store
3016                .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3017                .unwrap(),
3018            "an unconsumed approval must be revocable"
3019        );
3020        assert!(
3021            !store
3022                .claim_approved_for_execution(
3023                    &tx.transaction_id,
3024                    &digest,
3025                    CallerPrincipal::Uid(1000)
3026                )
3027                .unwrap(),
3028            "a revoked receipt must no longer be claimable"
3029        );
3030    }
3031
3032    #[test]
3033    fn revoke_cannot_retract_an_approval_that_was_already_used() {
3034        // The documented guarantee: once a receipt has been consumed, the
3035        // execution it authorised is a fact and revocation must not rewrite
3036        // history to say otherwise.
3037        let dir = tempdir().unwrap();
3038        let store = test_store(dir.path().join("tx.db"));
3039        let tx = store.record(queued_transaction()).unwrap();
3040        let receipt = store
3041            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3042            .unwrap()
3043            .expect("approved");
3044        let digest = audit_chain::approval_receipt_digest(&receipt);
3045        assert!(store
3046            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3047            .unwrap());
3048
3049        assert!(
3050            !store
3051                .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3052                .unwrap(),
3053            "a consumed approval must not be revocable"
3054        );
3055        assert_eq!(
3056            store.get(&tx.transaction_id).unwrap().unwrap().status,
3057            JobState::Running,
3058            "a failed revocation must not disturb the running transaction"
3059        );
3060    }
3061
3062    #[test]
3063    fn approve_refuses_a_transaction_that_is_no_longer_queued() {
3064        // Every existing approve test starts from a freshly-recorded Queued
3065        // row. Approving something already running (or finished) would mint a
3066        // receipt for work that is past the point of approval.
3067        let dir = tempdir().unwrap();
3068        let store = test_store(dir.path().join("tx.db"));
3069        let tx = store.record(queued_transaction()).unwrap();
3070        let receipt = store
3071            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3072            .unwrap()
3073            .expect("approved");
3074        let digest = audit_chain::approval_receipt_digest(&receipt);
3075        assert!(store
3076            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3077            .unwrap());
3078
3079        // Running.
3080        assert!(
3081            store
3082                .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3083                .unwrap()
3084                .is_none(),
3085            "a Running transaction must not be approvable"
3086        );
3087
3088        // Terminal.
3089        store
3090            .update_status(&tx.transaction_id, JobState::Succeeded)
3091            .unwrap();
3092        assert!(
3093            store
3094                .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3095                .unwrap()
3096                .is_none(),
3097            "a completed transaction must not be approvable"
3098        );
3099    }
3100
3101    #[test]
3102    fn only_one_of_two_concurrent_claims_can_execute() {
3103        // `claim_approved_for_execution` is the interlock that stops one
3104        // approval being spent twice. `update_status` has a concurrency test;
3105        // this path had none, and a double claim means the same approved
3106        // action runs twice.
3107        let dir = tempdir().unwrap();
3108        let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
3109        let tx = store.record(queued_transaction()).unwrap();
3110        let receipt = store
3111            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3112            .unwrap()
3113            .expect("approved");
3114        let digest = audit_chain::approval_receipt_digest(&receipt);
3115
3116        let mut handles = Vec::new();
3117        for _ in 0..2 {
3118            let store = std::sync::Arc::clone(&store);
3119            let id = tx.transaction_id.clone();
3120            let digest = digest.clone();
3121            handles.push(std::thread::spawn(move || {
3122                store.claim_approved_for_execution(&id, &digest, CallerPrincipal::Uid(1000))
3123            }));
3124        }
3125        let claims: Vec<bool> = handles
3126            .into_iter()
3127            .map(|h| h.join().unwrap().unwrap_or(false))
3128            .collect();
3129
3130        assert_eq!(
3131            claims.iter().filter(|c| **c).count(),
3132            1,
3133            "exactly one claim may win: {claims:?}"
3134        );
3135        assert_eq!(
3136            store.get(&tx.transaction_id).unwrap().unwrap().status,
3137            JobState::Running
3138        );
3139    }
3140
3141    #[test]
3142    fn cancel_queued_cancels_a_queued_transaction_once() {
3143        let dir = tempdir().unwrap();
3144        let store = test_store(dir.path().join("tx.db"));
3145        let tx = store.record(queued_transaction()).unwrap();
3146
3147        assert!(
3148            store
3149                .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3150                .unwrap(),
3151            "a queued transaction is cancelable"
3152        );
3153        assert_eq!(
3154            store.get(&tx.transaction_id).unwrap().unwrap().status,
3155            JobState::Canceled
3156        );
3157        assert!(
3158            !store
3159                .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3160                .unwrap(),
3161            "an already-canceled transaction is not cancelable again"
3162        );
3163        assert!(
3164            !store
3165                .cancel_queued("no-such-transaction", CallerPrincipal::Uid(1000))
3166                .unwrap(),
3167            "a missing transaction is not cancelable"
3168        );
3169        assert!(
3170            store
3171                .fetch_event_rows()
3172                .unwrap()
3173                .iter()
3174                .all(|event| event.kind == "status_canceled"),
3175            "canceling an unapproved transaction must not append an approval event"
3176        );
3177    }
3178
3179    #[test]
3180    fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
3181        let dir = tempdir().unwrap();
3182        let store = test_store(dir.path().join("tx.db"));
3183        let tx = store.record(queued_transaction()).unwrap();
3184        let receipt = store
3185            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3186            .unwrap()
3187            .expect("queued transaction is approvable");
3188        let digest = audit_chain::approval_receipt_digest(&receipt);
3189
3190        assert!(store
3191            .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3192            .unwrap());
3193        assert_eq!(
3194            store.get(&tx.transaction_id).unwrap().unwrap().status,
3195            JobState::Canceled
3196        );
3197        assert!(
3198            !store
3199                .claim_approved_for_execution(
3200                    &tx.transaction_id,
3201                    &digest,
3202                    CallerPrincipal::Uid(1000)
3203                )
3204                .unwrap(),
3205            "canceling must revoke the unconsumed receipt"
3206        );
3207        assert_eq!(
3208            store
3209                .fetch_event_rows()
3210                .unwrap()
3211                .iter()
3212                .map(|event| event.kind.as_str())
3213                .collect::<Vec<_>>(),
3214            vec!["approval_granted", "approval_revoked", "status_canceled"]
3215        );
3216    }
3217
3218    #[test]
3219    fn cancel_queued_refuses_a_running_transaction() {
3220        // Option A: never cancel an in-flight privileged action. Once a
3221        // transaction is claimed (Running), cancel must refuse and leave it.
3222        let dir = tempdir().unwrap();
3223        let store = test_store(dir.path().join("tx.db"));
3224        let tx = store.record(queued_transaction()).unwrap();
3225        let receipt = store
3226            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3227            .unwrap()
3228            .expect("approved");
3229        let digest = audit_chain::approval_receipt_digest(&receipt);
3230        assert!(store
3231            .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3232            .unwrap());
3233
3234        assert!(
3235            !store
3236                .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3237                .unwrap(),
3238            "a running transaction must not be cancelable"
3239        );
3240        assert_eq!(
3241            store.get(&tx.transaction_id).unwrap().unwrap().status,
3242            JobState::Running,
3243            "cancel must not disturb a running transaction"
3244        );
3245    }
3246
3247    #[test]
3248    fn approved_receipt_is_required_and_consumed_once() {
3249        let dir = tempdir().unwrap();
3250        let store = test_store(dir.path().join("tx.db"));
3251        let tx = store.record(queued_transaction()).unwrap();
3252
3253        assert!(
3254            !store
3255                .claim_approved_for_execution(
3256                    &tx.transaction_id,
3257                    "digest-a",
3258                    CallerPrincipal::Uid(1000)
3259                )
3260                .unwrap(),
3261            "an unapproved preview must not execute"
3262        );
3263        let receipt = store
3264            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3265            .unwrap()
3266            .expect("first approval must succeed");
3267        let digest = audit_chain::approval_receipt_digest(&receipt);
3268        assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
3269        assert!(
3270            store
3271                .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3272                .unwrap()
3273                .is_none(),
3274            "approval is immutable once issued"
3275        );
3276        assert!(
3277            !store
3278                .claim_approved_for_execution(
3279                    &tx.transaction_id,
3280                    "wrong-digest",
3281                    CallerPrincipal::Uid(1000)
3282                )
3283                .unwrap(),
3284            "a forged receipt must not execute"
3285        );
3286        assert!(
3287            store
3288                .claim_approved_for_execution(
3289                    &tx.transaction_id,
3290                    &digest,
3291                    CallerPrincipal::Uid(1000)
3292                )
3293                .unwrap(),
3294            "the exact approved receipt must execute"
3295        );
3296        assert!(
3297            !store
3298                .claim_approved_for_execution(
3299                    &tx.transaction_id,
3300                    &digest,
3301                    CallerPrincipal::Uid(1000)
3302                )
3303                .unwrap(),
3304            "the receipt must be one-time"
3305        );
3306    }
3307
3308    #[test]
3309    fn approval_commitment_is_covered_by_the_signed_chain() {
3310        let dir = tempdir().unwrap();
3311        let store = test_store(dir.path().join("tx.db"));
3312        let tx = store.record(queued_transaction()).unwrap();
3313        assert!(tx.approval_id.is_some());
3314
3315        store
3316            .connection()
3317            .unwrap()
3318            .execute(
3319                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3320                params![tx.transaction_id],
3321            )
3322            .unwrap();
3323
3324        let key = AuditKey::from_bytes(vec![0x42; 32]);
3325        assert!(matches!(
3326            store.verify_audit_chain(&key).unwrap(),
3327            VerifyOutcome::Broken { .. }
3328        ));
3329    }
3330
3331    #[test]
3332    fn approve_rejects_a_forged_commitment_at_runtime() {
3333        // Defense-in-depth: even before the chain-verify pass runs, approving a
3334        // transaction whose stored commitment was tampered must fail closed via
3335        // the constant-time check in `approve_transaction`, not issue a receipt.
3336        let dir = tempdir().unwrap();
3337        let store = test_store(dir.path().join("tx.db"));
3338        let tx = store.record(queued_transaction()).unwrap();
3339
3340        store
3341            .connection()
3342            .unwrap()
3343            .execute(
3344                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3345                params![tx.transaction_id],
3346            )
3347            .unwrap();
3348
3349        let err = store
3350            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3351            .expect_err("a forged commitment must be rejected, not approved");
3352        assert!(
3353            matches!(err, TransactionStoreError::DatabaseInvariant(_)),
3354            "forged commitment must surface as a DatabaseInvariant, got {err:?}"
3355        );
3356    }
3357
3358    #[test]
3359    fn stale_iso_timestamp_cannot_be_approved() {
3360        let dir = tempdir().unwrap();
3361        let store = test_store(dir.path().join("tx.db"));
3362        let tx = store.record(queued_transaction()).unwrap();
3363        let conn = store.connection().unwrap();
3364        conn.execute(
3365            "UPDATE transactions \
3366             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3367             WHERE transaction_id = ?1",
3368            params![tx.transaction_id],
3369        )
3370        .unwrap();
3371
3372        assert!(
3373            store
3374                .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3375                .unwrap()
3376                .is_none(),
3377            "a production-format timestamp outside the TTL must not be approved"
3378        );
3379    }
3380
3381    #[test]
3382    fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
3383        // The TTL is enforced at *execute* (claim) time too, not only at approve
3384        // time. An approval that ages past the 15-minute window between approve
3385        // and execute must not be claimable — the predicate is duplicated at
3386        // both sites, so both need coverage.
3387        let dir = tempdir().unwrap();
3388        let store = test_store(dir.path().join("tx.db"));
3389        let tx = store.record(queued_transaction()).unwrap();
3390        let receipt = store
3391            .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3392            .unwrap()
3393            .expect("a fresh approval succeeds");
3394        let digest = audit_chain::approval_receipt_digest(&receipt);
3395
3396        // Age the row past the TTL window *after* the approval was issued.
3397        let conn = store.connection().unwrap();
3398        conn.execute(
3399            "UPDATE transactions \
3400             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3401             WHERE transaction_id = ?1",
3402            params![tx.transaction_id],
3403        )
3404        .unwrap();
3405
3406        assert!(
3407            !store
3408                .claim_approved_for_execution(
3409                    &tx.transaction_id,
3410                    &digest,
3411                    CallerPrincipal::Uid(1000)
3412                )
3413                .unwrap(),
3414            "an approval aged past the TTL must not be claimable at execute time"
3415        );
3416        assert_eq!(
3417            store.get(&tx.transaction_id).unwrap().unwrap().status,
3418            JobState::Queued,
3419            "a TTL-expired claim must leave the transaction Queued, never Running"
3420        );
3421    }
3422
3423    #[test]
3424    fn cleanup_stale_queued_cancels_old_records() {
3425        let dir = tempdir().unwrap();
3426        let store = test_store(dir.path().join("tx.db"));
3427
3428        // Create two transactions: one fresh, one stale.
3429        let fresh = store.record(queued_transaction()).unwrap();
3430        let stale = store.record(queued_transaction()).unwrap();
3431
3432        // Backdate the stale one.
3433        let conn = store.connection().unwrap();
3434        conn.execute(
3435            "UPDATE transactions \
3436             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3437             WHERE transaction_id = ?1",
3438            params![stale.transaction_id],
3439        )
3440        .unwrap();
3441
3442        let canceled = store.cleanup_stale_queued().unwrap();
3443        assert_eq!(canceled, 1, "only the stale record should be canceled");
3444
3445        // The stale record should now be Canceled.
3446        let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
3447        assert_eq!(stale_record.status, JobState::Canceled);
3448
3449        // The fresh record should still be Queued.
3450        let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
3451        assert_eq!(fresh_record.status, JobState::Queued);
3452    }
3453
3454    #[test]
3455    fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
3456        let dir = tempdir().unwrap();
3457        let store = test_store(dir.path().join("tx.db"));
3458        let transactions: Vec<_> = (0..3)
3459            .map(|_| {
3460                let transaction = store.record(queued_transaction()).unwrap();
3461                store
3462                    .approve_transaction(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3463                    .unwrap()
3464                    .expect("stale transaction is approvable");
3465                transaction
3466            })
3467            .collect();
3468
3469        let conn = store.connection().unwrap();
3470        for transaction in &transactions {
3471            conn.execute(
3472                "UPDATE transactions \
3473                 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3474                 WHERE transaction_id = ?1",
3475                params![transaction.transaction_id],
3476            )
3477            .unwrap();
3478        }
3479
3480        assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
3481        for transaction in &transactions {
3482            assert_eq!(
3483                store
3484                    .get(&transaction.transaction_id)
3485                    .unwrap()
3486                    .unwrap()
3487                    .status,
3488                JobState::Canceled
3489            );
3490            assert!(!store
3491                .revoke_unconsumed_approval(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3492                .unwrap());
3493        }
3494        assert_eq!(
3495            store
3496                .fetch_event_rows()
3497                .unwrap()
3498                .iter()
3499                .map(|event| event.kind.as_str())
3500                .collect::<Vec<_>>(),
3501            vec![
3502                "approval_granted",
3503                "approval_granted",
3504                "approval_granted",
3505                "approval_revoked",
3506                "status_canceled",
3507                "approval_revoked",
3508                "status_canceled",
3509                "approval_revoked",
3510                "status_canceled"
3511            ]
3512        );
3513    }
3514
3515    // ── State-machine validation tests ──────────────────────────────────────
3516
3517    #[test]
3518    fn update_status_rejects_queued_to_succeeded() {
3519        let dir = tempdir().unwrap();
3520        let store = test_store(dir.path().join("tx.db"));
3521        let tx = store.record(queued_transaction()).unwrap();
3522
3523        let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
3524        assert!(
3525            matches!(
3526                result,
3527                Err(TransactionStoreError::InvalidTransition {
3528                    from: JobState::Queued,
3529                    to: JobState::Succeeded,
3530                })
3531            ),
3532            "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
3533        );
3534    }
3535
3536    #[test]
3537    fn update_status_rejects_succeeded_to_running() {
3538        let dir = tempdir().unwrap();
3539        let store = test_store(dir.path().join("tx.db"));
3540        let tx = store.record(queued_transaction()).unwrap();
3541
3542        store
3543            .update_status(&tx.transaction_id, JobState::Running)
3544            .unwrap();
3545        store
3546            .update_status(&tx.transaction_id, JobState::Succeeded)
3547            .unwrap();
3548
3549        let result = store.update_status(&tx.transaction_id, JobState::Running);
3550        assert!(
3551            matches!(
3552                result,
3553                Err(TransactionStoreError::InvalidTransition {
3554                    from: JobState::Succeeded,
3555                    to: JobState::Running,
3556                })
3557            ),
3558            "Succeeded -> Running must be rejected (terminal state): {result:?}"
3559        );
3560    }
3561
3562    #[test]
3563    fn update_status_accepts_running_to_failed() {
3564        let dir = tempdir().unwrap();
3565        let store = test_store(dir.path().join("tx.db"));
3566        let tx = store.record(queued_transaction()).unwrap();
3567
3568        store
3569            .update_status(&tx.transaction_id, JobState::Running)
3570            .unwrap();
3571        store
3572            .update_status(&tx.transaction_id, JobState::Failed)
3573            .unwrap();
3574
3575        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3576        assert_eq!(updated.status, JobState::Failed);
3577    }
3578
3579    #[test]
3580    fn update_status_accepts_running_to_rolled_back() {
3581        let dir = tempdir().unwrap();
3582        let store = test_store(dir.path().join("tx.db"));
3583        let tx = store.record(queued_transaction()).unwrap();
3584
3585        store
3586            .update_status(&tx.transaction_id, JobState::Running)
3587            .unwrap();
3588        store
3589            .update_status(&tx.transaction_id, JobState::RolledBack)
3590            .unwrap();
3591
3592        let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3593        assert_eq!(updated.status, JobState::RolledBack);
3594    }
3595
3596    // ── list_transactions tests ───────────────────────────────────────────
3597
3598    #[test]
3599    fn list_transactions_returns_empty_for_fresh_store() {
3600        let dir = tempdir().unwrap();
3601        let store = test_store(dir.path().join("tx.db"));
3602        let results = store.list_transactions(10, None, None, None).unwrap();
3603        assert!(results.is_empty());
3604    }
3605
3606    #[test]
3607    fn list_transactions_returns_all_records_ordered_by_newest_first() {
3608        let dir = tempdir().unwrap();
3609        let store = test_store(dir.path().join("tx.db"));
3610        store.record(queued_transaction()).unwrap();
3611
3612        let mut second = queued_transaction();
3613        second.action_name = "GetDiskUsage".to_string();
3614        second.risk_level = RiskLevel::Low;
3615        store.record(second).unwrap();
3616
3617        let results = store.list_transactions(10, None, None, None).unwrap();
3618        assert_eq!(results.len(), 2);
3619        // Most recent first (GetDiskUsage was recorded second).
3620        assert_eq!(results[0].action_name, "GetDiskUsage");
3621        assert_eq!(results[1].action_name, "UpdateSystem");
3622    }
3623
3624    #[test]
3625    fn list_history_populates_created_at_and_risk_level() {
3626        let dir = tempdir().unwrap();
3627        let store = test_store(dir.path().join("tx.db"));
3628        store.record(queued_transaction()).unwrap();
3629
3630        let entries = store.list_history(10, None, None, None).unwrap();
3631        assert_eq!(entries.len(), 1);
3632        let entry = &entries[0];
3633        assert_eq!(entry.action_name, "UpdateSystem");
3634        assert_eq!(entry.risk_level, RiskLevel::High);
3635        assert_eq!(entry.status, JobState::Queued);
3636        assert!(
3637            !entry.created_at.is_empty(),
3638            "created_at must be populated from the stored row, not left blank"
3639        );
3640    }
3641
3642    #[test]
3643    fn list_history_applies_the_same_filters_as_list_transactions() {
3644        let dir = tempdir().unwrap();
3645        let store = test_store(dir.path().join("tx.db"));
3646        store.record(queued_transaction()).unwrap();
3647        let mut low = queued_transaction();
3648        low.action_name = "GetDiskUsage".to_string();
3649        low.risk_level = RiskLevel::Low;
3650        store.record(low).unwrap();
3651
3652        let only = store
3653            .list_history(10, None, Some("GetDiskUsage"), None)
3654            .unwrap();
3655        assert_eq!(only.len(), 1);
3656        assert_eq!(only[0].action_name, "GetDiskUsage");
3657    }
3658
3659    #[test]
3660    fn list_transactions_respects_limit() {
3661        let dir = tempdir().unwrap();
3662        let store = test_store(dir.path().join("tx.db"));
3663        for _ in 0..5 {
3664            store.record(queued_transaction()).unwrap();
3665        }
3666        let results = store.list_transactions(3, None, None, None).unwrap();
3667        assert_eq!(results.len(), 3);
3668    }
3669
3670    #[test]
3671    fn list_transactions_filters_by_status() {
3672        let dir = tempdir().unwrap();
3673        let store = test_store(dir.path().join("tx.db"));
3674        let tx = store.record(queued_transaction()).unwrap();
3675        store
3676            .update_status(&tx.transaction_id, JobState::Running)
3677            .unwrap();
3678        store
3679            .update_status(&tx.transaction_id, JobState::Succeeded)
3680            .unwrap();
3681
3682        // Add another that stays Queued.
3683        store.record(queued_transaction()).unwrap();
3684
3685        let succeeded = store
3686            .list_transactions(10, Some("succeeded"), None, None)
3687            .unwrap();
3688        assert_eq!(succeeded.len(), 1);
3689        assert_eq!(succeeded[0].status, JobState::Succeeded);
3690
3691        let queued = store
3692            .list_transactions(10, Some("queued"), None, None)
3693            .unwrap();
3694        assert_eq!(queued.len(), 1);
3695        assert_eq!(queued[0].status, JobState::Queued);
3696    }
3697
3698    #[test]
3699    fn list_transactions_filters_by_action_name() {
3700        let dir = tempdir().unwrap();
3701        let store = test_store(dir.path().join("tx.db"));
3702        store.record(queued_transaction()).unwrap(); // UpdateSystem
3703
3704        let mut disk = queued_transaction();
3705        disk.action_name = "GetDiskUsage".to_string();
3706        store.record(disk).unwrap();
3707
3708        let results = store
3709            .list_transactions(10, None, Some("GetDiskUsage"), None)
3710            .unwrap();
3711        assert_eq!(results.len(), 1);
3712        assert_eq!(results[0].action_name, "GetDiskUsage");
3713    }
3714
3715    #[test]
3716    fn list_transactions_filters_by_since_hours() {
3717        let dir = tempdir().unwrap();
3718        let store = test_store(dir.path().join("tx.db"));
3719
3720        // Record a transaction and backdate it to 48 hours ago.
3721        let old = store.record(queued_transaction()).unwrap();
3722        let conn = store.connection().unwrap();
3723        conn.execute(
3724            "UPDATE transactions \
3725             SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
3726             WHERE transaction_id = ?1",
3727            params![old.transaction_id],
3728        )
3729        .unwrap();
3730
3731        // Record a fresh transaction.
3732        store.record(queued_transaction()).unwrap();
3733
3734        // since_hours=24 should only return the fresh one.
3735        let results = store.list_transactions(10, None, None, Some(24)).unwrap();
3736        assert_eq!(results.len(), 1);
3737
3738        // since_hours=72 should return both.
3739        let results = store.list_transactions(10, None, None, Some(72)).unwrap();
3740        assert_eq!(results.len(), 2);
3741    }
3742
3743    #[test]
3744    fn list_transactions_rejects_invalid_status_filter() {
3745        let dir = tempdir().unwrap();
3746        let store = test_store(dir.path().join("tx.db"));
3747        store.record(queued_transaction()).unwrap();
3748        let result = store.list_transactions(10, Some("bogus"), None, None);
3749        assert!(result.is_err(), "invalid status filter should return error");
3750    }
3751
3752    // ── Audit watermark sink tests ────────────────────────────────────────
3753    //
3754    // Each test below installs a `WatermarkSink` via `install_test_sink`.
3755    // `cargo nextest` runs every test in its own process, so the `OnceLock`
3756    // that backs the sink is always unset at the start of each test.
3757
3758    /// W1 — `record()` emits exactly one watermark per chain entry.
3759    #[test]
3760    fn record_emits_one_watermark_per_entry() {
3761        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3762        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3763
3764        let dir = tempdir().unwrap();
3765        let store = test_store(dir.path().join("tx.db"));
3766        store.record(queued_transaction()).unwrap();
3767
3768        let calls = crate::audit_watermark::take_watermarks(&sink);
3769        assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3770    }
3771
3772    /// W2 — `record_previewed()` emits exactly one watermark.
3773    #[test]
3774    fn record_previewed_emits_one_watermark() {
3775        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3776        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3777
3778        let dir = tempdir().unwrap();
3779        let store = test_store(dir.path().join("tx.db"));
3780        let preview = PreviewEnvelope {
3781            summary: "Upgrade the system".to_string(),
3782            risk_level: RiskLevel::High,
3783            current_state: serde_json::Value::Null,
3784            proposed_change: serde_json::Value::Null,
3785            expected_side_effects: vec![],
3786            reboot_required: false,
3787            rollback_available: false,
3788            warnings: vec![],
3789            request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3790        };
3791        store
3792            .record_previewed(queued_transaction(), preview)
3793            .unwrap();
3794
3795        let calls = crate::audit_watermark::take_watermarks(&sink);
3796        assert_eq!(
3797            calls.len(),
3798            1,
3799            "expected exactly 1 watermark per record_previewed()"
3800        );
3801    }
3802
3803    /// W3 — watermark seq and chain_hash_hex match the stored chain row.
3804    #[test]
3805    fn watermark_seq_and_hash_match_chain_row() {
3806        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3807        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3808
3809        let dir = tempdir().unwrap();
3810        let store = test_store(dir.path().join("tx.db"));
3811        store.record(queued_transaction()).unwrap();
3812
3813        let rows = store.fetch_chain_rows().unwrap();
3814        assert_eq!(rows.len(), 1);
3815        let row = &rows[0];
3816
3817        let calls = crate::audit_watermark::take_watermarks(&sink);
3818        assert_eq!(calls.len(), 1);
3819        assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3820        assert_eq!(
3821            calls[0].chain_hash_hex, row.chain_hash,
3822            "watermark chain_hash_hex must match stored chain_hash"
3823        );
3824    }
3825
3826    /// W4 — N records produce N watermarks, one per entry, in seq order.
3827    #[test]
3828    fn multiple_records_produce_one_watermark_each() {
3829        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3830        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3831
3832        let dir = tempdir().unwrap();
3833        let store = test_store(dir.path().join("tx.db"));
3834        for _ in 0..3 {
3835            store.record(queued_transaction()).unwrap();
3836        }
3837
3838        let calls = crate::audit_watermark::take_watermarks(&sink);
3839        assert_eq!(calls.len(), 3, "one watermark per record call");
3840        assert_eq!(calls[0].seq, 1);
3841        assert_eq!(calls[1].seq, 2);
3842        assert_eq!(calls[2].seq, 3);
3843    }
3844
3845    /// W5 — a failed SQL INSERT (unique-constraint violation via a crafted
3846    /// duplicate seq) must NOT emit a watermark, because the row was never
3847    /// committed to the chain.
3848    ///
3849    /// We simulate this by calling `insert_transaction` directly on an already-
3850    /// committed connection with duplicate seq. In practice this cannot happen
3851    /// through the public API (BEGIN IMMEDIATE + seq allocation inside the same
3852    /// DB transaction prevents races), but the unit test validates the ordering
3853    /// invariant: the watermark is emitted AFTER `tx.commit()` succeeds, so a
3854    /// rolled-back transaction emits nothing.
3855    ///
3856    /// Strategy: install the sink, then verify that a store that has never had
3857    /// `record()` called on it emits zero watermarks.
3858    #[test]
3859    fn no_watermark_emitted_before_any_record() {
3860        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3861        crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3862
3863        let dir = tempdir().unwrap();
3864        let _store = test_store(dir.path().join("tx.db"));
3865
3866        // No record() called — sink must be empty.
3867        let calls = crate::audit_watermark::take_watermarks(&sink);
3868        assert!(
3869            calls.is_empty(),
3870            "no watermark must be emitted without a record() call"
3871        );
3872    }
3873}