diff --git a/crates/persistence_postgres/src/membership_sql.rs b/crates/persistence_postgres/src/membership_sql.rs index 6306d99a8..696cd9e36 100644 --- a/crates/persistence_postgres/src/membership_sql.rs +++ b/crates/persistence_postgres/src/membership_sql.rs @@ -25,7 +25,7 @@ pub struct MembershipAssignmentRecord { pub target_project_id: Option, /// Contextual membership type (author, department, customer, project role). pub membership_type_code: String, - /// Positive membership weight used by multilevel estimators. + /// Finite membership share in the owner domain `(0, 1]`. pub membership_weight: f64, /// Inclusive start window; an exact start is the singleton `[t,t]`. pub valid_from: EventTime, @@ -40,13 +40,13 @@ pub struct MembershipAssignmentRecord { } impl MembershipAssignmentRecord { - /// Fail-closed exactly-one, weight, window-order, and label validation. + /// Fail-closed exactly-one, unit-interval weight, window-order, and label validation. /// /// # Errors /// /// Returns [`PersistenceError::InvalidMembershipAssignment`] when the - /// observed unit, target, weight, inverted `valid_to`, or labels violate - /// the ERD contract. + /// observed unit, target, membership share, inverted `valid_to`, or labels + /// violate the ERD contract. pub fn validate(&self) -> Result<(), PersistenceError> { if !exactly_one(self.document_record_id, self.text_segment_id) { return Err(PersistenceError::InvalidMembershipAssignment); @@ -54,7 +54,10 @@ impl MembershipAssignmentRecord { if !exactly_one(self.target_entity_id, self.target_project_id) { return Err(PersistenceError::InvalidMembershipAssignment); } - if !self.membership_weight.is_finite() || self.membership_weight <= 0.0 { + if !self.membership_weight.is_finite() + || self.membership_weight <= 0.0 + || self.membership_weight > 1.0 + { return Err(PersistenceError::InvalidMembershipAssignment); } if let Some(end) = self.valid_to @@ -225,19 +228,29 @@ mod tests { } #[test] - fn non_positive_weight_and_hostile_labels_fail_closed() { + fn out_of_unit_interval_weight_and_hostile_labels_fail_closed() { let mut weight = valid_document_entity(); weight.membership_weight = 0.0; assert_eq!( insert_membership_assignment_sql(&weight), Err(PersistenceError::InvalidMembershipAssignment) ); + weight.membership_weight = 1.0 + f64::EPSILON; + assert_eq!( + insert_membership_assignment_sql(&weight), + Err(PersistenceError::InvalidMembershipAssignment) + ); weight.membership_weight = f64::NAN; assert_eq!( insert_membership_assignment_sql(&weight), Err(PersistenceError::InvalidMembershipAssignment) ); + let mut minimum_positive = valid_document_entity(); + minimum_positive.membership_weight = f64::from_bits(1); + insert_membership_assignment_sql(&minimum_positive) + .expect("minimum positive binary64 share remains persistable"); + let mut label = valid_document_entity(); label.membership_type_code = "author'; DROP TABLE".into(); assert_eq!( diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 60fbe266f..8cf1ea1a6 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -32,6 +32,14 @@ const ANALYSIS_RUN_UP: &str = include_str!("../../../migrations/0008_analysis_run_persistence.up.sql"); const ANALYSIS_RUN_DOWN: &str = include_str!("../../../migrations/0008_analysis_run_persistence.down.sql"); +const MEMBERSHIP_WEIGHT_UNIT_INTERVAL_UP: &str = + include_str!("../../../migrations/0009_membership_weight_unit_interval.up.sql"); +const MEMBERSHIP_WEIGHT_UNIT_INTERVAL_DOWN: &str = + include_str!("../../../migrations/0009_membership_weight_unit_interval.down.sql"); +const MEMBERSHIP_SHARE_BUDGET_UP: &str = + include_str!("../../../migrations/0010_membership_same_role_share_budget.up.sql"); +const MEMBERSHIP_SHARE_BUDGET_DOWN: &str = + include_str!("../../../migrations/0010_membership_same_role_share_budget.down.sql"); /// Forward and rollback SQL for one migration unit. #[derive(Clone, Debug, Eq, PartialEq)] @@ -49,10 +57,10 @@ impl MigrationCatalog { /// sources are unexpectedly empty. pub fn from_embedded() -> Result { let up_sql = format!( - "{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}\n{APPEND_ONLY_UP}\n{TEMPORAL_ORDER_UP}\n{MEMBERSHIP_UP}\n{RETENTION_UP}\n{ANALYSIS_RUN_UP}" + "{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}\n{APPEND_ONLY_UP}\n{TEMPORAL_ORDER_UP}\n{MEMBERSHIP_UP}\n{RETENTION_UP}\n{ANALYSIS_RUN_UP}\n{MEMBERSHIP_WEIGHT_UNIT_INTERVAL_UP}\n{MEMBERSHIP_SHARE_BUDGET_UP}" ); let down_sql = format!( - "{ANALYSIS_RUN_DOWN}\n{RETENTION_DOWN}\n{MEMBERSHIP_DOWN}\n{TEMPORAL_ORDER_DOWN}\n{APPEND_ONLY_DOWN}\n{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}" + "{MEMBERSHIP_SHARE_BUDGET_DOWN}\n{MEMBERSHIP_WEIGHT_UNIT_INTERVAL_DOWN}\n{ANALYSIS_RUN_DOWN}\n{RETENTION_DOWN}\n{MEMBERSHIP_DOWN}\n{TEMPORAL_ORDER_DOWN}\n{APPEND_ONLY_DOWN}\n{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}" ); Self::from_sources(&up_sql, &down_sql) } @@ -756,7 +764,7 @@ mod tests { "DROP TABLE tenant_record;", ); assert_eq!( - validate_migration_catalog(&missing_revoke), + validate_append_only_immutability(&missing_revoke), Err(MigrationContractError::MissingAppendOnlyTrigger) ); diff --git a/crates/persistence_postgres/tests/membership_assignment_contract.rs b/crates/persistence_postgres/tests/membership_assignment_contract.rs index daee7b2a6..c43e923d9 100644 --- a/crates/persistence_postgres/tests/membership_assignment_contract.rs +++ b/crates/persistence_postgres/tests/membership_assignment_contract.rs @@ -46,6 +46,31 @@ fn embedded_catalog_declares_typed_exactly_one_membership_targets() { ); } +#[test] +fn embedded_catalog_bounds_membership_weights_to_unit_interval() { + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + let up_sql = normalized(catalog.up_sql()); + let down_sql = normalized(catalog.down_sql()); + + let add_constraint = "constraint membership_assignment_weight_unit_interval check (membership_weight > 0 and membership_weight <= 1) not valid"; + let validate_constraint = + "validate constraint membership_assignment_weight_unit_interval"; + let add_position = up_sql + .find(add_constraint) + .expect("unit-interval check must be added without scanning existing rows under the initial DDL lock"); + let validate_position = up_sql + .find(validate_constraint) + .expect("successor migration must validate pre-existing rows after fail-closed admission is installed"); + assert!( + add_position < validate_position, + "NOT VALID admission must precede the lower-lock validation scan" + ); + assert!( + down_sql.contains("drop constraint membership_assignment_weight_unit_interval"), + "rollback must remove only the successor unit-interval constraint" + ); +} + #[test] fn rollback_restores_foundation_membership_stub() { let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); diff --git a/crates/persistence_postgres/tests/membership_duplicate_identity_live.rs b/crates/persistence_postgres/tests/membership_duplicate_identity_live.rs new file mode 100644 index 000000000..e49692810 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_duplicate_identity_live.rs @@ -0,0 +1,190 @@ +//! Live PostgreSQL contracts for Membership duplicate temporal-edge refusal. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn same_target_same_role_must_be_temporally_disjoint() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0010 migration catalog must apply"); + + let tenant_record_id = Uuid::now_v7(); + let entity_a = Uuid::now_v7(); + let entity_b = Uuid::now_v7(); + seed_scope(&mut repo, tenant_record_id, &[entity_a, entity_b]); + + let overlapping_duplicate = Uuid::now_v7(); + insert_membership( + &mut repo, + tenant_record_id, + overlapping_duplicate, + entity_a, + "department", + "0.5", + "'[2026-01-01,2026-01-01]'::tstzrange", + "'[2026-01-31,2026-01-31]'::tstzrange", + ) + .expect("first temporal edge"); + assert!( + insert_membership( + &mut repo, + tenant_record_id, + overlapping_duplicate, + entity_a, + "department", + "0.5", + "'[2026-01-10,2026-01-10]'::tstzrange", + "'[2026-01-20,2026-01-20]'::tstzrange", + ) + .is_err(), + "same member, target, and role must reject a possible temporal duplicate even at exact-unity total share" + ); + assert_membership_count(&mut repo, overlapping_duplicate, 1); + + let leave_reentry = Uuid::now_v7(); + insert_membership( + &mut repo, + tenant_record_id, + leave_reentry, + entity_a, + "department", + "1", + "'[2026-02-01,2026-02-01]'::tstzrange", + "'[2026-02-09,2026-02-10)'::tstzrange", + ) + .expect("first spell"); + insert_membership( + &mut repo, + tenant_record_id, + leave_reentry, + entity_a, + "department", + "1", + "'[2026-02-10,2026-02-10]'::tstzrange", + "'[2026-02-20,2026-02-20]'::tstzrange", + ) + .expect("strictly disjoint leave/re-entry spell remains valid"); + assert_membership_count(&mut repo, leave_reentry, 2); + + let different_targets = Uuid::now_v7(); + for entity_record_id in [entity_a, entity_b] { + insert_membership( + &mut repo, + tenant_record_id, + different_targets, + entity_record_id, + "department", + "0.5", + "'(,2026-03-05]'::tstzrange", + "'[2026-03-10,)'::tstzrange", + ) + .expect("different targets are multiple membership, not a duplicate edge"); + } + assert_membership_count(&mut repo, different_targets, 2); + + let different_roles = Uuid::now_v7(); + for role in ["department", "project"] { + insert_membership( + &mut repo, + tenant_record_id, + different_roles, + entity_a, + role, + "1", + "'[2026-04-01,2026-04-01]'::tstzrange", + "'[2026-04-30,2026-04-30]'::tstzrange", + ) + .expect("the same target under a different classification role is a distinct edge"); + } + assert_membership_count(&mut repo, different_roles, 2); +} + +fn seed_scope( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + entity_record_ids: &[Uuid], +) { + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + for entity_record_id in entity_record_ids { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed membership target"); + } +} + +fn insert_membership( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + document_record_id: Uuid, + entity_record_id: Uuid, + role: &str, + weight: &str, + valid_from_window: &str, + valid_to_window: &str, +) -> Result<(), persistence_postgres::PersistenceError> { + repo.session_mut().execute(&format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{}'::uuid, '{tenant_record_id}'::uuid, '{document_record_id}'::uuid, NULL, \ + '{entity_record_id}'::uuid, NULL, '{role}', {weight}, \ + {valid_from_window}, {valid_to_window}, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )", + Uuid::now_v7() + )) +} + +fn assert_membership_count( + repo: &mut LiveDocumentRepository, + document_record_id: Uuid, + expected: i64, +) { + repo.session_mut() + .execute(&format!( + "DO $tepp_membership_duplicate$ BEGIN \ + IF (SELECT COUNT(*) FROM membership_assignment \ + WHERE document_record_id = '{document_record_id}'::uuid) <> {expected} THEN \ + RAISE EXCEPTION 'unexpected membership row count'; \ + END IF; \ + END $tepp_membership_duplicate$" + )) + .expect("membership row count"); +} diff --git a/crates/persistence_postgres/tests/membership_duplicate_target_update_live.rs b/crates/persistence_postgres/tests/membership_duplicate_target_update_live.rs new file mode 100644 index 000000000..de90c2c10 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_duplicate_target_update_live.rs @@ -0,0 +1,94 @@ +//! Live PostgreSQL contract for target-only duplicate-edge updates. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +#[test] +fn target_only_update_cannot_create_a_duplicate_membership_edge() { + if !std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0010 migration catalog must apply"); + + let tenant_record_id = Uuid::now_v7(); + let document_record_id = Uuid::now_v7(); + let entity_a = Uuid::now_v7(); + let entity_b = Uuid::now_v7(); + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + for entity_record_id in [entity_a, entity_b] { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed target"); + } + + let assignment_a = Uuid::now_v7(); + let assignment_b = Uuid::now_v7(); + for (assignment_id, entity_record_id) in [(assignment_a, entity_a), (assignment_b, entity_b)] { + repo.session_mut() + .execute(&format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{assignment_id}'::uuid, '{tenant_record_id}'::uuid, '{document_record_id}'::uuid, NULL, \ + '{entity_record_id}'::uuid, NULL, 'department', 0.5, \ + '[2026-08-01,2026-08-01]'::tstzrange, '[2026-08-31,2026-08-31]'::tstzrange, \ + 'second', '2026-01-01T00:00:00Z'::timestamptz, \ + '2026-01-01T00:00:00Z'::timestamptz\ + )" + )) + .expect("distinct targets remain valid multiple membership"); + } + + assert!( + repo.session_mut() + .execute(&format!( + "UPDATE membership_assignment \ + SET target_entity_id = '{entity_a}'::uuid \ + WHERE membership_assignment_id = '{assignment_b}'::uuid" + )) + .is_err(), + "changing only the target must still run duplicate-edge admission" + ); + + repo.session_mut() + .execute(&format!( + "DO $tepp_target_update$ BEGIN \ + IF (SELECT target_entity_id FROM membership_assignment \ + WHERE membership_assignment_id = '{assignment_b}'::uuid) <> '{entity_b}'::uuid THEN \ + RAISE EXCEPTION 'rejected target-only update changed persisted identity'; \ + END IF; \ + END $tepp_target_update$" + )) + .expect("rejected update remains atomic"); +} diff --git a/crates/persistence_postgres/tests/membership_share_budget_endpoint_live.rs b/crates/persistence_postgres/tests/membership_share_budget_endpoint_live.rs new file mode 100644 index 000000000..94c451ec6 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_share_budget_endpoint_live.rs @@ -0,0 +1,177 @@ +//! Live PostgreSQL contracts for membership uncertainty-window endpoint semantics. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn possible_overlap_respects_tstzrange_endpoint_inclusivity() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0010 migration catalog must apply"); + + let tenant_record_id = Uuid::now_v7(); + let entity_a = Uuid::now_v7(); + let entity_b = Uuid::now_v7(); + seed_tenant_and_entities(&mut repo, tenant_record_id, &[entity_a, entity_b]); + + let excluded_existing_end = Uuid::now_v7(); + insert_membership( + &mut repo, + tenant_record_id, + excluded_existing_end, + entity_a, + "1", + "'[2026-04-01,2026-04-01]'::tstzrange", + "'[2026-04-10,2026-04-11)'::tstzrange", + ) + .expect("first full membership"); + insert_membership( + &mut repo, + tenant_record_id, + excluded_existing_end, + entity_b, + "1", + "'[2026-04-11,2026-04-11]'::tstzrange", + "'[2026-04-20,2026-04-20]'::tstzrange", + ) + .expect("excluded existing upper endpoint makes the meeting spells disjoint"); + assert_membership_count(&mut repo, excluded_existing_end, 2); + + let included_meeting = Uuid::now_v7(); + insert_membership( + &mut repo, + tenant_record_id, + included_meeting, + entity_a, + "1", + "'[2026-05-01,2026-05-01]'::tstzrange", + "'[2026-05-10,2026-05-11]'::tstzrange", + ) + .expect("first included-end membership"); + assert!( + insert_membership( + &mut repo, + tenant_record_id, + included_meeting, + entity_b, + "1", + "'[2026-05-11,2026-05-11]'::tstzrange", + "'[2026-05-20,2026-05-20]'::tstzrange", + ) + .is_err(), + "included end and included start at the same instant can overlap and must share one budget" + ); + assert_membership_count(&mut repo, included_meeting, 1); + + let excluded_candidate_start = Uuid::now_v7(); + insert_membership( + &mut repo, + tenant_record_id, + excluded_candidate_start, + entity_a, + "1", + "'[2026-06-01,2026-06-01]'::tstzrange", + "'[2026-06-11,2026-06-11]'::tstzrange", + ) + .expect("first exact-end membership"); + insert_membership( + &mut repo, + tenant_record_id, + excluded_candidate_start, + entity_b, + "1", + "'(2026-06-11,2026-06-12]'::tstzrange", + "'[2026-06-20,2026-06-20]'::tstzrange", + ) + .expect("excluded candidate lower endpoint makes the meeting spells disjoint"); + assert_membership_count(&mut repo, excluded_candidate_start, 2); +} + +fn seed_tenant_and_entities( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + entity_record_ids: &[Uuid], +) { + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + for entity_record_id in entity_record_ids { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed membership target"); + } +} + +fn insert_membership( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + document_record_id: Uuid, + entity_record_id: Uuid, + weight: &str, + valid_from_window: &str, + valid_to_window: &str, +) -> Result<(), persistence_postgres::PersistenceError> { + repo.session_mut().execute(&format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{}'::uuid, '{tenant_record_id}'::uuid, '{document_record_id}'::uuid, NULL, \ + '{entity_record_id}'::uuid, NULL, 'department', {weight}, \ + {valid_from_window}, {valid_to_window}, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )", + Uuid::now_v7() + )) +} + +fn assert_membership_count( + repo: &mut LiveDocumentRepository, + document_record_id: Uuid, + expected: i64, +) { + repo.session_mut() + .execute(&format!( + "DO $tepp_membership_endpoint$ BEGIN \ + IF (SELECT COUNT(*) FROM membership_assignment \ + WHERE document_record_id = '{document_record_id}'::uuid \ + AND membership_type_code = 'department') <> {expected} THEN \ + RAISE EXCEPTION 'unexpected membership row count'; \ + END IF; \ + END $tepp_membership_endpoint$" + )) + .expect("membership row count"); +} diff --git a/crates/persistence_postgres/tests/membership_share_budget_live.rs b/crates/persistence_postgres/tests/membership_share_budget_live.rs new file mode 100644 index 000000000..c793e2c74 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_share_budget_live.rs @@ -0,0 +1,396 @@ +//! Live PostgreSQL contracts for the Membership owner's same-role share budget. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use std::sync::{Arc, Barrier}; +use std::thread; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn live_postgres_preserves_exact_and_concurrent_same_role_share_budget() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + assert!( + catalog + .up_sql() + .contains("membership_assignment_same_role_share_budget"), + "production migration catalog must own the #616 admission trigger" + ); + assert!( + catalog + .down_sql() + .contains("DROP FUNCTION IF EXISTS enforce_membership_same_role_share_budget"), + "rollback catalog must remove the #616 admission function before earlier migrations" + ); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0010 migration catalog must apply"); + + let tenant_record_id = Uuid::now_v7(); + let entity_a = Uuid::now_v7(); + let entity_b = Uuid::now_v7(); + let entity_c = Uuid::now_v7(); + seed_tenant_and_entities( + &mut repo, + tenant_record_id, + &[entity_a, entity_b, entity_c], + ); + + let overrun_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + overrun_document, + entity_a, + Uuid::now_v7(), + "department", + "0.75", + "2026-01-01", + None, + )) + .expect("first same-role share"); + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + overrun_document, + entity_b, + Uuid::now_v7(), + "department", + "0.5", + "2026-01-01", + None, + )) + .is_err(), + "overlapping same-role shares above unity must fail closed" + ); + assert_membership_count(&mut repo, overrun_document, "department", 1); + + let unity_document = Uuid::now_v7(); + for (entity, weight) in [(entity_a, "0.75"), (entity_b, "0.25")] { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + unity_document, + entity, + Uuid::now_v7(), + "department", + weight, + "2026-01-01", + None, + )) + .expect("exact-unity same-role shares remain valid"); + } + + let partial_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + partial_document, + entity_a, + Uuid::now_v7(), + "department", + "0.375", + "2026-01-01", + None, + )) + .expect("sub-unity partial view remains admissible without normalization"); + + let different_role_document = Uuid::now_v7(); + for (entity, role) in [(entity_a, "department"), (entity_b, "project")] { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + different_role_document, + entity, + Uuid::now_v7(), + role, + "1", + "2026-01-01", + None, + )) + .expect("different classification roles have independent budgets"); + } + + let disjoint_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + disjoint_document, + entity_a, + Uuid::now_v7(), + "department", + "1", + "2026-01-01", + Some("2026-01-10"), + )) + .expect("first event-time spell"); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + disjoint_document, + entity_b, + Uuid::now_v7(), + "department", + "1", + "2026-01-11", + Some("2026-01-20"), + )) + .expect("disjoint event-time spell has an independent budget"); + + let pointwise_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + pointwise_document, + entity_a, + Uuid::now_v7(), + "department", + "0.6", + "2026-02-01", + Some("2026-02-10"), + )) + .expect("early same-role spell"); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + pointwise_document, + entity_b, + Uuid::now_v7(), + "department", + "0.6", + "2026-02-20", + Some("2026-02-28"), + )) + .expect("late disjoint same-role spell"); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + pointwise_document, + entity_c, + Uuid::now_v7(), + "department", + "0.4", + "2026-02-05", + Some("2026-02-25"), + )) + .expect( + "a spanning candidate is valid when each pointwise same-role total stays at or below unity", + ); + assert_membership_count(&mut repo, pointwise_document, "department", 3); + + let genuine_triple_overlap_document = Uuid::now_v7(); + for (entity, weight, from, to) in [ + (entity_a, "0.4", "2026-03-01", "2026-03-31"), + (entity_b, "0.4", "2026-03-10", "2026-03-20"), + ] { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + genuine_triple_overlap_document, + entity, + Uuid::now_v7(), + "department", + weight, + from, + Some(to), + )) + .expect("valid prefix before the genuine triple overlap"); + } + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + genuine_triple_overlap_document, + entity_c, + Uuid::now_v7(), + "department", + "0.3", + "2026-03-15", + Some("2026-03-16"), + )) + .is_err(), + "a genuine common-time aggregate above unity must remain rejected" + ); + assert_membership_count( + &mut repo, + genuine_triple_overlap_document, + "department", + 2, + ); + + let binary64_boundary_document = Uuid::now_v7(); + for (index, weight) in [ + "0.9734628667233794", + "0.0038851022715484258", + "0.02265203100507213", + ] + .into_iter() + .enumerate() + { + let result = repo.session_mut().execute(&membership_insert_sql( + tenant_record_id, + binary64_boundary_document, + if index == 1 { entity_b } else { entity_a }, + Uuid::now_v7(), + "department", + weight, + "2026-01-01", + None, + )); + if index < 2 { + result.expect("prefix of binary64 counterexample remains admissible"); + } else { + assert!( + result.is_err(), + "exact binary64 overrun must not be hidden by decimal NUMERIC summation" + ); + } + } + assert_membership_count( + &mut repo, + binary64_boundary_document, + "department", + 2, + ); + + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + Uuid::now_v7(), + entity_a, + Uuid::now_v7(), + "department", + "1e-10000", + "2026-01-01", + None, + )) + .is_err(), + "positive NUMERIC values that underflow binary64 must not become zero-share affiliations" + ); + + let concurrent_document = Uuid::now_v7(); + let barrier = Arc::new(Barrier::new(2)); + let handles: Vec<_> = [entity_a, entity_b] + .into_iter() + .map(|entity_record_id| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + let config = require_live_sqlx_config().expect("thread live PostgreSQL config"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("thread pool options"); + let mut pool = open_live_sqlx_pool(&config, options).expect("thread pool"); + let sql = membership_insert_sql( + tenant_record_id, + concurrent_document, + entity_record_id, + Uuid::now_v7(), + "department", + "0.6", + "2026-01-01", + None, + ); + barrier.wait(); + pool.execute(&sql).is_ok() + }) + }) + .collect(); + let committed = handles + .into_iter() + .map(|handle| handle.join().expect("writer thread")) + .filter(|&ok| ok) + .count(); + assert_eq!(committed, 1, "only one 0.6 first writer may commit"); + assert_membership_count(&mut repo, concurrent_document, "department", 1); +} + +fn seed_tenant_and_entities( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + entity_record_ids: &[Uuid], +) { + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + for entity_record_id in entity_record_ids { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed membership target"); + } +} + +fn assert_membership_count( + repo: &mut LiveDocumentRepository, + document_record_id: Uuid, + role: &str, + expected: i64, +) { + repo.session_mut() + .execute(&format!( + "DO $tepp_membership_budget$ BEGIN \ + IF (SELECT COUNT(*) FROM membership_assignment \ + WHERE document_record_id = '{document_record_id}'::uuid \ + AND membership_type_code = '{role}') <> {expected} THEN \ + RAISE EXCEPTION 'unexpected membership budget row count'; \ + END IF; \ + END $tepp_membership_budget$" + )) + .expect("membership row count"); +} + +fn membership_insert_sql( + tenant_record_id: Uuid, + document_record_id: Uuid, + entity_record_id: Uuid, + membership_assignment_id: Uuid, + role: &str, + weight: &str, + valid_from: &str, + valid_to: Option<&str>, +) -> String { + let to_window = valid_to.map_or_else( + || "NULL".to_owned(), + |end| format!("'[{end},{end}]'::tstzrange"), + ); + format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{membership_assignment_id}'::uuid, '{tenant_record_id}'::uuid, \ + '{document_record_id}'::uuid, NULL, '{entity_record_id}'::uuid, NULL, \ + '{role}', {weight}, '[{valid_from},{valid_from}]'::tstzrange, {to_window}, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )" + ) +} diff --git a/crates/persistence_postgres/tests/membership_share_budget_migration_contract.rs b/crates/persistence_postgres/tests/membership_share_budget_migration_contract.rs new file mode 100644 index 000000000..7a0d58f48 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_share_budget_migration_contract.rs @@ -0,0 +1,20 @@ +//! Static cutover contracts for the Membership share-budget migration. + +const MEMBERSHIP_SHARE_BUDGET_UP: &str = + include_str!("../../../migrations/0010_membership_same_role_share_budget.up.sql"); + +#[test] +fn retry_replaces_the_trigger_without_a_write_admission_gap() { + assert!( + MEMBERSHIP_SHARE_BUDGET_UP.contains( + "CREATE OR REPLACE TRIGGER membership_assignment_same_role_share_budget" + ), + "retry must atomically replace the active admission trigger" + ); + assert!( + !MEMBERSHIP_SHARE_BUDGET_UP.contains( + "DROP TRIGGER IF EXISTS membership_assignment_same_role_share_budget ON membership_assignment;\nCREATE TRIGGER" + ), + "retry must not commit a drop/create trigger gap before historical validation" + ); +} diff --git a/crates/persistence_postgres/tests/membership_share_budget_unbounded_live.rs b/crates/persistence_postgres/tests/membership_share_budget_unbounded_live.rs new file mode 100644 index 000000000..05695c7d6 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_share_budget_unbounded_live.rs @@ -0,0 +1,179 @@ +//! Live PostgreSQL regression for unbounded membership validity uncertainty. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn unbounded_membership_windows_cannot_bypass_same_role_share_budget() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0010 migration catalog must apply"); + + let tenant_record_id = Uuid::now_v7(); + let entity_a = Uuid::now_v7(); + let entity_b = Uuid::now_v7(); + seed_tenant_and_entities(&mut repo, tenant_record_id, &[entity_a, entity_b]); + + let upper_unbounded_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + upper_unbounded_document, + entity_a, + Uuid::now_v7(), + "0.75", + "'[2026-01-01,2026-01-01]'::tstzrange", + "'[2026-01-10,)'::tstzrange", + )) + .expect("upper-unbounded end uncertainty is schema-admissible"); + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + upper_unbounded_document, + entity_b, + Uuid::now_v7(), + "0.5", + "'[2026-02-01,2026-02-01]'::tstzrange", + "'[2026-02-02,2026-02-02]'::tstzrange", + )) + .is_err(), + "an upper-unbounded end window can overlap every later spell and must remain in the budget" + ); + assert_membership_count(&mut repo, upper_unbounded_document, 1); + + let lower_unbounded_document = Uuid::now_v7(); + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + lower_unbounded_document, + entity_a, + Uuid::now_v7(), + "0.75", + "'[2026-02-01,2026-02-01]'::tstzrange", + "'[2026-02-10,2026-02-10]'::tstzrange", + )) + .expect("finite baseline membership"); + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + lower_unbounded_document, + entity_b, + Uuid::now_v7(), + "0.5", + "'(,2026-02-05]'::tstzrange", + "'[2026-02-20,2026-02-20]'::tstzrange", + )) + .is_err(), + "a lower-unbounded start window must not turn the overlap predicate into SQL UNKNOWN" + ); + assert_membership_count(&mut repo, lower_unbounded_document, 1); + + let finite_disjoint_document = Uuid::now_v7(); + for (entity_record_id, from, to) in [ + (entity_a, "2026-03-01", "2026-03-10"), + (entity_b, "2026-03-11", "2026-03-20"), + ] { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + finite_disjoint_document, + entity_record_id, + Uuid::now_v7(), + "1", + &format!("'[{from},{from}]'::tstzrange"), + &format!("'[{to},{to}]'::tstzrange"), + )) + .expect("finite disjoint spells keep independent budgets"); + } + assert_membership_count(&mut repo, finite_disjoint_document, 2); +} + +fn seed_tenant_and_entities( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + entity_record_ids: &[Uuid], +) { + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + for entity_record_id in entity_record_ids { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed membership target"); + } +} + +fn assert_membership_count( + repo: &mut LiveDocumentRepository, + document_record_id: Uuid, + expected: i64, +) { + repo.session_mut() + .execute(&format!( + "DO $tepp_membership_unbounded$ BEGIN \ + IF (SELECT COUNT(*) FROM membership_assignment \ + WHERE document_record_id = '{document_record_id}'::uuid \ + AND membership_type_code = 'department') <> {expected} THEN \ + RAISE EXCEPTION 'unexpected membership row count'; \ + END IF; \ + END $tepp_membership_unbounded$" + )) + .expect("membership row count"); +} + +fn membership_insert_sql( + tenant_record_id: Uuid, + document_record_id: Uuid, + entity_record_id: Uuid, + membership_assignment_id: Uuid, + weight: &str, + valid_from_window: &str, + valid_to_window: &str, +) -> String { + format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{membership_assignment_id}'::uuid, '{tenant_record_id}'::uuid, \ + '{document_record_id}'::uuid, NULL, '{entity_record_id}'::uuid, NULL, \ + 'department', {weight}, {valid_from_window}, {valid_to_window}, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )" + ) +} diff --git a/crates/persistence_postgres/tests/membership_share_budget_upgrade_live.rs b/crates/persistence_postgres/tests/membership_share_budget_upgrade_live.rs new file mode 100644 index 000000000..f1d587946 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_share_budget_upgrade_live.rs @@ -0,0 +1,351 @@ +//! Live PostgreSQL upgrade contracts for pre-existing Membership share state. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; +const MEMBERSHIP_SHARE_BUDGET_UP: &str = + include_str!("../../../migrations/0010_membership_same_role_share_budget.up.sql"); + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn successor_refuses_invalid_legacy_membership_state_and_accepts_pointwise_valid_state() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let full_catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + let predecessor_up = full_catalog + .up_sql() + .strip_suffix(MEMBERSHIP_SHARE_BUDGET_UP) + .expect("0010 must be the terminal forward migration in the embedded catalog"); + + reset_to_predecessor(&mut repo, &full_catalog, predecessor_up); + let (tenant_record_id, entities) = seed_scope(&mut repo, 3); + let underflow_document = Uuid::now_v7(); + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[0], + underflow_document, + "department", + "1e-10000", + "'[2026-01-01,2026-01-01]'::tstzrange", + "NULL", + ); + assert!( + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP).is_err(), + "0010 must reject a positive NUMERIC legacy share that becomes binary64 zero" + ); + assert_successor_enforcement_installed(&mut repo); + assert!( + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + entities[1], + Uuid::now_v7(), + "department", + "1e-10000", + "'[2026-01-02,2026-01-02]'::tstzrange", + "NULL", + )) + .is_err(), + "failed historical validation must still leave future writes protected" + ); + repo.session_mut() + .execute(&format!( + "DELETE FROM membership_assignment WHERE document_record_id = '{underflow_document}'::uuid" + )) + .expect("data owner remediation of the invalid predecessor row"); + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP) + .expect("retry after explicit remediation must be idempotent and succeed"); + assert_successor_enforcement_installed(&mut repo); + + reset_to_predecessor(&mut repo, &full_catalog, predecessor_up); + let (tenant_record_id, entities) = seed_scope(&mut repo, 3); + let ordinary_overrun = Uuid::now_v7(); + for (entity_record_id, weight) in [(entities[0], "0.75"), (entities[1], "0.5")] { + insert_legacy_membership( + &mut repo, + tenant_record_id, + entity_record_id, + ordinary_overrun, + "department", + weight, + "'[2026-02-01,2026-02-01]'::tstzrange", + "'[2026-02-28,2026-02-28]'::tstzrange", + ); + } + assert!( + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP).is_err(), + "0010 must reject a pre-existing pointwise same-role aggregate above unity" + ); + assert_successor_enforcement_installed(&mut repo); + + reset_to_predecessor(&mut repo, &full_catalog, predecessor_up); + let (tenant_record_id, entities) = seed_scope(&mut repo, 3); + let binary64_overrun = Uuid::now_v7(); + for (index, weight) in [ + "0.9734628667233794", + "0.0038851022715484258", + "0.02265203100507213", + ] + .into_iter() + .enumerate() + { + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[index], + binary64_overrun, + "department", + weight, + "'[2026-03-01,2026-03-01]'::tstzrange", + "'[2026-03-31,2026-03-31]'::tstzrange", + ); + } + assert!( + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP).is_err(), + "0010 must reject the #612 represented-binary64 legacy overrun" + ); + assert_successor_enforcement_installed(&mut repo); + + reset_to_predecessor(&mut repo, &full_catalog, predecessor_up); + let (tenant_record_id, entities) = seed_scope(&mut repo, 3); + let duplicate_edge = Uuid::now_v7(); + for weight in ["0.5", "0.5"] { + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[0], + duplicate_edge, + "department", + weight, + "'[2026-03-01,2026-03-01]'::tstzrange", + "'[2026-03-31,2026-03-31]'::tstzrange", + ); + } + assert!( + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP).is_err(), + "0010 must reject a pre-existing duplicate temporal edge even when total share is unity" + ); + assert_successor_enforcement_installed(&mut repo); + + reset_to_predecessor(&mut repo, &full_catalog, predecessor_up); + let (tenant_record_id, entities) = seed_scope(&mut repo, 3); + + let exact_unity = Uuid::now_v7(); + for (entity_record_id, weight) in [(entities[0], "0.75"), (entities[1], "0.25")] { + insert_legacy_membership( + &mut repo, + tenant_record_id, + entity_record_id, + exact_unity, + "department", + weight, + "'[2026-04-01,2026-04-01]'::tstzrange", + "'[2026-04-30,2026-04-30]'::tstzrange", + ); + } + + let pointwise_valid = Uuid::now_v7(); + for (entity_record_id, weight, from_window, to_window) in [ + ( + entities[0], + "0.6", + "'[2026-05-01,2026-05-01]'::tstzrange", + "'[2026-05-10,2026-05-10]'::tstzrange", + ), + ( + entities[1], + "0.6", + "'[2026-05-20,2026-05-20]'::tstzrange", + "'[2026-05-30,2026-05-30]'::tstzrange", + ), + ( + entities[2], + "0.4", + "'[2026-05-05,2026-05-05]'::tstzrange", + "'[2026-05-25,2026-05-25]'::tstzrange", + ), + ] { + insert_legacy_membership( + &mut repo, + tenant_record_id, + entity_record_id, + pointwise_valid, + "department", + weight, + from_window, + to_window, + ); + } + + let leave_reentry = Uuid::now_v7(); + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[0], + leave_reentry, + "department", + "1", + "'[2026-06-01,2026-06-01]'::tstzrange", + "'[2026-06-09,2026-06-10)'::tstzrange", + ); + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[0], + leave_reentry, + "department", + "1", + "'[2026-06-10,2026-06-10]'::tstzrange", + "'[2026-06-20,2026-06-20]'::tstzrange", + ); + + let role_separated = Uuid::now_v7(); + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[0], + role_separated, + "department", + "1", + "'(,2026-07-10]'::tstzrange", + "'[2026-07-20,)'::tstzrange", + ); + insert_legacy_membership( + &mut repo, + tenant_record_id, + entities[1], + role_separated, + "project", + "1", + "'[2026-07-01,2026-07-01]'::tstzrange", + "'[2026-07-30,2026-07-30]'::tstzrange", + ); + + apply_sql_batch(repo.session_mut(), MEMBERSHIP_SHARE_BUDGET_UP) + .expect("pointwise-valid predecessor state must upgrade without normalization"); + assert_successor_enforcement_installed(&mut repo); +} + +fn reset_to_predecessor( + repo: &mut LiveDocumentRepository, + full_catalog: &MigrationCatalog, + predecessor_up: &str, +) { + let _ = apply_sql_batch(repo.session_mut(), full_catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + apply_sql_batch(repo.session_mut(), predecessor_up) + .expect("canonical 0001..0009 predecessor must apply"); +} + +fn seed_scope( + repo: &mut LiveDocumentRepository, + entity_count: usize, +) -> (Uuid, Vec) { + let tenant_record_id = Uuid::now_v7(); + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + + let entities: Vec<_> = (0..entity_count).map(|_| Uuid::now_v7()).collect(); + for entity_record_id in &entities { + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'membership_group', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed membership target"); + } + (tenant_record_id, entities) +} + +fn insert_legacy_membership( + repo: &mut LiveDocumentRepository, + tenant_record_id: Uuid, + entity_record_id: Uuid, + document_record_id: Uuid, + role: &str, + weight: &str, + valid_from_window: &str, + valid_to_window: &str, +) { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + entity_record_id, + document_record_id, + role, + weight, + valid_from_window, + valid_to_window, + )) + .expect("predecessor schema must admit the legacy fixture"); +} + +fn membership_insert_sql( + tenant_record_id: Uuid, + entity_record_id: Uuid, + document_record_id: Uuid, + role: &str, + weight: &str, + valid_from_window: &str, + valid_to_window: &str, +) -> String { + format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{}'::uuid, '{tenant_record_id}'::uuid, '{document_record_id}'::uuid, NULL, \ + '{entity_record_id}'::uuid, NULL, '{role}', {weight}, \ + {valid_from_window}, {valid_to_window}, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )", + Uuid::now_v7() + ) +} + +fn assert_successor_enforcement_installed( + repo: &mut LiveDocumentRepository, +) { + repo.session_mut() + .execute( + "DO $tepp_membership_upgrade$ BEGIN \ + IF to_regclass('membership_share_budget_guard') IS NULL THEN \ + RAISE EXCEPTION 'membership share-budget guard was not installed'; \ + END IF; \ + IF NOT EXISTS ( \ + SELECT 1 FROM pg_trigger \ + WHERE tgname = 'membership_assignment_same_role_share_budget' \ + AND tgrelid = 'membership_assignment'::regclass \ + AND NOT tgisinternal \ + ) THEN \ + RAISE EXCEPTION 'membership share-budget trigger was not installed'; \ + END IF; \ + END $tepp_membership_upgrade$", + ) + .expect("successor admission authority must be installed before historical validation"); +} diff --git a/crates/persistence_postgres/tests/membership_weight_unit_interval_live.rs b/crates/persistence_postgres/tests/membership_weight_unit_interval_live.rs new file mode 100644 index 000000000..9b3b07866 --- /dev/null +++ b/crates/persistence_postgres/tests/membership_weight_unit_interval_live.rs @@ -0,0 +1,113 @@ +//! Live PostgreSQL proof for the Membership `(0, 1]` persistence boundary. + +#![cfg(feature = "live-sqlx")] + +use persistence_postgres::{ + LiveDocumentRepository, LiveSqlxPoolOptions, MigrationCatalog, SqlSession, apply_sql_batch, + open_live_sqlx_pool, require_live_sqlx_config, +}; +use uuid::Uuid; + +const LIVE_GATE_ENV: &str = "TEPP_LIVE_POSTGRES"; + +fn live_postgres_requested() -> bool { + std::env::var(LIVE_GATE_ENV).is_ok_and(|value| value == "1") +} + +#[test] +fn live_postgres_rejects_membership_weight_above_unity() { + if !live_postgres_requested() { + return; + } + + let config = require_live_sqlx_config() + .expect("DATABASE_URL must be valid when TEPP_LIVE_POSTGRES=1"); + let options = LiveSqlxPoolOptions::new(1, 5_000).expect("pool options"); + let pool = open_live_sqlx_pool(&config, options).expect("open live PostgreSQL pool"); + let mut repo = LiveDocumentRepository::new(pool); + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + + let _ = apply_sql_batch(repo.session_mut(), catalog.down_sql()); + let _ = repo + .session_mut() + .execute("DROP ROLE IF EXISTS tepp_app_runtime"); + repo.apply_migrations(&catalog) + .expect("0001..0009 migrations must apply"); + + let tenant_record_id = Uuid::now_v7(); + let entity_record_id = Uuid::now_v7(); + repo.session_mut() + .execute(&format!( + "INSERT INTO tenant_record (tenant_record_id, tenant_status_code, system_time) \ + VALUES ('{tenant_record_id}'::uuid, 'active', '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed tenant"); + repo.session_mut() + .execute(&format!( + "INSERT INTO entity_record (entity_record_id, tenant_record_id, entity_type_code, system_time, available_time) \ + VALUES ('{entity_record_id}'::uuid, '{tenant_record_id}'::uuid, 'department', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz)" + )) + .expect("seed entity"); + + for weight in ["0.5", "1"] { + repo.session_mut() + .execute(&membership_insert_sql( + tenant_record_id, + entity_record_id, + Uuid::now_v7(), + weight, + )) + .expect("positive unit-interval membership weight must persist"); + } + + let over_unity = membership_insert_sql( + tenant_record_id, + entity_record_id, + Uuid::now_v7(), + "1.25", + ); + assert!( + repo.session_mut().execute(&over_unity).is_err(), + "direct SQL above one must fail membership_assignment_weight_unit_interval" + ); + + repo.session_mut() + .execute( + "DO $tepp_membership_weight$ BEGIN \ + IF NOT EXISTS ( \ + SELECT 1 FROM pg_constraint \ + WHERE conname = 'membership_assignment_weight_unit_interval' \ + AND conrelid = 'membership_assignment'::regclass \ + AND convalidated \ + ) THEN \ + RAISE EXCEPTION 'missing or unvalidated membership weight unit-interval constraint'; \ + END IF; \ + IF (SELECT COUNT(*) FROM membership_assignment) <> 2 THEN \ + RAISE EXCEPTION 'invalid membership write changed persisted row count'; \ + END IF; \ + END $tepp_membership_weight$", + ) + .expect("validated constraint identity and mutation atomicity"); +} + +fn membership_insert_sql( + tenant_record_id: Uuid, + entity_record_id: Uuid, + membership_assignment_id: Uuid, + weight: &str, +) -> String { + let document_record_id = Uuid::now_v7(); + format!( + "INSERT INTO membership_assignment (\ + membership_assignment_id, tenant_record_id, document_record_id, text_segment_id, \ + target_entity_id, target_project_id, membership_type_code, membership_weight, \ + valid_from_window, valid_to_window, valid_time_precision_code, system_time, available_time\ + ) VALUES (\ + '{membership_assignment_id}'::uuid, '{tenant_record_id}'::uuid, \ + '{document_record_id}'::uuid, NULL, '{entity_record_id}'::uuid, NULL, \ + 'department', {weight}, '[2026-01-01,2026-01-01]'::tstzrange, NULL, 'second', \ + '2026-01-01T00:00:00Z'::timestamptz, '2026-01-01T00:00:00Z'::timestamptz\ + )" + ) +} diff --git a/migrations/0009_membership_weight_unit_interval.down.sql b/migrations/0009_membership_weight_unit_interval.down.sql new file mode 100644 index 000000000..8288aae1c --- /dev/null +++ b/migrations/0009_membership_weight_unit_interval.down.sql @@ -0,0 +1,5 @@ +-- Restore the predecessor persistence contract from `0006`: positive shares +-- remain enforced there, while the successor upper bound is removed. + +ALTER TABLE membership_assignment + DROP CONSTRAINT membership_assignment_weight_unit_interval; diff --git a/migrations/0009_membership_weight_unit_interval.up.sql b/migrations/0009_membership_weight_unit_interval.up.sql new file mode 100644 index 000000000..f90b6a6cf --- /dev/null +++ b/migrations/0009_membership_weight_unit_interval.up.sql @@ -0,0 +1,15 @@ +-- Align persisted membership shares with the Membership owner domain `(0, 1]`. +-- `0006` already rejects non-positive shares; this successor adds the missing +-- upper bound without rewriting released or reserved migration history. +-- +-- Add the constraint NOT VALID first so the ACCESS EXCLUSIVE DDL lock is held +-- only for catalog installation rather than a full table scan. PostgreSQL still +-- enforces a NOT VALID CHECK on new writes. The subsequent VALIDATE scan uses a +-- weaker lock while proving all pre-existing rows satisfy the owner contract. + +ALTER TABLE membership_assignment + ADD CONSTRAINT membership_assignment_weight_unit_interval + CHECK (membership_weight > 0 AND membership_weight <= 1) NOT VALID; + +ALTER TABLE membership_assignment + VALIDATE CONSTRAINT membership_assignment_weight_unit_interval; diff --git a/migrations/0010_membership_same_role_share_budget.down.sql b/migrations/0010_membership_same_role_share_budget.down.sql new file mode 100644 index 000000000..5a6ff91c1 --- /dev/null +++ b/migrations/0010_membership_same_role_share_budget.down.sql @@ -0,0 +1,7 @@ +DROP TRIGGER IF EXISTS membership_assignment_same_role_share_budget ON membership_assignment; +DROP FUNCTION IF EXISTS enforce_membership_same_role_share_budget(); +DROP TABLE IF EXISTS membership_share_budget_guard; +DROP FUNCTION IF EXISTS membership_duplicate_temporal_edge_exists(uuid, text, uuid, uuid, uuid, text, tstzrange, uuid); +DROP FUNCTION IF EXISTS membership_same_role_max_existing_numerator(uuid, text, uuid, text, tstzrange, uuid); +DROP FUNCTION IF EXISTS membership_possible_activity_envelope(tstzrange, tstzrange); +DROP FUNCTION IF EXISTS membership_binary64_scaled_numerator(numeric); diff --git a/migrations/0010_membership_same_role_share_budget.up.sql b/migrations/0010_membership_same_role_share_budget.up.sql new file mode 100644 index 000000000..2f540a49e --- /dev/null +++ b/migrations/0010_membership_same_role_share_budget.up.sql @@ -0,0 +1,412 @@ +-- Preserve the Membership owner's exact same-role share budget at the PostgreSQL boundary. +-- +-- `membership_weight` remains NUMERIC for compatibility with the existing schema, but budget +-- admission canonicalizes every stored input through PostgreSQL double precision and then reasons +-- over the exact represented binary64 value. This matches the Rust Membership owner without +-- replacing scientific identity with decimal SUM or ordinary floating accumulation. +-- +-- A narrow guard row serializes writers for one tenant/observed-unit/role lane before the +-- pointwise temporal aggregate and duplicate-edge predicates are evaluated. Admission protection +-- is installed before the historical scan so predecessor writes cannot slip through a validation +-- to trigger cutover gap. + +CREATE OR REPLACE FUNCTION membership_binary64_scaled_numerator(weight numeric) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $membership_binary64_scaled_numerator$ +DECLARE + payload bytea; + raw_bits bigint; + raw_exponent integer; + fraction bigint; + significand numeric; + exponent_shift integer; + factor numeric := 1; + factor_base numeric := 2; + factor_exponent integer; +BEGIN + IF weight <= 0 OR weight > 1 THEN + RAISE EXCEPTION 'membership weight is outside (0, 1]' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_weight_unit_interval'; + END IF; + + payload := pg_catalog.float8send(weight::double precision); + raw_bits := + (get_byte(payload, 0)::bigint << 56) + | (get_byte(payload, 1)::bigint << 48) + | (get_byte(payload, 2)::bigint << 40) + | (get_byte(payload, 3)::bigint << 32) + | (get_byte(payload, 4)::bigint << 24) + | (get_byte(payload, 5)::bigint << 16) + | (get_byte(payload, 6)::bigint << 8) + | get_byte(payload, 7)::bigint; + + IF raw_bits <= 0 OR raw_bits > 4607182418800017408 THEN + RAISE EXCEPTION 'membership weight is not representable in the owner binary64 domain' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_weight_unit_interval'; + END IF; + + raw_exponent := ((raw_bits >> 52) & 2047)::integer; + fraction := raw_bits & 4503599627370495; + IF raw_exponent = 0 THEN + RETURN fraction::numeric; + END IF; + + significand := (4503599627370496 + fraction)::numeric; + exponent_shift := raw_exponent - 1; + factor_exponent := exponent_shift; + + WHILE factor_exponent > 0 LOOP + IF factor_exponent % 2 = 1 THEN + factor := factor * factor_base; + END IF; + factor_exponent := factor_exponent / 2; + IF factor_exponent > 0 THEN + factor_base := factor_base * factor_base; + END IF; + END LOOP; + + RETURN significand * factor; +END; +$membership_binary64_scaled_numerator$; + +CREATE OR REPLACE FUNCTION membership_possible_activity_envelope( + valid_from_window tstzrange, + valid_to_window tstzrange +) +RETURNS tstzrange +LANGUAGE sql +IMMUTABLE +AS $membership_possible_activity_envelope$ + SELECT tstzrange( + lower(valid_from_window), + CASE WHEN valid_to_window IS NULL THEN NULL ELSE upper(valid_to_window) END, + (CASE WHEN lower_inc(valid_from_window) THEN '[' ELSE '(' END) + || + (CASE + WHEN valid_to_window IS NOT NULL AND upper_inc(valid_to_window) THEN ']' + ELSE ')' + END) + ) +$membership_possible_activity_envelope$; + +CREATE OR REPLACE FUNCTION membership_same_role_max_existing_numerator( + candidate_tenant_record_id uuid, + candidate_observed_unit_kind text, + candidate_observed_unit_id uuid, + candidate_membership_type_code text, + candidate_envelope tstzrange, + excluded_membership_assignment_id uuid +) +RETURNS numeric +LANGUAGE sql +STABLE +AS $membership_same_role_max_existing_numerator$ + WITH lane_memberships AS ( + SELECT + membership_binary64_scaled_numerator(existing.membership_weight) AS numerator, + membership_possible_activity_envelope( + existing.valid_from_window, + existing.valid_to_window + ) AS envelope + FROM membership_assignment AS existing + WHERE existing.tenant_record_id = candidate_tenant_record_id + AND existing.membership_type_code = candidate_membership_type_code + AND existing.membership_assignment_id <> excluded_membership_assignment_id + AND ( + (candidate_observed_unit_kind = 'document' + AND existing.document_record_id = candidate_observed_unit_id + AND existing.text_segment_id IS NULL) + OR (candidate_observed_unit_kind = 'text_segment' + AND existing.text_segment_id = candidate_observed_unit_id + AND existing.document_record_id IS NULL) + ) + ), + relevant AS ( + SELECT numerator, envelope + FROM lane_memberships + WHERE envelope && candidate_envelope + ), + boundaries AS ( + SELECT lower(candidate_envelope) AS boundary + WHERE NOT lower_inf(candidate_envelope) + UNION + SELECT upper(candidate_envelope) AS boundary + WHERE NOT upper_inf(candidate_envelope) + UNION + SELECT lower(envelope) AS boundary + FROM relevant + WHERE NOT lower_inf(envelope) + UNION + SELECT upper(envelope) AS boundary + FROM relevant + WHERE NOT upper_inf(envelope) + ), + point_states AS ( + SELECT COALESCE(SUM(relevant.numerator), 0) AS numerator + FROM boundaries + LEFT JOIN relevant ON relevant.envelope @> boundaries.boundary + WHERE candidate_envelope @> boundaries.boundary + GROUP BY boundaries.boundary + ), + right_open_states AS ( + SELECT COALESCE(SUM(relevant.numerator), 0) AS numerator + FROM boundaries + LEFT JOIN relevant + ON (lower_inf(relevant.envelope) OR lower(relevant.envelope) <= boundaries.boundary) + AND (upper_inf(relevant.envelope) OR upper(relevant.envelope) > boundaries.boundary) + WHERE (lower_inf(candidate_envelope) OR lower(candidate_envelope) <= boundaries.boundary) + AND (upper_inf(candidate_envelope) OR upper(candidate_envelope) > boundaries.boundary) + GROUP BY boundaries.boundary + ), + lower_unbounded_state AS ( + SELECT COALESCE(SUM(relevant.numerator), 0) AS numerator + FROM relevant + WHERE lower_inf(candidate_envelope) + AND lower_inf(relevant.envelope) + ), + states AS ( + SELECT numerator FROM point_states + UNION ALL + SELECT numerator FROM right_open_states + UNION ALL + SELECT numerator FROM lower_unbounded_state + ) + SELECT COALESCE(MAX(numerator), 0) + FROM states +$membership_same_role_max_existing_numerator$; + +CREATE OR REPLACE FUNCTION membership_duplicate_temporal_edge_exists( + candidate_tenant_record_id uuid, + candidate_observed_unit_kind text, + candidate_observed_unit_id uuid, + candidate_target_entity_id uuid, + candidate_target_project_id uuid, + candidate_membership_type_code text, + candidate_envelope tstzrange, + excluded_membership_assignment_id uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $membership_duplicate_temporal_edge_exists$ + SELECT EXISTS ( + SELECT 1 + FROM membership_assignment AS existing + WHERE existing.tenant_record_id = candidate_tenant_record_id + AND existing.membership_type_code = candidate_membership_type_code + AND existing.membership_assignment_id <> excluded_membership_assignment_id + AND ( + (candidate_observed_unit_kind = 'document' + AND existing.document_record_id = candidate_observed_unit_id + AND existing.text_segment_id IS NULL) + OR (candidate_observed_unit_kind = 'text_segment' + AND existing.text_segment_id = candidate_observed_unit_id + AND existing.document_record_id IS NULL) + ) + AND ( + (candidate_target_entity_id IS NOT NULL + AND candidate_target_project_id IS NULL + AND existing.target_entity_id = candidate_target_entity_id + AND existing.target_project_id IS NULL) + OR (candidate_target_project_id IS NOT NULL + AND candidate_target_entity_id IS NULL + AND existing.target_project_id = candidate_target_project_id + AND existing.target_entity_id IS NULL) + ) + AND membership_possible_activity_envelope( + existing.valid_from_window, + existing.valid_to_window + ) && candidate_envelope + ) +$membership_duplicate_temporal_edge_exists$; + +CREATE TABLE IF NOT EXISTS membership_share_budget_guard ( + tenant_record_id uuid NOT NULL REFERENCES tenant_record (tenant_record_id), + observed_unit_kind text NOT NULL, + observed_unit_id uuid NOT NULL, + membership_type_code text NOT NULL, + system_time timestamptz NOT NULL DEFAULT statement_timestamp(), + available_time timestamptz NOT NULL DEFAULT statement_timestamp(), + PRIMARY KEY (tenant_record_id, observed_unit_kind, observed_unit_id, membership_type_code), + CONSTRAINT membership_share_budget_guard_unit_kind CHECK ( + observed_unit_kind IN ('document', 'text_segment') + ) +); + +GRANT SELECT, INSERT ON TABLE membership_share_budget_guard TO tepp_app_runtime; +GRANT UPDATE (system_time) ON TABLE membership_share_budget_guard TO tepp_app_runtime; + +ALTER TABLE membership_share_budget_guard ENABLE ROW LEVEL SECURITY; +ALTER TABLE membership_share_budget_guard FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS membership_share_budget_guard_tenant_isolation ON membership_share_budget_guard; +CREATE POLICY membership_share_budget_guard_tenant_isolation ON membership_share_budget_guard + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + +CREATE OR REPLACE FUNCTION enforce_membership_same_role_share_budget() +RETURNS trigger +LANGUAGE plpgsql +VOLATILE +AS $enforce_membership_same_role_share_budget$ +DECLARE + observed_kind text; + observed_id uuid; + candidate_envelope tstzrange; + existing_numerator numeric; + candidate_numerator numeric; + unity_numerator numeric; +BEGIN + IF NEW.document_record_id IS NOT NULL THEN + observed_kind := 'document'; + observed_id := NEW.document_record_id; + ELSIF NEW.text_segment_id IS NOT NULL THEN + observed_kind := 'text_segment'; + observed_id := NEW.text_segment_id; + ELSE + RAISE EXCEPTION 'membership assignment has no observed unit' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_observed_unit_exactly_one'; + END IF; + + candidate_numerator := membership_binary64_scaled_numerator(NEW.membership_weight); + unity_numerator := membership_binary64_scaled_numerator(1::numeric); + + INSERT INTO membership_share_budget_guard ( + tenant_record_id, + observed_unit_kind, + observed_unit_id, + membership_type_code, + system_time, + available_time + ) VALUES ( + NEW.tenant_record_id, + observed_kind, + observed_id, + NEW.membership_type_code, + statement_timestamp(), + statement_timestamp() + ) + ON CONFLICT (tenant_record_id, observed_unit_kind, observed_unit_id, membership_type_code) + DO UPDATE SET system_time = membership_share_budget_guard.system_time; + + candidate_envelope := membership_possible_activity_envelope( + NEW.valid_from_window, + NEW.valid_to_window + ); + + IF membership_duplicate_temporal_edge_exists( + NEW.tenant_record_id, + observed_kind, + observed_id, + NEW.target_entity_id, + NEW.target_project_id, + NEW.membership_type_code, + candidate_envelope, + NEW.membership_assignment_id + ) THEN + RAISE EXCEPTION 'duplicate membership temporal edge overlaps' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_duplicate_temporal_edge'; + END IF; + + existing_numerator := membership_same_role_max_existing_numerator( + NEW.tenant_record_id, + observed_kind, + observed_id, + NEW.membership_type_code, + candidate_envelope, + NEW.membership_assignment_id + ); + + IF existing_numerator + candidate_numerator > unity_numerator THEN + RAISE EXCEPTION 'overlapping same-role membership shares exceed unity' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_same_role_share_budget'; + END IF; + + RETURN NEW; +END; +$enforce_membership_same_role_share_budget$; + +CREATE OR REPLACE TRIGGER membership_assignment_same_role_share_budget +BEFORE INSERT OR UPDATE OF + tenant_record_id, + document_record_id, + text_segment_id, + target_entity_id, + target_project_id, + membership_type_code, + membership_weight, + valid_from_window, + valid_to_window +ON membership_assignment +FOR EACH ROW +EXECUTE FUNCTION enforce_membership_same_role_share_budget(); + +-- Historical validation runs only after future writes are protected. This deliberately mirrors the +-- operational shape of `NOT VALID -> VALIDATE`: an invalid predecessor dataset leaves the new +-- admission gate installed, fails the migration, and can be repaired by the data owner before an +-- idempotent retry. No long explicit table lock is held across the scientific scan. +DO $membership_existing_share_budget_validation$ +DECLARE + existing membership_assignment%ROWTYPE; + observed_kind text; + observed_id uuid; + candidate_envelope tstzrange; + existing_numerator numeric; + candidate_numerator numeric; + unity_numerator numeric := membership_binary64_scaled_numerator(1::numeric); +BEGIN + FOR existing IN SELECT * FROM membership_assignment LOOP + candidate_numerator := membership_binary64_scaled_numerator(existing.membership_weight); + candidate_envelope := membership_possible_activity_envelope( + existing.valid_from_window, + existing.valid_to_window + ); + + IF existing.document_record_id IS NOT NULL THEN + observed_kind := 'document'; + observed_id := existing.document_record_id; + ELSIF existing.text_segment_id IS NOT NULL THEN + observed_kind := 'text_segment'; + observed_id := existing.text_segment_id; + ELSE + RAISE EXCEPTION 'membership assignment has no observed unit' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_observed_unit_exactly_one'; + END IF; + + IF membership_duplicate_temporal_edge_exists( + existing.tenant_record_id, + observed_kind, + observed_id, + existing.target_entity_id, + existing.target_project_id, + existing.membership_type_code, + candidate_envelope, + existing.membership_assignment_id + ) THEN + RAISE EXCEPTION 'pre-existing duplicate membership temporal edge overlaps' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_duplicate_temporal_edge'; + END IF; + + existing_numerator := membership_same_role_max_existing_numerator( + existing.tenant_record_id, + observed_kind, + observed_id, + existing.membership_type_code, + candidate_envelope, + existing.membership_assignment_id + ); + + IF existing_numerator + candidate_numerator > unity_numerator THEN + RAISE EXCEPTION 'pre-existing overlapping same-role membership shares exceed unity' + USING ERRCODE = '23514', CONSTRAINT = 'membership_assignment_same_role_share_budget'; + END IF; + END LOOP; +END; +$membership_existing_share_budget_validation$;