Skip to main content

sysknife_daemon/store/
postgres.rs

1//! Postgres backend for the audit log.
2//!
3//! Wire-compatible with AWS RDS / Aurora, GCP Cloud SQL + AlloyDB, Azure
4//! Database for PostgreSQL Flexible Server, Supabase (direct + pooler),
5//! Neon (direct + pooler), and self-hosted Postgres.
6//!
7//! ## Connection lifecycle
8//!
9//! [`PostgresStore::connect`] takes a [`PostgresConfig`] (URL plus pool +
10//! statement-cache tuning) and returns a configured `sqlx::PgPool`. The
11//! same pool is used for every request — sqlx handles connection reuse,
12//! retry on broken connections, and TLS via rustls.
13//!
14//! On connect, `PostgresStore::initialize` runs ordered, transactional schema
15//! migrations under a database advisory lock. The schema mirrors SQLite
16//! field-for-field; the only dialect
17//! difference is `BIGINT NOT NULL UNIQUE` for `seq`. `created_at` remains
18//! `TEXT` (RFC 3339 with `Z` suffix) in both backends and is cast to
19//! `timestamptz` for interval arithmetic.
20//!
21//! ## Concurrency
22//!
23//! Like the SQLite path, the chain-hash + seq computation is wrapped in a
24//! `BEGIN ... COMMIT` block. Postgres serialises this naturally via
25//! row-level locks on the `seq` write; we use a `SELECT … FOR UPDATE` on
26//! the most-recent row so concurrent writers see the same predecessor and
27//! the second commit fails atomically rather than corrupting the chain.
28
29use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33use std::time::Duration;
34use subtle::ConstantTimeEq;
35use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
36use uuid::Uuid;
37
38use crate::audit_chain::{
39    AuditEventKind, AuditKey, AuditVerification, ChainContent, ChainIdentity, ChainRow,
40    EventContent, EventRow, VerifyOutcome, CURRENT_KEY_ID,
41};
42use crate::audit_watermark::emit_chain_tip_watermark;
43use crate::store::AuditStore;
44use crate::transactions::{
45    NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
46    APPROVAL_RECEIPT_TTL_MINUTES,
47};
48
49const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
50
51/// Column list every `ChainRow` read shares, kept next to `row_to_chain_row`.
52///
53/// The two read paths each spelled the list out. Adding the caller-identity
54/// columns updated the mapper and one of the two queries, and the miss showed
55/// up only as a runtime "no column found for name: chain_version" from the
56/// live-Postgres test — the unit tests, which never touch this SQL, stayed
57/// green. Mirrors `CHAIN_ROW_COLUMNS` in `transactions.rs`.
58const CHAIN_ROW_COLUMNS: &str = "seq, key_id, transaction_id, request_id, request_hash, \
59     action_name, risk_level, summary, approval_id, warnings_json, \
60     created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
61     caller_principal";
62
63struct Migration {
64    version: i64,
65    name: &'static str,
66    statements: &'static [&'static str],
67}
68
69const MIGRATIONS: &[Migration] = &[Migration {
70    version: 1,
71    name: "initial_audit_schema",
72    statements: &[
73        r#"
74        CREATE TABLE IF NOT EXISTS transactions (
75            transaction_id TEXT PRIMARY KEY,
76            request_id TEXT NOT NULL,
77            request_hash TEXT NOT NULL,
78            action_name TEXT NOT NULL,
79            risk_level TEXT NOT NULL,
80            status TEXT NOT NULL,
81            approval_id TEXT,
82            summary TEXT NOT NULL,
83            warnings_json TEXT NOT NULL,
84            created_at TEXT NOT NULL,
85            seq BIGINT NOT NULL UNIQUE,
86            key_id TEXT NOT NULL,
87            chain_hash TEXT NOT NULL,
88            prev_chain_hash TEXT NOT NULL DEFAULT ''
89        )
90        "#,
91        r#"
92        CREATE TABLE IF NOT EXISTS transaction_approvals (
93            transaction_id TEXT PRIMARY KEY,
94            receipt_digest TEXT NOT NULL,
95            approved_at TEXT NOT NULL,
96            consumed_at TEXT
97        )
98        "#,
99        r#"
100        CREATE TABLE IF NOT EXISTS transaction_previews (
101            transaction_id TEXT PRIMARY KEY,
102            preview_json TEXT NOT NULL
103        )
104        "#,
105        "CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
106    ],
107},
108    // Mirrors SQLITE_MIGRATIONS v2 in `transactions.rs`. The columns are
109    // nullable with a legacy default because rows written by an earlier binary
110    // were signed over an encoding without them — see `ChainIdentity`.
111    Migration {
112        version: 2,
113        name: "caller_identity_and_approval_events",
114        statements: &[
115            "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
116            "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_role TEXT",
117            "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS event_tip TEXT",
118            r#"
119            CREATE TABLE IF NOT EXISTS audit_events (
120                seq BIGINT PRIMARY KEY,
121                key_id TEXT NOT NULL,
122                kind TEXT NOT NULL,
123                transaction_id TEXT NOT NULL,
124                receipt_digest TEXT NOT NULL,
125                created_at TEXT NOT NULL,
126                chain_hash TEXT NOT NULL,
127                prev_chain_hash TEXT NOT NULL DEFAULT ''
128            )
129            "#,
130            "CREATE INDEX IF NOT EXISTS audit_events_transaction_idx ON audit_events(transaction_id)",
131        ],
132    },
133    // Mirrors SQLite migration 3. Nullable for the same reason: rows written
134    // before this step were signed over an encoding with no principal, so any
135    // backfill would rewrite their message and report the chain as Broken.
136    Migration {
137        version: 3,
138        name: "caller_principal",
139        statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"],
140    },
141];
142
143/// Configuration for the Postgres backend. Built by `main.rs` from
144/// `[storage]` in `config.toml`.
145#[derive(Clone, Debug)]
146pub struct PostgresConfig {
147    /// `postgres://...` URL. SSL mode and other knobs are encoded here.
148    pub url: String,
149    /// Maximum pool size. 8 is comfortable for a single SysKnife daemon;
150    /// raise if multiple shells run concurrently.
151    pub max_connections: u32,
152    /// `acquire_timeout` for getting a connection from the pool. Raise above
153    /// the default 5s if you hit Neon cold starts (~600 ms first call).
154    pub acquire_timeout: Duration,
155    /// Set to 0 for transaction-mode PgBouncer (including some hosted
156    /// poolers), which cannot use sqlx's per-connection prepared-statement
157    /// cache. Default 100 covers direct Postgres connections.
158    pub statement_cache_capacity: usize,
159}
160
161impl Default for PostgresConfig {
162    fn default() -> Self {
163        Self {
164            url: String::new(),
165            max_connections: 8,
166            acquire_timeout: Duration::from_secs(10),
167            statement_cache_capacity: 100,
168        }
169    }
170}
171
172/// Postgres-backed [`AuditStore`].
173#[derive(Debug, Clone)]
174pub struct PostgresStore {
175    pool: PgPool,
176    audit_key: std::sync::Arc<AuditKey>,
177}
178
179struct InsertedTransaction {
180    record: TransactionRecord,
181    seq: u64,
182    chain_hash: String,
183}
184
185impl PostgresStore {
186    async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
187        let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
188            TransactionStoreError::Io(std::io::Error::new(
189                std::io::ErrorKind::InvalidInput,
190                format!("invalid postgres URL: {e}"),
191            ))
192        })?;
193        connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
194        PgPoolOptions::new()
195            .max_connections(config.max_connections)
196            .acquire_timeout(config.acquire_timeout)
197            .connect_with(connect_opts)
198            .await
199            .map_err(map_sqlx_err)
200    }
201
202    /// Connect to the configured Postgres database, run the migration,
203    /// and bind an audit key for chain computation.
204    pub async fn connect(
205        config: &PostgresConfig,
206        audit_key: std::sync::Arc<AuditKey>,
207    ) -> Result<Self, TransactionStoreError> {
208        let pool = Self::connect_pool(config).await?;
209
210        let store = Self { pool, audit_key };
211        store.initialize().await?;
212        Ok(store)
213    }
214
215    /// Verify an existing Postgres chain — transaction chain, approval-event
216    /// chain, and the binding between them — with only the exported public
217    /// key. This path performs no migrations and never loads the signing key.
218    pub async fn verify_all_with_pubkey(
219        config: &PostgresConfig,
220        verifying_key_hex: &str,
221    ) -> Result<AuditVerification, TransactionStoreError> {
222        let pool = Self::connect_pool(config).await?;
223        let tx_rows = fetch_chain_rows_from_pool(&pool).await?;
224        // A chain written before the migration has no `audit_events` table.
225        // That is an absent feature, not a missing verification: report zero
226        // events rather than failing the whole verify.
227        let event_rows = fetch_event_rows_from_pool(&pool).await.unwrap_or_default();
228        Ok(crate::audit_chain::verify_all_with_pubkey(
229            verifying_key_hex,
230            &tx_rows,
231            &event_rows,
232        ))
233    }
234
235    /// All three checks with the daemon's key.
236    pub async fn verify_all(
237        &self,
238        key: &AuditKey,
239    ) -> Result<AuditVerification, TransactionStoreError> {
240        let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
241        let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
242        Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
243    }
244
245    /// Apply pending schema migrations atomically. The advisory transaction
246    /// lock serializes concurrent daemon starts against the same database.
247    async fn initialize(&self) -> Result<(), TransactionStoreError> {
248        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
249        sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
250            .bind(MIGRATION_LOCK_ID)
251            .execute(&mut *tx)
252            .await
253            .map_err(map_sqlx_err)?;
254
255        sqlx_core::query::query(
256            r#"
257            CREATE TABLE IF NOT EXISTS schema_migrations (
258                version BIGINT PRIMARY KEY,
259                name TEXT NOT NULL,
260                applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
261            )
262            "#,
263        )
264        .execute(&mut *tx)
265        .await
266        .map_err(map_sqlx_err)?;
267
268        let current: i64 = sqlx_core::query_scalar::query_scalar(
269            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
270        )
271        .fetch_one(&mut *tx)
272        .await
273        .map_err(map_sqlx_err)?;
274        let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
275        if current > latest {
276            return Err(TransactionStoreError::DatabaseInvariant(format!(
277                "postgres schema version {current} is newer than this binary supports ({latest})"
278            )));
279        }
280
281        for migration in MIGRATIONS
282            .iter()
283            .filter(|migration| migration.version > current)
284        {
285            // Run statements separately for transaction-mode poolers that
286            // reject multi-statement query strings.
287            for statement in migration.statements {
288                sqlx_core::query::query(*statement)
289                    .execute(&mut *tx)
290                    .await
291                    .map_err(map_sqlx_err)?;
292            }
293            sqlx_core::query::query(
294                "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
295            )
296            .bind(migration.version)
297            .bind(migration.name)
298            .execute(&mut *tx)
299            .await
300            .map_err(map_sqlx_err)?;
301        }
302
303        tx.commit().await.map_err(map_sqlx_err)
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Helpers
309// ---------------------------------------------------------------------------
310
311fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
312    let message = format!("postgres: {e}");
313    let invariant = match &e {
314        sqlx_core::Error::Database(db) => db
315            .code()
316            .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
317        sqlx_core::Error::TypeNotFound { .. }
318        | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
319        | sqlx_core::Error::ColumnNotFound(_)
320        | sqlx_core::Error::ColumnDecode { .. }
321        | sqlx_core::Error::Decode(_) => true,
322        _ => false,
323    };
324    if invariant {
325        TransactionStoreError::DatabaseInvariant(message)
326    } else {
327        TransactionStoreError::Io(std::io::Error::other(message))
328    }
329}
330
331fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
332    serde_json::to_string(v).map_err(TransactionStoreError::Json)
333}
334
335fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
336    serde_json::from_str(s).map_err(TransactionStoreError::Json)
337}
338
339/// UTC timestamp in the same RFC 3339 millisecond shape the SQLite backend
340/// produces via `strftime('%Y-%m-%dT%H:%M:%fZ','now')`.
341///
342/// `%S` is load-bearing: chrono's `%.3f` renders only the fractional part, so
343/// omitting `%S` yields `2026-07-27T12:34:.567Z`. That value is signed into
344/// `ChainContent.created_at` on every audit row and cast to `timestamptz` by
345/// approve, claim-for-execution, the stale sweep, and the history filter.
346fn now_iso() -> String {
347    chrono::Utc::now()
348        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
349        .to_string()
350}
351
352// ---------------------------------------------------------------------------
353// AuditStore impl
354// ---------------------------------------------------------------------------
355
356#[async_trait]
357impl AuditStore for PostgresStore {
358    async fn record(
359        &self,
360        transaction: NewTransaction,
361    ) -> Result<TransactionRecord, TransactionStoreError> {
362        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
363        let transaction_id = Uuid::new_v4().to_string();
364        let inserted =
365            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
366        tx.commit().await.map_err(map_sqlx_err)?;
367        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
368        Ok(inserted.record)
369    }
370
371    async fn record_previewed(
372        &self,
373        transaction: NewTransaction,
374        preview: PreviewEnvelope,
375    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
376        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
377        let transaction_id = Uuid::new_v4().to_string();
378        let inserted =
379            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
380
381        sqlx_core::query::query(
382            "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
383        )
384        .bind(&inserted.record.transaction_id)
385        .bind(serialize(&preview)?)
386        .execute(&mut *tx)
387        .await
388        .map_err(map_sqlx_err)?;
389
390        tx.commit().await.map_err(map_sqlx_err)?;
391        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
392        Ok(RecordedPreviewedTransaction {
393            transaction: inserted.record,
394            preview,
395        })
396    }
397
398    async fn get(
399        &self,
400        transaction_id: &str,
401    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
402        let row = sqlx_core::query::query(
403            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
404                    status, approval_id, summary, warnings_json \
405             FROM transactions WHERE transaction_id = $1",
406        )
407        .bind(transaction_id)
408        .fetch_optional(&self.pool)
409        .await
410        .map_err(map_sqlx_err)?;
411        row.map(row_to_record).transpose()
412    }
413
414    async fn get_preview(
415        &self,
416        transaction_id: &str,
417    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
418        let row = sqlx_core::query::query(
419            "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
420        )
421        .bind(transaction_id)
422        .fetch_optional(&self.pool)
423        .await
424        .map_err(map_sqlx_err)?;
425        match row {
426            Some(r) => {
427                let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
428                Ok(Some(deserialize(&s)?))
429            }
430            None => Ok(None),
431        }
432    }
433
434    async fn update_status(
435        &self,
436        transaction_id: &str,
437        new_status: JobState,
438    ) -> Result<(), TransactionStoreError> {
439        // Read-validate-write with a state-machine guard, same shape as the
440        // SQLite path but strictly stronger: `SELECT ... FOR UPDATE` takes a
441        // row lock for the lifetime of this transaction, so no concurrent
442        // writer can change `status` between our read and our `UPDATE` — the
443        // read-modify-write here is atomic by construction. SQLite has no
444        // equivalent row-level lock, so its `update_status` instead uses an
445        // explicit compare-and-swap (`UPDATE ... WHERE status = <observed>`,
446        // checking `rows_affected`) to get the same guarantee.
447        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
448        let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
449            "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
450        )
451        .bind(transaction_id)
452        .fetch_optional(&mut *tx)
453        .await
454        .map_err(map_sqlx_err)?;
455        let current_str = current_str
456            .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
457        let current: JobState = deserialize(&current_str)?;
458        if !crate::jobs::allowed_transition(&current, &new_status) {
459            return Err(TransactionStoreError::InvalidTransition {
460                from: current,
461                to: new_status,
462            });
463        }
464        sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
465            .bind(serialize(&new_status)?)
466            .bind(transaction_id)
467            .execute(&mut *tx)
468            .await
469            .map_err(map_sqlx_err)?;
470        tx.commit().await.map_err(map_sqlx_err)?;
471        Ok(())
472    }
473
474    async fn approve_transaction(
475        &self,
476        transaction_id: &str,
477    ) -> Result<Option<String>, TransactionStoreError> {
478        let row = sqlx_core::query::query(
479            "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
480        )
481        .bind(transaction_id)
482        .fetch_optional(&self.pool)
483        .await
484        .map_err(map_sqlx_err)?;
485        let Some(row) = row else {
486            return Ok(None);
487        };
488        let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
489        let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
490        let receipt = self
491            .audit_key
492            .approval_receipt(transaction_id, &request_hash);
493        let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
494        let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
495            bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
496        });
497        if !commitment_matches {
498            return Err(TransactionStoreError::DatabaseInvariant(format!(
499                "transaction {transaction_id} approval commitment does not match its signed preview"
500            )));
501        }
502        let queued = serialize(&JobState::Queued)?;
503        // Approval row and chained event commit together — an approval absent
504        // from the event chain is exactly what that chain exists to prevent.
505        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
506        let result = sqlx_core::query::query(
507            sqlx_core::sql_str::AssertSqlSafe(format!(
508                "INSERT INTO transaction_approvals \
509                     (transaction_id, receipt_digest, approved_at) \
510                 SELECT transaction_id, $1, $2 FROM transactions \
511                 WHERE transaction_id = $3 \
512                   AND status = $4 \
513                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
514                 ON CONFLICT (transaction_id) DO NOTHING"
515            )),
516        )
517        .bind(&receipt_digest)
518        .bind(now_iso())
519        .bind(transaction_id)
520        .bind(&queued)
521        .execute(&mut *tx)
522        .await
523        .map_err(map_sqlx_err)?;
524        if result.rows_affected() > 0 {
525            append_event(
526                &mut tx,
527                &self.audit_key,
528                AuditEventKind::ApprovalGranted,
529                transaction_id,
530                &receipt_digest,
531            )
532            .await?;
533        }
534        tx.commit().await.map_err(map_sqlx_err)?;
535        Ok((result.rows_affected() > 0).then_some(receipt))
536    }
537
538    async fn revoke_unconsumed_approval(
539        &self,
540        transaction_id: &str,
541    ) -> Result<bool, TransactionStoreError> {
542        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
543        // Read the digest before the DELETE: the event names which receipt was
544        // retracted, and after the delete there is nothing left to name.
545        let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
546            "SELECT receipt_digest FROM transaction_approvals \
547             WHERE transaction_id = $1 AND consumed_at IS NULL",
548        )
549        .bind(transaction_id)
550        .fetch_optional(&mut *tx)
551        .await
552        .map_err(map_sqlx_err)?;
553        let result = sqlx_core::query::query(
554            "DELETE FROM transaction_approvals \
555             WHERE transaction_id = $1 AND consumed_at IS NULL",
556        )
557        .bind(transaction_id)
558        .execute(&mut *tx)
559        .await
560        .map_err(map_sqlx_err)?;
561        if result.rows_affected() > 0 {
562            let digest = digest.ok_or_else(|| {
563                TransactionStoreError::DatabaseInvariant(format!(
564                    "revoked an approval for {transaction_id} that had no receipt digest"
565                ))
566            })?;
567            append_event(
568                &mut tx,
569                &self.audit_key,
570                AuditEventKind::ApprovalRevoked,
571                transaction_id,
572                &digest,
573            )
574            .await?;
575        }
576        tx.commit().await.map_err(map_sqlx_err)?;
577        Ok(result.rows_affected() > 0)
578    }
579
580    async fn claim_approved_for_execution(
581        &self,
582        transaction_id: &str,
583        receipt_digest: &str,
584    ) -> Result<bool, TransactionStoreError> {
585        let queued = serialize(&JobState::Queued)?;
586        let running = serialize(&JobState::Running)?;
587        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
588        let result = sqlx_core::query::query(
589            sqlx_core::sql_str::AssertSqlSafe(format!(
590                "UPDATE transactions SET status = $1 \
591                 WHERE transaction_id = $2 \
592                   AND status = $3 \
593                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
594                   AND EXISTS ( \
595                       SELECT 1 FROM transaction_approvals \
596                       WHERE transaction_id = $2 \
597                         AND receipt_digest = $4 \
598                         AND consumed_at IS NULL \
599                   )"
600            )),
601        )
602        .bind(&running)
603        .bind(transaction_id)
604        .bind(&queued)
605        .bind(receipt_digest)
606        .execute(&mut *tx)
607        .await
608        .map_err(map_sqlx_err)?;
609        if result.rows_affected() > 0 {
610            sqlx_core::query::query(
611                "UPDATE transaction_approvals SET consumed_at = $1 \
612                 WHERE transaction_id = $2 AND consumed_at IS NULL",
613            )
614            .bind(now_iso())
615            .bind(transaction_id)
616            .execute(&mut *tx)
617            .await
618            .map_err(map_sqlx_err)?;
619            append_event(
620                &mut tx,
621                &self.audit_key,
622                AuditEventKind::ApprovalConsumed,
623                transaction_id,
624                receipt_digest,
625            )
626            .await?;
627        }
628        tx.commit().await.map_err(map_sqlx_err)?;
629        Ok(result.rows_affected() > 0)
630    }
631
632    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
633        let queued = serialize(&JobState::Queued)?;
634        let canceled = serialize(&JobState::Canceled)?;
635        let result = sqlx_core::query::query(
636            sqlx_core::sql_str::AssertSqlSafe(format!(
637                "UPDATE transactions SET status = $1 \
638                 WHERE status = $2 \
639                   AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
640            )),
641        )
642        .bind(&canceled)
643        .bind(&queued)
644        .execute(&self.pool)
645        .await
646        .map_err(map_sqlx_err)?;
647        Ok(result.rows_affected())
648    }
649
650    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
651        // Option A: the `status = $3` (Queued) guard means a Running (in-flight)
652        // transaction is never cancelled.
653        let queued = serialize(&JobState::Queued)?;
654        let canceled = serialize(&JobState::Canceled)?;
655        let result = sqlx_core::query::query(
656            "UPDATE transactions SET status = $1 \
657             WHERE transaction_id = $2 AND status = $3",
658        )
659        .bind(&canceled)
660        .bind(transaction_id)
661        .bind(&queued)
662        .execute(&self.pool)
663        .await
664        .map_err(map_sqlx_err)?;
665        Ok(result.rows_affected() > 0)
666    }
667
668    async fn list_transactions(
669        &self,
670        limit: u32,
671        status_filter: Option<&str>,
672        action_filter: Option<&str>,
673        since_hours: Option<u32>,
674    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
675        let limit = limit.min(100) as i64;
676
677        // Validate the status filter against the JobState enum so a typo
678        // produces an error instead of silent empty results.
679        let validated_status: Option<String> = status_filter
680            .map(|s| -> Result<String, TransactionStoreError> {
681                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
682                serialize(&parsed)
683            })
684            .transpose()?;
685
686        let mut sql = String::from(
687            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
688                    status, approval_id, summary, warnings_json \
689             FROM transactions WHERE TRUE",
690        );
691        let mut idx = 1;
692        if validated_status.is_some() {
693            sql.push_str(&format!(" AND status = ${idx}"));
694            idx += 1;
695        }
696        if action_filter.is_some() {
697            sql.push_str(&format!(" AND action_name = ${idx}"));
698            idx += 1;
699        }
700        if since_hours.is_some() {
701            sql.push_str(&format!(
702                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
703            ));
704            idx += 1;
705        }
706        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
707
708        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
709        if let Some(s) = &validated_status {
710            q = q.bind(s);
711        }
712        if let Some(a) = action_filter {
713            q = q.bind(a);
714        }
715        if let Some(h) = since_hours {
716            q = q.bind(h.to_string());
717        }
718        q = q.bind(limit);
719
720        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
721        rows.into_iter().map(row_to_record).collect()
722    }
723
724    async fn list_history(
725        &self,
726        limit: u32,
727        status_filter: Option<&str>,
728        action_filter: Option<&str>,
729        since_hours: Option<u32>,
730    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
731        // Parallels list_transactions but selects created_at and returns the
732        // structured JobHistoryEntry DTO. Same filter/validation semantics.
733        let limit = limit.min(100) as i64;
734        let validated_status: Option<String> = status_filter
735            .map(|s| -> Result<String, TransactionStoreError> {
736                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
737                serialize(&parsed)
738            })
739            .transpose()?;
740
741        let mut sql = String::from(
742            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
743             FROM transactions WHERE TRUE",
744        );
745        let mut idx = 1;
746        if validated_status.is_some() {
747            sql.push_str(&format!(" AND status = ${idx}"));
748            idx += 1;
749        }
750        if action_filter.is_some() {
751            sql.push_str(&format!(" AND action_name = ${idx}"));
752            idx += 1;
753        }
754        if since_hours.is_some() {
755            sql.push_str(&format!(
756                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
757            ));
758            idx += 1;
759        }
760        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
761
762        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
763        if let Some(s) = &validated_status {
764            q = q.bind(s);
765        }
766        if let Some(a) = action_filter {
767            q = q.bind(a);
768        }
769        if let Some(h) = since_hours {
770            q = q.bind(h.to_string());
771        }
772        q = q.bind(limit);
773
774        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
775        rows.into_iter().map(row_to_history_entry).collect()
776    }
777
778    async fn fetch_chain_row(
779        &self,
780        transaction_id: &str,
781    ) -> Result<Option<ChainRow>, TransactionStoreError> {
782        let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
783            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
784        )))
785        .bind(transaction_id)
786        .fetch_optional(&self.pool)
787        .await
788        .map_err(map_sqlx_err)?;
789        row.map(row_to_chain_row).transpose()
790    }
791
792    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
793        fetch_event_rows_from_pool(&self.pool).await
794    }
795
796    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
797        fetch_chain_rows_from_pool(&self.pool).await
798    }
799
800    async fn verify_audit_chain(
801        &self,
802        key: &AuditKey,
803    ) -> Result<VerifyOutcome, TransactionStoreError> {
804        let rows = self.fetch_chain_rows().await?;
805        Ok(crate::audit_chain::verify_chain(key, &rows))
806    }
807}
808
809async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
810    let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
811        "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
812    )))
813    .fetch_all(pool)
814    .await
815    .map_err(map_sqlx_err)?;
816    rows.into_iter().map(row_to_chain_row).collect()
817}
818
819/// Fetch every approval event in seq order.
820async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
821    let rows = sqlx_core::query::query(
822        "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
823                created_at, prev_chain_hash, chain_hash \
824         FROM audit_events ORDER BY seq ASC",
825    )
826    .fetch_all(pool)
827    .await
828    .map_err(map_sqlx_err)?;
829    rows.into_iter().map(row_to_event_row).collect()
830}
831
832/// Append one signed approval event inside the caller's transaction.
833///
834/// `FOR UPDATE` on the current tip serialises concurrent appends the same way
835/// `insert_transaction` serialises on the transaction chain tip; without it two
836/// approvals could compute the same `seq` and collide on the primary key.
837async fn append_event(
838    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
839    key: &AuditKey,
840    kind: AuditEventKind,
841    transaction_id: &str,
842    receipt_digest: &str,
843) -> Result<(), TransactionStoreError> {
844    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
845        "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
846    )
847    .fetch_optional(&mut **tx)
848    .await
849    .map_err(map_sqlx_err)?;
850    let (seq, prev_chain_hash) = match prev {
851        Some((s, h)) => ((s as u64) + 1, h),
852        None => (1, String::new()),
853    };
854    let created_at = now_iso();
855    let key_id = CURRENT_KEY_ID.to_string();
856    let chain_hash = key.event_hash(
857        &EventContent {
858            seq,
859            key_id: &key_id,
860            kind,
861            transaction_id,
862            receipt_digest,
863            created_at: &created_at,
864        },
865        &prev_chain_hash,
866    );
867    sqlx_core::query::query(
868        "INSERT INTO audit_events ( \
869            seq, key_id, kind, transaction_id, receipt_digest, \
870            created_at, chain_hash, prev_chain_hash \
871         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
872    )
873    .bind(seq as i64)
874    .bind(&key_id)
875    .bind(kind.as_str())
876    .bind(transaction_id)
877    .bind(receipt_digest)
878    .bind(&created_at)
879    .bind(&chain_hash)
880    .bind(&prev_chain_hash)
881    .execute(&mut **tx)
882    .await
883    .map_err(map_sqlx_err)?;
884    Ok(())
885}
886
887// ---------------------------------------------------------------------------
888// Insert path — chain-aware
889// ---------------------------------------------------------------------------
890
891async fn insert_transaction(
892    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
893    key: &AuditKey,
894    transaction_id: &str,
895    transaction: NewTransaction,
896) -> Result<InsertedTransaction, TransactionStoreError> {
897    let warnings_json = serialize(&transaction.warnings)?;
898    let status = JobState::Queued;
899    let created_at = now_iso();
900    let key_id = CURRENT_KEY_ID.to_string();
901    // Always the store's own commitment — see the matching note in
902    // `transactions.rs`; a caller may not supply this value.
903    let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
904
905    // SELECT … FOR UPDATE on the most-recent row (if any) so concurrent
906    // writers serialise on the chain tip. Without this, two parallel
907    // record() calls could both compute seq=N+1 and then race on the
908    // UNIQUE(seq) constraint — the loser would get a unique-violation
909    // error.
910    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
911        "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
912    )
913    .fetch_optional(&mut **tx)
914    .await
915    .map_err(map_sqlx_err)?;
916    let (seq, prev_chain_hash) = match prev {
917        Some((s, h)) => ((s as u64) + 1, h),
918        None => (1, String::new()),
919    };
920
921    // Bind the row to the approval-event chain tip as of this insert. Read
922    // inside the same DB transaction as the seq allocation above.
923    let event_tip: String = sqlx_core::query_scalar::query_scalar(
924        "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
925    )
926    .fetch_one(&mut **tx)
927    .await
928    .map_err(map_sqlx_err)?;
929    let caller_role = transaction.caller_role.as_str();
930    let caller_principal = transaction.caller_principal.as_signed_str();
931
932    // As in the SQLite path: derive the stored version from the identity that was
933    // signed, so the column cannot disagree with the message.
934    let identity = ChainIdentity::V3 {
935        caller_role,
936        event_tip: &event_tip,
937        caller_principal: &caller_principal,
938    };
939    let chain_hash = key.chain_hash(
940        &ChainContent {
941            seq,
942            key_id: &key_id,
943            transaction_id,
944            request_id: &transaction.request_id,
945            request_hash: &transaction.request_hash,
946            action_name: &transaction.action_name,
947            risk_level: transaction.risk_level,
948            summary: &transaction.summary,
949            approval_id: approval_id.as_deref(),
950            warnings_json: &warnings_json,
951            created_at: &created_at,
952            identity,
953        },
954        &prev_chain_hash,
955    );
956
957    sqlx_core::query::query(
958        "INSERT INTO transactions ( \
959            transaction_id, request_id, request_hash, action_name, risk_level, \
960            status, approval_id, summary, warnings_json, created_at, \
961            seq, key_id, chain_hash, prev_chain_hash, \
962            chain_version, caller_role, event_tip, caller_principal \
963         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
964    )
965    .bind(transaction_id)
966    .bind(&transaction.request_id)
967    .bind(&transaction.request_hash)
968    .bind(&transaction.action_name)
969    .bind(serialize(&transaction.risk_level)?)
970    .bind(serialize(&status)?)
971    .bind(&approval_id)
972    .bind(&transaction.summary)
973    .bind(&warnings_json)
974    .bind(&created_at)
975    .bind(seq as i64)
976    .bind(&key_id)
977    .bind(&chain_hash)
978    .bind(&prev_chain_hash)
979    .bind(i64::from(identity.version()))
980    .bind(caller_role)
981    .bind(&event_tip)
982    .bind(&caller_principal)
983    .execute(&mut **tx)
984    .await
985    .map_err(map_sqlx_err)?;
986
987    Ok(InsertedTransaction {
988        record: TransactionRecord {
989            transaction_id: transaction_id.to_string(),
990            request_id: transaction.request_id,
991            request_hash: transaction.request_hash,
992            action_name: transaction.action_name,
993            risk_level: transaction.risk_level,
994            status,
995            approval_id,
996            summary: transaction.summary,
997            warnings: transaction.warnings,
998        },
999        seq,
1000        chain_hash,
1001    })
1002}
1003
1004// ---------------------------------------------------------------------------
1005// Row mappers
1006// ---------------------------------------------------------------------------
1007
1008fn row_to_history_entry(
1009    row: sqlx_postgres::PgRow,
1010) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1011    Ok(crate::transactions::JobHistoryEntry {
1012        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1013        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1014        risk_level: deserialize(
1015            &row.try_get::<String, _>("risk_level")
1016                .map_err(map_sqlx_err)?,
1017        )?,
1018        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1019        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1020        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1021    })
1022}
1023
1024fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1025    Ok(TransactionRecord {
1026        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1027        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1028        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1029        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1030        risk_level: deserialize(
1031            &row.try_get::<String, _>("risk_level")
1032                .map_err(map_sqlx_err)?,
1033        )?,
1034        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1035        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1036        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1037        warnings: deserialize(
1038            &row.try_get::<String, _>("warnings_json")
1039                .map_err(map_sqlx_err)?,
1040        )?,
1041    })
1042}
1043
1044fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1045    Ok(ChainRow {
1046        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1047        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1048        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1049        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1050        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1051        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1052        risk_level: deserialize(
1053            &row.try_get::<String, _>("risk_level")
1054                .map_err(map_sqlx_err)?,
1055        )?,
1056        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1057        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1058        warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1059        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1060        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1061        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1062        chain_version: row
1063            .try_get::<i64, _>("chain_version")
1064            .map_err(map_sqlx_err)? as u32,
1065        caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1066        event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1067        caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1068    })
1069}
1070
1071fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1072    Ok(EventRow {
1073        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1074        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1075        kind: row.try_get("kind").map_err(map_sqlx_err)?,
1076        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1077        receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1078        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1079        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1080        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1081    })
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087
1088    /// Verifies that PostgresConfig defaults are sensible and don't accidentally
1089    /// disable the statement cache.
1090    #[test]
1091    fn default_config_keeps_statement_cache_enabled() {
1092        let c = PostgresConfig::default();
1093        assert!(c.statement_cache_capacity > 0);
1094        assert!(c.max_connections >= 4);
1095        assert!(c.acquire_timeout >= Duration::from_secs(5));
1096    }
1097
1098    #[test]
1099    fn url_parsing_accepts_standard_postgres_url() {
1100        let opts = PgConnectOptions::from_str(
1101            "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1102        )
1103        .expect("standard URL parses");
1104        // Ensure binding via PgPoolOptions wouldn't reject it (just smoke-check).
1105        let _ = opts.statement_cache_capacity(0);
1106    }
1107
1108    #[test]
1109    fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1110        // `now_iso()` is signed into `ChainContent.created_at` on every
1111        // audit row and is cast to `timestamptz` by approve, claim-for-
1112        // execution, the stale sweep, and the history filter. The format
1113        // string previously omitted `%S`, yielding `2026-07-27T12:34:.567Z`:
1114        // seconds silently vanished, the chain attested to a broken value
1115        // forever, and same-minute ordering became unrecoverable.
1116        let ts = now_iso();
1117        let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1118        assert!(
1119            parsed.is_ok(),
1120            "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1121        );
1122        assert_eq!(
1123            ts.len(),
1124            "2026-07-27T12:34:56.789Z".len(),
1125            "timestamp shape changed unexpectedly: {ts}"
1126        );
1127    }
1128
1129    #[test]
1130    fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1131        // Both backends feed the same signed chain, so a row written by
1132        // Postgres must be byte-comparable with one written by SQLite
1133        // (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`, which yields
1134        // milliseconds). Divergent shapes make cross-backend verification
1135        // and time-ordered queries subtly wrong.
1136        let ts = now_iso();
1137        let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1138        assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1139        assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1140        let time = rest.trim_end_matches('Z');
1141        let fields: Vec<&str> = time.split(':').collect();
1142        assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1143        assert!(
1144            fields[2].contains('.'),
1145            "seconds field must carry fractional milliseconds: {time:?}"
1146        );
1147        let (secs, millis) = fields[2].split_once('.').expect("checked above");
1148        assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1149        assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1150    }
1151
1152    #[test]
1153    fn schema_decode_errors_are_classified_as_invariant_failures() {
1154        let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1155        assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1156    }
1157}