diff --git a/CHANGELOG.md b/CHANGELOG.md index 625b5f3ca..b29952121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `persistence_postgres` event-instance SQL contracts: bitemporal insert and as-known-at lookup that refuse inverted valid/system windows and hostile type/lifecycle labels before SQL is rendered. - `persistence_postgres` event-mention SQL contracts: mention identity cannot equal the instance it supports; confidence must be finite and in `(0, 1]`. - `persistence_postgres` event-relation SQL contracts: closed ERD transition/provenance vocabulary bound to `transition_edge`, fail-closed unknown types and transition self-loops, live insert of `causes`/`references`. +- `persistence_postgres` source-artifact SQL contracts: append-only insert and primary-key lookup that refuse non-canonical `SHA-256` digests, negative sizes, and hostile media-type or object-store labels before SQL is rendered; identical-identity retries are `ON CONFLICT DO NOTHING` plus a stored-row match assertion, and a same-id payload change fails closed as `ConflictingSourceArtifact`. - `persistence_postgres` typed membership assignment (migration `0006`): `entity_record`, `project_record`, and `text_segment` plus exactly-one observed-unit and target constraints that replace the polymorphic `membership_target_id` stub, with SQL insert/lookup, fail-closed inverted-window and backslash-label refusal, and live proof that one document persists two entity memberships and one project membership. - Actions workflow fleet auditor (`scripts/actions_workflow_fleet.py`): paginated registry inventory bound to the exact default-branch SHA/tree, classification of present/orphan/disabled/GitHub-dynamic identities, and fail-closed orphan disable that confirms GitHub's official `disabled_manually` state. - `persistence_postgres` temporal interval ordering migration (`0005`): multi-word CHECK constraints on `document_record`, `event_instance`, and `membership_assignment` that reject inverted valid/system windows and non-positive document revisions while preserving open-ended NULL upper bounds and equal point bounds; catalog validation and live inverted-window proof. diff --git a/crates/persistence_postgres/src/artifact_sql.rs b/crates/persistence_postgres/src/artifact_sql.rs new file mode 100644 index 000000000..91e1ed742 --- /dev/null +++ b/crates/persistence_postgres/src/artifact_sql.rs @@ -0,0 +1,254 @@ +//! SQL contracts for append-only source artifacts (ADR 0008 / ADR 0013). + +use crate::PersistenceError; +use temporal_core::{AvailableTime, SystemTime}; +use uuid::Uuid; + +/// One append-only source artifact independent of document identity. +/// +/// Maps to `source_artifact`. The identity is never the content digest: +/// identical bytes may be acquired in different tenant or provenance +/// contexts. Digests are lowercase hex `SHA-256`. Size must be non-negative. +/// Media type and optional object-store references are fail-closed labels. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceArtifactRecord { + /// Artifact identity (independent of the content digest). + pub source_artifact_id: Uuid, + /// Owning tenant boundary. + pub tenant_record_id: Uuid, + /// Canonical `SHA-256` of the immutable source bytes. + pub content_sha256: String, + /// Declared payload size in bytes; must be `>= 0`. + pub source_size_bytes: i64, + /// Media type token (for example `text/plain`). + pub media_type_code: String, + /// Optional protected object-store reference. + pub protected_object_ref: Option, + /// System/record time when the artifact identity was asserted. + pub system_time: SystemTime, + /// Availability time of the artifact evidence. + pub available_time: AvailableTime, +} + +impl SourceArtifactRecord { + /// Fail-closed digest, size, and label validation. + /// + /// # Errors + /// + /// Returns [`PersistenceError::InvalidSourceArtifact`] when the digest is + /// not canonical lowercase hex `SHA-256`, the size is negative, or a + /// label is empty, oversized, or hostile. + pub fn validate(&self) -> Result<(), PersistenceError> { + validate_sha256_hex(&self.content_sha256)?; + if self.source_size_bytes < 0 { + return Err(PersistenceError::InvalidSourceArtifact); + } + validate_artifact_label(&self.media_type_code)?; + if let Some(object_ref) = &self.protected_object_ref { + validate_artifact_label(object_ref)?; + } + Ok(()) + } +} + +/// Render insert SQL for a validated source artifact. +/// +/// # Errors +/// +/// Returns [`PersistenceError::InvalidSourceArtifact`] before any SQL is produced. +pub fn insert_source_artifact_sql( + record: &SourceArtifactRecord, +) -> Result { + record.validate()?; + let object_ref_sql = match &record.protected_object_ref { + Some(value) => format!("'{value}'"), + None => "NULL".to_owned(), + }; + Ok(format!( + "INSERT INTO source_artifact (\ + source_artifact_id, tenant_record_id, content_sha256, source_size_bytes, \ + media_type_code, protected_object_ref, system_time, available_time\ + ) VALUES (\ + '{artifact}'::uuid, '{tenant}'::uuid, '{digest}', {size}, \ + '{media}', {object_ref_sql}, '{system}'::timestamptz, '{available}'::timestamptz\ + ) ON CONFLICT (source_artifact_id) DO NOTHING", + artifact = record.source_artifact_id, + tenant = record.tenant_record_id, + digest = record.content_sha256, + size = record.source_size_bytes, + media = record.media_type_code, + system = record.system_time.to_rfc3339(), + available = record.available_time.to_rfc3339(), + )) +} + +/// Compare two validated artifacts for ADR 0013 idempotent-retry equality. +#[must_use] +pub fn source_artifacts_are_idempotent_matches( + left: &SourceArtifactRecord, + right: &SourceArtifactRecord, +) -> bool { + left == right +} + +/// Render a fail-closed assertion that the stored row matches `record`. +/// +/// Used after `INSERT ... ON CONFLICT DO NOTHING` so a retry of the same +/// immutable identity succeeds and a same-id payload change raises +/// `conflicting source artifact`. +/// +/// # Errors +/// +/// Returns [`PersistenceError::InvalidSourceArtifact`] before any SQL is produced. +pub fn assert_source_artifact_matches_sql( + record: &SourceArtifactRecord, +) -> Result { + record.validate()?; + let object_ref_sql = match &record.protected_object_ref { + Some(value) => format!("'{value}'"), + None => "NULL".to_owned(), + }; + Ok(format!( + "DO $tepp_source_artifact_idempotent$\n\ + BEGIN\n\ + IF NOT EXISTS (\n\ + SELECT 1 FROM source_artifact\n\ + WHERE source_artifact_id = '{artifact}'::uuid\n\ + AND tenant_record_id = '{tenant}'::uuid\n\ + AND content_sha256 = '{digest}'\n\ + AND source_size_bytes = {size}\n\ + AND media_type_code = '{media}'\n\ + AND protected_object_ref IS NOT DISTINCT FROM {object_ref_sql}\n\ + AND system_time = '{system}'::timestamptz\n\ + AND available_time = '{available}'::timestamptz\n\ + ) THEN\n\ + RAISE EXCEPTION 'conflicting source artifact';\n\ + END IF;\n\ + END\n\ + $tepp_source_artifact_idempotent$", + artifact = record.source_artifact_id, + tenant = record.tenant_record_id, + digest = record.content_sha256, + size = record.source_size_bytes, + media = record.media_type_code, + system = record.system_time.to_rfc3339(), + available = record.available_time.to_rfc3339(), + )) +} + +/// Render selection of a source artifact by primary key. +#[must_use] +pub fn select_source_artifact_by_id_sql(source_artifact_id: Uuid) -> String { + format!( + "SELECT source_artifact_id, tenant_record_id, content_sha256, source_size_bytes, \ + media_type_code, protected_object_ref, system_time, available_time \ + FROM source_artifact \ + WHERE source_artifact_id = '{source_artifact_id}'::uuid \ + LIMIT 1" + ) +} + +fn is_lowercase_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn validate_sha256_hex(value: &str) -> Result<(), PersistenceError> { + if value.len() != 64 || !is_lowercase_hex(value) { + return Err(PersistenceError::InvalidSourceArtifact); + } + Ok(()) +} + +fn validate_artifact_label(value: &str) -> Result<(), PersistenceError> { + if value.is_empty() + || value.len() > 128 + || value + .chars() + .any(|ch| ch.is_control() || ch == '\'' || ch == ';' || ch == '\\') + { + return Err(PersistenceError::InvalidSourceArtifact); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + SourceArtifactRecord, assert_source_artifact_matches_sql, insert_source_artifact_sql, + is_lowercase_hex, select_source_artifact_by_id_sql, + source_artifacts_are_idempotent_matches, validate_artifact_label, validate_sha256_hex, + }; + use crate::PersistenceError; + use temporal_core::{AvailableTime, SystemTime}; + use uuid::Uuid; + + fn sample() -> SourceArtifactRecord { + SourceArtifactRecord { + source_artifact_id: Uuid::nil(), + tenant_record_id: Uuid::nil(), + content_sha256: "ab".repeat(32), + source_size_bytes: 4, + media_type_code: "text/plain".into(), + protected_object_ref: None, + system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), + } + } + + #[test] + fn artifact_sql_covers_valid_and_fail_closed_paths() { + let insert = insert_source_artifact_sql(&sample()).expect("insert"); + assert!(insert.contains("INSERT INTO source_artifact")); + assert!(insert.contains("ON CONFLICT (source_artifact_id) DO NOTHING")); + assert!(insert.contains("NULL")); + assert!(source_artifacts_are_idempotent_matches( + &sample(), + &sample() + )); + let assertion = assert_source_artifact_matches_sql(&sample()).expect("assert"); + assert!(assertion.contains("conflicting source artifact")); + assert_eq!( + assert_source_artifact_matches_sql(&SourceArtifactRecord { + source_size_bytes: -1, + ..sample() + }), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut with_ref = sample(); + with_ref.protected_object_ref = Some("s3://tepp/object".into()); + with_ref.source_size_bytes = 0; + let referenced = insert_source_artifact_sql(&with_ref).expect("ref"); + assert!(referenced.contains("s3://tepp/object")); + + assert_eq!( + insert_source_artifact_sql(&SourceArtifactRecord { + content_sha256: "nope".into(), + ..sample() + }), + Err(PersistenceError::InvalidSourceArtifact) + ); + assert_eq!( + insert_source_artifact_sql(&SourceArtifactRecord { + source_size_bytes: -1, + ..sample() + }), + Err(PersistenceError::InvalidSourceArtifact) + ); + assert!(validate_sha256_hex(&"a1".repeat(32)).is_ok()); + assert!(validate_sha256_hex("x").is_err()); + assert!(validate_sha256_hex(&"AB".repeat(32)).is_err()); + assert!(validate_artifact_label("text/plain").is_ok()); + assert!(validate_artifact_label("").is_err()); + assert!(validate_artifact_label("text/plain';x").is_err()); + assert!(validate_artifact_label("text/plain;x").is_err()); + assert!(validate_artifact_label("text/plain\\").is_err()); + assert!(validate_artifact_label("text/plain\n").is_err()); + assert!(validate_artifact_label(&"x".repeat(129)).is_err()); + assert!(is_lowercase_hex("a1")); + assert!(!is_lowercase_hex("AB")); + assert!(select_source_artifact_by_id_sql(Uuid::nil()).contains("FROM source_artifact")); + } +} diff --git a/crates/persistence_postgres/src/error.rs b/crates/persistence_postgres/src/error.rs index a768cc9c1..e073b4b3c 100644 --- a/crates/persistence_postgres/src/error.rs +++ b/crates/persistence_postgres/src/error.rs @@ -32,6 +32,10 @@ pub enum PersistenceError { InvalidEventMention, /// An event instance had inverted windows or a hostile label. InvalidEventInstance, + /// A source-artifact identity already exists with different immutable fields. + ConflictingSourceArtifact, + /// A source artifact had a non-canonical digest, negative size, or hostile label. + InvalidSourceArtifact, } impl fmt::Display for PersistenceError { @@ -50,6 +54,8 @@ impl fmt::Display for PersistenceError { Self::InvalidEventRelation => "invalid event relation", Self::InvalidEventMention => "invalid event mention", Self::InvalidEventInstance => "invalid event instance", + Self::ConflictingSourceArtifact => "conflicting source artifact", + Self::InvalidSourceArtifact => "invalid source artifact", }; formatter.write_str(message) } @@ -161,6 +167,14 @@ mod tests { PersistenceError::InvalidEventInstance.to_string(), "invalid event instance" ); + assert_eq!( + PersistenceError::ConflictingSourceArtifact.to_string(), + "conflicting source artifact" + ); + assert_eq!( + PersistenceError::InvalidSourceArtifact.to_string(), + "invalid source artifact" + ); 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 a040361bf..28e5a16a1 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -16,6 +16,7 @@ //! membership-assignment SQL (migration `0006`) replaces the polymorphic 0001 stub so documents //! can belong to multiple entities and projects without atomistic collapse. +mod artifact_sql; mod cutoff; mod document_sql; mod document_store; @@ -36,6 +37,16 @@ mod sqlx_gate; mod sqlx_live; mod tenant_session; +/// Append-only source artifact row. +pub use artifact_sql::SourceArtifactRecord; +/// Render a fail-closed stored-row match assertion for a source artifact. +pub use artifact_sql::assert_source_artifact_matches_sql; +/// Render insert SQL for a validated source artifact. +pub use artifact_sql::insert_source_artifact_sql; +/// Render selection SQL for a source artifact by primary key. +pub use artifact_sql::select_source_artifact_by_id_sql; +/// Compare two source artifacts for idempotent-retry equality. +pub use artifact_sql::source_artifacts_are_idempotent_matches; /// Knowledge-cutoff eligibility for historical analytical reads. pub use cutoff::is_cutoff_eligible; /// Render append-only audit insert SQL. diff --git a/crates/persistence_postgres/src/live_repository.rs b/crates/persistence_postgres/src/live_repository.rs index 561815b59..4f45b300b 100644 --- a/crates/persistence_postgres/src/live_repository.rs +++ b/crates/persistence_postgres/src/live_repository.rs @@ -1,5 +1,9 @@ //! Live document repository over a SQL transport. +use crate::artifact_sql::{ + SourceArtifactRecord, assert_source_artifact_matches_sql, insert_source_artifact_sql, + select_source_artifact_by_id_sql, +}; use crate::document_sql::{ append_audit_sql, as_known_at_sql, as_valid_at_sql, insert_document_sql, revise_document_sqls, }; @@ -303,6 +307,38 @@ impl LiveDocumentRepository { self.session.execute(&sql) } + /// Insert an append-only source artifact under the active tenant. + /// + /// A retry of the same immutable identity is a no-op. A same-id payload + /// change fails closed after `ON CONFLICT DO NOTHING`. + /// + /// # Errors + /// + /// Returns digest/size/label validation, identity-conflict, or transport + /// failures. + pub fn insert_source_artifact( + &mut self, + record: &SourceArtifactRecord, + ) -> Result<(), PersistenceError> { + let sql = insert_source_artifact_sql(record)?; + self.session.execute(&sql)?; + let assertion = assert_source_artifact_matches_sql(record)?; + self.session.execute(&assertion) + } + + /// Look up a source artifact by primary key. + /// + /// # Errors + /// + /// Returns transport failures from the underlying session. + pub fn submit_source_artifact_by_id( + &mut self, + source_artifact_id: Uuid, + ) -> Result<(), PersistenceError> { + let sql = select_source_artifact_by_id_sql(source_artifact_id); + self.session.execute(&sql) + } + /// Look up a model run by primary key. /// /// # Errors @@ -350,6 +386,7 @@ impl std::error::Error for LiveMigrationError {} #[cfg(test)] mod tests { use super::{LiveDocumentRepository, LiveMigrationError}; + use crate::artifact_sql::SourceArtifactRecord; use crate::document_store::{AuditEvent, DocumentRecord}; use crate::instance_sql::EventInstanceRecord; use crate::manifest_sql::ReproducibilityManifestRecord; @@ -559,6 +596,37 @@ mod tests { ); } + fn exercise_source_artifact(repo: &mut LiveDocumentRepository) { + let artifact = SourceArtifactRecord { + source_artifact_id: uuid::Uuid::from_u128(1), + tenant_record_id: uuid::Uuid::nil(), + content_sha256: "ab".repeat(32), + source_size_bytes: 4, + media_type_code: "text/plain".into(), + protected_object_ref: None, + system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), + available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), + }; + repo.insert_source_artifact(&artifact) + .expect("artifact insert"); + repo.insert_source_artifact(&artifact) + .expect("identical retry"); + repo.submit_source_artifact_by_id(artifact.source_artifact_id) + .expect("artifact by id"); + let mut invalid = artifact.clone(); + invalid.source_size_bytes = -1; + assert_eq!( + repo.insert_source_artifact(&invalid), + Err(PersistenceError::InvalidSourceArtifact) + ); + assert!( + repo.session() + .executed() + .iter() + .any(|sql| sql.contains("ON CONFLICT (source_artifact_id) DO NOTHING")) + ); + } + #[test] fn live_repository_applies_migrations_and_document_sql() { let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new()); @@ -615,6 +683,7 @@ mod tests { exercise_event_relation(&mut repo); exercise_event_mention(&mut repo); exercise_event_instance(&mut repo); + exercise_source_artifact(&mut repo); let audit = AuditEvent { audit_event_id: uuid::Uuid::nil(), diff --git a/crates/persistence_postgres/tests/source_artifact_sql_contract.rs b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs new file mode 100644 index 000000000..fae0effd8 --- /dev/null +++ b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs @@ -0,0 +1,180 @@ +//! Source-artifact SQL must refuse invalid digests, sizes, and hostile labels. + +use persistence_postgres::{ + PersistenceError, SourceArtifactRecord, assert_source_artifact_matches_sql, + insert_source_artifact_sql, select_source_artifact_by_id_sql, + source_artifacts_are_idempotent_matches, +}; +use temporal_core::{AvailableTime, SystemTime}; +use uuid::Uuid; + +fn clocks() -> (AvailableTime, SystemTime) { + ( + AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("available"), + SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("system"), + ) +} + +fn artifact() -> SourceArtifactRecord { + let (available, system) = clocks(); + SourceArtifactRecord { + source_artifact_id: Uuid::from_u128(1), + tenant_record_id: Uuid::nil(), + content_sha256: "ab".repeat(32), + source_size_bytes: 4, + media_type_code: "text/plain".into(), + protected_object_ref: None, + system_time: system, + available_time: available, + } +} + +#[test] +fn insert_sql_renders_open_object_ref_as_null() { + let sql = insert_source_artifact_sql(&artifact()).expect("sql"); + assert!(sql.contains("INSERT INTO source_artifact")); + assert!(sql.contains("content_sha256")); + assert!(sql.contains("source_size_bytes")); + assert!(sql.contains("media_type_code")); + assert!(sql.contains("NULL")); +} + +#[test] +fn insert_sql_renders_protected_object_ref() { + let mut with_ref = artifact(); + with_ref.protected_object_ref = Some("s3://tepp/evidence/object".into()); + with_ref.source_size_bytes = 0; + let sql = insert_source_artifact_sql(&with_ref).expect("sql"); + assert!(sql.contains("s3://tepp/evidence/object")); + assert!(!sql.contains("NULL")); +} + +#[test] +fn invalid_digest_size_and_hostile_labels_fail_closed() { + let mut short = artifact(); + short.content_sha256 = "ab".into(); + assert_eq!( + insert_source_artifact_sql(&short), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut uppercase = artifact(); + uppercase.content_sha256 = "AB".repeat(32); + assert_eq!( + insert_source_artifact_sql(&uppercase), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut negative = artifact(); + negative.source_size_bytes = -1; + assert_eq!( + insert_source_artifact_sql(&negative), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut empty_type = artifact(); + empty_type.media_type_code.clear(); + assert_eq!( + insert_source_artifact_sql(&empty_type), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut hostile = artifact(); + hostile.media_type_code = "text/plain'; DROP TABLE".into(); + assert_eq!( + insert_source_artifact_sql(&hostile), + Err(PersistenceError::InvalidSourceArtifact) + ); + hostile.media_type_code = "text/plain;role".into(); + assert_eq!( + insert_source_artifact_sql(&hostile), + Err(PersistenceError::InvalidSourceArtifact) + ); + hostile.media_type_code = "text/plain\\".into(); + assert_eq!( + insert_source_artifact_sql(&hostile), + Err(PersistenceError::InvalidSourceArtifact) + ); + hostile.media_type_code = "text/plain\nhtml".into(); + assert_eq!( + insert_source_artifact_sql(&hostile), + Err(PersistenceError::InvalidSourceArtifact) + ); + hostile.media_type_code = "x".repeat(129); + assert_eq!( + insert_source_artifact_sql(&hostile), + Err(PersistenceError::InvalidSourceArtifact) + ); + + let mut empty_ref = artifact(); + empty_ref.protected_object_ref = Some(String::new()); + assert_eq!( + insert_source_artifact_sql(&empty_ref), + Err(PersistenceError::InvalidSourceArtifact) + ); + empty_ref.protected_object_ref = Some("obj'; DROP".into()); + assert_eq!( + insert_source_artifact_sql(&empty_ref), + Err(PersistenceError::InvalidSourceArtifact) + ); +} + +#[test] +fn lookup_renders_primary_key_selection() { + let lookup = select_source_artifact_by_id_sql(Uuid::from_u128(1)); + assert!(lookup.contains("FROM source_artifact")); + assert!(lookup.contains("source_artifact_id")); +} + +#[test] +fn insert_sql_is_idempotent_on_primary_key() { + let sql = insert_source_artifact_sql(&artifact()).expect("sql"); + assert!(sql.contains("ON CONFLICT (source_artifact_id) DO NOTHING")); +} + +#[test] +fn identical_records_are_idempotent_matches() { + let first = artifact(); + let retry = artifact(); + assert!(source_artifacts_are_idempotent_matches(&first, &retry)); +} + +#[test] +fn divergent_identity_fields_are_not_idempotent_matches() { + let first = artifact(); + let mut other = artifact(); + other.tenant_record_id = Uuid::from_u128(2); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.content_sha256 = "cd".repeat(32); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.source_size_bytes = 8; + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.media_type_code = "text/csv".into(); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.protected_object_ref = Some("s3://tepp/other".into()); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.system_time = SystemTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("s"); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); + other = artifact(); + other.available_time = AvailableTime::parse_rfc3339("2026-02-01T00:00:00Z").expect("a"); + assert!(!source_artifacts_are_idempotent_matches(&first, &other)); +} + +#[test] +fn assert_sql_requires_every_stored_field_to_match() { + let sql = assert_source_artifact_matches_sql(&artifact()).expect("assert"); + assert!(sql.contains("conflicting source artifact")); + assert!(sql.contains("source_artifact_id")); + assert!(sql.contains("tenant_record_id")); + assert!(sql.contains("content_sha256")); + assert!(sql.contains("source_size_bytes")); + assert!(sql.contains("media_type_code")); + assert!(sql.contains("protected_object_ref IS NOT DISTINCT FROM")); + assert!(sql.contains("system_time")); + assert!(sql.contains("available_time")); +} diff --git a/docs/ERD.md b/docs/ERD.md index 252573a5b..651055f2b 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -82,6 +82,7 @@ erDiagram text media_type_code text protected_object_ref timestamptz system_time + timestamptz available_time } DOCUMENT_RECORD { diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 7094cb879..98ec0e0d6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -7,7 +7,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | Requirement / decision | Canonical basis | Source/evidence boundary | Maturity | |---|---|---|---| -| immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring | implemented-main | +| immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring; `persistence_postgres` source-artifact SQL insert/lookup plus idempotent retry (active PR) | implemented-main | | Rust numerical authority / CPU `f64` reference | ADR 0001 | current workspace foundation; future estimators | partial | | 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 | @@ -18,6 +18,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation vocabulary SQL (implemented-main), event-mention SQL (implemented-main), event-instance SQL (active PR); remaining physical ERD/backup remaining | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` on active PR); remaining physical ERD/backup remaining | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), source-artifact SQL plus idempotent retry (active PR); remaining physical ERD/backup remaining | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | 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 3d1da000e..23e84aeb2 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -3,6 +3,8 @@ **Decision status:** Accepted **Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), and temporal interval ordering CHECK constraints (migration `0005`) implemented-main; typed membership-assignment storage (migration `0006`) implemented on the active PR (not implemented-main); event-instance SQL insert/as-known-at on active PR; remaining physical ERD (relation transition vocabulary), concurrent write stress, and backup/restore remain accepted-target **Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), and temporal interval ordering CHECK constraints (migration `0005`) implemented-main; event-relation vocabulary SQL insert contracts implemented on the active PR (not implemented-main); remaining physical ERD (membership exactly-one FKs, catalog CHECKs), concurrent write stress, and backup/restore remain accepted-target +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), and temporal interval ordering CHECK constraints (migration `0005`) implemented-main; typed membership-assignment storage (migration `0006`) implemented on the active PR (not implemented-main); remaining physical ERD (relation transition vocabulary), concurrent write stress, and backup/restore remain accepted-target +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), and temporal interval ordering CHECK constraints (migration `0005`) implemented-main; source-artifact SQL insert/lookup plus idempotent same-identity retry (and same-id payload conflict) implemented on the active PR (not implemented-main); remaining physical ERD (relation transition vocabulary, membership exactly-one FKs), concurrent write stress, and backup/restore remain accepted-target **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership). diff --git a/docs/research/source-artifact-persistence.md b/docs/research/source-artifact-persistence.md new file mode 100644 index 000000000..3e40e346b --- /dev/null +++ b/docs/research/source-artifact-persistence.md @@ -0,0 +1,45 @@ +# Source-artifact persistence (doctoring) + +## Scope + +`source_artifact` already exists on the foundation schema as an append-only +identity distinct from `document_record`. This slice adds the fail-closed +insert and primary-key lookup contract so an artifact cannot be persisted +with a non-canonical digest, a negative size, or a hostile media-type or +object-store label. ADR 0013 also requires idempotent writes: a retry of the +same immutable identity must succeed, and a same-id payload change must fail +closed (Jensen & Snodgrass, 1999). The artifact identity remains independent of the content +digest: identical bytes may be acquired in different tenant or provenance +contexts (National Institute of Standards and Technology, 2015). + +This does not add a new migration number. Append-only triggers already live +in `0004`. Bytes themselves stay in the evidence crate or a protected object +store; this contract binds the identity, digest, declared size, and clocks. + +## 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 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +Identity and digest must stay separate. Collapsing the primary key onto +`content_sha256` would erase distinct acquisition events (Moreau & Missier, +2013). Availability and system time remain explicit so later cutoff +eligibility can exclude artifacts that were not yet available (Jensen & +Snodgrass, 1999). + +## Verification + +- contract tests reject short/uppercase digests, negative size, empty and + hostile media types, oversized labels, and empty/hostile object refs; +- zero-byte artifacts and `NULL` object refs render; +- recording-session coverage for insert and primary-key lookup; +- live PostgreSQL CI inserts a valid artifact, retries the same identity, + looks it up by identity, refuses a same-id digest change, and refuses a + negative size when `TEPP_LIVE_POSTGRES=1`. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 969c1d7c2..d03ec1d4c 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -20,6 +20,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | | Bitemporal persistence + live SQL port | `persistence_postgres` | partial | #36 typed membership | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005` interval CHECKs (implemented-main via #35) + `0006` typed membership (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#35 + `0006` | | Bitemporal persistence + live SQL port | `persistence_postgres` | partial | event-instance SQL | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005` interval CHECKs + event-instance insert/as-known-at | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#35 + event-instance SQL | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | source-artifact SQL + idempotent retry | migration contracts + recording transport + optional PgPool + live CI + tenant RLS isolation + source-artifact insert/lookup | Task 8 / PR #16 + #23 + #26 + #27 + #29 + tenant RLS + source-artifact SQL | | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 | | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 |