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