1use 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
53use 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 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 Migration {
138 version: 3,
139 name: "caller_principal",
140 statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"],
141 },
142 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 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#[derive(Clone, Debug)]
174pub struct PostgresConfig {
175 pub url: String,
177 pub max_connections: u32,
180 pub acquire_timeout: Duration,
183 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#[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 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 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 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 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 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 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 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 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 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 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 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
461fn 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
493fn now_iso() -> String {
501 chrono::Utc::now()
502 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
503 .to_string()
504}
505
506#[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 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(¤t_str)?;
612 if !crate::jobs::allowed_transition(¤t, &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 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 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 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 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 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 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 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 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
1065async 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
1080async 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
1091async 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
1143async 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
1156async 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 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
1220async 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 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
1237
1238 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 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 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
1337fn 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 #[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 let _ = opts.statement_cache_capacity(0);
1443 }
1444
1445 #[test]
1446 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1447 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 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}