Skip to main content

sysknife_daemon/store/
postgres.rs

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