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