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
274fn now_iso() -> String {
275    chrono::Utc::now()
276        .format("%Y-%m-%dT%H:%M:%.3fZ")
277        .to_string()
278}
279
280// ---------------------------------------------------------------------------
281// AuditStore impl
282// ---------------------------------------------------------------------------
283
284#[async_trait]
285impl AuditStore for PostgresStore {
286    async fn record(
287        &self,
288        transaction: NewTransaction,
289    ) -> Result<TransactionRecord, TransactionStoreError> {
290        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
291        let transaction_id = Uuid::new_v4().to_string();
292        let inserted =
293            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
294        tx.commit().await.map_err(map_sqlx_err)?;
295        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
296        Ok(inserted.record)
297    }
298
299    async fn record_previewed(
300        &self,
301        transaction: NewTransaction,
302        preview: PreviewEnvelope,
303    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
304        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
305        let transaction_id = Uuid::new_v4().to_string();
306        let inserted =
307            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
308
309        sqlx_core::query::query(
310            "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
311        )
312        .bind(&inserted.record.transaction_id)
313        .bind(serialize(&preview)?)
314        .execute(&mut *tx)
315        .await
316        .map_err(map_sqlx_err)?;
317
318        tx.commit().await.map_err(map_sqlx_err)?;
319        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
320        Ok(RecordedPreviewedTransaction {
321            transaction: inserted.record,
322            preview,
323        })
324    }
325
326    async fn get(
327        &self,
328        transaction_id: &str,
329    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
330        let row = sqlx_core::query::query(
331            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
332                    status, approval_id, summary, warnings_json \
333             FROM transactions WHERE transaction_id = $1",
334        )
335        .bind(transaction_id)
336        .fetch_optional(&self.pool)
337        .await
338        .map_err(map_sqlx_err)?;
339        row.map(row_to_record).transpose()
340    }
341
342    async fn get_preview(
343        &self,
344        transaction_id: &str,
345    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
346        let row = sqlx_core::query::query(
347            "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
348        )
349        .bind(transaction_id)
350        .fetch_optional(&self.pool)
351        .await
352        .map_err(map_sqlx_err)?;
353        match row {
354            Some(r) => {
355                let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
356                Ok(Some(deserialize(&s)?))
357            }
358            None => Ok(None),
359        }
360    }
361
362    async fn update_status(
363        &self,
364        transaction_id: &str,
365        new_status: JobState,
366    ) -> Result<(), TransactionStoreError> {
367        // Read-validate-write with state-machine guard, mirroring SQLite path.
368        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
369        let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
370            "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
371        )
372        .bind(transaction_id)
373        .fetch_optional(&mut *tx)
374        .await
375        .map_err(map_sqlx_err)?;
376        let current_str = current_str
377            .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
378        let current: JobState = deserialize(&current_str)?;
379        if !crate::jobs::allowed_transition(&current, &new_status) {
380            return Err(TransactionStoreError::InvalidTransition {
381                from: current,
382                to: new_status,
383            });
384        }
385        sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
386            .bind(serialize(&new_status)?)
387            .bind(transaction_id)
388            .execute(&mut *tx)
389            .await
390            .map_err(map_sqlx_err)?;
391        tx.commit().await.map_err(map_sqlx_err)?;
392        Ok(())
393    }
394
395    async fn approve_transaction(
396        &self,
397        transaction_id: &str,
398    ) -> Result<Option<String>, TransactionStoreError> {
399        let row = sqlx_core::query::query(
400            "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
401        )
402        .bind(transaction_id)
403        .fetch_optional(&self.pool)
404        .await
405        .map_err(map_sqlx_err)?;
406        let Some(row) = row else {
407            return Ok(None);
408        };
409        let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
410        let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
411        let receipt = self
412            .audit_key
413            .approval_receipt(transaction_id, &request_hash);
414        let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
415        let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
416            bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
417        });
418        if !commitment_matches {
419            return Err(TransactionStoreError::DatabaseInvariant(format!(
420                "transaction {transaction_id} approval commitment does not match its signed preview"
421            )));
422        }
423        let queued = serialize(&JobState::Queued)?;
424        let result = sqlx_core::query::query(
425            sqlx_core::sql_str::AssertSqlSafe(format!(
426                "INSERT INTO transaction_approvals \
427                     (transaction_id, receipt_digest, approved_at) \
428                 SELECT transaction_id, $1, $2 FROM transactions \
429                 WHERE transaction_id = $3 \
430                   AND status = $4 \
431                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
432                 ON CONFLICT (transaction_id) DO NOTHING"
433            )),
434        )
435        .bind(receipt_digest)
436        .bind(now_iso())
437        .bind(transaction_id)
438        .bind(&queued)
439        .execute(&self.pool)
440        .await
441        .map_err(map_sqlx_err)?;
442        Ok((result.rows_affected() > 0).then_some(receipt))
443    }
444
445    async fn revoke_unconsumed_approval(
446        &self,
447        transaction_id: &str,
448    ) -> Result<bool, TransactionStoreError> {
449        let result = sqlx_core::query::query(
450            "DELETE FROM transaction_approvals \
451             WHERE transaction_id = $1 AND consumed_at IS NULL",
452        )
453        .bind(transaction_id)
454        .execute(&self.pool)
455        .await
456        .map_err(map_sqlx_err)?;
457        Ok(result.rows_affected() > 0)
458    }
459
460    async fn claim_approved_for_execution(
461        &self,
462        transaction_id: &str,
463        receipt_digest: &str,
464    ) -> Result<bool, TransactionStoreError> {
465        let queued = serialize(&JobState::Queued)?;
466        let running = serialize(&JobState::Running)?;
467        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
468        let result = sqlx_core::query::query(
469            sqlx_core::sql_str::AssertSqlSafe(format!(
470                "UPDATE transactions SET status = $1 \
471                 WHERE transaction_id = $2 \
472                   AND status = $3 \
473                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
474                   AND EXISTS ( \
475                       SELECT 1 FROM transaction_approvals \
476                       WHERE transaction_id = $2 \
477                         AND receipt_digest = $4 \
478                         AND consumed_at IS NULL \
479                   )"
480            )),
481        )
482        .bind(&running)
483        .bind(transaction_id)
484        .bind(&queued)
485        .bind(receipt_digest)
486        .execute(&mut *tx)
487        .await
488        .map_err(map_sqlx_err)?;
489        if result.rows_affected() > 0 {
490            sqlx_core::query::query(
491                "UPDATE transaction_approvals SET consumed_at = $1 \
492                 WHERE transaction_id = $2 AND consumed_at IS NULL",
493            )
494            .bind(now_iso())
495            .bind(transaction_id)
496            .execute(&mut *tx)
497            .await
498            .map_err(map_sqlx_err)?;
499        }
500        tx.commit().await.map_err(map_sqlx_err)?;
501        Ok(result.rows_affected() > 0)
502    }
503
504    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
505        let queued = serialize(&JobState::Queued)?;
506        let canceled = serialize(&JobState::Canceled)?;
507        let result = sqlx_core::query::query(
508            sqlx_core::sql_str::AssertSqlSafe(format!(
509                "UPDATE transactions SET status = $1 \
510                 WHERE status = $2 \
511                   AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
512            )),
513        )
514        .bind(&canceled)
515        .bind(&queued)
516        .execute(&self.pool)
517        .await
518        .map_err(map_sqlx_err)?;
519        Ok(result.rows_affected())
520    }
521
522    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
523        // Option A: the `status = $3` (Queued) guard means a Running (in-flight)
524        // transaction is never cancelled.
525        let queued = serialize(&JobState::Queued)?;
526        let canceled = serialize(&JobState::Canceled)?;
527        let result = sqlx_core::query::query(
528            "UPDATE transactions SET status = $1 \
529             WHERE transaction_id = $2 AND status = $3",
530        )
531        .bind(&canceled)
532        .bind(transaction_id)
533        .bind(&queued)
534        .execute(&self.pool)
535        .await
536        .map_err(map_sqlx_err)?;
537        Ok(result.rows_affected() > 0)
538    }
539
540    async fn list_transactions(
541        &self,
542        limit: u32,
543        status_filter: Option<&str>,
544        action_filter: Option<&str>,
545        since_hours: Option<u32>,
546    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
547        let limit = limit.min(100) as i64;
548
549        // Validate the status filter against the JobState enum so a typo
550        // produces an error instead of silent empty results.
551        let validated_status: Option<String> = status_filter
552            .map(|s| -> Result<String, TransactionStoreError> {
553                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
554                serialize(&parsed)
555            })
556            .transpose()?;
557
558        let mut sql = String::from(
559            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
560                    status, approval_id, summary, warnings_json \
561             FROM transactions WHERE TRUE",
562        );
563        let mut idx = 1;
564        if validated_status.is_some() {
565            sql.push_str(&format!(" AND status = ${idx}"));
566            idx += 1;
567        }
568        if action_filter.is_some() {
569            sql.push_str(&format!(" AND action_name = ${idx}"));
570            idx += 1;
571        }
572        if since_hours.is_some() {
573            sql.push_str(&format!(
574                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
575            ));
576            idx += 1;
577        }
578        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
579
580        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
581        if let Some(s) = &validated_status {
582            q = q.bind(s);
583        }
584        if let Some(a) = action_filter {
585            q = q.bind(a);
586        }
587        if let Some(h) = since_hours {
588            q = q.bind(h.to_string());
589        }
590        q = q.bind(limit);
591
592        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
593        rows.into_iter().map(row_to_record).collect()
594    }
595
596    async fn list_history(
597        &self,
598        limit: u32,
599        status_filter: Option<&str>,
600        action_filter: Option<&str>,
601        since_hours: Option<u32>,
602    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
603        // Parallels list_transactions but selects created_at and returns the
604        // structured JobHistoryEntry DTO. Same filter/validation semantics.
605        let limit = limit.min(100) as i64;
606        let validated_status: Option<String> = status_filter
607            .map(|s| -> Result<String, TransactionStoreError> {
608                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
609                serialize(&parsed)
610            })
611            .transpose()?;
612
613        let mut sql = String::from(
614            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
615             FROM transactions WHERE TRUE",
616        );
617        let mut idx = 1;
618        if validated_status.is_some() {
619            sql.push_str(&format!(" AND status = ${idx}"));
620            idx += 1;
621        }
622        if action_filter.is_some() {
623            sql.push_str(&format!(" AND action_name = ${idx}"));
624            idx += 1;
625        }
626        if since_hours.is_some() {
627            sql.push_str(&format!(
628                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
629            ));
630            idx += 1;
631        }
632        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
633
634        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
635        if let Some(s) = &validated_status {
636            q = q.bind(s);
637        }
638        if let Some(a) = action_filter {
639            q = q.bind(a);
640        }
641        if let Some(h) = since_hours {
642            q = q.bind(h.to_string());
643        }
644        q = q.bind(limit);
645
646        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
647        rows.into_iter().map(row_to_history_entry).collect()
648    }
649
650    async fn fetch_chain_row(
651        &self,
652        transaction_id: &str,
653    ) -> Result<Option<ChainRow>, TransactionStoreError> {
654        let row = sqlx_core::query::query(
655            "SELECT seq, key_id, transaction_id, request_id, request_hash, \
656                    action_name, risk_level, summary, approval_id, warnings_json, \
657                    created_at, prev_chain_hash, chain_hash \
658             FROM transactions WHERE transaction_id = $1",
659        )
660        .bind(transaction_id)
661        .fetch_optional(&self.pool)
662        .await
663        .map_err(map_sqlx_err)?;
664        row.map(row_to_chain_row).transpose()
665    }
666
667    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
668        fetch_chain_rows_from_pool(&self.pool).await
669    }
670
671    async fn verify_audit_chain(
672        &self,
673        key: &AuditKey,
674    ) -> Result<VerifyOutcome, TransactionStoreError> {
675        let rows = self.fetch_chain_rows().await?;
676        Ok(crate::audit_chain::verify_chain(key, &rows))
677    }
678}
679
680async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
681    let rows = sqlx_core::query::query(
682        "SELECT seq, key_id, transaction_id, request_id, request_hash, \
683                    action_name, risk_level, summary, approval_id, warnings_json, \
684                    created_at, prev_chain_hash, chain_hash \
685             FROM transactions ORDER BY seq ASC",
686    )
687    .fetch_all(pool)
688    .await
689    .map_err(map_sqlx_err)?;
690    rows.into_iter().map(row_to_chain_row).collect()
691}
692
693// ---------------------------------------------------------------------------
694// Insert path — chain-aware
695// ---------------------------------------------------------------------------
696
697async fn insert_transaction(
698    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
699    key: &AuditKey,
700    transaction_id: &str,
701    transaction: NewTransaction,
702) -> Result<InsertedTransaction, TransactionStoreError> {
703    let warnings_json = serialize(&transaction.warnings)?;
704    let status = JobState::Queued;
705    let created_at = now_iso();
706    let key_id = CURRENT_KEY_ID.to_string();
707    let approval_id = transaction
708        .approval_id
709        .clone()
710        .or_else(|| Some(key.approval_commitment(transaction_id, &transaction.request_hash)));
711
712    // SELECT … FOR UPDATE on the most-recent row (if any) so concurrent
713    // writers serialise on the chain tip. Without this, two parallel
714    // record() calls could both compute seq=N+1 and then race on the
715    // UNIQUE(seq) constraint — the loser would get a unique-violation
716    // error.
717    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
718        "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
719    )
720    .fetch_optional(&mut **tx)
721    .await
722    .map_err(map_sqlx_err)?;
723    let (seq, prev_chain_hash) = match prev {
724        Some((s, h)) => ((s as u64) + 1, h),
725        None => (1, String::new()),
726    };
727
728    let chain_hash = key.chain_hash(
729        &ChainContent {
730            seq,
731            key_id: &key_id,
732            transaction_id,
733            request_id: &transaction.request_id,
734            request_hash: &transaction.request_hash,
735            action_name: &transaction.action_name,
736            risk_level: transaction.risk_level,
737            summary: &transaction.summary,
738            approval_id: approval_id.as_deref(),
739            warnings_json: &warnings_json,
740            created_at: &created_at,
741        },
742        &prev_chain_hash,
743    );
744
745    sqlx_core::query::query(
746        "INSERT INTO transactions ( \
747            transaction_id, request_id, request_hash, action_name, risk_level, \
748            status, approval_id, summary, warnings_json, created_at, \
749            seq, key_id, chain_hash, prev_chain_hash \
750         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
751    )
752    .bind(transaction_id)
753    .bind(&transaction.request_id)
754    .bind(&transaction.request_hash)
755    .bind(&transaction.action_name)
756    .bind(serialize(&transaction.risk_level)?)
757    .bind(serialize(&status)?)
758    .bind(&approval_id)
759    .bind(&transaction.summary)
760    .bind(&warnings_json)
761    .bind(&created_at)
762    .bind(seq as i64)
763    .bind(&key_id)
764    .bind(&chain_hash)
765    .bind(&prev_chain_hash)
766    .execute(&mut **tx)
767    .await
768    .map_err(map_sqlx_err)?;
769
770    Ok(InsertedTransaction {
771        record: TransactionRecord {
772            transaction_id: transaction_id.to_string(),
773            request_id: transaction.request_id,
774            request_hash: transaction.request_hash,
775            action_name: transaction.action_name,
776            risk_level: transaction.risk_level,
777            status,
778            approval_id,
779            summary: transaction.summary,
780            warnings: transaction.warnings,
781        },
782        seq,
783        chain_hash,
784    })
785}
786
787// ---------------------------------------------------------------------------
788// Row mappers
789// ---------------------------------------------------------------------------
790
791fn row_to_history_entry(
792    row: sqlx_postgres::PgRow,
793) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
794    Ok(crate::transactions::JobHistoryEntry {
795        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
796        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
797        risk_level: deserialize(
798            &row.try_get::<String, _>("risk_level")
799                .map_err(map_sqlx_err)?,
800        )?,
801        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
802        summary: row.try_get("summary").map_err(map_sqlx_err)?,
803        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
804    })
805}
806
807fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
808    Ok(TransactionRecord {
809        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
810        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
811        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
812        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
813        risk_level: deserialize(
814            &row.try_get::<String, _>("risk_level")
815                .map_err(map_sqlx_err)?,
816        )?,
817        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
818        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
819        summary: row.try_get("summary").map_err(map_sqlx_err)?,
820        warnings: deserialize(
821            &row.try_get::<String, _>("warnings_json")
822                .map_err(map_sqlx_err)?,
823        )?,
824    })
825}
826
827fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
828    Ok(ChainRow {
829        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
830        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
831        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
832        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
833        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
834        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
835        risk_level: deserialize(
836            &row.try_get::<String, _>("risk_level")
837                .map_err(map_sqlx_err)?,
838        )?,
839        summary: row.try_get("summary").map_err(map_sqlx_err)?,
840        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
841        warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
842        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
843        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
844        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
845    })
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    /// Verifies that PostgresConfig defaults are sensible and don't accidentally
853    /// disable the statement cache.
854    #[test]
855    fn default_config_keeps_statement_cache_enabled() {
856        let c = PostgresConfig::default();
857        assert!(c.statement_cache_capacity > 0);
858        assert!(c.max_connections >= 4);
859        assert!(c.acquire_timeout >= Duration::from_secs(5));
860    }
861
862    #[test]
863    fn url_parsing_accepts_standard_postgres_url() {
864        let opts = PgConnectOptions::from_str(
865            "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
866        )
867        .expect("standard URL parses");
868        // Ensure binding via PgPoolOptions wouldn't reject it (just smoke-check).
869        let _ = opts.statement_cache_capacity(0);
870    }
871
872    #[test]
873    fn schema_decode_errors_are_classified_as_invariant_failures() {
874        let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
875        assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
876    }
877}