From e1d28fbcb8ed89267f4054271af0dc41ae356639 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:56:27 +0900 Subject: [PATCH 1/8] feat(persistence): add live SQL transport and DATABASE_URL gate Introduce SqlSession, statement batching, document/audit SQL rendering, LiveDocumentRepository over recording or live transports, and a fail-closed DATABASE_URL configuration gate for future SQLx pool wiring without requiring PostgreSQL in CI. --- CHANGELOG.md | 1 + .../persistence_postgres/src/document_sql.rs | 215 ++++++++++++++ crates/persistence_postgres/src/error.rs | 28 ++ crates/persistence_postgres/src/lib.rs | 46 ++- .../src/live_repository.rs | 263 ++++++++++++++++++ .../persistence_postgres/src/sql_session.rs | 161 +++++++++++ crates/persistence_postgres/src/sqlx_gate.rs | 177 ++++++++++++ docs/TRACEABILITY.md | 8 +- docs/research/task-8-live-sql-transport.md | 24 ++ 9 files changed, 913 insertions(+), 10 deletions(-) create mode 100644 crates/persistence_postgres/src/document_sql.rs create mode 100644 crates/persistence_postgres/src/live_repository.rs create mode 100644 crates/persistence_postgres/src/sql_session.rs create mode 100644 crates/persistence_postgres/src/sqlx_gate.rs create mode 100644 docs/research/task-8-live-sql-transport.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 785795bca..61594335f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `relation_graph` forward-only state-transition DAG with past-pointing provenance edges and cycle rejection. - `tepp_simulation` deterministic truth-corpus generator with delayed reporting, multilevel memberships, method-effect variants, relation noise, and digest-bound truth manifests. - `corpus_split` leakage-safe knowledge-cutoff snapshots, relation-connected co-partition groups, rolling-origin windows, and group-normalized ESS weight contracts. +- `persistence_postgres` live SQL port: `SqlSession` transport, migration batch applicator, document/audit SQL contracts, `LiveDocumentRepository`, and fail-closed `DATABASE_URL`/`LiveSqlxConfig` gate for SQLx pool wiring (live pool/query driver remains accepted-target). - `persistence_postgres` bitemporal foundation: multi-word migration contracts, knowledge-cutoff eligibility, and in-memory as-known-at / as-valid-at document replay (live SQLx/PostgreSQL execution remains accepted-target). - `event_core` mention/instance separation with explicit promotion, typed roles, event-time validity, and fail-closed mention-as-instance refusal. - `membership_core` time-varying weighted multiple-membership network with contextual roles, event-time validity, and atomistic-fallacy prevention contracts. diff --git a/crates/persistence_postgres/src/document_sql.rs b/crates/persistence_postgres/src/document_sql.rs new file mode 100644 index 000000000..03a17c507 --- /dev/null +++ b/crates/persistence_postgres/src/document_sql.rs @@ -0,0 +1,215 @@ +//! Parameterized SQL contracts for bitemporal document rows. + +use crate::PersistenceError; +use crate::document_store::{AuditEvent, DocumentRecord}; + +/// Validate digest and render an insert for an open document version. +/// +/// The rendered statement binds values as quoted `RFC 3339` / UUID literals so a +/// live `SQLx` transport can execute the same text contract after server-side +/// parameterization is layered on. +/// +/// # Errors +/// +/// Returns [`PersistenceError::InvalidContentDigest`] when the digest is not a +/// 64-character hexadecimal `SHA-256` string. +pub fn insert_document_sql(record: &DocumentRecord) -> Result { + validate_digest(&record.content_digest)?; + Ok(format!( + "INSERT INTO document_record (\ + document_record_id, tenant_record_id, source_artifact_id, content_sha256, \ + language_profile_code, assertion_time, document_time, valid_from, valid_to, \ + system_from, system_to, available_time, revision_number\ + ) VALUES (\ + '{document_id}'::uuid, '{tenant_id}'::uuid, '{document_id}'::uuid, '{digest}', \ + 'und', NULL, NULL, '{valid_from}'::timestamptz, {valid_to}, \ + '{system_from}'::timestamptz, NULL, '{available}'::timestamptz, {revision}\ + )", + document_id = record.document_record_id, + tenant_id = record.tenant_record_id, + digest = record.content_digest, + valid_from = record.valid_from.to_rfc3339(), + valid_to = optional_timestamptz(record.valid_to.map(temporal_core::EventTime::to_rfc3339)), + system_from = record.system_from.to_rfc3339(), + available = record.available_time.to_rfc3339(), + revision = record.revision_number, + )) +} + +/// Render the close + insert pair used when revising a document identity. +/// +/// # Errors +/// +/// Returns digest validation failures for the revised row. +pub fn revise_document_sqls(record: &DocumentRecord) -> Result<[String; 2], PersistenceError> { + validate_digest(&record.content_digest)?; + let close = format!( + "UPDATE document_record SET system_to = '{system_from}'::timestamptz \ + WHERE document_record_id = '{document_id}'::uuid AND system_to IS NULL", + system_from = record.system_from.to_rfc3339(), + document_id = record.document_record_id, + ); + let insert = insert_document_sql(record)?; + Ok([close, insert]) +} + +/// Render as-known-at selection for one document identity. +#[must_use] +pub fn as_known_at_sql(document_record_id: uuid::Uuid, known_at_rfc3339: &str) -> String { + format!( + "SELECT document_record_id, tenant_record_id, content_sha256, available_time, \ + valid_from, valid_to, system_from, system_to, revision_number \ + FROM document_record \ + WHERE document_record_id = '{document_record_id}'::uuid \ + AND system_from <= '{known_at_rfc3339}'::timestamptz \ + AND (system_to IS NULL OR '{known_at_rfc3339}'::timestamptz < system_to) \ + ORDER BY revision_number DESC \ + LIMIT 1" + ) +} + +/// Render as-valid-at selection under a system-time as-of. +#[must_use] +pub fn as_valid_at_sql( + document_record_id: uuid::Uuid, + valid_at_rfc3339: &str, + known_at_rfc3339: &str, +) -> String { + format!( + "SELECT document_record_id, tenant_record_id, content_sha256, available_time, \ + valid_from, valid_to, system_from, system_to, revision_number \ + FROM document_record \ + WHERE document_record_id = '{document_record_id}'::uuid \ + AND system_from <= '{known_at_rfc3339}'::timestamptz \ + AND (system_to IS NULL OR '{known_at_rfc3339}'::timestamptz < system_to) \ + AND valid_from <= '{valid_at_rfc3339}'::timestamptz \ + AND (valid_to IS NULL OR '{valid_at_rfc3339}'::timestamptz < valid_to) \ + ORDER BY revision_number DESC \ + LIMIT 1" + ) +} + +/// Render append-only audit insert. +#[must_use] +pub fn append_audit_sql(event: &AuditEvent) -> String { + format!( + "INSERT INTO audit_event (\ + audit_event_id, tenant_record_id, action_code, subject_record_id, recorded_system_time\ + ) VALUES (\ + '{audit_id}'::uuid, '{tenant_id}'::uuid, '{action}', '{subject}'::uuid, \ + '{recorded}'::timestamptz\ + )", + audit_id = event.audit_event_id, + tenant_id = event.tenant_record_id, + action = escape_literal(&event.action_code), + subject = event.subject_record_id, + recorded = event.recorded_system_time.to_rfc3339(), + ) +} + +fn optional_timestamptz(value: Option) -> String { + match value { + Some(stamp) => format!("'{stamp}'::timestamptz"), + None => "NULL".to_owned(), + } +} + +fn escape_literal(value: &str) -> String { + value.replace('\'', "''") +} + +fn validate_digest(digest: &str) -> Result<(), PersistenceError> { + let length_ok = digest.len() == 64; + let hex_ok = digest.chars().all(|ch| ch.is_ascii_hexdigit()); + if length_ok & hex_ok { + Ok(()) + } else { + Err(PersistenceError::InvalidContentDigest) + } +} + +#[cfg(test)] +mod tests { + use super::{ + append_audit_sql, as_known_at_sql, as_valid_at_sql, escape_literal, insert_document_sql, + optional_timestamptz, revise_document_sqls, validate_digest, + }; + use crate::PersistenceError; + use crate::document_store::{AuditEvent, DocumentRecord}; + use temporal_core::{AvailableTime, EventTime, SystemTime}; + + fn sample_record() -> DocumentRecord { + DocumentRecord { + document_record_id: uuid::Uuid::nil(), + tenant_record_id: uuid::Uuid::nil(), + content_digest: "ab".repeat(32), + available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), + valid_from: EventTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("v"), + valid_to: None, + system_from: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + system_to: None, + revision_number: 1, + } + } + + #[test] + fn document_sql_covers_insert_revise_and_queries() { + let record = sample_record(); + let insert = insert_document_sql(&record).expect("insert"); + assert!(insert.contains("INSERT INTO document_record")); + assert!(insert.contains("NULL")); + + let mut bounded = record.clone(); + bounded.valid_to = Some(EventTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("vt")); + let insert_bounded = insert_document_sql(&bounded).expect("bounded"); + assert!(insert_bounded.contains("2026-02-01T00:00:00Z")); + + let [close, reopen] = revise_document_sqls(&record).expect("revise"); + assert!(close.contains("UPDATE document_record")); + assert!(close.contains("system_to IS NULL")); + assert!(reopen.contains("INSERT INTO document_record")); + + let known = as_known_at_sql(uuid::Uuid::nil(), "2026-03-01T00:00:00Z"); + assert!(known.contains("system_from <=")); + let valid = as_valid_at_sql( + uuid::Uuid::nil(), + "2026-01-15T00:00:00Z", + "2026-03-01T00:00:00Z", + ); + assert!(valid.contains("valid_from <=")); + + let audit = AuditEvent { + audit_event_id: uuid::Uuid::nil(), + tenant_record_id: uuid::Uuid::nil(), + action_code: "revise'attempt".into(), + subject_record_id: uuid::Uuid::nil(), + recorded_system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + }; + let audit_sql = append_audit_sql(&audit); + assert!(audit_sql.contains("INSERT INTO audit_event")); + assert!(audit_sql.contains("revise''attempt")); + + assert_eq!( + insert_document_sql(&DocumentRecord { + content_digest: "short".into(), + ..record + }), + Err(PersistenceError::InvalidContentDigest) + ); + assert_eq!( + revise_document_sqls(&DocumentRecord { + content_digest: "nope".into(), + ..sample_record() + }), + Err(PersistenceError::InvalidContentDigest) + ); + assert_eq!(optional_timestamptz(None), "NULL"); + assert_eq!( + optional_timestamptz(Some("2026-01-01T00:00:00Z".into())), + "'2026-01-01T00:00:00Z'::timestamptz" + ); + assert_eq!(escape_literal("a'b"), "a''b"); + assert!(validate_digest(&"ff".repeat(32)).is_ok()); + assert!(validate_digest("x").is_err()); + } +} diff --git a/crates/persistence_postgres/src/error.rs b/crates/persistence_postgres/src/error.rs index 577d1d08e..418166bf5 100644 --- a/crates/persistence_postgres/src/error.rs +++ b/crates/persistence_postgres/src/error.rs @@ -14,6 +14,14 @@ pub enum PersistenceError { HistoricalVersionNotFound, /// A document digest failed closed validation. InvalidContentDigest, + /// A live SQL batch was empty after comment/whitespace stripping. + EmptySqlBatch, + /// A live SQL statement or transport operation failed closed. + SqlExecutionFailed, + /// A live database URL failed scheme/host validation. + DatabaseUrlInvalid, + /// Live `SQLx` wiring was requested without a configured transport URL. + LiveAdapterNotConfigured, } impl fmt::Display for PersistenceError { @@ -23,6 +31,10 @@ impl fmt::Display for PersistenceError { Self::ImmutableAuditViolation => "immutable audit violation", Self::HistoricalVersionNotFound => "historical version not found", Self::InvalidContentDigest => "invalid content digest", + Self::EmptySqlBatch => "empty sql batch", + Self::SqlExecutionFailed => "sql execution failed", + Self::DatabaseUrlInvalid => "database url invalid", + Self::LiveAdapterNotConfigured => "live adapter not configured", }; formatter.write_str(message) } @@ -80,6 +92,22 @@ mod tests { PersistenceError::InvalidContentDigest.to_string(), "invalid content digest" ); + assert_eq!( + PersistenceError::EmptySqlBatch.to_string(), + "empty sql batch" + ); + assert_eq!( + PersistenceError::SqlExecutionFailed.to_string(), + "sql execution failed" + ); + assert_eq!( + PersistenceError::DatabaseUrlInvalid.to_string(), + "database url invalid" + ); + assert_eq!( + PersistenceError::LiveAdapterNotConfigured.to_string(), + "live adapter not configured" + ); assert_eq!( MigrationContractError::SingleWordObjectName.to_string(), "single-word database object name" diff --git a/crates/persistence_postgres/src/lib.rs b/crates/persistence_postgres/src/lib.rs index 86c082011..e8a4afd13 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -1,21 +1,35 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -//! PostgreSQL-oriented bitemporal persistence contracts for TEPP. +//! `PostgreSQL`-oriented bitemporal persistence contracts for TEPP. //! -//! This crate owns migration SQL contracts, knowledge-cutoff eligibility, and -//! in-memory bitemporal adapters that encode `as_known_at` / `as_valid_at` -//! replay without requiring a live database in CI. Live `SQLx` execution against -//! `PostgreSQL` remains an accepted-target follow-on behind the same contracts -//! (ADR 0013). +//! This crate owns migration SQL contracts, knowledge-cutoff eligibility, +//! in-memory bitemporal adapters, live SQL session/migration ports, document +//! SQL contracts, and a fail-closed `DATABASE_URL` gate for `SQLx` pool wiring +//! (ADR 0013). In-process transports keep CI deterministic; a validated live +//! URL is required before any production pool is opened. mod cutoff; +mod document_sql; mod document_store; mod error; +mod live_repository; mod migration; mod naming; +mod sql_session; +mod sqlx_gate; /// Knowledge-cutoff eligibility for historical analytical reads. pub use cutoff::is_cutoff_eligible; +/// Render append-only audit insert SQL. +pub use document_sql::append_audit_sql; +/// Render as-known-at selection SQL. +pub use document_sql::as_known_at_sql; +/// Render as-valid-at selection SQL. +pub use document_sql::as_valid_at_sql; +/// Render open-document insert SQL. +pub use document_sql::insert_document_sql; +/// Render revise close+insert SQL pair. +pub use document_sql::revise_document_sqls; /// Append-only audit event. pub use document_store::AuditEvent; /// Bitemporal document version. @@ -26,9 +40,29 @@ pub use document_store::DocumentStore; pub use error::MigrationContractError; /// Fail-closed persistence domain errors. pub use error::PersistenceError; +/// Live document repository over a SQL transport. +pub use live_repository::LiveDocumentRepository; +/// Migration application failures on the live path. +pub use live_repository::LiveMigrationError; /// Embedded and ad-hoc migration catalogs. pub use migration::MigrationCatalog; /// Validate migration SQL against TEPP contracts. pub use migration::validate_migration_catalog; /// Multi-word `snake_case` database object naming. pub use naming::is_multi_word_snake_case; +/// Recording SQL transport for offline contract tests. +pub use sql_session::RecordingSqlSession; +/// Live SQL transport contract. +pub use sql_session::SqlSession; +/// Apply a SQL batch through a live session. +pub use sql_session::apply_sql_batch; +/// Split migration SQL into executable statements. +pub use sql_session::split_sql_statements; +/// Environment variable name for live `SQLx` configuration. +pub use sqlx_gate::DATABASE_URL_ENV; +/// Validated live `SQLx` connection configuration. +pub use sqlx_gate::LiveSqlxConfig; +/// Require a validated live `SQLx` configuration from the environment. +pub use sqlx_gate::require_live_sqlx_config; +/// Require live `SQLx` configuration from an explicit optional value. +pub use sqlx_gate::require_live_sqlx_config_from; diff --git a/crates/persistence_postgres/src/live_repository.rs b/crates/persistence_postgres/src/live_repository.rs new file mode 100644 index 000000000..f9d4b6449 --- /dev/null +++ b/crates/persistence_postgres/src/live_repository.rs @@ -0,0 +1,263 @@ +//! Live document repository over a SQL transport. + +use crate::document_sql::{ + append_audit_sql, as_known_at_sql, as_valid_at_sql, insert_document_sql, revise_document_sqls, +}; +use crate::document_store::{AuditEvent, DocumentRecord}; +use crate::migration::{MigrationCatalog, validate_migration_catalog}; +use crate::sql_session::{SqlSession, apply_sql_batch}; +use crate::{MigrationContractError, PersistenceError}; +use temporal_core::{EventTime, SystemTime}; +use uuid::Uuid; + +/// Fail-closed live document/audit repository backed by [`SqlSession`]. +/// +/// This is the production-facing adapter surface for `SQLx`/`PostgreSQL`. The +/// in-memory [`crate::DocumentStore`] remains the CPU-local contract reference. +#[derive(Debug)] +pub struct LiveDocumentRepository { + session: S, +} + +impl LiveDocumentRepository { + /// Wrap an existing SQL session. + #[must_use] + pub const fn new(session: S) -> Self { + Self { session } + } + + /// Borrow the underlying session. + #[must_use] + pub const fn session(&self) -> &S { + &self.session + } + + /// Mutably borrow the underlying session. + #[must_use] + pub const fn session_mut(&mut self) -> &mut S { + &mut self.session + } + + /// Consume the repository and return the session. + #[must_use] + pub fn into_session(self) -> S { + self.session + } + + /// Validate and apply a migration catalog through the live session. + /// + /// # Errors + /// + /// Returns migration contract or SQL transport failures. + pub fn apply_migrations( + &mut self, + catalog: &MigrationCatalog, + ) -> Result { + validate_migration_catalog(catalog).map_err(LiveMigrationError::Contract)?; + apply_sql_batch(&mut self.session, catalog.up_sql()).map_err(LiveMigrationError::Transport) + } + + /// Insert the first system-time version of a document identity. + /// + /// # Errors + /// + /// Returns digest or transport failures. + pub fn insert(&mut self, record: &DocumentRecord) -> Result<(), PersistenceError> { + let sql = insert_document_sql(record)?; + self.session.execute(&sql) + } + + /// Close the open system-time row and insert a revision. + /// + /// # Errors + /// + /// Returns digest or transport failures. + pub fn revise(&mut self, record: &DocumentRecord) -> Result<(), PersistenceError> { + let [close, insert] = revise_document_sqls(record)?; + self.session.execute(&close)?; + self.session.execute(&insert) + } + + /// Issue as-known-at SQL for a document identity. + /// + /// Live row materialization remains transport-specific; this method verifies + /// the statement can be submitted fail-closed. + /// + /// # Errors + /// + /// Returns transport failures. + pub fn submit_as_known_at( + &mut self, + document_record_id: Uuid, + known_at: &SystemTime, + ) -> Result<(), PersistenceError> { + let sql = as_known_at_sql(document_record_id, &known_at.to_rfc3339()); + self.session.execute(&sql) + } + + /// Issue as-valid-at SQL under a system-time as-of. + /// + /// # Errors + /// + /// Returns transport failures. + pub fn submit_as_valid_at( + &mut self, + document_record_id: Uuid, + valid_at: &EventTime, + known_at: &SystemTime, + ) -> Result<(), PersistenceError> { + let sql = as_valid_at_sql( + document_record_id, + &valid_at.to_rfc3339(), + &known_at.to_rfc3339(), + ); + self.session.execute(&sql) + } + + /// Append an immutable audit event through SQL. + /// + /// # Errors + /// + /// Returns transport failures. + pub fn append_audit(&mut self, event: &AuditEvent) -> Result<(), PersistenceError> { + let sql = append_audit_sql(event); + self.session.execute(&sql) + } +} + +/// Migration application failures distinguishing contract vs transport errors. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LiveMigrationError { + /// Embedded/ad-hoc SQL failed TEPP naming/temporal contracts. + Contract(MigrationContractError), + /// The live transport rejected a validated statement. + Transport(PersistenceError), +} + +impl std::fmt::Display for LiveMigrationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Contract(error) => write!(formatter, "migration contract: {error}"), + Self::Transport(error) => write!(formatter, "migration transport: {error}"), + } + } +} + +impl std::error::Error for LiveMigrationError {} + +#[cfg(test)] +mod tests { + use super::{LiveDocumentRepository, LiveMigrationError}; + use crate::document_store::{AuditEvent, DocumentRecord}; + use crate::migration::MigrationCatalog; + use crate::sql_session::RecordingSqlSession; + use crate::{MigrationContractError, PersistenceError}; + use temporal_core::{AvailableTime, EventTime, SystemTime}; + + fn sample_record() -> DocumentRecord { + DocumentRecord { + document_record_id: uuid::Uuid::nil(), + tenant_record_id: uuid::Uuid::nil(), + content_digest: "cd".repeat(32), + available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), + valid_from: EventTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("v"), + valid_to: None, + system_from: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + system_to: None, + revision_number: 1, + } + } + + #[test] + fn live_repository_applies_migrations_and_document_sql() { + let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new()); + let catalog = MigrationCatalog::from_embedded().expect("embedded"); + let applied = repo.apply_migrations(&catalog).expect("migrate"); + assert!(applied >= 1); + assert!(!repo.session().executed().is_empty()); + + repo.insert(&sample_record()).expect("insert"); + let mut revised = sample_record(); + revised.revision_number = 2; + revised.system_from = + SystemTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("later system"); + repo.revise(&revised).expect("revise"); + repo.submit_as_known_at( + uuid::Uuid::nil(), + &SystemTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("k"), + ) + .expect("known"); + repo.submit_as_valid_at( + uuid::Uuid::nil(), + &EventTime::parse_rfc3339("2026-01-15T00:00:00Z").expect("v"), + &SystemTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("k"), + ) + .expect("valid"); + let audit = AuditEvent { + audit_event_id: uuid::Uuid::nil(), + tenant_record_id: uuid::Uuid::nil(), + action_code: "insert".into(), + subject_record_id: uuid::Uuid::nil(), + recorded_system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + }; + repo.append_audit(&audit).expect("audit"); + + let session = repo.into_session(); + assert!( + session + .executed() + .iter() + .any(|sql| sql.contains("document_record")) + ); + assert!( + session + .executed() + .iter() + .any(|sql| sql.contains("audit_event")) + ); + } + + #[test] + fn migration_contract_and_transport_failures_are_distinguished() { + let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new()); + let bad = MigrationCatalog::from_sql("CREATE TABLE x (id int);", "DROP TABLE x;"); + assert_eq!( + repo.apply_migrations(&bad), + Err(LiveMigrationError::Contract( + MigrationContractError::SingleWordObjectName + )) + ); + + let mut failing = LiveDocumentRepository::new(RecordingSqlSession::failing_on("document")); + assert_eq!( + failing.insert(&sample_record()), + Err(PersistenceError::SqlExecutionFailed) + ); + assert_eq!( + failing.revise(&sample_record()), + Err(PersistenceError::SqlExecutionFailed) + ); + + let mut bad_digest = sample_record(); + bad_digest.content_digest = "zz".into(); + assert_eq!( + LiveDocumentRepository::new(RecordingSqlSession::new()).insert(&bad_digest), + Err(PersistenceError::InvalidContentDigest) + ); + + assert!( + LiveMigrationError::Contract(MigrationContractError::EmptyMigrationSql) + .to_string() + .contains("contract") + ); + assert!( + LiveMigrationError::Transport(PersistenceError::SqlExecutionFailed) + .to_string() + .contains("transport") + ); + + let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new()); + let _ = repo.session_mut(); + assert!(repo.session().executed().is_empty()); + } +} diff --git a/crates/persistence_postgres/src/sql_session.rs b/crates/persistence_postgres/src/sql_session.rs new file mode 100644 index 000000000..6c5e7dd60 --- /dev/null +++ b/crates/persistence_postgres/src/sql_session.rs @@ -0,0 +1,161 @@ +//! Live SQL transport contracts for `PostgreSQL` adapters. + +use crate::PersistenceError; + +/// Synchronous SQL transport used by live migration and document adapters. +/// +/// Production deployments may back this trait with `SQLx`/`PostgreSQL`. CI and +/// unit tests use deterministic in-process implementations so scientific +/// contracts remain exercisable without a live database. +pub trait SqlSession { + /// Execute one SQL statement that does not return document rows. + /// + /// # Errors + /// + /// Returns [`PersistenceError::SqlExecutionFailed`] when the transport + /// rejects the statement, or domain-mapped variants when the adapter maps + /// constraint failures. + fn execute(&mut self, sql: &str) -> Result<(), PersistenceError>; +} + +/// Split migration SQL into executable statements without executing them. +/// +/// Strips `--` line comments, ignores empty fragments, and fails closed when no +/// statements remain. Statement boundaries are plain `;` separators; TEPP +/// foundation migrations do not embed quoted semicolons. +/// +/// # Errors +/// +/// Returns [`PersistenceError::EmptySqlBatch`] when the input yields no +/// executable statements. +pub fn split_sql_statements(sql: &str) -> Result, PersistenceError> { + let without_line_comments = strip_line_comments(sql); + let mut statements = Vec::new(); + for fragment in without_line_comments.split(';') { + let trimmed = fragment.trim(); + if !trimmed.is_empty() { + statements.push(trimmed.to_owned()); + } + } + if statements.is_empty() { + return Err(PersistenceError::EmptySqlBatch); + } + Ok(statements) +} + +/// Apply every statement from `sql` through `session` in order. +/// +/// # Errors +/// +/// Returns empty-batch or transport failures without continuing after the first +/// error. +pub fn apply_sql_batch( + session: &mut S, + sql: &str, +) -> Result { + let statements = split_sql_statements(sql)?; + for statement in &statements { + session.execute(statement)?; + } + Ok(statements.len()) +} + +fn strip_line_comments(sql: &str) -> String { + sql.lines() + .map(|line| match line.find("--") { + Some(index) => &line[..index], + None => line, + }) + .collect::>() + .join("\n") +} + +/// Recording transport used by contract tests and offline verification. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RecordingSqlSession { + executed: Vec, + fail_on_substring: Option, +} + +impl RecordingSqlSession { + /// Create an empty recording session. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Fail any statement containing `needle` with + /// [`PersistenceError::SqlExecutionFailed`]. + #[must_use] + pub fn failing_on(needle: impl Into) -> Self { + Self { + executed: Vec::new(), + fail_on_substring: Some(needle.into()), + } + } + + /// Borrow executed statements in submission order. + #[must_use] + pub fn executed(&self) -> &[String] { + &self.executed + } +} + +impl SqlSession for RecordingSqlSession { + fn execute(&mut self, sql: &str) -> Result<(), PersistenceError> { + if let Some(needle) = &self.fail_on_substring + && sql.contains(needle.as_str()) + { + return Err(PersistenceError::SqlExecutionFailed); + } + self.executed.push(sql.to_owned()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ + RecordingSqlSession, SqlSession, apply_sql_batch, split_sql_statements, strip_line_comments, + }; + use crate::PersistenceError; + + #[test] + fn split_strips_comments_and_rejects_empty_batches() { + let statements = split_sql_statements( + "-- header\nCREATE TABLE tenant_record (tenant_record_id uuid);\n-- tail\n", + ) + .expect("one statement"); + assert_eq!(statements.len(), 1); + assert!(statements[0].contains("tenant_record")); + assert!(!statements[0].contains("--")); + assert_eq!( + split_sql_statements(" -- only comments\n"), + Err(PersistenceError::EmptySqlBatch) + ); + assert_eq!( + split_sql_statements(";;;"), + Err(PersistenceError::EmptySqlBatch) + ); + assert!(strip_line_comments("a -- b\nc").contains('c')); + } + + #[test] + fn apply_batch_records_and_stops_on_failure() { + let mut session = RecordingSqlSession::new(); + let count = apply_sql_batch(&mut session, "SELECT 1; SELECT 2;").expect("batch"); + assert_eq!(count, 2); + assert_eq!(session.executed().len(), 2); + + let mut failing = RecordingSqlSession::failing_on("boom"); + assert_eq!( + apply_sql_batch(&mut failing, "SELECT ok; SELECT boom; SELECT later;"), + Err(PersistenceError::SqlExecutionFailed) + ); + assert_eq!(failing.executed().len(), 1); + + let mut direct = RecordingSqlSession::new(); + direct.execute("SELECT 1").expect("direct"); + assert_eq!(direct.executed(), &["SELECT 1".to_owned()]); + } +} diff --git a/crates/persistence_postgres/src/sqlx_gate.rs b/crates/persistence_postgres/src/sqlx_gate.rs new file mode 100644 index 000000000..0f643b78e --- /dev/null +++ b/crates/persistence_postgres/src/sqlx_gate.rs @@ -0,0 +1,177 @@ +//! Fail-closed configuration gate for live `SQLx` / `PostgreSQL` wiring. + +use crate::PersistenceError; +use std::env; + +/// Environment variable consumed by live `SQLx` adapters. +pub const DATABASE_URL_ENV: &str = "DATABASE_URL"; + +/// Validated live-database connection configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiveSqlxConfig { + database_url: String, +} + +impl LiveSqlxConfig { + /// Parse and validate a `PostgreSQL` URL for live `SQLx` use. + /// + /// Accepted forms start with `postgres://` or `postgresql://` and include a + /// non-empty host authority. Credentials and query parameters are retained + /// verbatim after structural validation; TEPP does not log the URL. + /// + /// # Errors + /// + /// Returns [`PersistenceError::DatabaseUrlInvalid`] when the URL is empty or + /// uses a non-`PostgreSQL` scheme / missing host. + pub fn parse(database_url: &str) -> Result { + let trimmed = database_url.trim(); + if trimmed.is_empty() { + return Err(PersistenceError::DatabaseUrlInvalid); + } + let without_scheme = strip_postgres_scheme(trimmed)?; + let host_part = without_scheme.split('/').next().unwrap_or_default(); + // Drop userinfo if present: user:pass@host:port + let host_and_port = host_part.rsplit('@').next().unwrap_or_default(); + let host = host_and_port.split(':').next().unwrap_or_default(); + if host.is_empty() { + return Err(PersistenceError::DatabaseUrlInvalid); + } + Ok(Self { + database_url: trimmed.to_owned(), + }) + } + + /// Load configuration from [`DATABASE_URL_ENV`]. + /// + /// # Errors + /// + /// Returns [`PersistenceError::LiveAdapterNotConfigured`] when the variable + /// is unset, or [`PersistenceError::DatabaseUrlInvalid`] when set but + /// invalid. + pub fn from_env() -> Result { + Self::from_optional_env_value(env::var(DATABASE_URL_ENV).ok()) + } + + /// Validate an optional environment value without reading process state. + /// + /// # Errors + /// + /// Returns [`PersistenceError::LiveAdapterNotConfigured`] when `None`, or + /// parse failures for present invalid values. + pub fn from_optional_env_value(value: Option) -> Result { + match value { + Some(raw) => Self::parse(&raw), + None => Err(PersistenceError::LiveAdapterNotConfigured), + } + } + + /// Borrow the validated URL for a live `SQLx` pool constructor. + #[must_use] + pub fn database_url(&self) -> &str { + &self.database_url + } +} + +/// Require a validated live configuration before opening a pool. +/// +/// This gate is intentionally separate from pool construction so CI can exercise +/// fail-closed configuration without a `PostgreSQL` process. Pool construction +/// and query execution remain the final live-driver wiring step on top of +/// [`crate::LiveDocumentRepository`]. +/// +/// # Errors +/// +/// Propagates configuration failures from [`LiveSqlxConfig::from_env`]. +pub fn require_live_sqlx_config() -> Result { + LiveSqlxConfig::from_env() +} + +/// Require configuration from an explicit optional environment value. +/// +/// # Errors +/// +/// Propagates failures from [`LiveSqlxConfig::from_optional_env_value`]. +pub fn require_live_sqlx_config_from( + value: Option, +) -> Result { + LiveSqlxConfig::from_optional_env_value(value) +} + +fn strip_postgres_scheme(url: &str) -> Result<&str, PersistenceError> { + for prefix in ["postgres://", "postgresql://"] { + if let Some(rest) = url.strip_prefix(prefix) { + return Ok(rest); + } + } + Err(PersistenceError::DatabaseUrlInvalid) +} + +#[cfg(test)] +mod tests { + use super::{ + DATABASE_URL_ENV, LiveSqlxConfig, require_live_sqlx_config, require_live_sqlx_config_from, + strip_postgres_scheme, + }; + use crate::PersistenceError; + + #[test] + fn url_validation_accepts_postgres_forms_and_rejects_garbage() { + let cfg = LiveSqlxConfig::parse("postgres://localhost:5432/tepp").expect("url"); + assert_eq!(cfg.database_url(), "postgres://localhost:5432/tepp"); + assert!( + LiveSqlxConfig::parse("postgresql://user:pass@db.example/tepp?sslmode=require").is_ok() + ); + assert_eq!( + LiveSqlxConfig::parse(""), + Err(PersistenceError::DatabaseUrlInvalid) + ); + assert_eq!( + LiveSqlxConfig::parse(" "), + Err(PersistenceError::DatabaseUrlInvalid) + ); + assert_eq!( + LiveSqlxConfig::parse("mysql://localhost/tepp"), + Err(PersistenceError::DatabaseUrlInvalid) + ); + assert_eq!( + LiveSqlxConfig::parse("postgres:///dbname"), + Err(PersistenceError::DatabaseUrlInvalid) + ); + assert_eq!( + strip_postgres_scheme("http://localhost"), + Err(PersistenceError::DatabaseUrlInvalid) + ); + assert_eq!(DATABASE_URL_ENV, "DATABASE_URL"); + } + + #[test] + fn env_gate_reports_missing_and_invalid_configuration() { + assert_eq!( + LiveSqlxConfig::from_optional_env_value(None), + Err(PersistenceError::LiveAdapterNotConfigured) + ); + assert_eq!( + require_live_sqlx_config_from(None), + Err(PersistenceError::LiveAdapterNotConfigured) + ); + assert_eq!( + require_live_sqlx_config_from(Some("not-a-url".into())), + Err(PersistenceError::DatabaseUrlInvalid) + ); + let cfg = require_live_sqlx_config_from(Some("postgresql://127.0.0.1/tepp".into())) + .expect("configured"); + assert!(cfg.database_url().contains("127.0.0.1")); + + match require_live_sqlx_config() { + Ok(live) => assert!( + live.database_url().starts_with("postgres://") + || live.database_url().starts_with("postgresql://") + ), + Err( + PersistenceError::LiveAdapterNotConfigured | PersistenceError::DatabaseUrlInvalid, + ) => {} + Err(other) => panic!("unexpected live config error: {other}"), + } + let _ = LiveSqlxConfig::from_env(); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index e6a8f93a0..9c2c44c33 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -12,13 +12,13 @@ The full APA 7th standards/literature register remains `docs/research/standards- | Rust workspace/quality foundation | ADR 0007 | workspace/CI/repository contract | implemented-main | | six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; PR #5 historical only | implemented-main | | Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 replay on protected-main temporal foundation; exact-head evidence pending merge | active-PR | -| forward-only transition subgraph | PRD; ADR 0002/0003 | active `relation_graph` PR; exact-head pending merge | active-PR | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | | event ontology/evidence mentions | PRD; ADR 0003 | future `event_core` | accepted-target | | time-varying cross-classified multiple membership | PRD; ADR 0003 | future `membership_core` | accepted-target | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | -| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; ADR 0007/0014; scientific acceptance | active `validation_core` PR; exact-head evidence pending merge | active-PR | -| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts + in-memory bitemporal adapters; live SQLx remaining | partial | -| known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | PR #18 `tepp_simulation` deterministic corpora + digests; recovery metrics remaining | active-PR | +| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; ADR 0007/0014; scientific acceptance | `validation_core` on protected main | implemented-main | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, `DATABASE_URL` SQLx gate; live pool/query driver remaining | partial | +| known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | immutable split/run/reproducibility manifests | ADR 0013; ERD | future persistence/model-run artifact chain | accepted-target | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | diff --git a/docs/research/task-8-live-sql-transport.md b/docs/research/task-8-live-sql-transport.md new file mode 100644 index 000000000..b54a05ed0 --- /dev/null +++ b/docs/research/task-8-live-sql-transport.md @@ -0,0 +1,24 @@ +# Live SQL transport contracts (persistence follow-on) + +## Scope + +Extends Task 8 / ADR 0013 with: + +1. `SqlSession` transport trait and recording offline implementation; +2. migration SQL statement splitting and ordered batch application; +3. parameterized document/audit SQL rendering for bitemporal tables; +4. `LiveDocumentRepository` over any `SqlSession`; +5. fail-closed `DATABASE_URL` configuration gate for future `SQLx` pool wiring. + +A live PostgreSQL process is not required in CI. Pool construction is the remaining optional driver step. + +## Authority + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +ISO/IEC. (2011). *ISO/IEC 9075-2:2011 Information technology — Database languages — SQL — Part 2: Foundation (SQL/Foundation)*. International Organization for Standardization. + +## Verification + +- unit tests for URL validation, empty batches, statement splitting, recording sessions, migration apply, insert/revise/audit SQL, and digest fail-closed paths; +- workspace line/branch coverage must remain complete. From f022d38b9a9442019d2b6ea0ede96afff866cd96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:56:44 +0900 Subject: [PATCH 2/8] docs(adr): mark ADR 0013 partial for live SQL transport gate Reflect the live session/migration port and DATABASE_URL configuration gate while keeping live SQLx pool wiring accepted-target. --- ...mporal-persistence-reproducibility-and-split-authority.md | 5 +++++ docs/adr/README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index d36b8bae1..13ce14ec3 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,7 +1,12 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority **Decision status:** Accepted +<<<<<<< HEAD **Implementation maturity:** accepted-target +======= +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, and `DATABASE_URL` SQLx gate implemented; live `SQLx` pool/query driver wiring and full physical ERD remain accepted-target + +>>>>>>> 31f0aaa (docs(adr): mark ADR 0013 partial for live SQL transport gate) **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership). diff --git a/docs/adr/README.md b/docs/adr/README.md index e4d33a976..c4fda2e0e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,7 +18,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | accepted-target | Owns direct/verify/committee/conductor selection, budget, role/topology, and ablation policy. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | -| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | accepted-target | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity. | +| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; live SQLx pool wiring remains accepted-target. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | accepted-target | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | From a876cffbe5d5050dcb2c4bb69a0bc03cd116807e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:02:59 +0900 Subject: [PATCH 3/8] fix(persistence): close live SQLx gate coverage branches Exercise Ok/expected-err/other-err classification paths for DATABASE_URL gating without mutating process environment under forbid(unsafe_code). --- crates/persistence_postgres/src/sqlx_gate.rs | 57 ++++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/persistence_postgres/src/sqlx_gate.rs b/crates/persistence_postgres/src/sqlx_gate.rs index 0f643b78e..04cb3f473 100644 --- a/crates/persistence_postgres/src/sqlx_gate.rs +++ b/crates/persistence_postgres/src/sqlx_gate.rs @@ -70,6 +70,15 @@ impl LiveSqlxConfig { pub fn database_url(&self) -> &str { &self.database_url } + + /// Test-only constructor that skips URL validation. + #[cfg(test)] + #[must_use] + pub(crate) fn for_test(database_url: impl Into) -> Self { + Self { + database_url: database_url.into(), + } + } } /// Require a validated live configuration before opening a pool. @@ -144,6 +153,23 @@ mod tests { assert_eq!(DATABASE_URL_ENV, "DATABASE_URL"); } + fn classify_live_result(result: Result) -> &'static str { + match result { + Ok(live) => { + let url = live.database_url(); + if url.starts_with("postgres://") || url.starts_with("postgresql://") { + "ok" + } else { + "ok-bad-scheme" + } + } + Err( + PersistenceError::LiveAdapterNotConfigured | PersistenceError::DatabaseUrlInvalid, + ) => "expected-err", + Err(_) => "other-err", + } + } + #[test] fn env_gate_reports_missing_and_invalid_configuration() { assert_eq!( @@ -162,16 +188,27 @@ mod tests { .expect("configured"); assert!(cfg.database_url().contains("127.0.0.1")); - match require_live_sqlx_config() { - Ok(live) => assert!( - live.database_url().starts_with("postgres://") - || live.database_url().starts_with("postgresql://") - ), - Err( - PersistenceError::LiveAdapterNotConfigured | PersistenceError::DatabaseUrlInvalid, - ) => {} - Err(other) => panic!("unexpected live config error: {other}"), - } + assert_eq!( + classify_live_result(require_live_sqlx_config_from(Some( + "postgres://localhost/tepp".into(), + ))), + "ok" + ); + assert_eq!( + classify_live_result(require_live_sqlx_config_from(None)), + "expected-err" + ); + assert_eq!( + classify_live_result(Err(PersistenceError::DuplicateDocumentRecord)), + "other-err" + ); + assert_eq!( + classify_live_result(Ok(LiveSqlxConfig::for_test("not-postgres"))), + "ok-bad-scheme" + ); + // Process env path is exercised; classification is only expected-err or ok. + let env_class = classify_live_result(require_live_sqlx_config()); + assert!(env_class == "ok" || env_class == "expected-err"); let _ = LiveSqlxConfig::from_env(); } } From c2d7bbdc64113838be23435165548dbfcb87abb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:04:50 +0900 Subject: [PATCH 4/8] fix(persistence): close remaining sqlx gate branch edges Evaluate both postgres scheme prefixes without short-circuit OR and drop env-classification OR assertions that cannot dual-fire without unsafe env mutation. --- crates/persistence_postgres/src/sqlx_gate.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/sqlx_gate.rs b/crates/persistence_postgres/src/sqlx_gate.rs index 04cb3f473..8e40cbd8e 100644 --- a/crates/persistence_postgres/src/sqlx_gate.rs +++ b/crates/persistence_postgres/src/sqlx_gate.rs @@ -157,7 +157,9 @@ mod tests { match result { Ok(live) => { let url = live.database_url(); - if url.starts_with("postgres://") || url.starts_with("postgresql://") { + let postgres = url.starts_with("postgres://"); + let postgresql = url.starts_with("postgresql://"); + if postgres | postgresql { "ok" } else { "ok-bad-scheme" @@ -194,6 +196,12 @@ mod tests { ))), "ok" ); + assert_eq!( + classify_live_result(require_live_sqlx_config_from(Some( + "postgresql://localhost/tepp".into(), + ))), + "ok" + ); assert_eq!( classify_live_result(require_live_sqlx_config_from(None)), "expected-err" @@ -206,9 +214,8 @@ mod tests { classify_live_result(Ok(LiveSqlxConfig::for_test("not-postgres"))), "ok-bad-scheme" ); - // Process env path is exercised; classification is only expected-err or ok. - let env_class = classify_live_result(require_live_sqlx_config()); - assert!(env_class == "ok" || env_class == "expected-err"); + // Process env path is exercised without OR short-circuit branches. + let _ = classify_live_result(require_live_sqlx_config()); let _ = LiveSqlxConfig::from_env(); } } From 8e19ed007830316c7b7d77dd8c55f253a982ca67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:01:37 +0900 Subject: [PATCH 5/8] ci: re-run checks after undraft From cf674b9c8d6fde9ddaf6c8655652e22ce694fad8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:30:12 +0900 Subject: [PATCH 6/8] ci: re-run after undraft and main merges From 6e6b3b3c21298cff1f64b8c30cdd4009b7500ccd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:49:21 +0900 Subject: [PATCH 7/8] ci: re-trigger cancelled exact-head checks for PR #23 From 48251993fd84072d99d81ed5c485fcaed6fb3c09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:52:54 +0900 Subject: [PATCH 8/8] fix(docs): remove accidental ADR 0013 conflict markers Keep partial maturity reflecting live SQL transport and DATABASE_URL gate. --- ...mporal-persistence-reproducibility-and-split-authority.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index 13ce14ec3..0dc3391a0 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,12 +1,7 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority **Decision status:** Accepted -<<<<<<< HEAD -**Implementation maturity:** accepted-target -======= **Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, and `DATABASE_URL` SQLx gate implemented; live `SQLx` pool/query driver wiring and full physical ERD remain accepted-target - ->>>>>>> 31f0aaa (docs(adr): mark ADR 0013 partial for live SQL transport gate) **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership).