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