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