diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cea0ee20..3e0c80bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `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` 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. diff --git a/crates/persistence_postgres/src/error.rs b/crates/persistence_postgres/src/error.rs index 744d4f348..c7706e14e 100644 --- a/crates/persistence_postgres/src/error.rs +++ b/crates/persistence_postgres/src/error.rs @@ -28,6 +28,8 @@ pub enum PersistenceError { InvalidMembershipAssignment, /// An event relation violated the closed ERD transition vocabulary. InvalidEventRelation, + /// An event mention reused an instance identity or had an invalid confidence. + InvalidEventMention, } impl fmt::Display for PersistenceError { @@ -44,6 +46,7 @@ impl fmt::Display for PersistenceError { Self::LiveAdapterNotConfigured => "live adapter not configured", Self::InvalidMembershipAssignment => "invalid membership assignment", Self::InvalidEventRelation => "invalid event relation", + Self::InvalidEventMention => "invalid event mention", }; formatter.write_str(message) } @@ -147,6 +150,10 @@ mod tests { PersistenceError::InvalidEventRelation.to_string(), "invalid event relation" ); + assert_eq!( + PersistenceError::InvalidEventMention.to_string(), + "invalid event mention" + ); 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 56766f702..48993a232 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -24,6 +24,7 @@ mod live_pool; mod live_repository; mod manifest_sql; mod membership_sql; +mod mention_sql; mod migration; mod model_run_sql; mod naming; @@ -84,7 +85,10 @@ 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. +/// Event-mention row that cannot collapse into an instance identity. +pub use mention_sql::EventMentionRecord; +/// Render insert SQL for a validated event mention. +pub use mention_sql::insert_event_mention_sql; /// Embedded and ad-hoc migration catalogs. pub use migration::MigrationCatalog; /// Validate migration SQL against TEPP contracts. diff --git a/crates/persistence_postgres/src/live_repository.rs b/crates/persistence_postgres/src/live_repository.rs index 71904a84e..0aae3125d 100644 --- a/crates/persistence_postgres/src/live_repository.rs +++ b/crates/persistence_postgres/src/live_repository.rs @@ -12,6 +12,7 @@ use crate::membership_sql::{ MembershipAssignmentRecord, insert_membership_assignment_sql, select_membership_assignments_for_document_sql, }; +use crate::mention_sql::{EventMentionRecord, insert_event_mention_sql}; use crate::migration::{MigrationCatalog, validate_migration_catalog}; use crate::model_run_sql::{ CorpusSplitManifestRecord, ModelArtifactRecord, ModelRunRecord, @@ -258,6 +259,20 @@ impl LiveDocumentRepository { self.session.execute(&sql) } + /// Insert an event mention that is not an instance identity. + /// + /// # Errors + /// + /// Returns mention/instance or confidence validation failures, or transport + /// failures. + pub fn insert_event_mention( + &mut self, + record: &EventMentionRecord, + ) -> Result<(), PersistenceError> { + let sql = insert_event_mention_sql(record)?; + self.session.execute(&sql) + } + /// Look up a model run by primary key. /// /// # Errors @@ -307,6 +322,7 @@ mod tests { use super::{LiveDocumentRepository, LiveMigrationError}; use crate::document_store::{AuditEvent, DocumentRecord}; use crate::manifest_sql::ReproducibilityManifestRecord; + use crate::mention_sql::EventMentionRecord; use crate::migration::MigrationCatalog; use crate::model_run_sql::{CorpusSplitManifestRecord, ModelArtifactRecord, ModelRunRecord}; use crate::relation_sql::EventRelationRecord; @@ -457,6 +473,30 @@ mod tests { ); } + fn exercise_event_mention(repo: &mut LiveDocumentRepository) { + let mention = EventMentionRecord { + event_mention_id: uuid::Uuid::from_u128(2), + event_instance_id: uuid::Uuid::from_u128(1), + tenant_record_id: uuid::Uuid::nil(), + confidence_score: 0.75, + 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_event_mention(&mention).expect("mention insert"); + let mut collapsed = mention.clone(); + collapsed.event_mention_id = collapsed.event_instance_id; + assert_eq!( + repo.insert_event_mention(&collapsed), + Err(PersistenceError::InvalidEventMention) + ); + assert!( + repo.session() + .executed() + .iter() + .any(|sql| sql.contains("INSERT INTO event_mention")) + ); + } + #[test] fn live_repository_applies_migrations_and_document_sql() { let mut repo = LiveDocumentRepository::new(RecordingSqlSession::new()); @@ -511,6 +551,7 @@ mod tests { exercise_model_run_chain(&mut repo, &manifest); exercise_membership_assignment(&mut repo); exercise_event_relation(&mut repo); + exercise_event_mention(&mut repo); let audit = AuditEvent { audit_event_id: uuid::Uuid::nil(), diff --git a/crates/persistence_postgres/src/mention_sql.rs b/crates/persistence_postgres/src/mention_sql.rs new file mode 100644 index 000000000..7a1d8c806 --- /dev/null +++ b/crates/persistence_postgres/src/mention_sql.rs @@ -0,0 +1,70 @@ +//! SQL contracts for event mentions distinct from event instances. + +use crate::PersistenceError; +use temporal_core::{AvailableTime, SystemTime}; +use uuid::Uuid; + +/// One append-only event mention that cannot be treated as an instance. +/// +/// Maps to `event_mention`. `event_mention_id` must differ from +/// `event_instance_id`; confidence must be finite and in `(0, 1]`. +#[derive(Clone, Debug, PartialEq)] +pub struct EventMentionRecord { + /// Mention identity (never interchangeable with the instance). + pub event_mention_id: Uuid, + /// Promoted instance this mention supports. + pub event_instance_id: Uuid, + /// Owning tenant boundary. + pub tenant_record_id: Uuid, + /// Mention confidence in `(0, 1]`. + pub confidence_score: f64, + /// System/record time when the mention was asserted. + pub system_time: SystemTime, + /// Availability time of the mention evidence. + pub available_time: AvailableTime, +} + +impl EventMentionRecord { + /// Fail-closed mention/instance separation and confidence validation. + /// + /// # Errors + /// + /// Returns [`PersistenceError::InvalidEventMention`] when the mention is + /// the instance identity or the confidence is not in `(0, 1]`. + pub fn validate(&self) -> Result<(), PersistenceError> { + if self.event_mention_id == self.event_instance_id { + return Err(PersistenceError::InvalidEventMention); + } + if !self.confidence_score.is_finite() + || self.confidence_score <= 0.0 + || self.confidence_score > 1.0 + { + return Err(PersistenceError::InvalidEventMention); + } + Ok(()) + } +} + +/// Render insert SQL for a validated event mention. +/// +/// # Errors +/// +/// Returns [`PersistenceError::InvalidEventMention`] before any SQL is produced. +pub fn insert_event_mention_sql(record: &EventMentionRecord) -> Result { + record.validate()?; + Ok(format!( + "INSERT INTO event_mention (\ + event_mention_id, event_instance_id, tenant_record_id, \ + confidence_score, system_time, available_time\ + ) VALUES (\ + '{mention}'::uuid, '{instance}'::uuid, '{tenant}'::uuid, \ + {confidence}, '{system}'::timestamptz, '{available}'::timestamptz\ + )", + mention = record.event_mention_id, + instance = record.event_instance_id, + tenant = record.tenant_record_id, + confidence = record.confidence_score, + system = record.system_time.to_rfc3339(), + available = record.available_time.to_rfc3339(), + )) +} diff --git a/crates/persistence_postgres/tests/event_mention_sql_contract.rs b/crates/persistence_postgres/tests/event_mention_sql_contract.rs new file mode 100644 index 000000000..b0f23baf5 --- /dev/null +++ b/crates/persistence_postgres/tests/event_mention_sql_contract.rs @@ -0,0 +1,65 @@ +//! Event-mention SQL must refuse treating a mention as an instance. + +use persistence_postgres::{EventMentionRecord, PersistenceError, insert_event_mention_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 mention(instance: Uuid, mention: Uuid, confidence: f64) -> EventMentionRecord { + let (available, system) = clocks(); + EventMentionRecord { + event_mention_id: mention, + event_instance_id: instance, + tenant_record_id: Uuid::nil(), + confidence_score: confidence, + system_time: system, + available_time: available, + } +} + +#[test] +fn insert_sql_keeps_mention_and_instance_distinct() { + let sql = insert_event_mention_sql(&mention(Uuid::from_u128(1), Uuid::from_u128(2), 0.8)) + .expect("sql"); + assert!(sql.contains("INSERT INTO event_mention")); + assert!(sql.contains("event_instance_id")); + assert!(sql.contains("0.8")); +} + +#[test] +fn mention_as_instance_and_invalid_confidence_fail_closed() { + let same = Uuid::from_u128(7); + assert_eq!( + insert_event_mention_sql(&mention(same, same, 0.8)), + Err(PersistenceError::InvalidEventMention) + ); + assert_eq!( + insert_event_mention_sql(&mention(Uuid::from_u128(1), Uuid::from_u128(2), 0.0)), + Err(PersistenceError::InvalidEventMention) + ); + assert_eq!( + insert_event_mention_sql(&mention(Uuid::from_u128(1), Uuid::from_u128(2), 1.01)), + Err(PersistenceError::InvalidEventMention) + ); + assert_eq!( + insert_event_mention_sql(&mention(Uuid::from_u128(1), Uuid::from_u128(2), f64::NAN)), + Err(PersistenceError::InvalidEventMention) + ); + assert_eq!( + insert_event_mention_sql(&mention( + Uuid::from_u128(1), + Uuid::from_u128(2), + f64::INFINITY + )), + Err(PersistenceError::InvalidEventMention) + ); + assert!( + insert_event_mention_sql(&mention(Uuid::from_u128(1), Uuid::from_u128(2), 1.0)).is_ok() + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 6fad26a1d..7ef87fb62 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -13,7 +13,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 `temporal_core` path-consistency on protected main | implemented-main | | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | -| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; full intelligence stack remaining | partial | +| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL (active PR) refuses mention-as-instance; full intelligence stack remaining | partial | | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | | 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 | diff --git a/docs/research/event-mention-persistence.md b/docs/research/event-mention-persistence.md new file mode 100644 index 000000000..1ca1fb6ea --- /dev/null +++ b/docs/research/event-mention-persistence.md @@ -0,0 +1,36 @@ +# Event-mention persistence (doctoring) + +## Scope + +`event_mention` already exists on the foundation schema. This slice adds the +fail-closed insert contract that keeps mention identity distinct from the +promoted instance it supports. A mention cannot be persisted as if it were +the instance (`event_mention_id != event_instance_id`), and confidence is +restricted to a finite score in `(0, 1]`. + +This does not add a new migration number, so it can land independently of +stacked `0005`/`0006` PRs. + +## Authority + +Hovy, E., Marcus, M., Palmer, M., Ramshaw, L., & Weischedel, R. (2006). +OntoNotes: The 90% solution. In *Proceedings of the Human Language Technology +Conference of the NAACL, Companion Volume: Short Papers* (pp. 57–60). +Association for Computational Linguistics. + +Pustejovsky, J., Castano, J., Ingria, R., Saurí, R., Gaizauskas, R., Setzer, +A., Katz, G., & Radev, D. (2003). TimeML: Robust specification of event and +temporal expressions in text. In *New Directions in Question Answering* (pp. +28–34). AAAI Press. + +Mentions are observations; instances are promoted entities. Collapsing those +identities would treat an observation as the event itself (Hovy et al., 2006; +Pustejovsky et al., 2003). + +## Verification + +- contract tests reject mention-as-instance and non-finite or out-of-range + confidence; +- recording-session coverage for insert SQL; +- live PostgreSQL CI inserts a valid mention and refuses identity collapse + when `TEPP_LIVE_POSTGRES=1`.