Skip to main content

sysknife_daemon/
transactions.rs

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