diff --git a/CHANGELOG.d/lineage-criterion-anchor-contract.md b/CHANGELOG.d/lineage-criterion-anchor-contract.md new file mode 100644 index 000000000..c382ef01d --- /dev/null +++ b/CHANGELOG.d/lineage-criterion-anchor-contract.md @@ -0,0 +1,5 @@ +### Added + +- Publish the strict `tepp.lineage_criterion_anchor.v1` artifact contract so + LineageWeave can activate fast-mlsirm channel weights only after an exact, + TEPP-authored independent criterion-validity result. diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 8b9e4dd32..1b66606df 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -19,6 +19,7 @@ mod corpus_split_manifest; mod envelope; mod error; mod export; +mod lineage_criterion_anchor; mod lineageweave_http; mod live_http; mod naruon_http; @@ -94,6 +95,20 @@ pub use authorization::ExportAuthorizationRequest; pub use authorization::authorize_export; /// Fail closed when an export decision is denied. pub use authorization::require_export_allowed; +/// TEPP-owned criterion-validity outcome for one Event Lineage weight run. +pub use lineage_criterion_anchor::CriterionValidityStatus; +/// Default maximum lineage criterion-anchor artifact size. +pub use lineage_criterion_anchor::DEFAULT_LINEAGE_CRITERION_ANCHOR_BYTE_LIMIT; +/// Semantic version of the lineage criterion-anchor artifact. +pub use lineage_criterion_anchor::LINEAGE_CRITERION_ANCHOR_CONTRACT_VERSION; +/// Analysis-run model contract that requests a lineage criterion anchor. +pub use lineage_criterion_anchor::LINEAGE_CRITERION_MODEL_CONTRACT; +/// Analysis-run output profile that requests a lineage criterion anchor. +pub use lineage_criterion_anchor::LINEAGE_CRITERION_OUTPUT_PROFILE; +/// Terminal result-schema identity for a lineage criterion anchor. +pub use lineage_criterion_anchor::LINEAGE_CRITERION_RESULT_SCHEMA; +/// Versioned TEPP criterion-validity artifact for one Event Lineage weight run. +pub use lineage_criterion_anchor::LineageCriterionAnchor; /// Published `LineageWeave` modular-consumer identity. pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; /// Published Naruon modular-consumer identity. diff --git a/crates/tepp_api/src/lineage_criterion_anchor.rs b/crates/tepp_api/src/lineage_criterion_anchor.rs new file mode 100644 index 000000000..a83e4ead3 --- /dev/null +++ b/crates/tepp_api/src/lineage_criterion_anchor.rs @@ -0,0 +1,233 @@ +//! Versioned TEPP criterion-validity artifact for Event Lineage channel weights. +//! +//! TEPP is the authority that evaluates an independently supplied lineage +//! criterion. This DTO only transports TEPP's decision and exact provenance; +//! it does not let a consumer calculate or reinterpret validity locally. + +use serde::{Deserialize, Serialize}; +use temporal_core::KnowledgeCutoff; + +use crate::ApiError; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json_with_limit, +}; + +/// Semantic contract version for lineage criterion anchors. +pub const LINEAGE_CRITERION_ANCHOR_CONTRACT_VERSION: u16 = 1; + +/// Analysis-run model contract requested by a `LineageWeave` consumer. +pub const LINEAGE_CRITERION_MODEL_CONTRACT: &str = "tepp-lineage-criterion-v1"; + +/// Analysis-run output profile that produces this artifact. +pub const LINEAGE_CRITERION_OUTPUT_PROFILE: &str = "lineage_pair_criterion_anchor"; + +/// Result-schema identity carried by the terminal analysis result. +pub const LINEAGE_CRITERION_RESULT_SCHEMA: &str = "tepp.lineage_criterion_anchor.v1"; + +/// Default maximum serialized anchor artifact size. +pub const DEFAULT_LINEAGE_CRITERION_ANCHOR_BYTE_LIMIT: usize = 16 * 1024; + +/// TEPP-owned criterion-validity outcome. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CriterionValidityStatus { + /// The proposed channel-weight run passed TEPP's registered criterion validation. + Accepted, + /// The proposed channel-weight run failed TEPP's registered criterion validation. + Rejected, +} + +/// Exact, identity-bound TEPP criterion-validity result for one weight run. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LineageCriterionAnchor { + /// Semantic contract version, always one for this shape. + pub contract_version: u16, + /// Artifact kind, always `lineage_pair_criterion`. + pub anchor_kind_code: String, + /// Opaque consumer-owned fast-mlsirm estimation-run identity. + pub estimation_run_id: String, + /// Immutable source snapshot SHA-256 shared with the proposed weights. + pub source_snapshot_sha256: String, + /// Exact RFC 3339 knowledge cutoff shared with the proposed weights. + pub knowledge_cutoff: String, + /// TEPP-owned criterion-validity outcome. + pub criterion_validity_status: CriterionValidityStatus, + /// Number of pair outcomes that entered the independent validation. + pub validated_pair_count: u64, +} + +impl LineageCriterionAnchor { + /// Parse and validate an anchor with the default payload limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, identity, digest, time, or count error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_LINEAGE_CRITERION_ANCHOR_BYTE_LIMIT) + } + + /// Parse and validate an anchor with a caller-supplied payload limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, identity, digest, time, or count error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let value: Self = from_json(payload)?; + value.validate()?; + Ok(value) + } + + /// Serialize this validated anchor artifact. + /// + /// # Errors + /// + /// Returns a fail-closed validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_LINEAGE_CRITERION_ANCHOR_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + LINEAGE_CRITERION_ANCHOR_CONTRACT_VERSION, + )?; + if self.anchor_kind_code != "lineage_pair_criterion" + || self.validated_pair_count == 0 + || !is_canonical_sha256(&self.source_snapshot_sha256) + { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.estimation_run_id)?; + let estimation_run_id = uuid::Uuid::parse_str(&self.estimation_run_id) + .map_err(|_| ApiError::InvalidWirePayload)?; + if estimation_run_id.hyphenated().to_string() != self.estimation_run_id { + return Err(ApiError::InvalidWirePayload); + } + KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(()) + } +} + +fn is_canonical_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn anchor() -> LineageCriterionAnchor { + LineageCriterionAnchor { + contract_version: LINEAGE_CRITERION_ANCHOR_CONTRACT_VERSION, + anchor_kind_code: "lineage_pair_criterion".into(), + estimation_run_id: "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1".into(), + source_snapshot_sha256: "a".repeat(64), + knowledge_cutoff: "2026-08-25T00:00:00Z".into(), + criterion_validity_status: CriterionValidityStatus::Accepted, + validated_pair_count: 600, + } + } + + #[test] + fn accepted_and_rejected_results_round_trip_without_local_reinterpretation() { + for status in [ + CriterionValidityStatus::Accepted, + CriterionValidityStatus::Rejected, + ] { + let mut value = anchor(); + value.criterion_validity_status = status; + let json = value.to_json().expect("serialize"); + assert_eq!( + LineageCriterionAnchor::from_json(&json).expect("parse"), + value + ); + } + let mut digit_digest = anchor(); + digit_digest.source_snapshot_sha256 = "0".repeat(64); + assert!(digit_digest.to_json().is_ok()); + } + + #[test] + fn malformed_or_unbound_artifacts_fail_closed() { + for mutate in 0..9 { + let mut value = anchor(); + match mutate { + 0 => value.contract_version = 2, + 1 => value.anchor_kind_code = "internal_structure".into(), + 2 => value.estimation_run_id = "not-a-uuid".into(), + 3 => value.source_snapshot_sha256 = "A".repeat(64), + 4 => value.source_snapshot_sha256 = "a".repeat(63), + 5 => value.estimation_run_id.clear(), + 6 => value.knowledge_cutoff = "not-a-time".into(), + 7 => value.estimation_run_id = "018f47e77b5b7cc098c615fdf9e3d9b1".into(), + _ => value.validated_pair_count = 0, + } + assert!(value.to_json().is_err()); + } + } + + #[test] + fn unknown_fields_are_rejected() { + let json = anchor().to_json().expect("serialize"); + let payload = json.strip_suffix('}').expect("object"); + assert!(LineageCriterionAnchor::from_json(&format!("{payload},\"theta\":0.8}}")).is_err()); + + let mut semantically_invalid = anchor(); + semantically_invalid.validated_pair_count = 0; + let json = serde_json::to_string(&semantically_invalid).expect("wire shape"); + assert!(LineageCriterionAnchor::from_json(&json).is_err()); + } + + #[test] + fn caller_payload_limit_is_enforced_before_parsing() { + let json = anchor().to_json().expect("serialize"); + assert!(LineageCriterionAnchor::from_json_with_limit(&json, 1).is_err()); + } + + #[test] + fn published_schema_keeps_the_executable_identity_constraints() { + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../schemas/lineage_criterion_anchor_v1.json" + )) + .expect("published schema"); + let properties = schema["properties"].as_object().expect("properties"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!( + schema["required"], + serde_json::json!([ + "contract_version", + "anchor_kind_code", + "estimation_run_id", + "source_snapshot_sha256", + "knowledge_cutoff", + "criterion_validity_status", + "validated_pair_count" + ]) + ); + assert_eq!(properties["contract_version"]["const"], 1); + assert_eq!( + properties["anchor_kind_code"]["const"], + "lineage_pair_criterion" + ); + assert_eq!( + properties["criterion_validity_status"]["enum"], + serde_json::json!(["accepted", "rejected"]) + ); + assert_eq!(properties["validated_pair_count"]["minimum"], 1); + assert_eq!( + properties["estimation_run_id"]["pattern"], + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ); + assert_eq!( + properties["source_snapshot_sha256"]["pattern"], + "^[0-9a-f]{64}$" + ); + } +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 2f2a14d62..7f2ba619d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -44,6 +44,16 @@ Wire payloads: - reconstruct through domain validation rather than deserialize directly into private state; - use stable machine-readable error codes plus content-redacting messages. +The TEPP-owned Event Lineage criterion artifact is +`tepp.lineage_criterion_anchor.v1` (`schemas/lineage_criterion_anchor_v1.json`). +A LineageWeave analysis run requests model contract +`tepp-lineage-criterion-v1` and output profile +`lineage_pair_criterion_anchor`. The artifact carries TEPP's accepted or +rejected criterion-validity outcome bound to one fast-mlsirm estimation run, +snapshot, cutoff, and validated pair count. The contract does not authorize a +consumer to self-assert validity; no weight vector activates until TEPP's +registered implementation returns the digest-bound artifact. + ## 4. Target HTTP resource model When the service layer is introduced, use resources such as: diff --git a/docs/adr/0023-lineage-criterion-anchor-contract.md b/docs/adr/0023-lineage-criterion-anchor-contract.md new file mode 100644 index 000000000..59c77e157 --- /dev/null +++ b/docs/adr/0023-lineage-criterion-anchor-contract.md @@ -0,0 +1,73 @@ +# ADR 0023 — TEPP-owned Event Lineage criterion anchor + +**Decision status:** Accepted +**Implementation maturity:** active-PR — the transport contract is implemented on PR #237; the registered TEPP analysis remains accepted-target +**Date:** 2026-08-25 +**Supersedes:** None; complements ADR 0014's scientific promotion boundary. + +## Context + +LineageWeave uses fast-mlsirm to estimate relative Event Lineage channel +information. Internal response structure is not independent criterion +validity, and a consumer-authored validity flag would not make it independent. + +## Decision + +TEPP owns the `tepp.lineage_criterion_anchor.v1` result artifact and the +`tepp-lineage-criterion-v1` / `lineage_pair_criterion_anchor` analysis-run +request identity. The artifact binds TEPP's accepted or rejected criterion +decision to one opaque estimation run, immutable snapshot SHA-256, knowledge +cutoff, and positive validated-pair count. +The estimation-run identity uses the canonical lowercase, hyphenated UUID +form in both the executable DTO and the published JSON Schema. + +The wire contract does not define an arbitrary correlation threshold, invent a +theta, or allow LineageWeave to reinterpret a rejection. The registered TEPP +analysis implementation and its scientific validation evidence own the +criterion design and acceptance procedure. Until that implementation emits a +digest-bound artifact through the terminal-result contract, consumers must +treat channel weighting as unavailable. + +This separation follows the Standards' requirement that an intended score +interpretation and use be stated and supported by appropriate validity +evidence; internal model fit is not silently promoted to evidence for the +Event Lineage use. + +## Alternatives considered + +1. Let the consumer author a validity flag — rejected because the evidence + would not be independent of the proposed weights. +2. Treat an accepted transport receipt as validity evidence — rejected because + transport acceptance does not establish the intended score interpretation. +3. Publish a TEPP-owned, identity-bound outcome — accepted because it keeps + criterion authority and provenance at the measurement boundary. + +## Consequences + +- An accepted transport receipt is never a criterion anchor. +- Unknown fields and malformed provenance fail closed. +- Both accepted and rejected outcomes are preserved; only TEPP can author the + outcome, and a consumer may activate weights only for an exact accepted + artifact. +- The executable estimator remains a separately gated delivery slice. + +## Verification + +The Rust contract tests cover accepted and rejected round trips, canonical UUID +identity, malformed provenance, unknown fields, and payload limits. The JSON +Schema is checked against the same canonical UUID examples by the API schema +test suite. + +## Rollback and supersession + +Rollback stops publishing the result profile while preserving previously issued +versioned artifacts. Supersession requires a new ADR that preserves independent +criterion authority, exact run/snapshot/cutoff binding, and fail-closed consumer +activation. + +## Reference + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American Educational +Research Association. https://www.testingstandards.net/open-access-files.html diff --git a/docs/adr/README.md b/docs/adr/README.md index b5efd4633..05f55a1cb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | +| [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [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 | partial | Typed clocks/intervals are implemented-main via `temporal_core`; input-process-outcome event-time order is `outcome_order` on the active PR. Remaining clock-identity and split enforcement stay 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 and the forward-transition graph are implemented-main; IPO event-time order is `outcome_order` on the active PR; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c870b1560..cdb1f9872 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,21 @@ # Product and Technical Gap Baseline +## 2026-08-25 Event Lineage anchor contract slice + +- Exact base: protected `main` `cf0e0ad74d23c5d2e0e33d389bb0bb4d37067c31`. +- This branch publishes TEPP's strict request identity and + `tepp.lineage_criterion_anchor.v1` accepted/rejected artifact contract. +- The buyer-visible integrity gain is fail-closed: LineageWeave cannot promote + fast-mlsirm's internal response structure into calibrated Event Lineage + weights without an exact TEPP-authored criterion result. +- Remaining product gap: the registered TEPP criterion estimator and terminal + artifact delivery are not implemented by this contract slice. Until they + exist and pass scientific recovery/validity gates, production activation + remains unavailable; the consumer must not invent a substitute. +- Acceptance evidence for this slice: complete `tepp_api` tests, warning-free + clippy, strict unknown-field/provenance rejection, schema and ADR/API + traceability, followed by exact-head protected checks and independent review. + **Status:** Live delivery baseline **Product:** Temporal Event Psychometrics Platform (TEPP) **Snapshot:** 2026-08-25T04:24:53Z diff --git a/schemas/lineage_criterion_anchor_v1.json b/schemas/lineage_criterion_anchor_v1.json new file mode 100644 index 000000000..77bf01f43 --- /dev/null +++ b/schemas/lineage_criterion_anchor_v1.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tepp.local/schemas/lineage_criterion_anchor_v1.json", + "title": "LineageCriterionAnchorV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "anchor_kind_code", + "estimation_run_id", + "source_snapshot_sha256", + "knowledge_cutoff", + "criterion_validity_status", + "validated_pair_count" + ], + "properties": { + "contract_version": { "type": "integer", "const": 1 }, + "anchor_kind_code": { "type": "string", "const": "lineage_pair_criterion" }, + "estimation_run_id": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "source_snapshot_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "knowledge_cutoff": { "type": "string", "format": "date-time" }, + "criterion_validity_status": { + "type": "string", + "enum": ["accepted", "rejected"] + }, + "validated_pair_count": { "type": "integer", "minimum": 1 } + } +}