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    HistorySince, 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: Option<HistorySince>,
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        match since {
864            Some(HistorySince::Hours(_)) => {
865                sql.push_str(&format!(
866                    " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
867                ));
868                idx += 1;
869            }
870            Some(HistorySince::Cutoff(_)) => {
871                sql.push_str(&format!(
872                    " AND created_at::timestamptz >= ${idx}::timestamptz"
873                ));
874                idx += 1;
875            }
876            None => {}
877        }
878        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
879
880        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
881        if let Some(s) = &validated_status {
882            q = q.bind(s);
883        }
884        if let Some(a) = action_filter {
885            q = q.bind(a);
886        }
887        match since {
888            Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
889            Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
890            None => {}
891        }
892        q = q.bind(limit);
893
894        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
895        rows.into_iter().map(row_to_record).collect()
896    }
897
898    async fn list_history(
899        &self,
900        limit: u32,
901        status_filter: Option<&str>,
902        action_filter: Option<&str>,
903        since: Option<HistorySince>,
904    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
905        // Parallels list_transactions but selects created_at and returns the
906        // structured JobHistoryEntry DTO. Same filter/validation semantics.
907        let limit = limit.min(100) as i64;
908        let validated_status: Option<String> = status_filter
909            .map(|s| -> Result<String, TransactionStoreError> {
910                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
911                serialize(&parsed)
912            })
913            .transpose()?;
914
915        let mut sql = String::from(
916            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
917             FROM transactions WHERE TRUE",
918        );
919        let mut idx = 1;
920        if validated_status.is_some() {
921            sql.push_str(&format!(" AND status = ${idx}"));
922            idx += 1;
923        }
924        if action_filter.is_some() {
925            sql.push_str(&format!(" AND action_name = ${idx}"));
926            idx += 1;
927        }
928        match since {
929            Some(HistorySince::Hours(_)) => {
930                sql.push_str(&format!(
931                    " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
932                ));
933                idx += 1;
934            }
935            Some(HistorySince::Cutoff(_)) => {
936                sql.push_str(&format!(
937                    " AND created_at::timestamptz >= ${idx}::timestamptz"
938                ));
939                idx += 1;
940            }
941            None => {}
942        }
943        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
944
945        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
946        if let Some(s) = &validated_status {
947            q = q.bind(s);
948        }
949        if let Some(a) = action_filter {
950            q = q.bind(a);
951        }
952        match since {
953            Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
954            Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
955            None => {}
956        }
957        q = q.bind(limit);
958
959        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
960        rows.into_iter().map(row_to_history_entry).collect()
961    }
962
963    async fn fetch_chain_row(
964        &self,
965        transaction_id: &str,
966    ) -> Result<Option<ChainRow>, TransactionStoreError> {
967        let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
968            "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
969        )))
970        .bind(transaction_id)
971        .fetch_optional(&self.pool)
972        .await
973        .map_err(map_sqlx_err)?;
974        row.map(row_to_chain_row).transpose()
975    }
976
977    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
978        fetch_event_rows_from_pool(&self.pool).await
979    }
980
981    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
982        fetch_chain_rows_from_pool(&self.pool).await
983    }
984
985    async fn verify_audit_chain(
986        &self,
987        key: &AuditKey,
988    ) -> Result<VerifyOutcome, TransactionStoreError> {
989        let rows = self.fetch_chain_rows().await?;
990        Ok(crate::audit_chain::verify_chain(key, &rows))
991    }
992}
993
994async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
995    let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
996        "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
997    )))
998    .fetch_all(pool)
999    .await
1000    .map_err(map_sqlx_err)?;
1001    rows.into_iter().map(row_to_chain_row).collect()
1002}
1003
1004/// Is `audit_events` genuinely not there, as opposed to unreadable?
1005///
1006/// `to_regclass` resolves the name through the same `search_path` the queries
1007/// use and returns NULL when nothing matches. It answers existence, not access:
1008/// a table the caller may not SELECT still resolves, which is exactly the
1009/// distinction the caller needs.
1010async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
1011    let present: Option<String> =
1012        sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
1013            .fetch_one(pool)
1014            .await
1015            .map_err(map_sqlx_err)?;
1016    Ok(present.is_none())
1017}
1018
1019/// Fetch every approval event in seq order.
1020async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
1021    let rows = sqlx_core::query::query(
1022        "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1023                created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1024         FROM audit_events ORDER BY seq ASC",
1025    )
1026    .fetch_all(pool)
1027    .await
1028    .map_err(map_sqlx_err)?;
1029    rows.into_iter().map(row_to_event_row).collect()
1030}
1031
1032/// Append one signed approval event inside the caller's transaction.
1033///
1034/// `FOR UPDATE` on the current tip serialises concurrent appends the same way
1035/// `insert_transaction` serialises on the transaction chain tip; without it two
1036/// approvals could compute the same `seq` and collide on the primary key.
1037async fn append_event(
1038    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1039    key: &AuditKey,
1040    kind: AuditEventKind,
1041    transaction_id: &str,
1042    receipt_digest: &str,
1043    identity: EventIdentity<'_>,
1044) -> Result<(), TransactionStoreError> {
1045    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1046        "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1047    )
1048    .fetch_optional(&mut **tx)
1049    .await
1050    .map_err(map_sqlx_err)?;
1051    let (seq, prev_chain_hash) = match prev {
1052        Some((s, h)) => ((s as u64) + 1, h),
1053        None => (1, String::new()),
1054    };
1055    let created_at = now_iso();
1056    let key_id = CURRENT_KEY_ID.to_string();
1057    // Build the content once and derive the stored version from its identity,
1058    // so the chain_version column is provably the version whose message was
1059    // signed — the same discipline the SQLite backend follows.
1060    let content = EventContent {
1061        seq,
1062        key_id: &key_id,
1063        kind,
1064        transaction_id,
1065        receipt_digest,
1066        created_at: &created_at,
1067        identity,
1068    };
1069    let chain_hash = key.event_hash(&content, &prev_chain_hash);
1070    let stored_principal = match identity {
1071        EventIdentity::V2 { caller_principal } => Some(caller_principal),
1072        EventIdentity::LegacyV1 => None,
1073    };
1074    sqlx_core::query::query(
1075        "INSERT INTO audit_events ( \
1076            seq, key_id, kind, transaction_id, receipt_digest, \
1077            created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1078         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1079    )
1080    .bind(seq as i64)
1081    .bind(&key_id)
1082    .bind(kind.as_str())
1083    .bind(transaction_id)
1084    .bind(receipt_digest)
1085    .bind(&created_at)
1086    .bind(&chain_hash)
1087    .bind(&prev_chain_hash)
1088    .bind(identity.version() as i64)
1089    .bind(stored_principal)
1090    .execute(&mut **tx)
1091    .await
1092    .map_err(map_sqlx_err)?;
1093    Ok(())
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Insert path — chain-aware
1098// ---------------------------------------------------------------------------
1099
1100async fn insert_transaction(
1101    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1102    key: &AuditKey,
1103    transaction_id: &str,
1104    transaction: NewTransaction,
1105) -> Result<InsertedTransaction, TransactionStoreError> {
1106    let warnings_json = serialize(&transaction.warnings)?;
1107    let status = JobState::Queued;
1108    let created_at = now_iso();
1109    let key_id = CURRENT_KEY_ID.to_string();
1110    // Always the store's own commitment — see the matching note in
1111    // `transactions.rs`; a caller may not supply this value.
1112    let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
1113
1114    // SELECT … FOR UPDATE on the most-recent row (if any) so concurrent
1115    // writers serialise on the chain tip. Without this, two parallel
1116    // record() calls could both compute seq=N+1 and then race on the
1117    // UNIQUE(seq) constraint — the loser would get a unique-violation
1118    // error.
1119    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1120        "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1121    )
1122    .fetch_optional(&mut **tx)
1123    .await
1124    .map_err(map_sqlx_err)?;
1125    let (seq, prev_chain_hash) = match prev {
1126        Some((s, h)) => ((s as u64) + 1, h),
1127        None => (1, String::new()),
1128    };
1129
1130    // Bind the row to the approval-event chain tip as of this insert. Read
1131    // inside the same DB transaction as the seq allocation above.
1132    let event_tip: String = sqlx_core::query_scalar::query_scalar(
1133        "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
1134    )
1135    .fetch_one(&mut **tx)
1136    .await
1137    .map_err(map_sqlx_err)?;
1138    let caller_role = transaction.caller_role.as_str();
1139    let caller_principal = transaction.caller_principal.as_signed_str();
1140
1141    // As in the SQLite path: derive the stored version from the identity that was
1142    // signed, so the column cannot disagree with the message.
1143    let identity = ChainIdentity::V4 {
1144        caller_role,
1145        event_tip: &event_tip,
1146        caller_principal: &caller_principal,
1147    };
1148    let chain_hash = key.chain_hash(
1149        &ChainContent {
1150            seq,
1151            key_id: &key_id,
1152            transaction_id,
1153            request_id: &transaction.request_id,
1154            request_hash: &transaction.request_hash,
1155            action_name: &transaction.action_name,
1156            risk_level: transaction.risk_level,
1157            summary: &transaction.summary,
1158            approval_id: approval_id.as_deref(),
1159            warnings_json: &warnings_json,
1160            created_at: &created_at,
1161            identity,
1162        },
1163        &prev_chain_hash,
1164    );
1165
1166    sqlx_core::query::query(
1167        "INSERT INTO transactions ( \
1168            transaction_id, request_id, request_hash, action_name, risk_level, \
1169            status, approval_id, summary, warnings_json, created_at, \
1170            seq, key_id, chain_hash, prev_chain_hash, \
1171            chain_version, caller_role, event_tip, caller_principal \
1172         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
1173    )
1174    .bind(transaction_id)
1175    .bind(&transaction.request_id)
1176    .bind(&transaction.request_hash)
1177    .bind(&transaction.action_name)
1178    .bind(serialize(&transaction.risk_level)?)
1179    .bind(serialize(&status)?)
1180    .bind(&approval_id)
1181    .bind(&transaction.summary)
1182    .bind(&warnings_json)
1183    .bind(&created_at)
1184    .bind(seq as i64)
1185    .bind(&key_id)
1186    .bind(&chain_hash)
1187    .bind(&prev_chain_hash)
1188    .bind(i64::from(identity.version()))
1189    .bind(caller_role)
1190    .bind(&event_tip)
1191    .bind(&caller_principal)
1192    .execute(&mut **tx)
1193    .await
1194    .map_err(map_sqlx_err)?;
1195
1196    Ok(InsertedTransaction {
1197        record: TransactionRecord {
1198            transaction_id: transaction_id.to_string(),
1199            request_id: transaction.request_id,
1200            request_hash: transaction.request_hash,
1201            action_name: transaction.action_name,
1202            risk_level: transaction.risk_level,
1203            status,
1204            approval_id,
1205            summary: transaction.summary,
1206            warnings: transaction.warnings,
1207        },
1208        seq,
1209        chain_hash,
1210    })
1211}
1212
1213// ---------------------------------------------------------------------------
1214// Row mappers
1215// ---------------------------------------------------------------------------
1216
1217fn row_to_history_entry(
1218    row: sqlx_postgres::PgRow,
1219) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1220    Ok(crate::transactions::JobHistoryEntry {
1221        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1222        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1223        risk_level: deserialize(
1224            &row.try_get::<String, _>("risk_level")
1225                .map_err(map_sqlx_err)?,
1226        )?,
1227        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1228        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1229        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1230    })
1231}
1232
1233fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1234    Ok(TransactionRecord {
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        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1244        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1245        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1246        warnings: deserialize(
1247            &row.try_get::<String, _>("warnings_json")
1248                .map_err(map_sqlx_err)?,
1249        )?,
1250    })
1251}
1252
1253fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1254    Ok(ChainRow {
1255        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1256        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1257        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1258        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1259        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1260        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1261        risk_level: deserialize(
1262            &row.try_get::<String, _>("risk_level")
1263                .map_err(map_sqlx_err)?,
1264        )?,
1265        summary: row.try_get("summary").map_err(map_sqlx_err)?,
1266        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1267        warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1268        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1269        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1270        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1271        chain_version: row
1272            .try_get::<i64, _>("chain_version")
1273            .map_err(map_sqlx_err)? as u32,
1274        caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1275        event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1276        caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1277    })
1278}
1279
1280fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1281    Ok(EventRow {
1282        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1283        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1284        kind: row.try_get("kind").map_err(map_sqlx_err)?,
1285        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1286        receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1287        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1288        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1289        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1290        chain_version: row
1291            .try_get::<i64, _>("chain_version")
1292            .map_err(map_sqlx_err)? as u32,
1293        caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1294    })
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    /// Verifies that PostgresConfig defaults are sensible and don't accidentally
1302    /// disable the statement cache.
1303    #[test]
1304    fn default_config_keeps_statement_cache_enabled() {
1305        let c = PostgresConfig::default();
1306        assert!(c.statement_cache_capacity > 0);
1307        assert!(c.max_connections >= 4);
1308        assert!(c.acquire_timeout >= Duration::from_secs(5));
1309    }
1310
1311    #[test]
1312    fn url_parsing_accepts_standard_postgres_url() {
1313        let opts = PgConnectOptions::from_str(
1314            "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1315        )
1316        .expect("standard URL parses");
1317        // Ensure binding via PgPoolOptions wouldn't reject it (just smoke-check).
1318        let _ = opts.statement_cache_capacity(0);
1319    }
1320
1321    #[test]
1322    fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1323        // `now_iso()` is signed into `ChainContent.created_at` on every
1324        // audit row and is cast to `timestamptz` by approve, claim-for-
1325        // execution, the stale sweep, and the history filter. The format
1326        // string previously omitted `%S`, yielding `2026-07-27T12:34:.567Z`:
1327        // seconds silently vanished, the chain attested to a broken value
1328        // forever, and same-minute ordering became unrecoverable.
1329        let ts = now_iso();
1330        let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1331        assert!(
1332            parsed.is_ok(),
1333            "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1334        );
1335        assert_eq!(
1336            ts.len(),
1337            "2026-07-27T12:34:56.789Z".len(),
1338            "timestamp shape changed unexpectedly: {ts}"
1339        );
1340    }
1341
1342    #[test]
1343    fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1344        // Both backends feed the same signed chain, so a row written by
1345        // Postgres must be byte-comparable with one written by SQLite
1346        // (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`, which yields
1347        // milliseconds). Divergent shapes make cross-backend verification
1348        // and time-ordered queries subtly wrong.
1349        let ts = now_iso();
1350        let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1351        assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1352        assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1353        let time = rest.trim_end_matches('Z');
1354        let fields: Vec<&str> = time.split(':').collect();
1355        assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1356        assert!(
1357            fields[2].contains('.'),
1358            "seconds field must carry fractional milliseconds: {time:?}"
1359        );
1360        let (secs, millis) = fields[2].split_once('.').expect("checked above");
1361        assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1362        assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1363    }
1364
1365    #[test]
1366    fn schema_decode_errors_are_classified_as_invariant_failures() {
1367        let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1368        assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1369    }
1370}