diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1f6eb5e9e..393e6f59a 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 | +| `copy_identity` | a template copy is not the source document and not a state transition | | `intake_authorization` | untrusted intake fails closed without a grant; bounds are not authorization | | `summarizes_edge` | a summary is not a state transition and not the source document | | `outcome_order` | input-process-outcome edges cannot move backward in event time | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac121802..fe4fdd788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `copy_identity` identity gate: a template or pasted copy cannot reuse the source document identity or become a state transition; recovered copy kinds match known truth at a higher computed rate than collapsing every copy to the source (ADR 0003). +- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `provider_receipt` disclosure receipt: records provider field codes and purpose-bound receipt metadata without persisting source text or source identity (ADR 0009). diff --git a/Cargo.lock b/Cargo.lock index ec2a00368..18f8df115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,10 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "copy_identity" +version = "0.1.0" + [[package]] name = "corpus_split" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 56ef8db09..34010482d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/copy_identity", "crates/provider_receipt", "crates/intake_authorization", "crates/summarizes_edge", @@ -53,6 +54,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/copy_identity", "crates/provider_receipt", "crates/intake_authorization", "crates/summarizes_edge", diff --git a/README.md b/README.md index 89b425a36..77dcd0edb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/copy_identity crates/provider_receipt crates/intake_authorization crates/summarizes_edge diff --git a/crates/copy_identity/Cargo.toml b/crates/copy_identity/Cargo.toml new file mode 100644 index 000000000..32352053d --- /dev/null +++ b/crates/copy_identity/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "copy_identity" +description = "A template copy is not the source document and not a state transition." +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/copy_identity/src/error.rs b/crates/copy_identity/src/error.rs new file mode 100644 index 000000000..fba22b620 --- /dev/null +++ b/crates/copy_identity/src/error.rs @@ -0,0 +1,53 @@ +//! Fail-closed copy-identity errors. + +use std::fmt; + +/// A fail-closed copy-identity error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CopyIdentityError { + /// A template copy was treated as the source document identity. + CopyIsNotSourceIdentity, + /// A template copy was treated as a state transition. + CopyIsNotTransition, + /// A recovery slice was empty or length-mismatched. + InvalidCopyPayload, +} + +impl fmt::Display for CopyIdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::CopyIsNotSourceIdentity => "a template copy is not the source document identity", + Self::CopyIsNotTransition => "a template copy is not a state transition", + Self::InvalidCopyPayload => "invalid copy-identity payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for CopyIdentityError {} + +#[cfg(test)] +mod tests { + use super::CopyIdentityError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + CopyIdentityError::CopyIsNotSourceIdentity, + "a template copy is not the source document identity", + ), + ( + CopyIdentityError::CopyIsNotTransition, + "a template copy is not a state transition", + ), + ( + CopyIdentityError::InvalidCopyPayload, + "invalid copy-identity payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/copy_identity/src/kind.rs b/crates/copy_identity/src/kind.rs new file mode 100644 index 000000000..7bf976b5e --- /dev/null +++ b/crates/copy_identity/src/kind.rs @@ -0,0 +1,127 @@ +//! Template-copy identity versus the copied source document. + +use crate::CopyIdentityError; + +/// Closed vocabulary of copy-related document identities. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CopyKind { + /// A template or pasted copy of an earlier source. + TemplateCopy, + /// The earlier source document being copied. + SourceDocument, +} + +impl CopyKind { + /// Return the stable wire kind name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::TemplateCopy => "template_copy_of", + Self::SourceDocument => "source_document", + } + } + + /// Parse a stable wire kind name. + /// + /// # Errors + /// + /// Returns [`CopyIdentityError::InvalidCopyPayload`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "template_copy_of" => Ok(Self::TemplateCopy), + "source_document" => Ok(Self::SourceDocument), + _ => Err(CopyIdentityError::InvalidCopyPayload), + } + } +} + +/// Refuse to treat a template copy as the source document identity. +/// +/// # Errors +/// +/// Returns [`CopyIdentityError::CopyIsNotSourceIdentity`] when `kind` is +/// [`CopyKind::TemplateCopy`]. +pub fn refuse_copy_as_source_identity(kind: CopyKind) -> Result<(), CopyIdentityError> { + match kind { + CopyKind::TemplateCopy => Err(CopyIdentityError::CopyIsNotSourceIdentity), + CopyKind::SourceDocument => Ok(()), + } +} + +/// Refuse to treat a template copy as a forward state transition. +/// +/// # Errors +/// +/// Returns [`CopyIdentityError::CopyIsNotTransition`] when `kind` is +/// [`CopyKind::TemplateCopy`]. +pub fn refuse_copy_as_transition(kind: CopyKind) -> Result<(), CopyIdentityError> { + match kind { + CopyKind::TemplateCopy => Err(CopyIdentityError::CopyIsNotTransition), + CopyKind::SourceDocument => Ok(()), + } +} + +/// Fraction of recovered copy kinds that match known truth. +/// +/// # Errors +/// +/// Returns [`CopyIdentityError::InvalidCopyPayload`] when either slice is empty +/// or the lengths differ. +pub fn identity_recovery_rate( + truth: &[CopyKind], + decided: &[CopyKind], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(CopyIdentityError::InvalidCopyPayload); + } + let mut matches = 0_u32; + for (truth_kind, decided_kind) in truth.iter().zip(decided) { + if truth_kind == decided_kind { + matches += 1; + } + } + Ok(f64::from(matches) / truth.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + CopyKind, identity_recovery_rate, refuse_copy_as_source_identity, refuse_copy_as_transition, + }; + use crate::CopyIdentityError; + + #[test] + fn local_branches_cover_kinds_payloads_and_wire_names() { + assert_eq!( + refuse_copy_as_source_identity(CopyKind::TemplateCopy), + Err(CopyIdentityError::CopyIsNotSourceIdentity) + ); + assert_eq!( + refuse_copy_as_transition(CopyKind::TemplateCopy), + Err(CopyIdentityError::CopyIsNotTransition) + ); + refuse_copy_as_source_identity(CopyKind::SourceDocument).expect("source"); + refuse_copy_as_transition(CopyKind::SourceDocument).expect("source"); + for kind in [CopyKind::TemplateCopy, CopyKind::SourceDocument] { + assert_eq!( + CopyKind::from_wire_name(kind.wire_name()).expect("round-trip"), + kind + ); + } + assert_eq!( + CopyKind::from_wire_name("summarizes"), + Err(CopyIdentityError::InvalidCopyPayload) + ); + let matched = identity_recovery_rate(&[CopyKind::TemplateCopy], &[CopyKind::TemplateCopy]) + .expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(CopyIdentityError::InvalidCopyPayload) + ); + assert_eq!( + identity_recovery_rate(&[CopyKind::TemplateCopy], &[]), + Err(CopyIdentityError::InvalidCopyPayload) + ); + } +} diff --git a/crates/copy_identity/src/lib.rs b/crates/copy_identity/src/lib.rs new file mode 100644 index 000000000..47243f14d --- /dev/null +++ b/crates/copy_identity/src/lib.rs @@ -0,0 +1,22 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! A template copy is not the source document and not a state transition. +//! +//! Copy variants keep a distinct identity for relation-aware splits. They +//! never become input-process-outcome edges and never reuse the source +//! identity (ADR 0003). + +mod error; +mod kind; + +/// Fail-closed copy-identity errors. +pub use error::CopyIdentityError; +/// Closed vocabulary of copy-related document identities. +pub use kind::CopyKind; +/// Fraction of recovered copy kinds that match known truth. +pub use kind::identity_recovery_rate; +/// Refuse to treat a template copy as the source document identity. +pub use kind::refuse_copy_as_source_identity; +/// Refuse to treat a template copy as a forward state transition. +pub use kind::refuse_copy_as_transition; diff --git a/crates/copy_identity/tests/copy_identity_contract.rs b/crates/copy_identity/tests/copy_identity_contract.rs new file mode 100644 index 000000000..18bb7cfc0 --- /dev/null +++ b/crates/copy_identity/tests/copy_identity_contract.rs @@ -0,0 +1,67 @@ +//! A template copy is not the source document and not a state transition. + +use copy_identity::{ + CopyIdentityError, CopyKind, identity_recovery_rate, refuse_copy_as_source_identity, + refuse_copy_as_transition, +}; + +#[test] +fn a_copy_cannot_become_the_source_identity_or_a_transition() { + assert_eq!( + refuse_copy_as_source_identity(CopyKind::TemplateCopy), + Err(CopyIdentityError::CopyIsNotSourceIdentity) + ); + assert_eq!( + refuse_copy_as_transition(CopyKind::TemplateCopy), + Err(CopyIdentityError::CopyIsNotTransition) + ); + refuse_copy_as_source_identity(CopyKind::SourceDocument).expect("source"); + refuse_copy_as_transition(CopyKind::SourceDocument).expect("source"); +} + +#[test] +fn recovered_kinds_match_known_truth_better_than_a_source_collapse() { + let truth = [ + CopyKind::TemplateCopy, + CopyKind::SourceDocument, + CopyKind::TemplateCopy, + ]; + let recovered = truth; + let collapsed = [ + CopyKind::SourceDocument, + CopyKind::SourceDocument, + CopyKind::SourceDocument, + ]; + let recovered_rate = identity_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = identity_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_kind, decided_kind) in truth.iter().zip(recovered.iter()) { + if truth_kind == decided_kind { + 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_mismatched_kind_payloads_fail_closed() { + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(CopyIdentityError::InvalidCopyPayload) + ); + assert_eq!( + identity_recovery_rate(&[CopyKind::TemplateCopy], &[]), + Err(CopyIdentityError::InvalidCopyPayload) + ); + assert_eq!( + identity_recovery_rate( + &[CopyKind::TemplateCopy, CopyKind::SourceDocument], + &[CopyKind::TemplateCopy] + ), + Err(CopyIdentityError::InvalidCopyPayload) + ); +} diff --git a/crates/copy_identity/tests/crate_contract.rs b/crates/copy_identity/tests/crate_contract.rs new file mode 100644 index 000000000..d701da8e2 --- /dev/null +++ b/crates/copy_identity/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `copy_identity` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "copy_identity"); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 968f633d2..169b7c802 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -12,7 +12,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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; `system_clock` system-vs-other-clock 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; `summarizes_edge` summary-versus-source identity on the active PR | partial | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `copy_identity` copy-versus-source identity on the active PR | partial | | 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 | | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; `inferred_status` inferred-versus-observed identity on the active PR; multilevel estimators remaining | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index 15d1cc1e1..4501fa697 100644 --- a/docs/adr/0003-relational-event-multiple-membership.md +++ b/docs/adr/0003-relational-event-multiple-membership.md @@ -1,6 +1,7 @@ # ADR 0003 — Relational event ontology and time-varying multiple membership **Decision status:** Accepted +**Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; copy-versus-source identity in `copy_identity` on the active PR; typed relation graph with forward-only transitions active-PR; multilevel estimators and persistence remain accepted-target **Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; summary-versus-source identity in `summarizes_edge` on the active PR; typed relation graph with forward-only transitions active-PR; multilevel estimators and persistence remain accepted-target **Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; typed relation graph with forward-only transitions implemented-main; IPO event-time order in `outcome_order` on the active PR; multilevel estimators and persistence remain accepted-target **Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; retrospective-reporting identity in `retrospective_edge` on the active PR; typed relation graph with forward-only transitions active-PR; multilevel estimators and persistence remain accepted-target diff --git a/docs/adr/README.md b/docs/adr/README.md index c57375e06..1e385e420 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,6 +12,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | partial | Typed clocks/intervals are implemented-main via `temporal_core`; retrospective-reporting identity is `retrospective_edge` on the active PR. Later graph/split enforcement remains target work. | | [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 implemented-main (PR #12); retrospective-reporting identity is `retrospective_edge` on the active PR; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Unmerged PR #8 is the canonical Task 3 replacement implementing typed clocks/intervals against the current protected-main lineage; conflicted PR #5 is superseded lineage. Later graph/split enforcement remains target work. | +| [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 implemented-main (PR #12); copy-versus-source identity is `copy_identity` on the active PR; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [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 implemented-main (PR #12); summary-versus-source identity is `summarizes_edge` on the active PR; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [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 implemented-main (PR #12); inferred-versus-observed identity is `inferred_status` on the active PR; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Evidential-vs-transition gate in `support_edge` on the active PR; remaining graph/split enforcement stays accepted-target. | diff --git a/docs/research/copy-identity.md b/docs/research/copy-identity.md new file mode 100644 index 000000000..3a8b1440d --- /dev/null +++ b/docs/research/copy-identity.md @@ -0,0 +1,27 @@ +# A template copy is not the source document (doctoring) + +## Scope + +`copy_identity` keeps template and pasted copies out of the source +document identity and out of the forward state-transition vocabulary. +Recovery is the computed share of recovered kinds that match known truth. + +This slice does not persist the graph, allocate migration `0008`, or +replace `relation_graph`, `summarizes_edge`, or `method_effects`. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0003-relational-event-multiple-membership.md` — + translation, revision, and copy variants keep distinct identities so + relation-aware splits can hold them together without collapsing them. + +### Supporting literature + +Moreau and Missier (2013) treat a derived entity as distinct from the +entity it was generated from. A template copy is a derivation, not a +reuse of the source identity and not a state transition. + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data +model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index e8cd09c4d..1766a42cb 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -123,9 +123,7 @@ Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ -TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. A summary is a PROV derivation of the source document, not a state transition and not a reuse of the source identity (Moreau & Missier, 2013). - -International Organization for Standardization and International Electrotechnical Commission. (2011). *Information technology—Security techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). Data minimization informs `provider_receipt`; it is not a certification claim. +TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. A template or pasted copy is a PROV derivation of the source document, not a reuse of the source identity and not a state transition (Moreau & Missier, 2013). ## Privacy lifecycle, retention, and legal hold diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index c1cad65bd..3a002d329 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -20,6 +20,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | | Multiple membership | `membership_core` | partial | nested ICC + non-nested refusal | unit + ESS + nested ICC recovery | Task 7 / PR #12 + #25 + this increment | | Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | +| Copy-versus-source identity | `copy_identity` | accepted-target | active PR | refuse copy-as-source/transition + recovery vs source collapse | ADR 0003 | | Summary-versus-source identity | `summarizes_edge` | accepted-target | active PR | refuse summary-as-transition/source + recovery vs source collapse | ADR 0003 | | Input-process-outcome event-time order | `outcome_order` | accepted-target | active PR | refuse reverse/uncertain IPO order + outcome_of-is-not-transition + recovery vs input collapse | ADR 0002/0003 | | Retrospective reporting identity | `retrospective_edge` | accepted-target | active PR | refuse retrospective-as-transition/translation + recovery vs forward collapse | ADR 0002/0003 | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index dacec89f7..a32d6f4bf 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "copy_identity", "provider_receipt", "intake_authorization", "summarizes_edge",