diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c909a8c2b..d6d21616d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,6 +61,7 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | +| `revision_order` | later document revisions must have later system time | | `encrypted_mapping` | purpose-bound in-memory AES-256-GCM identity mappings; no plaintext persistence or KMS integration | | `citation_edge` | citation, revision, translation, and retrospective edges are not state transitions | | `psychometric_fit` | CPU `f64` ESEM loading recovery and event-time DSEM lag gates | diff --git a/CHANGELOG.md b/CHANGELOG.md index 247252124..2cf1d9f52 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 +- `revision_order` system-time gate: a higher document revision number cannot carry earlier or equal system time; recovered order flags match known truth at a higher computed rate than accepting every pair (ADR 0002/0013). - `encrypted_mapping` purpose-bound AES-256-GCM envelope: source identities are sealed with an operating-system-generated nonce and analytical/key identifiers as authenticated associated data, with a 1 MiB resource bound, so analytical, log, and model-artifact purposes cannot recover plaintext; recovered identities match known truth at a higher computed rate than collapsing every mapping to one name. Persistence and KMS wait for a later migration (ADR 0009). - `citation_edge` provenance gate: citation, translation, revision, and retrospective-report edges may point to the past but cannot become input-process-outcome transitions; recovered kinds match known truth at a higher computed rate than collapsing every edge to citation (ADR 0002/0003). - `psychometric_fit` CPU `f64` ESEM/DSEM fit: exploratory OLS recovers known cross-loadings from admitted log-ratio or logistic-normal coordinates with computed RMSE below a zero-loading collapse; reverse or zero event-time lagged paths fail closed; a good global fit cannot reclassify formative or network constructs as reflective (ADR 0005). No new migration number (`#45` still owns `0007`). diff --git a/Cargo.lock b/Cargo.lock index 6a076f32b..4ce817a22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1130,6 +1130,10 @@ dependencies = [ "uuid", ] +[[package]] +name = "revision_order" +version = "0.1.0" + [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index 49934ce60..6442f7d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/revision_order", "crates/encrypted_mapping", "crates/citation_edge", "crates/psychometric_fit", @@ -38,6 +39,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/revision_order", "crates/encrypted_mapping", "crates/citation_edge", "crates/psychometric_fit", diff --git a/README.md b/README.md index 2f358663e..4fd7bfe15 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/revision_order crates/encrypted_mapping crates/citation_edge crates/psychometric_fit diff --git a/crates/revision_order/Cargo.toml b/crates/revision_order/Cargo.toml new file mode 100644 index 000000000..78bba5b92 --- /dev/null +++ b/crates/revision_order/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "revision_order" +description = "Later document revisions cannot move backward in system time." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/revision_order/src/error.rs b/crates/revision_order/src/error.rs new file mode 100644 index 000000000..474053995 --- /dev/null +++ b/crates/revision_order/src/error.rs @@ -0,0 +1,48 @@ +//! Fail-closed revision-order errors. + +use std::fmt; + +/// A fail-closed revision-order error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RevisionOrderError { + /// A later revision did not have a later system time. + SystemTimeDidNotIncrease, + /// A revision number or recovery slice was empty, zero, or mismatched. + InvalidRevisionPayload, +} + +impl fmt::Display for RevisionOrderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::SystemTimeDidNotIncrease => { + "later document revisions must have later system time" + } + Self::InvalidRevisionPayload => "invalid revision-order payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for RevisionOrderError {} + +#[cfg(test)] +mod tests { + use super::RevisionOrderError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + RevisionOrderError::SystemTimeDidNotIncrease, + "later document revisions must have later system time", + ), + ( + RevisionOrderError::InvalidRevisionPayload, + "invalid revision-order payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/revision_order/src/lib.rs b/crates/revision_order/src/lib.rs new file mode 100644 index 000000000..5a6c59458 --- /dev/null +++ b/crates/revision_order/src/lib.rs @@ -0,0 +1,21 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Later document revisions cannot move backward in system time. +//! +//! A higher revision number is a later assertion about the same document +//! identity. Its system time must strictly increase (ADR 0002/0013). + +mod error; +mod revision; + +/// Fail-closed revision-order errors. +pub use error::RevisionOrderError; +/// One document revision with a positive revision number and system time. +pub use revision::DocumentRevision; +/// Fraction of recovered order flags that match known truth. +pub use revision::order_recovery_rate; +/// Refuse a later revision whose system time did not increase. +pub use revision::refuse_nonincreasing_system_time; +/// Return whether a later revision has a later system time. +pub use revision::revisions_are_increasing; diff --git a/crates/revision_order/src/revision.rs b/crates/revision_order/src/revision.rs new file mode 100644 index 000000000..a58b90d28 --- /dev/null +++ b/crates/revision_order/src/revision.rs @@ -0,0 +1,138 @@ +//! Document revisions stamped with system time. + +use crate::RevisionOrderError; + +/// One document revision with a positive revision number and system time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DocumentRevision { + revision_number: u32, + system_time_seconds: i64, +} + +impl DocumentRevision { + /// Construct a revision whose number is at least one. + /// + /// # Errors + /// + /// Returns [`RevisionOrderError::InvalidRevisionPayload`] when + /// `revision_number` is zero. + pub const fn new( + revision_number: u32, + system_time_seconds: i64, + ) -> Result { + if revision_number == 0 { + return Err(RevisionOrderError::InvalidRevisionPayload); + } + Ok(Self { + revision_number, + system_time_seconds, + }) + } + + /// Positive revision number. + #[must_use] + pub const fn revision_number(self) -> u32 { + self.revision_number + } + + /// System/record time in seconds. + #[must_use] + pub const fn system_time_seconds(self) -> i64 { + self.system_time_seconds + } +} + +/// Return whether `later` has a greater revision number and later system time. +/// +/// # Errors +/// +/// Returns [`RevisionOrderError::InvalidRevisionPayload`] when `later` is not +/// a strictly greater revision number than `earlier`. +pub fn revisions_are_increasing( + earlier: DocumentRevision, + later: DocumentRevision, +) -> Result { + if later.revision_number <= earlier.revision_number { + return Err(RevisionOrderError::InvalidRevisionPayload); + } + Ok(later.system_time_seconds > earlier.system_time_seconds) +} + +/// Refuse a later revision whose system time did not increase. +/// +/// # Errors +/// +/// Returns revision-construction errors, or +/// [`RevisionOrderError::SystemTimeDidNotIncrease`] when the system times +/// are not strictly increasing. +pub fn refuse_nonincreasing_system_time( + earlier: DocumentRevision, + later: DocumentRevision, +) -> Result<(), RevisionOrderError> { + if revisions_are_increasing(earlier, later)? { + return Ok(()); + } + Err(RevisionOrderError::SystemTimeDidNotIncrease) +} + +/// Fraction of recovered order flags that match known truth. +/// +/// # Errors +/// +/// Returns [`RevisionOrderError::InvalidRevisionPayload`] when either slice +/// is empty or the lengths differ. +pub fn order_recovery_rate(truth: &[bool], decided: &[bool]) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(RevisionOrderError::InvalidRevisionPayload); + } + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(decided) { + if truth_flag == decided_flag { + matches += 1; + } + } + Ok(f64::from(matches) / truth.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + DocumentRevision, order_recovery_rate, refuse_nonincreasing_system_time, + revisions_are_increasing, + }; + use crate::RevisionOrderError; + + #[test] + fn local_branches_cover_order_and_payloads() { + let first = DocumentRevision::new(1, 10).expect("first"); + let second = DocumentRevision::new(2, 20).expect("second"); + assert_eq!(first.revision_number(), 1); + assert_eq!(first.system_time_seconds(), 10); + assert!(revisions_are_increasing(first, second).expect("increasing")); + refuse_nonincreasing_system_time(first, second).expect("ok"); + let same_time = DocumentRevision::new(3, 20).expect("same"); + assert!(!revisions_are_increasing(second, same_time).expect("flat")); + assert_eq!( + refuse_nonincreasing_system_time(second, same_time), + Err(RevisionOrderError::SystemTimeDidNotIncrease) + ); + assert_eq!( + revisions_are_increasing(second, first), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + assert_eq!( + DocumentRevision::new(0, 1), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + let matched = order_recovery_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + order_recovery_rate(&[], &[]), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + assert_eq!( + order_recovery_rate(&[true], &[]), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + } +} diff --git a/crates/revision_order/tests/crate_contract.rs b/crates/revision_order/tests/crate_contract.rs new file mode 100644 index 000000000..2b7f3862a --- /dev/null +++ b/crates/revision_order/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `revision_order` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "revision_order"); +} diff --git a/crates/revision_order/tests/order_contract.rs b/crates/revision_order/tests/order_contract.rs new file mode 100644 index 000000000..8dfbef910 --- /dev/null +++ b/crates/revision_order/tests/order_contract.rs @@ -0,0 +1,77 @@ +//! Later revisions cannot carry earlier or equal system time. + +use revision_order::{ + DocumentRevision, RevisionOrderError, order_recovery_rate, refuse_nonincreasing_system_time, + revisions_are_increasing, +}; + +fn revision(number: u32, system: i64) -> DocumentRevision { + DocumentRevision::new(number, system).expect("revision") +} + +#[test] +fn later_revisions_cannot_move_backward_in_system_time() { + let first = revision(1, 10); + let second = revision(2, 20); + let backward = revision(3, 15); + assert!(revisions_are_increasing(first, second).expect("increasing")); + refuse_nonincreasing_system_time(first, second).expect("ok"); + assert!(!revisions_are_increasing(second, backward).expect("backward")); + assert_eq!( + refuse_nonincreasing_system_time(second, backward), + Err(RevisionOrderError::SystemTimeDidNotIncrease) + ); + assert_eq!( + refuse_nonincreasing_system_time(second, revision(4, 20)), + Err(RevisionOrderError::SystemTimeDidNotIncrease) + ); +} + +#[test] +fn recovered_order_flags_match_known_truth_better_than_accepting_all() { + let pairs = [ + (revision(1, 10), revision(2, 20)), + (revision(2, 20), revision(3, 15)), + (revision(3, 30), revision(4, 40)), + ]; + let truth = [true, false, true]; + let recovered = [ + revisions_are_increasing(pairs[0].0, pairs[0].1).expect("p0"), + revisions_are_increasing(pairs[1].0, pairs[1].1).expect("p1"), + revisions_are_increasing(pairs[2].0, pairs[2].1).expect("p2"), + ]; + let collapsed = [true, true, true]; + let recovered_rate = order_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = order_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(recovered.iter()) { + if truth_flag == decided_flag { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); +} + +#[test] +fn empty_or_invalid_revision_payloads_fail_closed() { + assert_eq!( + DocumentRevision::new(0, 10), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + assert_eq!( + order_recovery_rate(&[], &[]), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + assert_eq!( + order_recovery_rate(&[true], &[]), + Err(RevisionOrderError::InvalidRevisionPayload) + ); + assert_eq!( + order_recovery_rate(&[true, false], &[true]), + Err(RevisionOrderError::InvalidRevisionPayload) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 326785c1e..9b37c880c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,7 +17,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | -| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), and backup/restore integrity revalidation (#44 implemented-main); remaining physical ERD constraints | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` on protected main as before; `revision_order` later-revision system-time gate on the active PR; remaining physical ERD constraints | 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 and corpus-split leakage-audit wire (`CorpusSplitManifest` v1) on this PR; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | diff --git a/docs/adr/README.md b/docs/adr/README.md index 88f9b4079..4ec44418c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | partial | `tepp_api` router/ablation/orchestrator binding implemented-main; live NIM execution and production ablation evidence remain accepted-target. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | +| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, tenant RLS, and `0006` membership implemented-main; `0007` retention/deletion/legal-hold implemented-main; document revision system-time order in `revision_order` is active on this PR; remaining physical ERD/backup accepted-target. | | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, tenant RLS, `0006` membership, `0007` retention/deletion/legal-hold, and backup/restore integrity revalidation implemented-main; remaining physical ERD/DR-runbook depth accepted-target. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | active-PR | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates; `topic_lineage` implements the active/dormant/reactivated identity slice on the active PR. | | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, recovery identity, retention/deletion/legal-hold, backup/restore integrity, and concurrent writes; optional `live-sqlx` `PgPool`, live PG CI, tenant RLS, and `0006` membership implemented-main; remaining physical ERD/backup evidence accepted-target. | diff --git a/docs/research/revision-system-time-order.md b/docs/research/revision-system-time-order.md new file mode 100644 index 000000000..3b2e705db --- /dev/null +++ b/docs/research/revision-system-time-order.md @@ -0,0 +1,29 @@ +# Document revision system-time order (doctoring) + +## Scope + +`revision_order` requires a later document revision number to carry a +strictly later system time. Recovery is the computed share of order flags +that match known truth. + +This slice does not persist revisions, allocate migration `0008`, or +replace `persistence_postgres` interval CHECKs. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0002-six-clock-temporal-semantics.md` — system/record time is + distinct from event time; later assertions cannot rewrite earlier + system-time order. +- `docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md` + — document versions are bitemporal; revision identity is ordered. + +### Supporting literature + +Snodgrass (2000) treats transaction/system time as the time a fact was +recorded. A later recorded version cannot precede an earlier one in +system time. + +Snodgrass, R. T. (2000). *Developing time-oriented database applications +in SQL*. Morgan Kaufmann. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index aaa58b714..6527850dd 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -137,6 +137,8 @@ Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communicat Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434. Interval relations inform `citation_edge`; they do not make a citation a state transition. +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Transaction/system time informs `revision_order`; a later recorded revision cannot precede an earlier one. + ## Privacy lifecycle, retention, and legal hold European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. Official Journal of the European Union, L 119, 1–88. https://eur-lex.europa.eu/eli/reg/2016/679/oj diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 636e10806..7dd561926 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -27,6 +27,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Mention-confidence Brier score | `event_core` | active-PR | calibration vs binary truth | perfect 0 / half 0.25 RMSE | ADR 0003; `docs/research/mention-confidence-brier.md` | | Checkpoint is not the estimator | `checkpoint_authority` | accepted-target | active PR | refuse checkpoint-as-estimator + unvalidated artifact + recovery vs estimator collapse | ADR 0001/0014 | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Revision system-time order | `revision_order` | active-PR | this PR | order-flag recovery vs accept-all | ADR 0002/0013 | | Provenance-vs-transition gate | `citation_edge` | active-PR | this PR | recovered kind rate vs citation collapse | ADR 0002/0003 | | ESEM/DSEM CPU fit | `psychometric_fit` | active-PR | this PR | loading RMSE vs zero-collapse; reverse-lag refusal | ADR 0005; does not recreate `psychometric_core` | | Subevent parent containment | `subevent_containment` | active-PR | this PR | containment-flag recovery vs accept-all | ADR 0003 | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index dda5281aa..4f6649544 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "revision_order", "encrypted_mapping", "citation_edge", "psychometric_fit",