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::{AuditKey, ChainContent, ChainRow, VerifyOutcome, CURRENT_KEY_ID};
39use crate::audit_watermark::emit_chain_tip_watermark;
40use crate::store::AuditStore;
41use crate::transactions::{
42    NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
43    APPROVAL_RECEIPT_TTL_MINUTES,
44};
45
46const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
47
48struct Migration {
49    version: i64,
50    name: &'static str,
51    statements: &'static [&'static str],
52}
53
54const MIGRATIONS: &[Migration] = &[Migration {
55    version: 1,
56    name: "initial_audit_schema",
57    statements: &[
58        r#"
59        CREATE TABLE IF NOT EXISTS transactions (
60            transaction_id TEXT PRIMARY KEY,
61            request_id TEXT NOT NULL,
62            request_hash TEXT NOT NULL,
63            action_name TEXT NOT NULL,
64            risk_level TEXT NOT NULL,
65            status TEXT NOT NULL,
66            approval_id TEXT,
67            summary TEXT NOT NULL,
68            warnings_json TEXT NOT NULL,
69            created_at TEXT NOT NULL,
70            seq BIGINT NOT NULL UNIQUE,
71            key_id TEXT NOT NULL,
72            chain_hash TEXT NOT NULL,
73            prev_chain_hash TEXT NOT NULL DEFAULT ''
74        )
75        "#,
76        r#"
77        CREATE TABLE IF NOT EXISTS transaction_approvals (
78            transaction_id TEXT PRIMARY KEY,
79            receipt_digest TEXT NOT NULL,
80            approved_at TEXT NOT NULL,
81            consumed_at TEXT
82        )
83        "#,
84        r#"
85        CREATE TABLE IF NOT EXISTS transaction_previews (
86            transaction_id TEXT PRIMARY KEY,
87            preview_json TEXT NOT NULL
88        )
89        "#,
90        "CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
91    ],
92}];
93
94/// Configuration for the Postgres backend. Built by `main.rs` from
95/// `[storage]` in `config.toml`.
96#[derive(Clone, Debug)]
97pub struct PostgresConfig {
98    /// `postgres://...` URL. SSL mode and other knobs are encoded here.
99    pub url: String,
100    /// Maximum pool size. 8 is comfortable for a single SysKnife daemon;
101    /// raise if multiple shells run concurrently.
102    pub max_connections: u32,
103    /// `acquire_timeout` for getting a connection from the pool. Raise above
104    /// the default 5s if you hit Neon cold starts (~600 ms first call).
105    pub acquire_timeout: Duration,
106    /// Set to 0 for transaction-mode PgBouncer (including some hosted
107    /// poolers), which cannot use sqlx's per-connection prepared-statement
108    /// cache. Default 100 covers direct Postgres connections.
109    pub statement_cache_capacity: usize,
110}
111
112impl Default for PostgresConfig {
113    fn default() -> Self {
114        Self {
115            url: String::new(),
116            max_connections: 8,
117            acquire_timeout: Duration::from_secs(10),
118            statement_cache_capacity: 100,
119        }
120    }
121}
122
123/// Postgres-backed [`AuditStore`].
124#[derive(Debug, Clone)]
125pub struct PostgresStore {
126    pool: PgPool,
127    audit_key: std::sync::Arc<AuditKey>,
128}
129
130struct InsertedTransaction {
131    record: TransactionRecord,
132    seq: u64,
133    chain_hash: String,
134}
135
136impl PostgresStore {
137    async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
138        let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
139            TransactionStoreError::Io(std::io::Error::new(
140                std::io::ErrorKind::InvalidInput,
141                format!("invalid postgres URL: {e}"),
142            ))
143        })?;
144        connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
145        PgPoolOptions::new()
146            .max_connections(config.max_connections)
147            .acquire_timeout(config.acquire_timeout)
148            .connect_with(connect_opts)
149            .await
150            .map_err(map_sqlx_err)
151    }
152
153    /// Connect to the configured Postgres database, run the migration,
154    /// and bind an audit key for chain computation.
155    pub async fn connect(
156        config: &PostgresConfig,
157        audit_key: std::sync::Arc<AuditKey>,
158    ) -> Result<Self, TransactionStoreError> {
159        let pool = Self::connect_pool(config).await?;
160
161        let store = Self { pool, audit_key };
162        store.initialize().await?;
163        Ok(store)
164    }
165
166    /// Verify an existing Postgres chain with only the exported public key.
167    /// This path performs no migrations and never loads the signing key.
168    pub async fn verify_with_pubkey(
169        config: &PostgresConfig,
170        verifying_key_hex: &str,
171    ) -> Result<VerifyOutcome, TransactionStoreError> {
172        let pool = Self::connect_pool(config).await?;
173        let rows = fetch_chain_rows_from_pool(&pool).await?;
174        Ok(crate::audit_chain::verify_chain_with_pubkey(
175            verifying_key_hex,
176            &rows,
177        ))
178    }
179
180    /// Apply pending schema migrations atomically. The advisory transaction
181    /// lock serializes concurrent daemon starts against the same database.
182    async fn initialize(&self) -> Result<(), TransactionStoreError> {
183        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
184        sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
185            .bind(MIGRATION_LOCK_ID)
186            .execute(&mut *tx)
187            .await
188            .map_err(map_sqlx_err)?;
189
190        sqlx_core::query::query(
191            r#"
192            CREATE TABLE IF NOT EXISTS schema_migrations (
193                version BIGINT PRIMARY KEY,
194                name TEXT NOT NULL,
195                applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
196            )
197            "#,
198        )
199        .execute(&mut *tx)
200        .await
201        .map_err(map_sqlx_err)?;
202
203        let current: i64 = sqlx_core::query_scalar::query_scalar(
204            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
205        )
206        .fetch_one(&mut *tx)
207        .await
208        .map_err(map_sqlx_err)?;
209        let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
210        if current > latest {
211            return Err(TransactionStoreError::DatabaseInvariant(format!(
212                "postgres schema version {current} is newer than this binary supports ({latest})"
213            )));
214        }
215
216        for migration in MIGRATIONS
217            .iter()
218            .filter(|migration| migration.version > current)
219        {
220            // Run statements separately for transaction-mode poolers that
221            // reject multi-statement query strings.
222            for statement in migration.statements {
223                sqlx_core::query::query(*statement)
224                    .execute(&mut *tx)
225                    .await
226                    .map_err(map_sqlx_err)?;
227            }
228            sqlx_core::query::query(
229                "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
230            )
231            .bind(migration.version)
232            .bind(migration.name)
233            .execute(&mut *tx)
234            .await
235            .map_err(map_sqlx_err)?;
236        }
237
238        tx.commit().await.map_err(map_sqlx_err)
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Helpers
244// ---------------------------------------------------------------------------
245
246fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
247    let message = format!("postgres: {e}");
248    let invariant = match &e {
249        sqlx_core::Error::Database(db) => db
250            .code()
251            .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
252        sqlx_core::Error::TypeNotFound { .. }
253        | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
254        | sqlx_core::Error::ColumnNotFound(_)
255        | sqlx_core::Error::ColumnDecode { .. }
256        | sqlx_core::Error::Decode(_) => true,
257        _ => false,
258    };
259    if invariant {
260        TransactionStoreError::DatabaseInvariant(message)
261    } else {
262        TransactionStoreError::Io(std::io::Error::other(message))
263    }
264}
265
266fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
267    serde_json::to_string(v).map_err(TransactionStoreError::Json)
268}
269
270fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
271    serde_json::from_str(s).map_err(TransactionStoreError::Json)
272}
273
274/// UTC timestamp in the same RFC 3339 millisecond shape the SQLite backend
275/// produces via `strftime('%Y-%m-%dT%H:%M:%fZ','now')`.
276///
277/// `%S` is load-bearing: chrono's `%.3f` renders only the fractional part, so
278/// omitting `%S` yields `2026-07-27T12:34:.567Z`. That value is signed into
279/// `ChainContent.created_at` on every audit row and cast to `timestamptz` by
280/// approve, claim-for-execution, the stale sweep, and the history filter.
281fn now_iso() -> String {
282    chrono::Utc::now()
283        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
284        .to_string()
285}
286
287// ---------------------------------------------------------------------------
288// AuditStore impl
289// ---------------------------------------------------------------------------
290
291#[async_trait]
292impl AuditStore for PostgresStore {
293    async fn record(
294        &self,
295        transaction: NewTransaction,
296    ) -> Result<TransactionRecord, TransactionStoreError> {
297        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
298        let transaction_id = Uuid::new_v4().to_string();
299        let inserted =
300            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
301        tx.commit().await.map_err(map_sqlx_err)?;
302        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
303        Ok(inserted.record)
304    }
305
306    async fn record_previewed(
307        &self,
308        transaction: NewTransaction,
309        preview: PreviewEnvelope,
310    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
311        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
312        let transaction_id = Uuid::new_v4().to_string();
313        let inserted =
314            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
315
316        sqlx_core::query::query(
317            "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
318        )
319        .bind(&inserted.record.transaction_id)
320        .bind(serialize(&preview)?)
321        .execute(&mut *tx)
322        .await
323        .map_err(map_sqlx_err)?;
324
325        tx.commit().await.map_err(map_sqlx_err)?;
326        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
327        Ok(RecordedPreviewedTransaction {
328            transaction: inserted.record,
329            preview,
330        })
331    }
332
333    async fn get(
334        &self,
335        transaction_id: &str,
336    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
337        let row = sqlx_core::query::query(
338            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
339                    status, approval_id, summary, warnings_json \
340             FROM transactions WHERE transaction_id = $1",
341        )
342        .bind(transaction_id)
343        .fetch_optional(&self.pool)
344        .await
345        .map_err(map_sqlx_err)?;
346        row.map(row_to_record).transpose()
347    }
348
349    async fn get_preview(
350        &self,
351        transaction_id: &str,
352    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
353        let row = sqlx_core::query::query(
354            "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
355        )
356        .bind(transaction_id)
357        .fetch_optional(&self.pool)
358        .await
359        .map_err(map_sqlx_err)?;
360        match row {
361            Some(r) => {
362                let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
363                Ok(Some(deserialize(&s)?))
364            }
365            None => Ok(None),
366        }
367    }
368
369    async fn update_status(
370        &self,
371        transaction_id: &str,
372        new_status: JobState,
373    ) -> Result<(), TransactionStoreError> {
374        // Read-validate-write with a state-machine guard, same shape as the
375        // SQLite path but strictly stronger: `SELECT ... FOR UPDATE` takes a
376        // row lock for the lifetime of this transaction, so no concurrent
377        // writer can change `status` between our read and our `UPDATE` — the
378        // read-modify-write here is atomic by construction. SQLite has no
379        // equivalent row-level lock, so its `update_status` instead uses an
380        // explicit compare-and-swap (`UPDATE ... WHERE status = <observed>`,
381        // checking `rows_affected`) to get the same guarantee.
382        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
383        let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
384            "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
385        )
386        .bind(transaction_id)
387        .fetch_optional(&mut *tx)
388        .await
389        .map_err(map_sqlx_err)?;
390        let current_str = current_str
391            .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
392        let current: JobState = deserialize(&current_str)?;
393        if !crate::jobs::allowed_transition(&current, &new_status) {
394            return Err(TransactionStoreError::InvalidTransition {
395                from: current,
396                to: new_status,
397            });
398        }
399        sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
400            .bind(serialize(&new_status)?)
401            .bind(transaction_id)
402            .execute(&mut *tx)
403            .await
404            .map_err(map_sqlx_err)?;
405        tx.commit().await.map_err(map_sqlx_err)?;
406        Ok(())
407    }
408
409    async fn approve_transaction(
410        &self,
411        transaction_id: &str,
412    ) -> Result<Option<String>, TransactionStoreError> {
413        let row = sqlx_core::query::query(
414            "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
415        )
416        .bind(transaction_id)
417        .fetch_optional(&self.pool)
418        .await
419        .map_err(map_sqlx_err)?;
420        let Some(row) = row else {
421            return Ok(None);
422        };
423        let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
424        let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
425        let receipt = self
426            .audit_key
427            .approval_receipt(transaction_id, &request_hash);
428        let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
429        let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
430            bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
431        });
432        if !commitment_matches {
433            return Err(TransactionStoreError::DatabaseInvariant(format!(
434                "transaction {transaction_id} approval commitment does not match its signed preview"
435            )));
436        }
437        let queued = serialize(&JobState::Queued)?;
438        let result = sqlx_core::query::query(
439            sqlx_core::sql_str::AssertSqlSafe(format!(
440                "INSERT INTO transaction_approvals \
441                     (transaction_id, receipt_digest, approved_at) \
442                 SELECT transaction_id, $1, $2 FROM transactions \
443                 WHERE transaction_id = $3 \
444                   AND status = $4 \
445                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
446                 ON CONFLICT (transaction_id) DO NOTHING"
447            )),
448        )
449        .bind(receipt_digest)
450        .bind(now_iso())
451        .bind(transaction_id)
452        .bind(&queued)
453        .execute(&self.pool)
454        .await
455        .map_err(map_sqlx_err)?;
456        Ok((result.rows_affected() > 0).then_some(receipt))
457    }
458
459    async fn revoke_unconsumed_approval(
460        &self,
461        transaction_id: &str,
462    ) -> Result<bool, TransactionStoreError> {
463        let result = sqlx_core::query::query(
464            "DELETE FROM transaction_approvals \
465             WHERE transaction_id = $1 AND consumed_at IS NULL",
466        )
467        .bind(transaction_id)
468        .execute(&self.pool)
469        .await
470        .map_err(map_sqlx_err)?;
471        Ok(result.rows_affected() > 0)
472    }
473
474    async fn claim_approved_for_execution(
475        &self,
476        transaction_id: &str,
477        receipt_digest: &str,
478    ) -> Result<bool, TransactionStoreError> {
479        let queued = serialize(&JobState::Queued)?;
480        let running = serialize(&JobState::Running)?;
481        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
482        let result = sqlx_core::query::query(
483            sqlx_core::sql_str::AssertSqlSafe(format!(
484                "UPDATE transactions SET status = $1 \
485                 WHERE transaction_id = $2 \
486                   AND status = $3 \
487                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
488                   AND EXISTS ( \
489                       SELECT 1 FROM transaction_approvals \
490                       WHERE transaction_id = $2 \
491                         AND receipt_digest = $4 \
492                         AND consumed_at IS NULL \
493                   )"
494            )),
495        )
496        .bind(&running)
497        .bind(transaction_id)
498        .bind(&queued)
499        .bind(receipt_digest)
500        .execute(&mut *tx)
501        .await
502        .map_err(map_sqlx_err)?;
503        if result.rows_affected() > 0 {
504            sqlx_core::query::query(
505                "UPDATE transaction_approvals SET consumed_at = $1 \
506                 WHERE transaction_id = $2 AND consumed_at IS NULL",
507            )
508            .bind(now_iso())
509            .bind(transaction_id)
510            .execute(&mut *tx)
511            .await
512            .map_err(map_sqlx_err)?;
513        }
514        tx.commit().await.map_err(map_sqlx_err)?;
515        Ok(result.rows_affected() > 0)
516    }
517
518    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
519        let queued = serialize(&JobState::Queued)?;
520        let canceled = serialize(&JobState::Canceled)?;
521        let result = sqlx_core::query::query(
522            sqlx_core::sql_str::AssertSqlSafe(format!(
523                "UPDATE transactions SET status = $1 \
524                 WHERE status = $2 \
525                   AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
526            )),
527        )
528        .bind(&canceled)
529        .bind(&queued)
530        .execute(&self.pool)
531        .await
532        .map_err(map_sqlx_err)?;
533        Ok(result.rows_affected())
534    }
535
536    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
537        // Option A: the `status = $3` (Queued) guard means a Running (in-flight)
538        // transaction is never cancelled.
539        let queued = serialize(&JobState::Queued)?;
540        let canceled = serialize(&JobState::Canceled)?;
541        let result = sqlx_core::query::query(
542            "UPDATE transactions SET status = $1 \
543             WHERE transaction_id = $2 AND status = $3",
544        )
545        .bind(&canceled)
546        .bind(transaction_id)
547        .bind(&queued)
548        .execute(&self.pool)
549        .await
550        .map_err(map_sqlx_err)?;
551        Ok(result.rows_affected() > 0)
552    }
553
554    async fn list_transactions(
555        &self,
556        limit: u32,
557        status_filter: Option<&str>,
558        action_filter: Option<&str>,
559        since_hours: Option<u32>,
560    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
561        let limit = limit.min(100) as i64;
562
563        // Validate the status filter against the JobState enum so a typo
564        // produces an error instead of silent empty results.
565        let validated_status: Option<String> = status_filter
566            .map(|s| -> Result<String, TransactionStoreError> {
567                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
568                serialize(&parsed)
569            })
570            .transpose()?;
571
572        let mut sql = String::from(
573            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
574                    status, approval_id, summary, warnings_json \
575             FROM transactions WHERE TRUE",
576        );
577        let mut idx = 1;
578        if validated_status.is_some() {
579            sql.push_str(&format!(" AND status = ${idx}"));
580            idx += 1;
581        }
582        if action_filter.is_some() {
583            sql.push_str(&format!(" AND action_name = ${idx}"));
584            idx += 1;
585        }
586        if since_hours.is_some() {
587            sql.push_str(&format!(
588                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
589            ));
590            idx += 1;
591        }
592        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
593
594        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
595        if let Some(s) = &validated_status {
596            q = q.bind(s);
597        }
598        if let Some(a) = action_filter {
599            q = q.bind(a);
600        }
601        if let Some(h) = since_hours {
602            q = q.bind(h.to_string());
603        }
604        q = q.bind(limit);
605
606        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
607        rows.into_iter().map(row_to_record).collect()
608    }
609
610    async fn list_history(
611        &self,
612        limit: u32,
613        status_filter: Option<&str>,
614        action_filter: Option<&str>,
615        since_hours: Option<u32>,
616    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
617        // Parallels list_transactions but selects created_at and returns the
618        // structured JobHistoryEntry DTO. Same filter/validation semantics.
619        let limit = limit.min(100) as i64;
620        let validated_status: Option<String> = status_filter
621            .map(|s| -> Result<String, TransactionStoreError> {
622                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
623                serialize(&parsed)
624            })
625            .transpose()?;
626
627        let mut sql = String::from(
628            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
629             FROM transactions WHERE TRUE",
630        );
631        let mut idx = 1;
632        if validated_status.is_some() {
633            sql.push_str(&format!(" AND status = ${idx}"));
634            idx += 1;
635        }
636        if action_filter.is_some() {
637            sql.push_str(&format!(" AND action_name = ${idx}"));
638            idx += 1;
639        }
640        if since_hours.is_some() {
641            sql.push_str(&format!(
642                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
643            ));
644            idx += 1;
645        }
646        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
647
648        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
649        if let Some(s) = &validated_status {
650            q = q.bind(s);
651        }
652        if let Some(a) = action_filter {
653            q = q.bind(a);
654        }
655        if let Some(h) = since_hours {
656            q = q.bind(h.to_string());
657        }
658        q = q.bind(limit);
659
660        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
661        rows.into_iter().map(row_to_history_entry).collect()
662    }
663
664    async fn fetch_chain_row(
665        &self,
666        transaction_id: &str,
667    ) -> Result<Option<ChainRow>, TransactionStoreError> {
668        let row = sqlx_core::query::query(
669            "SELECT seq, key_id, transaction_id, request_id, request_hash, \
670                    action_name, risk_level, summary, approval_id, warnings_json, \
671                    created_at, prev_chain_hash, chain_hash \
672             FROM transactions WHERE transaction_id = $1",
673        )
674        .bind(transaction_id)
675        .fetch_optional(&self.pool)
676        .await
677        .map_err(map_sqlx_err)?;
678        row.map(row_to_chain_row).transpose()
679    }
680
681    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
682        fetch_chain_rows_from_pool(&self.pool).await
683    }
684
685    async fn verify_audit_chain(
686        &self,
687        key: &AuditKey,
688    ) -> Result<VerifyOutcome, TransactionStoreError> {
689        let rows = self.fetch_chain_rows().await?;
690        Ok(crate::audit_chain::verify_chain(key, &rows))
691    }
692}
693
694async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
695    let rows = sqlx_core::query::query(
696        "SELECT seq, key_id, transaction_id, request_id, request_hash, \
697                    action_name, risk_level, summary, approval_id, warnings_json, \
698                    created_at, prev_chain_hash, chain_hash \
699             FROM transactions ORDER BY seq ASC",
700    )
701    .fetch_all(pool)
702    .await
703    .map_err(map_sqlx_err)?;
704    rows.into_iter().map(row_to_chain_row).collect()
705}
706
707// ---------------------------------------------------------------------------
708// Insert path — chain-aware
709// ---------------------------------------------------------------------------
710
711async fn insert_transaction(
712    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
713    key: &AuditKey,
714    transaction_id: &str,
715    transaction: NewTransaction,
716) -> Result<InsertedTransaction, TransactionStoreError> {
717    let warnings_json = serialize(&transaction.warnings)?;
718    let status = JobState::Queued;
719    let created_at = now_iso();
720    let key_id = CURRENT_KEY_ID.to_string();
721    let approval_id = transaction
722        .approval_id
723        .clone()
724        .or_else(|| Some(key.approval_commitment(transaction_id, &transaction.request_hash)));
725
726    // SELECT … FOR UPDATE on the most-recent row (if any) so concurrent
727    // writers serialise on the chain tip. Without this, two parallel
728    // record() calls could both compute seq=N+1 and then race on the
729    // UNIQUE(seq) constraint — the loser would get a unique-violation
730    // error.
731    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
732        "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
733    )
734    .fetch_optional(&mut **tx)
735    .await
736    .map_err(map_sqlx_err)?;
737    let (seq, prev_chain_hash) = match prev {
738        Some((s, h)) => ((s as u64) + 1, h),
739        None => (1, String::new()),
740    };
741
742    let chain_hash = key.chain_hash(
743        &ChainContent {
744            seq,
745            key_id: &key_id,
746            transaction_id,
747            request_id: &transaction.request_id,
748            request_hash: &transaction.request_hash,
749            action_name: &transaction.action_name,
750            risk_level: transaction.risk_level,
751            summary: &transaction.summary,
752            approval_id: approval_id.as_deref(),
753            warnings_json: &warnings_json,
754            created_at: &created_at,
755        },
756        &prev_chain_hash,
757    );
758
759    sqlx_core::query::query(
760        "INSERT INTO transactions ( \
761            transaction_id, request_id, request_hash, action_name, risk_level, \
762            status, approval_id, summary, warnings_json, created_at, \
763            seq, key_id, chain_hash, prev_chain_hash \
764         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
765    )
766    .bind(transaction_id)
767    .bind(&transaction.request_id)
768    .bind(&transaction.request_hash)
769    .bind(&transaction.action_name)
770    .bind(serialize(&transaction.risk_level)?)
771    .bind(serialize(&status)?)
772    .bind(&approval_id)
773    .bind(&transaction.summary)
774    .bind(&warnings_json)
775    .bind(&created_at)
776    .bind(seq as i64)
777    .bind(&key_id)
778    .bind(&chain_hash)
779    .bind(&prev_chain_hash)
780    .execute(&mut **tx)
781    .await
782    .map_err(map_sqlx_err)?;
783
784    Ok(InsertedTransaction {
785        record: TransactionRecord {
786            transaction_id: transaction_id.to_string(),
787            request_id: transaction.request_id,
788            request_hash: transaction.request_hash,
789            action_name: transaction.action_name,
790            risk_level: transaction.risk_level,
791            status,
792            approval_id,
793            summary: transaction.summary,
794            warnings: transaction.warnings,
795        },
796        seq,
797        chain_hash,
798    })
799}
800
801// ---------------------------------------------------------------------------
802// Row mappers
803// ---------------------------------------------------------------------------
804
805fn row_to_history_entry(
806    row: sqlx_postgres::PgRow,
807) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
808    Ok(crate::transactions::JobHistoryEntry {
809        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
810        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
811        risk_level: deserialize(
812            &row.try_get::<String, _>("risk_level")
813                .map_err(map_sqlx_err)?,
814        )?,
815        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
816        summary: row.try_get("summary").map_err(map_sqlx_err)?,
817        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
818    })
819}
820
821fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
822    Ok(TransactionRecord {
823        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
824        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
825        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
826        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
827        risk_level: deserialize(
828            &row.try_get::<String, _>("risk_level")
829                .map_err(map_sqlx_err)?,
830        )?,
831        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
832        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
833        summary: row.try_get("summary").map_err(map_sqlx_err)?,
834        warnings: deserialize(
835            &row.try_get::<String, _>("warnings_json")
836                .map_err(map_sqlx_err)?,
837        )?,
838    })
839}
840
841fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
842    Ok(ChainRow {
843        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
844        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
845        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
846        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
847        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
848        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
849        risk_level: deserialize(
850            &row.try_get::<String, _>("risk_level")
851                .map_err(map_sqlx_err)?,
852        )?,
853        summary: row.try_get("summary").map_err(map_sqlx_err)?,
854        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
855        warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
856        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
857        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
858        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
859    })
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    /// Verifies that PostgresConfig defaults are sensible and don't accidentally
867    /// disable the statement cache.
868    #[test]
869    fn default_config_keeps_statement_cache_enabled() {
870        let c = PostgresConfig::default();
871        assert!(c.statement_cache_capacity > 0);
872        assert!(c.max_connections >= 4);
873        assert!(c.acquire_timeout >= Duration::from_secs(5));
874    }
875
876    #[test]
877    fn url_parsing_accepts_standard_postgres_url() {
878        let opts = PgConnectOptions::from_str(
879            "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
880        )
881        .expect("standard URL parses");
882        // Ensure binding via PgPoolOptions wouldn't reject it (just smoke-check).
883        let _ = opts.statement_cache_capacity(0);
884    }
885
886    #[test]
887    fn now_iso_produces_a_parseable_rfc3339_timestamp() {
888        // `now_iso()` is signed into `ChainContent.created_at` on every
889        // audit row and is cast to `timestamptz` by approve, claim-for-
890        // execution, the stale sweep, and the history filter. The format
891        // string previously omitted `%S`, yielding `2026-07-27T12:34:.567Z`:
892        // seconds silently vanished, the chain attested to a broken value
893        // forever, and same-minute ordering became unrecoverable.
894        let ts = now_iso();
895        let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
896        assert!(
897            parsed.is_ok(),
898            "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
899        );
900        assert_eq!(
901            ts.len(),
902            "2026-07-27T12:34:56.789Z".len(),
903            "timestamp shape changed unexpectedly: {ts}"
904        );
905    }
906
907    #[test]
908    fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
909        // Both backends feed the same signed chain, so a row written by
910        // Postgres must be byte-comparable with one written by SQLite
911        // (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`, which yields
912        // milliseconds). Divergent shapes make cross-backend verification
913        // and time-ordered queries subtly wrong.
914        let ts = now_iso();
915        let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
916        assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
917        assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
918        let time = rest.trim_end_matches('Z');
919        let fields: Vec<&str> = time.split(':').collect();
920        assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
921        assert!(
922            fields[2].contains('.'),
923            "seconds field must carry fractional milliseconds: {time:?}"
924        );
925        let (secs, millis) = fields[2].split_once('.').expect("checked above");
926        assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
927        assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
928    }
929
930    #[test]
931    fn schema_decode_errors_are_classified_as_invariant_failures() {
932        let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
933        assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
934    }
935}