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