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