Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `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` 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.
Expand Down
7 changes: 7 additions & 0 deletions crates/persistence_postgres/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub enum PersistenceError {
LiveAdapterNotConfigured,
/// A membership assignment violated exactly-one, weight, window, or label contracts.
InvalidMembershipAssignment,
/// An event relation violated the closed ERD transition vocabulary.
InvalidEventRelation,
}

impl fmt::Display for PersistenceError {
Expand All @@ -41,6 +43,7 @@ impl fmt::Display for PersistenceError {
Self::PoolOptionsInvalid => "pool options invalid",
Self::LiveAdapterNotConfigured => "live adapter not configured",
Self::InvalidMembershipAssignment => "invalid membership assignment",
Self::InvalidEventRelation => "invalid event relation",
};
formatter.write_str(message)
}
Expand Down Expand Up @@ -140,6 +143,10 @@ mod tests {
PersistenceError::InvalidMembershipAssignment.to_string(),
"invalid membership assignment"
);
assert_eq!(
PersistenceError::InvalidEventRelation.to_string(),
"invalid event relation"
);
assert_eq!(
MigrationContractError::SingleWordObjectName.to_string(),
"single-word database object name"
Expand Down
6 changes: 6 additions & 0 deletions crates/persistence_postgres/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod membership_sql;
mod migration;
mod model_run_sql;
mod naming;
mod relation_sql;
mod sql_session;
mod sqlx_gate;
#[cfg(feature = "live-sqlx")]
Expand Down Expand Up @@ -83,6 +84,7 @@ pub use membership_sql::MembershipAssignmentRecord;
pub use membership_sql::insert_membership_assignment_sql;
/// Render selection SQL for document-level membership assignments.
pub use membership_sql::select_membership_assignments_for_document_sql;
/// Typed event-relation row bound to the ERD transition vocabulary.
/// Embedded and ad-hoc migration catalogs.
pub use migration::MigrationCatalog;
/// Validate migration SQL against TEPP contracts.
Expand All @@ -105,6 +107,10 @@ pub use model_run_sql::select_model_artifacts_by_run_sql;
pub use model_run_sql::select_model_run_by_id_sql;
/// Multi-word `snake_case` database object naming.
pub use naming::is_multi_word_snake_case;
/// Typed event-relation row bound to the ERD transition vocabulary.
pub use relation_sql::EventRelationRecord;
/// Render insert SQL for a validated event relation.
pub use relation_sql::insert_event_relation_sql;
/// Recording SQL transport for offline contract tests.
pub use sql_session::RecordingSqlSession;
/// Live SQL transport contract.
Expand Down
47 changes: 47 additions & 0 deletions crates/persistence_postgres/src/live_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::model_run_sql::{
insert_corpus_split_manifest_sql, insert_model_artifact_sql, insert_model_run_sql,
select_model_artifacts_by_run_sql, select_model_run_by_id_sql,
};
use crate::relation_sql::{EventRelationRecord, insert_event_relation_sql};
use crate::sql_session::{SqlSession, apply_sql_batch};
use crate::{MigrationContractError, PersistenceError};
use temporal_core::{EventTime, SystemTime};
Expand Down Expand Up @@ -244,6 +245,19 @@ impl<S: SqlSession> LiveDocumentRepository<S> {
self.session.execute(&sql)
}

/// Insert a typed event relation under the active tenant.
///
/// # Errors
///
/// Returns vocabulary/flag validation or transport failures.
pub fn insert_event_relation(
&mut self,
record: &EventRelationRecord,
) -> Result<(), PersistenceError> {
let sql = insert_event_relation_sql(record)?;
self.session.execute(&sql)
}

/// Look up a model run by primary key.
///
/// # Errors
Expand Down Expand Up @@ -295,6 +309,7 @@ mod tests {
use crate::manifest_sql::ReproducibilityManifestRecord;
use crate::migration::MigrationCatalog;
use crate::model_run_sql::{CorpusSplitManifestRecord, ModelArtifactRecord, ModelRunRecord};
use crate::relation_sql::EventRelationRecord;
use crate::sql_session::RecordingSqlSession;
use crate::{MigrationContractError, PersistenceError};
use temporal_core::{AvailableTime, EventTime, SystemTime};
Expand Down Expand Up @@ -411,6 +426,37 @@ mod tests {
);
}

fn exercise_event_relation(repo: &mut LiveDocumentRepository<RecordingSqlSession>) {
let (available, system) = (
AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"),
SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"),
);
let relation = EventRelationRecord {
event_relation_id: uuid::Uuid::nil(),
tenant_record_id: uuid::Uuid::nil(),
source_event_id: uuid::Uuid::from_u128(1),
target_event_id: uuid::Uuid::from_u128(2),
relation_type_code: "causes".into(),
transition_edge: true,
system_time: system,
available_time: available,
};
repo.insert_event_relation(&relation)
.expect("relation insert");
let mut bad = relation;
bad.transition_edge = false;
assert_eq!(
repo.insert_event_relation(&bad),
Err(PersistenceError::InvalidEventRelation)
);
assert!(
repo.session()
.executed()
.iter()
.any(|sql| sql.contains("INSERT INTO event_relation"))
);
}

#[test]
fn live_repository_applies_migrations_and_document_sql() {
let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new());
Expand Down Expand Up @@ -464,6 +510,7 @@ mod tests {
);
exercise_model_run_chain(&mut repo, &manifest);
exercise_membership_assignment(&mut repo);
exercise_event_relation(&mut repo);

let audit = AuditEvent {
audit_event_id: uuid::Uuid::nil(),
Expand Down
105 changes: 105 additions & 0 deletions crates/persistence_postgres/src/relation_sql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! SQL contracts for typed event relations (ADR 0013 / ERD relation vocabulary).

use crate::PersistenceError;
use temporal_core::{AvailableTime, SystemTime};
use uuid::Uuid;

const TRANSITION_TYPES: [&str; 8] = [
"causes",
"enables",
"intervenes_on",
"leads_to",
"produces",
"transitions_to",
"input_to",
"process_to",
];

const PROVENANCE_TYPES: [&str; 8] = [
"references",
"summarizes",
"revises",
"translates",
"retrospectively_reports",
"supports",
"contradicts",
"outcome_of",
];

/// One append-only event relation with ERD-bound transition classification.
///
/// Maps to `event_relation`. `transition_edge` must match the closed
/// transition/provenance vocabulary; unknown types fail closed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EventRelationRecord {
/// Primary key for this relation identity.
pub event_relation_id: Uuid,
/// Owning tenant boundary.
pub tenant_record_id: Uuid,
/// Source event identity.
pub source_event_id: Uuid,
/// Target event identity.
pub target_event_id: Uuid,
/// Closed ERD relation type code.
pub relation_type_code: String,
/// Whether this row is a forward state-transition edge.
pub transition_edge: bool,
/// System/record time when the relation was asserted.
pub system_time: SystemTime,
/// Availability time of the relation evidence.
pub available_time: AvailableTime,
}

impl EventRelationRecord {
/// Fail-closed vocabulary, flag, and self-loop validation.
///
/// # Errors
///
/// Returns [`PersistenceError::InvalidEventRelation`] when the type is
/// unknown, the transition flag disagrees with the vocabulary, or a
/// transition is a self-loop.
pub fn validate(&self) -> Result<(), PersistenceError> {
let is_transition = TRANSITION_TYPES.contains(&self.relation_type_code.as_str());
let is_provenance = PROVENANCE_TYPES.contains(&self.relation_type_code.as_str());
if !is_transition && !is_provenance {
return Err(PersistenceError::InvalidEventRelation);
}
if is_transition != self.transition_edge {
return Err(PersistenceError::InvalidEventRelation);
}
if is_transition && self.source_event_id == self.target_event_id {
return Err(PersistenceError::InvalidEventRelation);
}
Ok(())
}
}

/// Render insert SQL for a validated event relation.
///
/// # Errors
///
/// Returns [`PersistenceError::InvalidEventRelation`] before any SQL is produced.
pub fn insert_event_relation_sql(record: &EventRelationRecord) -> Result<String, PersistenceError> {
record.validate()?;
let flag = if record.transition_edge {
"TRUE"
} else {
"FALSE"
};
Ok(format!(
"INSERT INTO event_relation (\
event_relation_id, tenant_record_id, source_event_id, target_event_id, \
relation_type_code, transition_edge, system_time, available_time\
) VALUES (\
'{relation_id}'::uuid, '{tenant_id}'::uuid, '{source}'::uuid, '{target}'::uuid, \
'{kind}', {flag}, '{system}'::timestamptz, '{available}'::timestamptz\
)",
relation_id = record.event_relation_id,
tenant_id = record.tenant_record_id,
source = record.source_event_id,
target = record.target_event_id,
kind = record.relation_type_code,
system = record.system_time.to_rfc3339(),
available = record.available_time.to_rfc3339(),
))
}
73 changes: 73 additions & 0 deletions crates/persistence_postgres/tests/event_relation_sql_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Event-relation SQL must bind ERD transition vocabulary to `transition_edge`.

use persistence_postgres::{EventRelationRecord, PersistenceError, insert_event_relation_sql};
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 base_record(relation_type_code: &str, transition_edge: bool) -> EventRelationRecord {
let (available, system) = clocks();
EventRelationRecord {
event_relation_id: Uuid::nil(),
tenant_record_id: Uuid::nil(),
source_event_id: Uuid::from_u128(1),
target_event_id: Uuid::from_u128(2),
relation_type_code: relation_type_code.into(),
transition_edge,
system_time: system,
available_time: available,
}
}

#[test]
fn forward_transition_inserts_and_provenance_is_not_a_transition() {
let sql = insert_event_relation_sql(&base_record("causes", true)).expect("causes");
assert!(sql.contains("INSERT INTO event_relation"));
assert!(sql.contains("transition_edge"));
assert!(sql.contains("TRUE"));

let sql = insert_event_relation_sql(&base_record("references", false)).expect("references");
assert!(sql.contains("FALSE"));
}

#[test]
fn mismatched_transition_flag_and_unknown_type_fail_closed() {
assert_eq!(
insert_event_relation_sql(&base_record("causes", false)),
Err(PersistenceError::InvalidEventRelation)
);
assert_eq!(
insert_event_relation_sql(&base_record("references", true)),
Err(PersistenceError::InvalidEventRelation)
);
assert_eq!(
insert_event_relation_sql(&base_record("invented_link", true)),
Err(PersistenceError::InvalidEventRelation)
);
}

#[test]
fn provenance_self_loop_is_allowed() {
let mut record = base_record("references", false);
record.target_event_id = record.source_event_id;

let sql = insert_event_relation_sql(&record).expect("provenance self-loop must remain valid");
assert!(sql.contains("references"));
assert!(sql.contains("FALSE"));
}

#[test]
fn transition_self_loop_fails_closed() {
let mut record = base_record("produces", true);
record.target_event_id = record.source_event_id;
assert_eq!(
insert_event_relation_sql(&record),
Err(PersistenceError::InvalidEventRelation)
);
}
1 change: 1 addition & 0 deletions docs/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
**Last reviewed:** 2026-08-13

Protected main implements storage-independent domain objects plus `persistence_postgres` foundation tables (`0001`), tenant row-level security (`0002`), the model-run/artifact chain (`0003`), append-only immutability triggers (`0004`), and temporal interval ordering CHECKs (`0005`) as executable migration contracts with live CI. Migration `0006` (active PR) replaces the polymorphic `membership_target_id` stub with typed exactly-one membership foreign keys and is not implemented-main until exact-head checks, review, and protected-main integration complete. Broader planned ERD entities, concurrent-write acceptance, and backup/recovery gates remain accepted-target.
Protected main implements storage-independent domain objects plus `persistence_postgres` foundation tables (`0001`), tenant row-level security (`0002`), the model-run/artifact chain (`0003`), and append-only immutability triggers (`0004`) as executable migration contracts with live CI. Event-relation SQL insert contracts (active PR) bind the closed transition/provenance vocabulary to `transition_edge` without a new migration number. Broader planned ERD entities, exactly-one membership constraints, concurrent-write acceptance, and backup/recovery gates remain accepted-target until each lands with exact-head evidence.

## Current domain foundation

Expand Down
1 change: 1 addition & 0 deletions docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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` 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`), event-relation vocabulary SQL (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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

**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); 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
**Date:** 2026-08-12
**Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership).

Expand Down
Loading
Loading