diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5df8f20d5..d381b977a 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 | +| `available_clock` | availability time cannot be replaced by event or system time | | `document_clocks` | document rows must carry assertion time and document time | | `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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2dc0d8..caaee7034 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 +- `available_clock` identity gate: event time and system time cannot stand in for availability time; recovered availability stamps match known truth at a higher computed rate than treating every stamp as system time (ADR 0002). - `document_clocks` six-clock gate: a document analytical row cannot omit assertion time or document time, and event/system time cannot stand in for those clocks; recovered completeness flags match known truth at a higher computed rate than treating every row as complete (ADR 0002/0013). - `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). diff --git a/Cargo.lock b/Cargo.lock index a62df0d4d..3151825c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,10 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "available_clock" +version = "0.1.0" + [[package]] name = "backtrace" version = "0.3.76" diff --git a/Cargo.toml b/Cargo.toml index d56c60629..b3bef482c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/available_clock", "crates/document_clocks", "crates/revision_order", "crates/encrypted_mapping", @@ -40,6 +41,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/available_clock", "crates/document_clocks", "crates/revision_order", "crates/encrypted_mapping", diff --git a/README.md b/README.md index 51020ce8d..97d6fdea7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/available_clock crates/document_clocks crates/revision_order crates/encrypted_mapping diff --git a/crates/available_clock/Cargo.toml b/crates/available_clock/Cargo.toml new file mode 100644 index 000000000..ebc901cc0 --- /dev/null +++ b/crates/available_clock/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "available_clock" +description = "Availability time cannot be replaced by event or 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/available_clock/src/clock.rs b/crates/available_clock/src/clock.rs new file mode 100644 index 000000000..2c192eb54 --- /dev/null +++ b/crates/available_clock/src/clock.rs @@ -0,0 +1,99 @@ +//! Clock-family identity for availability stamps. + +use crate::AvailableClockError; + +/// Closed vocabulary of clocks that must not be confused with availability. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClockFamily { + /// Event/valid time. + EventTime, + /// System/record time. + SystemTime, + /// Availability time. + AvailableTime, +} + +/// Return whether a stamp is on the availability clock. +/// +/// # Errors +/// +/// This function is infallible for the closed vocabulary and exists to keep +/// the public comparison surface explicit. +#[allow(clippy::unnecessary_wraps)] +pub fn stamp_is_available(family: ClockFamily) -> Result { + Ok(matches!(family, ClockFamily::AvailableTime)) +} + +/// Refuse to treat event time as availability time. +/// +/// # Errors +/// +/// Always returns [`AvailableClockError::EventTimeIsNotAvailableTime`]. +pub fn refuse_event_time_as_available() -> Result<(), AvailableClockError> { + Err(AvailableClockError::EventTimeIsNotAvailableTime) +} + +/// Refuse to treat system time as availability time. +/// +/// # Errors +/// +/// Always returns [`AvailableClockError::SystemTimeIsNotAvailableTime`]. +pub fn refuse_system_time_as_available() -> Result<(), AvailableClockError> { + Err(AvailableClockError::SystemTimeIsNotAvailableTime) +} + +/// Fraction of recovered availability flags that match known truth. +/// +/// # Errors +/// +/// Returns [`AvailableClockError::InvalidAvailabilityPayload`] when either +/// slice is empty or the lengths differ. +pub fn eligibility_recovery_rate( + truth: &[bool], + decided: &[bool], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(AvailableClockError::InvalidAvailabilityPayload); + } + 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::{ + ClockFamily, eligibility_recovery_rate, refuse_event_time_as_available, + refuse_system_time_as_available, stamp_is_available, + }; + use crate::AvailableClockError; + + #[test] + fn local_branches_cover_families_and_payloads() { + assert!(stamp_is_available(ClockFamily::AvailableTime).expect("available")); + assert!(!stamp_is_available(ClockFamily::EventTime).expect("event")); + assert!(!stamp_is_available(ClockFamily::SystemTime).expect("system")); + assert_eq!( + refuse_event_time_as_available(), + Err(AvailableClockError::EventTimeIsNotAvailableTime) + ); + assert_eq!( + refuse_system_time_as_available(), + Err(AvailableClockError::SystemTimeIsNotAvailableTime) + ); + let matched = eligibility_recovery_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + eligibility_recovery_rate(&[], &[]), + Err(AvailableClockError::InvalidAvailabilityPayload) + ); + assert_eq!( + eligibility_recovery_rate(&[true], &[]), + Err(AvailableClockError::InvalidAvailabilityPayload) + ); + } +} diff --git a/crates/available_clock/src/error.rs b/crates/available_clock/src/error.rs new file mode 100644 index 000000000..ee46cc2a7 --- /dev/null +++ b/crates/available_clock/src/error.rs @@ -0,0 +1,53 @@ +//! Fail-closed available-clock errors. + +use std::fmt; + +/// A fail-closed available-clock error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AvailableClockError { + /// Event time was treated as availability time. + EventTimeIsNotAvailableTime, + /// System time was treated as availability time. + SystemTimeIsNotAvailableTime, + /// A recovery slice was empty or length-mismatched. + InvalidAvailabilityPayload, +} + +impl fmt::Display for AvailableClockError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EventTimeIsNotAvailableTime => "event time is not availability time", + Self::SystemTimeIsNotAvailableTime => "system time is not availability time", + Self::InvalidAvailabilityPayload => "invalid available-clock payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AvailableClockError {} + +#[cfg(test)] +mod tests { + use super::AvailableClockError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + AvailableClockError::EventTimeIsNotAvailableTime, + "event time is not availability time", + ), + ( + AvailableClockError::SystemTimeIsNotAvailableTime, + "system time is not availability time", + ), + ( + AvailableClockError::InvalidAvailabilityPayload, + "invalid available-clock payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/available_clock/src/lib.rs b/crates/available_clock/src/lib.rs new file mode 100644 index 000000000..85143afaa --- /dev/null +++ b/crates/available_clock/src/lib.rs @@ -0,0 +1,23 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Availability time cannot be replaced by event or system time. +//! +//! Historical eligibility uses availability versus knowledge cutoff. Event +//! time and system time are not substitutes (ADR 0002). + +mod clock; +mod error; + +/// Closed vocabulary of clocks that must not be confused with availability. +pub use clock::ClockFamily; +/// Fraction of recovered availability flags that match known truth. +pub use clock::eligibility_recovery_rate; +/// Refuse to treat event time as availability time. +pub use clock::refuse_event_time_as_available; +/// Refuse to treat system time as availability time. +pub use clock::refuse_system_time_as_available; +/// Return whether a stamp is on the availability clock. +pub use clock::stamp_is_available; +/// Fail-closed available-clock errors. +pub use error::AvailableClockError; diff --git a/crates/available_clock/tests/available_clock_contract.rs b/crates/available_clock/tests/available_clock_contract.rs new file mode 100644 index 000000000..2394df38a --- /dev/null +++ b/crates/available_clock/tests/available_clock_contract.rs @@ -0,0 +1,76 @@ +//! Event and system time cannot stand in for availability. + +use available_clock::{ + AvailableClockError, ClockFamily, eligibility_recovery_rate, refuse_event_time_as_available, + refuse_system_time_as_available, stamp_is_available, +}; + +#[test] +fn event_and_system_time_cannot_stand_in_for_availability() { + assert_eq!( + refuse_event_time_as_available(), + Err(AvailableClockError::EventTimeIsNotAvailableTime) + ); + assert_eq!( + refuse_system_time_as_available(), + Err(AvailableClockError::SystemTimeIsNotAvailableTime) + ); + assert!(stamp_is_available(ClockFamily::AvailableTime).expect("available")); + assert!(!stamp_is_available(ClockFamily::EventTime).expect("event")); + assert!(!stamp_is_available(ClockFamily::SystemTime).expect("system")); +} + +#[test] +fn recovered_availability_stamps_match_known_truth_better_than_system_stand_in() { + let truth = [ + ClockFamily::AvailableTime, + ClockFamily::AvailableTime, + ClockFamily::AvailableTime, + ]; + let recovered = truth; + let collapsed = [ + ClockFamily::SystemTime, + ClockFamily::SystemTime, + ClockFamily::SystemTime, + ]; + let recovered_flags = [ + stamp_is_available(recovered[0]).expect("r0"), + stamp_is_available(recovered[1]).expect("r1"), + stamp_is_available(recovered[2]).expect("r2"), + ]; + let collapsed_flags = [ + stamp_is_available(collapsed[0]).expect("c0"), + stamp_is_available(collapsed[1]).expect("c1"), + stamp_is_available(collapsed[2]).expect("c2"), + ]; + let truth_flags = [true, true, true]; + let recovered_rate = eligibility_recovery_rate(&truth_flags, &recovered_flags).expect("ok"); + let collapsed_rate = eligibility_recovery_rate(&truth_flags, &collapsed_flags).expect("bad"); + let expected = { + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth_flags.iter().zip(recovered_flags.iter()) { + if truth_flag == decided_flag { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth_flags.len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); +} + +#[test] +fn empty_or_mismatched_eligibility_payloads_fail_closed() { + assert_eq!( + eligibility_recovery_rate(&[], &[]), + Err(AvailableClockError::InvalidAvailabilityPayload) + ); + assert_eq!( + eligibility_recovery_rate(&[true], &[]), + Err(AvailableClockError::InvalidAvailabilityPayload) + ); + assert_eq!( + eligibility_recovery_rate(&[true, false], &[true]), + Err(AvailableClockError::InvalidAvailabilityPayload) + ); +} diff --git a/crates/available_clock/tests/crate_contract.rs b/crates/available_clock/tests/crate_contract.rs new file mode 100644 index 000000000..8e436d2b6 --- /dev/null +++ b/crates/available_clock/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `available_clock` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "available_clock"); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ae36f8ef5..105e46d7c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -10,7 +10,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring; `persistence_postgres` source-artifact SQL insert/lookup plus idempotent retry (#40 implemented-main); typed `text_segment` byte-span SQL (active PR) | implemented-main | | Rust numerical authority / CPU `f64` reference | ADR 0001 | current workspace foundation; future estimators | partial | | Rust workspace/quality foundation | ADR 0007 | workspace/CI/repository contract | implemented-main | -| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; PR #5 historical only; `document_clocks` refuse omitted assertion/document time on the active PR | active-PR | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `available_clock` availability-vs-event/system identity on the active PR | active-PR | | Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 `temporal_core` path-consistency on protected main | implemented-main | | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `citation_edge` provenance-vs-transition gate on the active PR | active-PR | | event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; full intelligence stack remaining | partial | diff --git a/docs/adr/0002-six-clock-temporal-semantics.md b/docs/adr/0002-six-clock-temporal-semantics.md index 6b66c98fe..5434c013b 100644 --- a/docs/adr/0002-six-clock-temporal-semantics.md +++ b/docs/adr/0002-six-clock-temporal-semantics.md @@ -1,6 +1,7 @@ # ADR 0002 — Six-clock temporal semantics and leakage prevention **Decision status:** Accepted +**Implementation maturity:** active-PR — availability-clock identity in `available_clock` on the active PR; remaining graph/split enforcement stays accepted-target **Implementation maturity:** active-PR — typed clocks/intervals are implemented-main; `document_clocks` refuses omitted assertion time and document time on this PR; downstream transition/split enforcement remains accepted-target **Implementation maturity:** active-PR — provenance-vs-transition gate in `citation_edge` on the active PR; remaining graph/split enforcement stays accepted-target **Implementation maturity:** partial — typed six-clock values and uncertain intervals are implemented-main on protected `main` (merged PR #8 / `temporal_core`); Allen interval algebra and bounded path-consistency are implemented-main on protected `main` (merged PR #9 / `temporal_core`). Superseded PRs #5 and #6 are historical lineage only and are not current-product claims. Downstream estimator, event-intelligence, and remaining persistence-policy uses of these primitives follow their owning ADRs and [`docs/TRACEABILITY.md`](../TRACEABILITY.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 05bbf6463..94641053a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -7,6 +7,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | ADR | Decision | Decision status | Implementation maturity | Clarification / supersession | |---|---|---|---|---| | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | +| [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Availability-clock identity in `available_clock` on the active PR; remaining graph/split enforcement stays accepted-target. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Typed clocks/intervals are implemented-main; `document_clocks` refuses omitted assertion/document time on the active PR. Later graph/split enforcement remains target work. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Provenance-vs-transition gate in `citation_edge` on the active PR; remaining graph/split enforcement stays accepted-target. | | [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Weighted time-varying membership network/roles are active-PR (PR #12); full multilevel estimators, graph ontology, and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | diff --git a/docs/research/available-clock-identity.md b/docs/research/available-clock-identity.md new file mode 100644 index 000000000..991e2a561 --- /dev/null +++ b/docs/research/available-clock-identity.md @@ -0,0 +1,28 @@ +# Availability-clock identity (doctoring) + +## Scope + +`available_clock` keeps availability time distinct from event time and +system time. Recovery is the computed share of availability stamps that +match known truth. + +This slice does not persist clocks, replace `temporal_core`, or recreate +`document_clocks`. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0002-six-clock-temporal-semantics.md` — availability time is + the time evidence became usable; it is not event time or system time. +- Historical analyses may not treat record time as the moment evidence + was available. + +### Supporting literature + +Snodgrass (2000) separates valid time from transaction time. Availability +is a third TEPP clock: when the analyst could use the evidence. Neither +valid time nor transaction time is a substitute. + +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 61690e679..98f608a3d 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -141,6 +141,8 @@ Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL* Jensen, C. S., & Snodgrass, R. T. (1996). Semantics of time-varying information. *Information Systems, 21*(4), 311–352. https://doi.org/10.1016/0306-4379(96)00017-8 Valid time versus transaction/system time informs `document_clocks`; assertion time and document time remain additional TEPP clocks and cannot be omitted or replaced by event or system time. +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Valid vs transaction time informs `available_clock`; availability is a third TEPP clock. + ## 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 66a3d4cf6..d69b3c347 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 | +| Availability-clock identity | `available_clock` | active-PR | this PR | recovered availability flags vs system-time stand-in | ADR 0002 | | 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` | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index 7f9f6132e..c097b2db9 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "available_clock", "document_clocks", "revision_order", "encrypted_mapping", diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2ac95c987..8f86cbffc 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -27,6 +27,7 @@ def test_live_repository_is_documented(self) -> None: sources = docstrings.rust_sources(REPOSITORY_ROOT) crate_roots = sorted(REPOSITORY_ROOT.glob("crates/*/src/lib.rs")) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertEqual(len(crate_roots), 11) self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) expected_crate_roots = {