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