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