diff --git a/.codegraph/codegraph.db b/.codegraph/codegraph.db deleted file mode 100644 index 3bb262c9f..000000000 Binary files a/.codegraph/codegraph.db and /dev/null differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..fbf0a7ef8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.codegraph +.git +node_modules +target diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d331df5fd..6955293a2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,4 +4,6 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 open-pull-requests-limit: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2adcd8e3..3f27d3a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,7 +223,7 @@ jobs: with: persist-credentials: false - name: Install pinned nightly with LLVM tools - run: rustup toolchain install nightly-2026-08-18 --profile minimal --component llvm-tools-preview + run: rustup toolchain install nightly-2026-08-21 --profile minimal --component llvm-tools-preview - name: Restore pinned cargo-llvm-cov id: llvm-cov-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -237,13 +237,12 @@ jobs: run: cargo llvm-cov --version | grep -F "$CARGO_LLVM_COV_VERSION" - name: Generate exact branch coverage id: branch-report - run: cargo +nightly-2026-08-01 llvm-cov --branch --workspace --all-features --json --output-path coverage-branches.json --ignore-filename-regex 'sqlx_live\.rs' - run: cargo +nightly-2026-08-18 llvm-cov --branch --workspace --all-features --json --summary-only --output-path coverage-branches.json --ignore-filename-regex 'sqlx_live\.rs' + run: cargo +nightly-2026-08-21 llvm-cov --branch --workspace --all-features --json --output-path coverage-branches.json --ignore-filename-regex 'sqlx_live\.rs' - name: Enforce complete branch coverage run: python3 scripts/check_coverage.py coverage-branches.json --kind branches - name: Show exact missing branch diagnostics if: ${{ failure() && steps.branch-report.outcome == 'success' }} - run: cargo +nightly-2026-08-18 llvm-cov report --branch --text --show-missing-lines + run: cargo +nightly-2026-08-21 llvm-cov report --branch --text --show-missing-lines - name: Upload exact branch coverage diagnostics if: ${{ failure() && steps.branch-report.outcome == 'success' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/hourly-nim-product-development.yml b/.github/workflows/hourly-nim-product-development.yml index 6303e7eab..d514b9ce1 100644 --- a/.github/workflows/hourly-nim-product-development.yml +++ b/.github/workflows/hourly-nim-product-development.yml @@ -466,7 +466,7 @@ jobs: if [ "${{ steps.llvm-cov-cache.outputs.cache-hit }}" != true ]; then cargo install cargo-llvm-cov --locked --version 0.8.6 fi - rustup toolchain install nightly-2026-08-01 --profile minimal --component llvm-tools-preview + rustup toolchain install nightly-2026-08-21 --profile minimal --component llvm-tools-preview - name: Run every release-quality gate env: @@ -495,9 +495,9 @@ jobs: cargo deny check line_coverage="$RUNNER_TEMP/coverage.lcov" branch_coverage="$RUNNER_TEMP/coverage-branches.json" - cargo llvm-cov --workspace --all-features --lcov --output-path "$line_coverage" + cargo llvm-cov --workspace --all-features --lcov --output-path "$line_coverage" --ignore-filename-regex 'sqlx_live\.rs' python3 scripts/check_coverage.py "$line_coverage" --kind lines --format lcov - cargo +nightly-2026-08-01 llvm-cov --branch --workspace --all-features --json --output-path "$branch_coverage" + cargo +nightly-2026-08-21 llvm-cov --branch --workspace --all-features --json --output-path "$branch_coverage" --ignore-filename-regex 'sqlx_live\.rs' python3 scripts/check_coverage.py "$branch_coverage" --kind branches [ -z "$(git diff --name-only)" ] [ -z "$(git ls-files --others --exclude-standard)" ] diff --git a/CHANGELOG.d/lineageweave-project-history.md b/CHANGELOG.d/lineageweave-project-history.md new file mode 100644 index 000000000..95011e20e --- /dev/null +++ b/CHANGELOG.d/lineageweave-project-history.md @@ -0,0 +1,7 @@ +# LineageWeave project-history projection + +- `tepp_api` projects already-authorized LineageWeave evidence into a strict, cutoff-safe project history, preserves explicit source-event identities, validates deterministic chronological ordering, recomputes non-causal findings, and rejects fabricated, credential-bearing, or oversized payloads. +- Request and generated-projection serialization now share the 256 KiB wire limit, preventing a successful projection that cannot pass TEPP's own response parser. +- ADR 0020 records the credential-free bounded service boundary and its split of authorization (LineageWeave) from temporal projection (TEPP). +- This fragment preserves the child release note while the stacked branch retains the parent consumer-ingress changelog during the ordinary parent merge. +- The loopback timeout regression is now asserted in the committed Rust test; documentation CI is read-only and no longer mutates contributor branches. diff --git a/CHANGELOG.d/lineageweave-temporal-context-service.md b/CHANGELOG.d/lineageweave-temporal-context-service.md new file mode 100644 index 000000000..e4a1fea8b --- /dev/null +++ b/CHANGELOG.d/lineageweave-temporal-context-service.md @@ -0,0 +1,3 @@ +### Added + +- Package the existing cutoff-safe `POST /v1/temporal-context` contract as the loopback-only `tepp-loopback` binary and container for trusted same-host consumers such as LineageWeave. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae79cdecd..859a8d096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `corpus_background` identity gate: corpus-level background wording is not unique latent content or a state transition; recovery tests distinguish background evidence from unique content. +- `modality_source` identity gate: non-lexical modality is not unique lexical content or a state transition; recovery tests keep modality evidence distinct from unique content. +- `copied_text` identity gate: copied-text residue is not unique latent content or a state transition; recovery tests distinguish copied-text evidence from genuinely new content. +- `style_source` identity gate: house-voice style residue is not unique latent content and is not erased by a stopword list; recovery tests distinguish style from unique content. +- `stopword_deletion` method gate: a default or global stopword list cannot erase repeated report language; recovery tests distinguish deliberate method treatment from stopword deletion. +- `copy_identity` identity gate: a template or pasted copy cannot reuse the source document identity or become a state transition; recovery tests distinguish copy kinds from source identity. +- `intake_authorization` identity gate: documents, serialized records, checkpoints, and LLM outputs require a purpose-bound grant in addition to identity, provenance, size, and depth validation; recovery tests reject ungranted intake. +- `tepp_api` LineageWeave temporal-context contract (v1): cutoff-safe event eligibility, deterministic event-time ordering, explicit non-causal association/gap boundaries, HTTPS interchange construction, and loopback listener handling at `POST /v1/temporal-context`; read-only context requests no longer require the write-only idempotency header, and no causal inference or completed-result service is included. +- `tepp_api` LineageWeave consumer-scoped analysis-run ingress: versioned, credential-free requests use a published consumer identity and isolate idempotency by consumer, tenant workspace, and opaque caller key; the one-shot restack workflow is removed after the protected-main merge is verified. +- ADR 0018 records the consumer-scoped analysis-run ingress, its in-memory loopback maturity, and the persistence boundary required before production use. +- ADR 0020 records the credential-free bounded LineageWeave project-history service boundary and keeps source authorization with LineageWeave while TEPP owns temporal validation and deterministic projection. +- `tepp_api` project-history wire-size symmetry (ADR 0019): request and projection serialization enforce the shared 256 KiB limit, and generated projections fail closed before returning when their deterministic response would exceed it. +- `summarizes_edge` identity gate: summaries may point to earlier event time without becoming state transitions or reusing source-document identity; recovery tests outperform collapsing every summary to the source. +- `outcome_order` identity gate: `input_to` and `process_to` require strict forward event-time rank, while `outcome_of` remains non-transition provenance; recovery tests outperform collapsing every kind to `input_to`. - `location_membership` identity gate: geographic and market assignments are time-varying memberships, not permanent entity identity and not language channels; recovered location kinds match known truth at a higher computed rate than collapsing every assignment to entity identity (ADR 0003). - `prompt_source` identity gate: instruction and prompt boilerplate is not unique latent content and is not erased by a stopword list; `identity_recovery_rate` reports exact kind matches, with a contract test comparing correct recovery with an all-unique collapse on a mixed known-truth fixture (ADR 0004/0012). - `corpus_background` identity gate: corpus-level background wording is not unique latent content and is not erased by a stopword list; recovered background kinds match known truth at a higher computed rate than collapsing every token to unique content (ADR 0004/0012). @@ -26,15 +40,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `outcome_order` identity gate: `input_to` and `process_to` cannot move backward or stay contemporaneous in event-time rank; `outcome_of` may point at an earlier producer and cannot become a state transition; recovered kinds match known truth at a higher computed rate than collapsing every kind to `input_to` (ADR 0002/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. - `retrospective_edge` identity gate: retrospective reporting may point to earlier event time but cannot become a state transition or a translation; recovered reporting kinds match known truth at a higher computed rate than collapsing every report to a contemporaneous forward report (ADR 0002/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. - `payload_bound` identity gate: documents, serialized records, model checkpoints, and LLM outputs stay untrusted until identity, provenance, size, and depth validate; recovered accept/reject flags match known truth at a higher computed rate than accepting every payload (ADR 0008/0013). -- `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. - `inferred_status` identity gate: inferred relations cannot be promoted to observed evidence or to state transitions; recovered observed/inferred labels match known truth at a higher computed rate than treating every status as observed (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. - `support_edge` identity gate: support, contradiction, summary, and `outcome_of` edges cannot become state transitions; recovered evidential kinds match known truth at a higher computed rate than collapsing every kind to support (ADR 0002/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. - `system_clock` identity gate: event, assertion, document, availability, and knowledge-cutoff time cannot stand in for system time; recovered system stamps match known truth at a higher computed rate than treating every stamp as event time (ADR 0002). - `event_clock` identity gate: assertion, system, document, and availability time cannot stand in for event/valid time; recovered event stamps match known truth at a higher computed rate than treating every stamp as assertion time (ADR 0002). +- Dependabot Rust toolchain updates now use a seven-day cooldown so newly published versions receive a bounded review window before automated proposals. - `assertion_clock` identity gate: event, system, document, and availability time cannot stand in for assertion time; recovered assertion stamps match known truth at a higher computed rate than treating every stamp as event time (ADR 0002). - `cutoff_clock` identity gate: event time, system time, and availability time cannot stand in for knowledge cutoff; recovered cutoff stamps match known truth at a higher computed rate than treating every stamp as availability time (ADR 0002). - `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). @@ -144,6 +155,20 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Changed +- The LineageWeave temporal-context read exchange no longer emits a fabricated + `idempotency-key`; that header remains reserved for retryable write/export + operations with a caller-owned operation key. +- `tepp_api` project-history requests and projections now share the strict + `temporal_core` RFC 3339 parser and nominal `KnowledgeCutoff` boundary, + rejecting unknown offsets and other timestamp forms that the transport + parser could otherwise accept. +- Coverage validation now ignores LLVM rows for multiline call and iterator + syntax that have no independently executable source coordinate, while + retaining the authored-line 100% gate. +- Removed the temporary PR-155 review-repair workflows and source-fix helper after the bounded repair; subsequent changes use the normal reviewed branch path. +- Pinned Rust branch-coverage workflows to `nightly-2026-08-21`, which is newer than the workspace Rust 1.97.1 MSRV and avoids the previous nightly/MSRV mismatch. +- Applied the documented `sqlx_live.rs` authored-coverage exclusion to the hourly release gate so live-PostgreSQL success-path coverage is not reported as a false source failure. +- Removed unreachable duplicate Naruon host-control validation because the shared `require_nonempty` boundary already rejects C0/C1 controls; retained a C1 regression case alongside the existing C0 case. - Kept one maturity row per capability in the traceability matrix while recording the active provider-receipt evidence without duplicating or downgrading existing capabilities. - `tepp_api` corpus-split manifest validation now rejects governed link-kind arrays that are unsorted or duplicated, keeping untrusted JSON aligned with the schema's unique canonical representation and preventing equivalent audits from receiving different valid digests. - Grounded `derived_sensitivity` doctoring on GDPR Article 4(1)/Recital 26 and WP29 Opinion 4/2007 (WP 136) as read from the official texts, and replaced the withdrawn ISO/IEC 29100:2011 use-limitation overclaim with the current 29100:2024 catalogue edition without quoting unread clause text. @@ -154,8 +179,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang head SHA to that exact-head register, including #201 `6afd650667e1` (RFC 5646 cited once; first GAP-005 slice, not implemented-main), stacked drafts #202–#204, and #164 `ff2e645b1785` as the predecessor register head. Duplicate - PR #179 remains closed. Stacked-merged heads and queued Checks are not - implemented-main. + PR #179 remains closed. Stacked-merged heads and queued Checks are not + implemented-main. - Clarified ADR 0001 so it owns Rust-first numerical/reference-backend authority while ADR 0011 owns cross-service MSA/service authority. - Clarified ADR 0006 so it owns GPU/VRAM and model-credential boundaries; ADR 0010 now owns LLM orchestration policy and ADR 0015 owns autonomous repository-write/review/merge authority. - Expanded ADR 0002–0005 and 0009–0011 with explicit implementation maturity, alternatives, failure/recovery, compatibility/migration, verification, and rollback/supersession boundaries where they were previously implicit. @@ -181,7 +206,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - Required 100% production line and branch coverage and complete public API docstrings. - Required true-parameter recovery, RMSE, bias, interval coverage, temporal leakage, graph recovery, invariance, and CPU/GPU parity evidence. -- Expanded documentation contracts to require the canonical threat/privacy/assurance/API/orchestration/fitness documents, ADR policy, and every numbered ADR 0001–0016 to remain indexed and structurally complete. +- Expanded documentation contracts to require the canonical threat/privacy/assurance/API/orchestration/fitness documents, ADR policy, and every numbered ADR present in the canonical index to remain indexed and structurally complete. - Added deterministic validation that ADR files and the index have identical decision numbers and that every ADR declares valid decision status, implementation maturity, supersession scope, core decision sections, verification, and rollback behavior. - Added 100% statement and branch coverage for the repository quality-gate scripts. - Made a zero executable-code coverage denominator explicit for the skeleton-only slice rather than treating it as evidence of implemented behavior. diff --git a/Cargo.toml b/Cargo.toml index ad4ed3ac8..dc00dc37c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,6 @@ members = [ "crates/psychometric_fit", "crates/subevent_containment", "crates/prediction_contradiction", - "crates/provider_receipt", "crates/operational_log", "crates/service_tls", "crates/derived_sensitivity", @@ -89,7 +88,6 @@ default-members = [ "crates/psychometric_fit", "crates/subevent_containment", "crates/prediction_contradiction", - "crates/provider_receipt", "crates/operational_log", "crates/service_tls", "crates/derived_sensitivity", diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..c129ed18d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS build +WORKDIR /src +COPY . . +RUN cargo build --locked --release -p tepp_api --bin tepp-loopback + +FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/target/release/tepp-loopback /usr/local/bin/tepp-loopback +USER 65532:65532 +HEALTHCHECK --interval=10s --timeout=3s --start-period=2s --retries=5 \ + CMD curl --fail --silent --show-error \ + --header "content-type: application/json" \ + --header "tepp-consumer: lineageweave" \ + --header "tepp-contract-version: 1" \ + --data '{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":"health-post","events":[{"event_id":"health-event","source_post_id":"health-post","event_type_code":"health_probe","event_label":"Health probe","event_time":"2026-08-20T00:00:00Z","available_time":"2026-08-20T00:00:00Z","project_reference":null,"actor_references":["health-actor"]}]}' \ + http://127.0.0.1:18081/v1/temporal-context >/dev/null \ + || exit 1 +ENTRYPOINT ["/usr/local/bin/tepp-loopback"] diff --git a/crates/persistence_postgres/tests/live_postgres.rs b/crates/persistence_postgres/tests/live_postgres.rs index 8603413cb..63a7d65e5 100644 --- a/crates/persistence_postgres/tests/live_postgres.rs +++ b/crates/persistence_postgres/tests/live_postgres.rs @@ -11,14 +11,9 @@ use persistence_postgres::{ DocumentRecord, EvidenceTombstoneRecord, LegalHoldRecord, LiveDocumentRepository, LiveSqlxPoolOptions, MembershipAssignmentRecord, MigrationCatalog, ModelArtifactRecord, ModelRunRecord, PersistenceError, ReproducibilityManifestRecord, RetentionPolicyRecord, - SqlSession, apply_sql_batch, assume_app_runtime_role_sql, clear_session_tenant_sql, - AuditEvent, CorpusSplitManifestRecord, DeletionRequestRecord, DocumentRecord, - EvidenceTombstoneRecord, LegalHoldRecord, LiveDocumentRepository, LiveSqlxPoolOptions, - MembershipAssignmentRecord, MigrationCatalog, ModelArtifactRecord, ModelRunRecord, - PersistenceError, ReproducibilityManifestRecord, RetentionPolicyRecord, SqlSession, - TextSegmentRecord, apply_sql_batch, assume_app_runtime_role_sql, clear_session_tenant_sql, - open_live_sqlx_pool, require_live_sqlx_config, reset_app_runtime_role_sql, - select_active_analysis_document_sql, set_session_tenant_sql, + SqlSession, TextSegmentRecord, apply_sql_batch, assume_app_runtime_role_sql, + clear_session_tenant_sql, open_live_sqlx_pool, require_live_sqlx_config, + reset_app_runtime_role_sql, select_active_analysis_document_sql, set_session_tenant_sql, }; use std::sync::mpsc; use std::sync::{Arc, Barrier}; diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 62511bd94..dd1475e3c 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -22,5 +22,11 @@ sha2 = { workspace = true } temporal_core = { path = "../temporal_core", version = "0.1.0" } uuid = { workspace = true } +[[bin]] +name = "tepp-loopback" +path = "src/bin/tepp_loopback.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs new file mode 100644 index 000000000..6768c6ef1 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -0,0 +1,970 @@ +//! Consumer-neutral live analysis-run ingress for modular CWL services. +//! +//! This module keeps the Naruon compatibility listener intact while providing +//! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context` +//! boundaries needed by Naruon and `LineageWeave`. It accepts transport +//! acknowledgements and temporal evidence context only; completed psychometric +//! results remain outside this crate. + +use std::collections::HashMap; +use std::io::Write; +use std::net::{SocketAddr, TcpListener}; + +use crate::lineageweave_http::{LINEAGEWEAVE_CONSUMER_CODE, consumer_is_supported}; +use crate::live_http::{ + header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, + split_request_with_limit, validate_common_headers, +}; +use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; +use crate::{ + AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, + ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, + build_temporal_context, project_history_projection, requests_are_idempotent_matches, +}; + +const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +#[cfg(test)] +use crate::live_http::{declared_content_length, host_implies_table_access, split_header_line}; + +/// Loopback HTTP/1.1 analysis-run service shared by published CWL consumers. +/// +/// The service accepts only Naruon and `LineageWeave` consumer identities. Its +/// idempotency namespace includes consumer, tenant, and caller key so one +/// product cannot replay or conflict with another product's accepted run. +#[derive(Debug)] +pub struct AnalysisRunLiveService { + listener: Option, + bound_addr: Option, + next_run_serial: u64, + next_request_serial: u64, + accepted_runs: HashMap, + accepted_project_histories: HashMap, +} + +impl Default for AnalysisRunLiveService { + fn default() -> Self { + Self::new() + } +} + +impl AnalysisRunLiveService { + /// Construct an in-memory handler with no bound socket. + #[must_use] + pub fn new() -> Self { + Self { + listener: None, + bound_addr: None, + next_run_serial: 1, + next_request_serial: 1, + accepted_runs: HashMap::new(), + accepted_project_histories: HashMap::new(), + } + } + + /// Bind an ephemeral IPv4 loopback port. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the operating system + /// refuses the loopback bind. + pub fn bind_loopback() -> Result { + Self::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + } + + /// Bind a caller-supplied loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback address + /// and [`ApiError::InvalidWirePayload`] when the socket cannot be opened. + pub fn bind(addr: SocketAddr) -> Result { + if !addr.ip().is_loopback() { + return Err(ApiError::AuthorizationDenied); + } + let listener = TcpListener::bind(addr).map_err(|error| map_io_error(&error))?; + let bound_addr = listener + .local_addr() + .map_err(|error| map_io_error(&error))?; + Ok(Self { + listener: Some(listener), + bound_addr: Some(bound_addr), + ..Self::new() + }) + } + + /// Return the bound loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound. + pub fn local_addr(&self) -> Result { + self.bound_addr.ok_or(ApiError::InvalidWirePayload) + } + + /// Accept and serve one HTTP/1.1 request. + /// + /// # Errors + /// + /// Returns a fail-closed API error when no socket is bound or socket I/O + /// fails. Protocol errors are returned as redacted HTTP responses. + pub fn serve_one(&mut self) -> Result { + let listener = self.listener.as_ref().ok_or(ApiError::InvalidWirePayload)?; + let (mut stream, _) = listener.accept().map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + let response = match read_http_request_with_limit(&mut stream, MAX_LIVE_REQUEST_BODY_BYTES) + { + Ok(request) => self.handle_http_request(&request), + Err(error) => self.response_from_error(error), + }; + stream + .write_all(&response.to_http_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + Ok(response) + } + + /// Parse and handle one complete HTTP/1.1 request already in memory. + #[must_use] + pub fn handle_http_request(&mut self, request: &str) -> NaruonLiveResponse { + match self.dispatch_http_request(request) { + Ok(response) => response, + Err(error) => self.response_from_error(error), + } + } + + fn dispatch_http_request(&mut self, request: &str) -> Result { + let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?; + let mut lines = header_block.split("\r\n"); + let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; + if method != "POST" + || (path != NARUON_ANALYSIS_RUN_PATH + && path != TEMPORAL_CONTEXT_PATH + && path != PROJECT_HISTORY_PATH) + { + return Err(ApiError::InvalidWirePayload); + } + let headers = parse_headers(&mut lines)?; + let consumer = require_headers( + &headers, + self.bound_addr, + path == NARUON_ANALYSIS_RUN_PATH || path == PROJECT_HISTORY_PATH, + )?; + if path == TEMPORAL_CONTEXT_PATH { + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let context_request = TemporalContextRequest::from_json(body)?; + let response = build_temporal_context(&context_request)?; + return Ok(json_response(200, "OK", response.to_json()?)); + } + if path == PROJECT_HISTORY_PATH { + return self.accept_project_history(consumer, &headers, body); + } + self.accept_analysis_run(consumer, &headers, body) + } + + fn accept_analysis_run( + &mut self, + consumer: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let request = AnalysisRunRequest::from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != request.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = consumer_tenant_idempotency_key( + consumer, + &request.tenant_workspace_id, + idempotency_key, + ); + if let Some((stored_request, stored_accepted)) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(stored_request, &request) { + return Ok(json_response(202, "Accepted", stored_accepted.to_json()?)); + } + return Err(ApiError::InvalidWirePayload); + } + let run_id = format!("tepp-run-{}", self.next_run_serial); + self.next_run_serial += 1; + let accepted = + AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; + let response_body = accepted.to_json()?; + self.accepted_runs.insert(replay_key, (request, accepted)); + Ok(json_response(202, "Accepted", response_body)) + } + + fn accept_project_history( + &mut self, + consumer: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let request = ProjectHistoryRequest::from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != request.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = consumer_tenant_idempotency_key( + consumer, + &request.tenant_workspace_id, + idempotency_key, + ); + if let Some((stored_request, stored_projection)) = + self.accepted_project_histories.get(&replay_key) + { + if stored_request == &request { + return Ok(json_response(200, "OK", stored_projection.to_json()?)); + } + return Err(ApiError::InvalidWirePayload); + } + let projection = project_history_projection(&request)?; + let response_body = projection.to_json()?; + self.accepted_project_histories + .insert(replay_key, (request, projection)); + Ok(json_response(200, "OK", response_body)) + } + + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { + let request_id = format!("analysis-run-live-{}", self.next_request_serial); + self.next_request_serial += 1; + let (status_code, reason_phrase) = status_for(error); + let body = error_envelope_json(error, request_id); + json_response(status_code, reason_phrase, body) + } +} + +fn require_headers( + headers: &HashMap, + bound_addr: Option, + require_idempotency_key: bool, +) -> Result<&str, ApiError> { + validate_common_headers(headers, bound_addr)?; + if header_value(headers, "tepp-contract-version")? != "1" { + return Err(ApiError::InvalidWirePayload); + } + let consumer = header_value(headers, "tepp-consumer")?; + if !consumer_is_supported(consumer) { + return Err(ApiError::InvalidWirePayload); + } + if require_idempotency_key { + let _idempotency_key = header_value(headers, "idempotency-key")?; + } + Ok(consumer) +} + +fn consumer_tenant_idempotency_key( + consumer: &str, + tenant_workspace_id: &str, + idempotency_key: &str, +) -> String { + format!("{consumer}\u{1f}{tenant_workspace_id}\u{1f}{idempotency_key}") +} + +fn status_for(error: ApiError) -> (u16, &'static str) { + match error { + ApiError::InvalidWirePayload => (400, "Bad Request"), + ApiError::AuthorizationDenied => (403, "Forbidden"), + ApiError::LimitExceeded => (413, "Payload Too Large"), + ApiError::UnsupportedContractVersion => (422, "Unprocessable Entity"), + } +} + +fn error_envelope_json(error: ApiError, request_id: String) -> String { + ErrorEnvelope::from_api_error(error, request_id) + .and_then(|envelope| envelope.to_json()) + .unwrap_or_else(|_| { + "{\"error_code\":\"invalid_wire_payload\",\"message\":\"invalid API wire payload\",\"request_id\":\"analysis-run-live-fallback\",\"retryable\":false}".to_owned() + }) +} + +fn json_response( + status_code: u16, + reason_phrase: &'static str, + body: String, +) -> NaruonLiveResponse { + NaruonLiveResponse { + status_code, + reason_phrase, + body, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::fmt::Write as _; + use std::io::{Cursor, Read, Write}; + use std::net::TcpStream; + use std::thread; + use std::time::Duration; + + use super::{ + AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length, + error_envelope_json, host_implies_table_access, map_io_error, parse_headers, + require_headers, split_header_line, status_for, + }; + use crate::live_http::{host_is_loopback, read_http_request, split_request}; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, + }; + + fn sample_run() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "analysis-live-idem-001".into(), + tenant_workspace_id: "analysis-live-tenant".into(), + snapshot_id: "analysis-live-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + } + } + + fn http_request(body: &str, headers: &[(&str, &str)]) -> String { + let mut request = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\n"); + for (name, value) in headers { + write!(request, "{name}: {value}\r\n").expect("header"); + } + write!(request, "content-length: {}\r\n\r\n{body}", body.len()).expect("body"); + request + } + + fn valid_request(run: &AnalysisRunRequest, consumer: &str, host: &str) -> String { + let body = run.to_json().expect("run json"); + http_request( + &body, + &[ + ("Host", host), + ("content-type", "application/json"), + ("tepp-consumer", consumer), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + ) + } + + fn envelope(body: &str) -> ErrorEnvelope { + serde_json::from_str(body).expect("error envelope") + } + + #[test] + fn helper_contracts_cover_consumer_identity_and_loopback_ports() { + assert_eq!( + consumer_tenant_idempotency_key(LINEAGEWEAVE_CONSUMER_CODE, "tenant", "key"), + "lineageweave\u{1f}tenant\u{1f}key" + ); + assert_ne!( + consumer_tenant_idempotency_key(LINEAGEWEAVE_CONSUMER_CODE, "tenant", "key"), + consumer_tenant_idempotency_key(NARUON_CONSUMER_CODE, "tenant", "key") + ); + assert!(host_is_loopback("localhost:8080", None)); + assert!(!host_is_loopback("localhost:not-a-port", None)); + assert_eq!( + AnalysisRunLiveService::bind("0.0.0.0:0".parse().expect("addr")).expect_err("denied"), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunLiveService::new() + .local_addr() + .expect_err("unbound"), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn bind_and_error_helpers_cover_loopback_and_fail_closed_edges() { + let default_service = AnalysisRunLiveService::default(); + assert_eq!( + default_service + .local_addr() + .expect_err("default is unbound"), + ApiError::InvalidWirePayload + ); + let service = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); + let addr = service.local_addr().expect("bound address"); + assert!(addr.ip().is_loopback()); + assert_eq!( + AnalysisRunLiveService::bind(addr).expect_err("in-use address"), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunLiveService::new() + .serve_one() + .expect_err("unbound serve"), + ApiError::InvalidWirePayload + ); + + assert_eq!( + status_for(ApiError::InvalidWirePayload), + (400, "Bad Request") + ); + assert_eq!( + status_for(ApiError::AuthorizationDenied), + (403, "Forbidden") + ); + assert_eq!( + status_for(ApiError::LimitExceeded), + (413, "Payload Too Large") + ); + assert_eq!( + status_for(ApiError::UnsupportedContractVersion), + (422, "Unprocessable Entity") + ); + assert_eq!( + map_io_error(&std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timeout" + )), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "would block" + )), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::other("broken")), + ApiError::InvalidWirePayload + ); + assert!(host_implies_table_access("db.postgres.local")); + assert!(host_implies_table_access("jdbc.local")); + assert!(host_implies_table_access("127.0.0.1/sql")); + assert!(host_implies_table_access("127.0.0.1/tables/x")); + assert!(host_implies_table_access("bad host")); + assert!(host_implies_table_access("bad;host")); + assert!(host_implies_table_access("bad'host")); + assert!(host_implies_table_access("bad\\host")); + assert!(host_implies_table_access("bad\u{0001}host")); + assert!(!host_implies_table_access("127.0.0.1:43789")); + assert!( + error_envelope_json(ApiError::InvalidWirePayload, String::new()) + .contains("analysis-run-live-fallback") + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_acceptance_replay_and_header_security() { + let run = sample_run(); + let body = run.to_json().expect("body"); + let mut service = AnalysisRunLiveService::new(); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + for request_line in [ + "POST /wrong HTTP/1.1", + "POST /v1/analysis-runs HTTP/1.0", + "POST /v1/analysis-runs HTTP/1.1 extra", + ] { + assert_eq!( + service + .handle_http_request(&format!("{request_line}\r\ncontent-length: 0\r\n\r\n")) + .status_code, + 400 + ); + } + + let naruon = + service.handle_http_request(&valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1")); + let lineageweave = service.handle_http_request(&valid_request( + &run, + LINEAGEWEAVE_CONSUMER_CODE, + "127.0.0.1", + )); + assert_eq!(naruon.status_code, 202); + assert_eq!(lineageweave.status_code, 202); + let replay = service.handle_http_request(&valid_request( + &run, + LINEAGEWEAVE_CONSUMER_CODE, + "127.0.0.1", + )); + assert_eq!(replay.status_code, 202); + assert_eq!(replay.body, lineageweave.body); + + let mut conflict = run.clone(); + conflict.snapshot_id = "different-snapshot".into(); + assert_eq!( + service + .handle_http_request(&valid_request( + &conflict, + LINEAGEWEAVE_CONSUMER_CODE, + "127.0.0.1", + )) + .status_code, + 400 + ); + + let mismatch = http_request( + &body, + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", "different-key"), + ], + ); + assert_eq!(service.handle_http_request(&mismatch).status_code, 400); + + let unsupported = body.replace("\"contract_version\":1", "\"contract_version\":9"); + let unsupported_response = service.handle_http_request(&http_request( + &unsupported, + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + )); + assert_eq!(unsupported_response.status_code, 422); + assert_eq!( + envelope(&unsupported_response.body).error_code(), + "unsupported_contract_version" + ); + + for (name, value) in [ + ("authorization", "Bearer secret"), + ("proxy-authorization", "Basic secret"), + ("cookie", "session=secret"), + ("x-api-key", "secret"), + ] { + let response = service.handle_http_request(&http_request( + &body, + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + (name, value), + ], + )); + assert_eq!(response.status_code, 403, "header={name}"); + assert!(!response.body.contains(value)); + } + + for (headers, status) in [ + ( + vec![ + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", ""), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", "127.0.0.1/sql"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", "8.8.8.8"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 403, + ), + ( + vec![ + ("Host", "127.0.0.1"), + ("content-type", "text/plain"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "2"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", "unpublished"), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + 400, + ), + ( + vec![ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", ""), + ], + 400, + ), + ] { + assert_eq!( + service + .handle_http_request(&http_request(&body, &headers)) + .status_code, + status + ); + } + + let transfer = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: key\r\ntransfer-encoding: chunked\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + assert_eq!(service.handle_http_request(&transfer).status_code, 400); + } + + #[test] + fn temporal_read_headers_and_defensive_write_edges_are_covered() { + let run = sample_run(); + let body = run.to_json().expect("body"); + let mut service = AnalysisRunLiveService::new(); + + for missing_header in ["tepp-contract-version", "tepp-consumer"] { + let headers = [ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ] + .into_iter() + .filter(|(name, _)| *name != missing_header) + .collect::>(); + assert_eq!( + service + .handle_http_request(&http_request(&body, &headers)) + .status_code, + 400, + "missing={missing_header}" + ); + } + + let temporal_body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + let temporal_request = format!( + "POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{temporal_body}", + temporal_body.len() + ); + assert_eq!( + service.handle_http_request(&temporal_request).status_code, + 200 + ); + + let mut headers = HashMap::from([ + ("host".to_owned(), "127.0.0.1".to_owned()), + ("content-type".to_owned(), "application/json".to_owned()), + ("tepp-consumer".to_owned(), NARUON_CONSUMER_CODE.to_owned()), + ("tepp-contract-version".to_owned(), "1".to_owned()), + ]); + assert_eq!( + service.accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body), + Err(ApiError::InvalidWirePayload) + ); + headers.insert("idempotency-key".to_owned(), run.idempotency_key.clone()); + assert_eq!( + require_headers(&headers, None, true), + Ok(NARUON_CONSUMER_CODE) + ); + headers.insert("tepp-contract-version".to_owned(), "2".to_owned()); + assert_eq!( + require_headers(&headers, None, true), + Err(ApiError::InvalidWirePayload) + ); + headers.insert("tepp-contract-version".to_owned(), "1".to_owned()); + headers.insert("tepp-consumer".to_owned(), "unpublished".to_owned()); + assert_eq!( + require_headers(&headers, None, true), + Err(ApiError::InvalidWirePayload) + ); + headers.insert("tepp-consumer".to_owned(), NARUON_CONSUMER_CODE.to_owned()); + let accepted = service + .accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body) + .expect("accepted"); + assert_eq!(accepted.status_code, 202); + let replay = service + .accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body) + .expect("replay"); + assert_eq!(replay.body, accepted.body); + } + + #[test] + fn parser_helpers_cover_framing_header_and_limit_edges() { + assert_eq!( + split_request("").expect_err("empty"), + ApiError::InvalidWirePayload + ); + assert_eq!( + split_request(&"x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT)), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + split_request(&format!( + "{}\r\n\r\n", + "x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT + 1) + )), + Err(ApiError::LimitExceeded) + ); + let oversized_body = "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1); + assert_eq!( + split_request(&format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: {}\r\n\r\n{oversized_body}", + oversized_body.len() + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + split_header_line("NoColon"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line(": empty-name"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Bad Name: value"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Bad\u{0001}Name: value"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Host: 127.0.0.1").expect("header"), + ("Host", "127.0.0.1") + ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 1\r\ncontent-length: 1\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: \r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: +1\r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 999999999999999999999\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + let mut crowded = (0..=NARUON_LIVE_HEADER_COUNT_LIMIT) + .map(|index| Box::leak(format!("x-{index}: value").into_boxed_str()) as &str); + assert_eq!(parse_headers(&mut crowded), Err(ApiError::LimitExceeded)); + let mut duplicate = ["x-header: one", "X-HEADER: two"].into_iter(); + assert_eq!( + parse_headers(&mut duplicate), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_request("POST /v1/analysis-runs HTTP/1.1\r\ncontent-length: 2\r\n\r\na"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn read_http_request_covers_transport_utf8_and_body_limits() { + assert_eq!( + read_http_request(&mut Cursor::new(Vec::::new())).expect_err("eof"), + ApiError::InvalidWirePayload + ); + assert_eq!( + read_http_request(&mut ScriptedRead::error(std::io::ErrorKind::TimedOut)) + .expect_err("timeout"), + ApiError::LimitExceeded + ); + assert_eq!( + read_http_request(&mut ScriptedRead::error(std::io::ErrorKind::Other)) + .expect_err("other"), + ApiError::InvalidWirePayload + ); + assert_eq!( + read_http_request(&mut Cursor::new(vec![ + b'x'; + NARUON_LIVE_HEADER_BYTE_LIMIT + 1 + ])) + .expect_err("header limit"), + ApiError::LimitExceeded + ); + + let run = sample_run(); + let request = valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1"); + assert_eq!( + read_http_request(&mut ScriptedRead::bytes(request.as_bytes())).expect("request"), + request + ); + let zero = request.replace(&run.to_json().expect("body"), ""); + let zero = zero.replace( + &format!("content-length: {}", run.to_json().expect("body").len()), + "content-length: 0", + ); + assert!(read_http_request(&mut Cursor::new(zero.into_bytes())).is_ok()); + + let mut invalid_header = b"POST /v1/analysis-runs HTTP/1.1\r\n".to_vec(); + invalid_header.push(0xff); + invalid_header.extend_from_slice(b"\r\ncontent-length: 0\r\n\r\n"); + assert_eq!( + read_http_request(&mut Cursor::new(invalid_header)).expect_err("header utf8"), + ApiError::InvalidWirePayload + ); + let header = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-length: 1\r\n\r\n" + ); + let invalid_body = [header.as_bytes(), &[0xff]].concat(); + assert_eq!( + read_http_request(&mut Cursor::new(invalid_body)).expect_err("body utf8"), + ApiError::InvalidWirePayload + ); + let truncated = + format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: 4\r\n\r\nab"); + assert_eq!( + read_http_request(&mut Cursor::new(truncated.into_bytes())).expect_err("short body"), + ApiError::InvalidWirePayload + ); + let huge = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: {}\r\n\r\n", + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1 + ); + assert_eq!( + read_http_request(&mut Cursor::new(huge.into_bytes())).expect_err("body limit"), + ApiError::LimitExceeded + ); + } + + #[test] + fn serve_one_covers_loopback_success_disconnect_and_timeout() { + let run = sample_run(); + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("address"); + let worker = thread::spawn(move || service.serve_one()); + let mut stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .write_all(valid_request(&run, NARUON_CONSUMER_CODE, &addr.to_string()).as_bytes()) + .expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + assert!(response.starts_with("HTTP/1.1 202 Accepted")); + assert_eq!( + worker.join().expect("join").expect("served").status_code, + 202 + ); + + let mut idle = AnalysisRunLiveService::bind_loopback().expect("idle bind"); + let idle_addr = idle.local_addr().expect("idle address"); + let idle_worker = thread::spawn(move || idle.serve_one()); + drop(TcpStream::connect(idle_addr).expect("idle connect")); + assert_eq!( + idle_worker + .join() + .expect("idle join") + .expect("idle served") + .status_code, + 400 + ); + + let mut timeout = AnalysisRunLiveService::bind_loopback().expect("timeout bind"); + let timeout_addr = timeout.local_addr().expect("timeout address"); + let timeout_worker = thread::spawn(move || timeout.serve_one()); + let stream = TcpStream::connect(timeout_addr).expect("timeout connect"); + let started = std::time::Instant::now(); + let timeout_response = timeout_worker + .join() + .expect("timeout join") + .expect("timeout served"); + drop(stream); + assert!(started.elapsed() >= NARUON_LIVE_IO_TIMEOUT); + assert_eq!(timeout_response.status_code, 413); + assert_eq!( + envelope(&timeout_response.body).error_code(), + "limit_exceeded" + ); + } + + struct ScriptedRead { + reader: Cursor>, + first_error: Option, + } + + impl ScriptedRead { + fn bytes(bytes: &[u8]) -> Self { + Self { + reader: Cursor::new(bytes.to_vec()), + first_error: None, + } + } + + fn error(kind: std::io::ErrorKind) -> Self { + Self { + reader: Cursor::new(Vec::new()), + first_error: Some(kind), + } + } + } + + impl Read for ScriptedRead { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if let Some(kind) = self.first_error.take() { + return Err(std::io::Error::new(kind, "scripted error")); + } + self.reader.read(buffer) + } + } +} diff --git a/crates/tepp_api/src/bin/tepp_loopback.rs b/crates/tepp_api/src/bin/tepp_loopback.rs new file mode 100644 index 000000000..90800a8cb --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_loopback.rs @@ -0,0 +1,24 @@ +//! Runnable loopback ingress for trusted same-host TEPP consumers. + +use std::net::SocketAddr; + +use tepp_api::AnalysisRunLiveService; + +const DEFAULT_BIND_ADDR: &str = "127.0.0.1:18081"; + +fn main() -> Result<(), Box> { + let mut arguments = std::env::args().skip(1); + let bind_addr = arguments + .next() + .unwrap_or(DEFAULT_BIND_ADDR.to_owned()) + .parse::()?; + let request_limit = arguments + .next() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(usize::MAX); + let mut service = AnalysisRunLiveService::bind(bind_addr)?; + println!("{}", service.local_addr()?); + (0..request_limit).for_each(|_| drop(service.serve_one())); + Ok(()) +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 56937ed82..0bd0c9548 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -5,21 +5,27 @@ //! These pure wire contracts let TEPP operate standalone and as a modular CWL //! component without sharing application tables. Domain estimation remains in //! scientific crates; this crate only defines fail-closed interchange shapes. -//! naruon HTTP interchange is a versioned `https` POST to analysis-run and -//! export paths; table-access URLs, review/Copilot/NIM/proxy headers, and -//! lexical inference claims fail closed. A loopback live listener proves -//! those POSTs over TCP without claiming production TLS (ADR 0011). +//! Naruon and `LineageWeave` use the versioned analysis-run contract; `LineageWeave` +//! may also request a cutoff-safe project-history projection from explicit +//! source evidence. Naruon owns the current purpose-bound export adapter. +//! Loopback listeners prove the HTTP boundary without claiming production TLS, +//! causality, or completed psychometric model results. mod analysis_run; +mod analysis_run_live; mod authorization; mod corpus_split_manifest; mod envelope; mod error; mod export; +mod lineageweave_http; +mod live_http; mod naruon_http; mod naruon_live; mod orchestration; +mod project_history; mod provider_payload; +mod temporal_context; mod wire; /// Analysis-run contract version constant. @@ -32,6 +38,8 @@ pub use analysis_run::AnalysisRunRequest; pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; /// Idempotent request equality helper. pub use analysis_run::requests_are_idempotent_matches; +/// Consumer-neutral loopback analysis-run service. +pub use analysis_run_live::AnalysisRunLiveService; /// Corpus-split leakage-audit contract version. pub use corpus_split_manifest::CORPUS_SPLIT_MANIFEST_CONTRACT_VERSION; /// Versioned corpus-split leakage-audit manifest. @@ -61,19 +69,29 @@ pub use authorization::ExportAuthorizationRequest; pub use authorization::authorize_export; /// Fail closed when an export decision is denied. pub use authorization::require_export_allowed; -/// Versioned analysis-run path naruon may call. +/// Published `LineageWeave` modular-consumer identity. +pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; +/// Published Naruon modular-consumer identity. +pub use lineageweave_http::NARUON_CONSUMER_CODE; +/// Build a `LineageWeave` analysis-run exchange without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_exchange; +/// Build a `LineageWeave` project-history exchange without provider credentials. +pub use lineageweave_http::lineageweave_project_history_exchange; +/// Build a credential-free `LineageWeave` temporal-context exchange. +pub use lineageweave_http::lineageweave_temporal_context_exchange; +/// Versioned analysis-run path modular consumers may call. pub use naruon_http::NARUON_ANALYSIS_RUN_PATH; -/// Versioned export path naruon may call. +/// Versioned export path Naruon may call. pub use naruon_http::NARUON_EXPORT_PATH; -/// Allowed TEPP inference method code naruon may claim. +/// Allowed TEPP inference method code Naruon may claim. pub use naruon_http::NARUON_TEPP_INFERENCE_METHOD; -/// Fail-closed HTTP exchange naruon may send to TEPP. +/// Fail-closed HTTP exchange a modular consumer may send to TEPP. pub use naruon_http::NaruonHttpExchange; -/// Build a naruon analysis-run create exchange. +/// Build a Naruon analysis-run create exchange. pub use naruon_http::naruon_analysis_run_exchange; -/// Build an analysis-run exchange and refuse credential headers. +/// Build a Naruon analysis-run exchange and refuse credential headers. pub use naruon_http::naruon_analysis_run_exchange_with_headers; -/// Build a naruon export-authorization exchange. +/// Build a Naruon export-authorization exchange. pub use naruon_http::naruon_export_exchange; /// Refuse lexical heuristics as TEPP inference claims. pub use naruon_http::naruon_may_claim_tepp_inference; @@ -83,9 +101,9 @@ pub use naruon_live::NARUON_LIVE_HEADER_BYTE_LIMIT; pub use naruon_live::NARUON_LIVE_HEADER_COUNT_LIMIT; /// Accepted-stream read/write deadline. pub use naruon_live::NARUON_LIVE_IO_TIMEOUT; -/// HTTP/1.1 response from the naruon live listener. +/// HTTP/1.1 response from the loopback listener. pub use naruon_live::NaruonLiveResponse; -/// Loopback live HTTP/1.1 service for naruon POSTs. +/// Backward-compatible Naruon loopback HTTP/1.1 service. pub use naruon_live::NaruonLiveService; /// Comparable-budget ablation record. pub use orchestration::BudgetAblationRecord; @@ -123,6 +141,26 @@ pub use orchestration::bind_contextual_orchestrator; pub use orchestration::record_budget_ablation; /// Route a task onto a versioned orchestration plan. pub use orchestration::route_orchestration; +/// Default maximum serialized project-history request bytes. +pub use project_history::DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; +/// Default maximum project-history event count. +pub use project_history::DEFAULT_PROJECT_HISTORY_EVENT_LIMIT; +/// Supported project-history contract version. +pub use project_history::PROJECT_HISTORY_CONTRACT_VERSION; +/// Versioned project-history path. +pub use project_history::PROJECT_HISTORY_PATH; +/// Explicit source-grounded project event. +pub use project_history::ProjectHistoryEvent; +/// One non-causal temporal finding. +pub use project_history::ProjectHistoryFinding; +/// Project-history HTTP exchange. +pub use project_history::ProjectHistoryHttpExchange; +/// Deterministic TEPP project-history projection. +pub use project_history::ProjectHistoryProjection; +/// Versioned project-history request. +pub use project_history::ProjectHistoryRequest; +/// Build a cutoff-safe project-history projection. +pub use project_history::project_history_projection; /// Elevated re-identification result. pub use provider_payload::DisclosedIdentityMapping; /// Separately protected identity mapping. @@ -145,3 +183,23 @@ pub use provider_payload::ReidentificationAuditSink; pub use provider_payload::disclose_identity_mapping; /// Minimize evidence for a model provider. pub use provider_payload::minimize_provider_payload; +/// Temporal association claim boundary. +pub use temporal_context::TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY; +/// Temporal-context contract version constant. +pub use temporal_context::TEMPORAL_CONTEXT_CONTRACT_VERSION; +/// Versioned temporal-context HTTP path. +pub use temporal_context::TEMPORAL_CONTEXT_PATH; +/// One opaque event in a temporal-context request. +pub use temporal_context::TemporalContextEvent; +/// One adjacent temporal relation. +pub use temporal_context::TemporalContextRelation; +/// Temporal-context request. +pub use temporal_context::TemporalContextRequest; +/// Temporal-context response. +pub use temporal_context::TemporalContextResponse; +/// One ordered event in a temporal-context response. +pub use temporal_context::TemporalContextTimelineEvent; +/// One non-causal transition-gap candidate. +pub use temporal_context::TemporalTransitionGapCandidate; +/// Build a cutoff-safe, non-causal temporal context. +pub use temporal_context::build_temporal_context; diff --git a/crates/tepp_api/src/lineageweave_http.rs b/crates/tepp_api/src/lineageweave_http.rs new file mode 100644 index 000000000..6094760ed --- /dev/null +++ b/crates/tepp_api/src/lineageweave_http.rs @@ -0,0 +1,135 @@ +//! Published modular-consumer identity and `LineageWeave` TEPP exchanges. + +use crate::naruon_http::compose_https_target; +use crate::project_history::build_project_history_exchange; +use crate::{ + AnalysisRunRequest, ApiError, NaruonHttpExchange, ProjectHistoryHttpExchange, + ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, TEMPORAL_CONTEXT_PATH, + TemporalContextRequest, naruon_analysis_run_exchange, +}; + +/// Stable consumer identity used by the Naruon adapter. +pub const NARUON_CONSUMER_CODE: &str = "naruon"; + +/// Stable consumer identity used by the `LineageWeave` adapter. +pub const LINEAGEWEAVE_CONSUMER_CODE: &str = "lineageweave"; + +/// Build a `LineageWeave` → TEPP analysis-run exchange without provider credentials. +/// +/// The function reuses TEPP's existing origin, body, and header validation, +/// then replaces only the published modular-consumer identity. The accepted +/// response remains an asynchronous transport acknowledgement, not a completed +/// psychometric result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_exchange`]. +pub fn lineageweave_analysis_run_exchange( + origin: &str, + request: &AnalysisRunRequest, +) -> Result { + let mut exchange = naruon_analysis_run_exchange(origin, request)?; + let consumer_header = exchange + .headers + .iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case("tepp-consumer")) + .ok_or(ApiError::InvalidWirePayload)?; + LINEAGEWEAVE_CONSUMER_CODE.clone_into(&mut consumer_header.1); + Ok(exchange) +} + +/// Build a credential-free `LineageWeave` temporal-context exchange. +/// +/// # Errors +/// +/// Returns a fail-closed error for a hostile origin or invalid temporal-context +/// request. +pub fn lineageweave_temporal_context_exchange( + origin: &str, + request: &TemporalContextRequest, +) -> Result { + let target_url = compose_https_target(origin, TEMPORAL_CONTEXT_PATH)?; + let body = request.to_json()?; + let headers = vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()), + ( + "tepp-contract-version".into(), + TEMPORAL_CONTEXT_CONTRACT_VERSION.to_string(), + ), + ]; + Ok(NaruonHttpExchange { + method: "POST", + target_url, + headers, + body, + }) +} + +/// Build a `LineageWeave` → TEPP project-history exchange without credentials. +/// +/// The request contains only bounded source evidence selected after +/// `LineageWeave` authorization. TEPP validates the cutoff and returns a +/// deterministic temporal-association projection, never a causal score. +/// +/// # Errors +/// +/// Returns a fail-closed origin, request, version, size, or timestamp error. +pub fn lineageweave_project_history_exchange( + origin: &str, + request: &ProjectHistoryRequest, +) -> Result { + build_project_history_exchange(origin, LINEAGEWEAVE_CONSUMER_CODE, request) +} + +/// Return whether a modular analysis-run consumer is published by TEPP. +pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool { + matches!( + consumer_code, + NARUON_CONSUMER_CODE | LINEAGEWEAVE_CONSUMER_CODE + ) +} + +#[cfg(test)] +mod tests { + use super::{ + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, + lineageweave_analysis_run_exchange, + }; + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; + + fn sample_run() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-1".into(), + tenant_workspace_id: "tenant-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + } + } + + #[test] + fn supported_consumer_set_is_closed() { + assert!(consumer_is_supported(NARUON_CONSUMER_CODE)); + assert!(consumer_is_supported(LINEAGEWEAVE_CONSUMER_CODE)); + assert!(!consumer_is_supported("unknown")); + } + + #[test] + fn lineageweave_exchange_preserves_existing_fail_closed_validation() { + let run = sample_run(); + let exchange = lineageweave_analysis_run_exchange("https://tepp.example.test", &run) + .expect("exchange"); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert_eq!( + lineageweave_analysis_run_exchange("http://tepp.example.test", &run), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/src/live_http.rs b/crates/tepp_api/src/live_http.rs new file mode 100644 index 000000000..ed8d7e564 --- /dev/null +++ b/crates/tepp_api/src/live_http.rs @@ -0,0 +1,238 @@ +//! Shared fail-closed framing and host validation for loopback HTTP listeners. + +use std::collections::HashMap; +use std::io::Read; +use std::net::{IpAddr, SocketAddr}; + +use crate::naruon_http::header_is_credential; +use crate::{ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT}; + +/// Maximum request-line plus header bytes accepted before the body. +pub const NARUON_LIVE_HEADER_BYTE_LIMIT: usize = 8 * 1024; + +/// Maximum number of HTTP header lines on one live request. +pub const NARUON_LIVE_HEADER_COUNT_LIMIT: usize = 32; + +/// Read one HTTP/1.1 request, including its declared UTF-8 body. +pub(crate) fn read_http_request(reader: &mut R) -> Result { + read_http_request_with_limit(reader, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) +} + +/// Read one HTTP/1.1 request with a caller-selected body limit. +pub(crate) fn read_http_request_with_limit( + reader: &mut R, + maximum_body_bytes: usize, +) -> Result { + let mut header_bytes = Vec::new(); + let mut byte = [0_u8; 1]; + while !header_bytes.ends_with(b"\r\n\r\n") { + if header_bytes.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let read = reader + .read(&mut byte) + .map_err(|error| map_io_error(&error))?; + if read == 0 { + return Err(ApiError::InvalidWirePayload); + } + header_bytes.push(byte[0]); + } + let header_text = + std::str::from_utf8(&header_bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let content_length = declared_content_length(header_text)?; + if content_length > maximum_body_bytes { + return Err(ApiError::LimitExceeded); + } + let mut body = vec![0_u8; content_length]; + if content_length > 0 { + reader + .read_exact(&mut body) + .map_err(|error| map_io_error(&error))?; + } + let body_text = std::str::from_utf8(&body).map_err(|_| ApiError::InvalidWirePayload)?; + Ok(format!("{header_text}{body_text}")) +} + +/// Split one complete request into its header block and UTF-8 body. +pub(crate) fn split_request(request: &str) -> Result<(&str, &str), ApiError> { + split_request_with_limit(request, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) +} + +/// Split one complete request with a caller-selected body limit. +pub(crate) fn split_request_with_limit( + request: &str, + maximum_body_bytes: usize, +) -> Result<(&str, &str), ApiError> { + let Some(index) = request.find("\r\n\r\n") else { + if request.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + return Err(ApiError::InvalidWirePayload); + }; + if index > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let header_block = &request[..index]; + let body = &request[index + 4..]; + let declared = declared_content_length(&format!("{header_block}\r\n\r\n"))?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + if declared > maximum_body_bytes { + return Err(ApiError::LimitExceeded); + } + Ok((header_block, body)) +} + +/// Parse the single decimal content length from a complete header block. +pub(crate) fn declared_content_length(header_text: &str) -> Result { + let header_block = header_text + .strip_suffix("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut found = None; + for line in header_block.split("\r\n").skip(1) { + let (name, value) = split_header_line(line)?; + if name.eq_ignore_ascii_case("content-length") { + if found.is_some() + || value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(ApiError::InvalidWirePayload); + } + found = Some(value.parse().map_err(|_| ApiError::InvalidWirePayload)?); + } + } + found.ok_or(ApiError::InvalidWirePayload) +} + +/// Parse a strict HTTP/1.1 request line into method and path. +pub(crate) fn parse_request_line(line: &str) -> Result<(&str, &str), ApiError> { + let mut parts = line.split(' '); + let method = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let path = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let version = parts.next().ok_or(ApiError::InvalidWirePayload)?; + if parts.next().is_some() || version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + if !path.starts_with('/') || path.contains('?') || path.contains('#') || path.contains("://") { + return Err(ApiError::InvalidWirePayload); + } + Ok((method, path)) +} + +/// Parse and normalize bounded, unique HTTP headers. +pub(crate) fn parse_headers<'a, I>(lines: I) -> Result, ApiError> +where + I: Iterator, +{ + let mut headers = HashMap::new(); + let mut count = 0_usize; + for line in lines { + count += 1; + if count > NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = split_header_line(line)?; + let key = name.to_ascii_lowercase(); + if headers.contains_key(&key) { + return Err(ApiError::InvalidWirePayload); + } + headers.insert(key, value.to_owned()); + } + Ok(headers) +} + +/// Split one header line while rejecting malformed names. +pub(crate) fn split_header_line(line: &str) -> Result<(&str, &str), ApiError> { + let Some((name, value)) = line.split_once(':') else { + return Err(ApiError::InvalidWirePayload); + }; + if name.is_empty() || name.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(ApiError::InvalidWirePayload); + } + Ok((name, value.trim())) +} + +/// Return one required non-empty normalized header value. +pub(crate) fn header_value<'a>( + headers: &'a HashMap, + name: &str, +) -> Result<&'a str, ApiError> { + let value = headers.get(name).ok_or(ApiError::InvalidWirePayload)?; + if value.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + Ok(value.as_str()) +} + +/// Validate common credential, framing, content-type, and loopback boundaries. +pub(crate) fn validate_common_headers( + headers: &HashMap, + bound_addr: Option, +) -> Result<(), ApiError> { + for name in headers.keys() { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + } + if headers.contains_key("transfer-encoding") { + return Err(ApiError::InvalidWirePayload); + } + let host = header_value(headers, "host")?; + if host_implies_table_access(host) { + return Err(ApiError::InvalidWirePayload); + } + if !host_is_loopback(host, bound_addr) { + return Err(ApiError::AuthorizationDenied); + } + if header_value(headers, "content-type")? != "application/json" { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +/// Return whether a host value implies direct database or table access. +pub(crate) fn host_implies_table_access(host: &str) -> bool { + let lowered = host.to_ascii_lowercase(); + lowered.contains("postgres") + || lowered.contains("jdbc") + || lowered.contains("/sql") + || lowered.contains("/tables/") + || lowered.contains('\'') + || lowered.contains(';') + || lowered.contains('\\') + || lowered.contains(' ') + || lowered.chars().any(char::is_control) +} + +/// Return whether a host resolves to loopback or the bound loopback socket. +pub(crate) fn host_is_loopback(host: &str, bound_addr: Option) -> bool { + if let Some(bound) = bound_addr + && (host == bound.to_string() || host == bound.ip().to_string()) + { + return true; + } + let lowered = host.to_ascii_lowercase(); + if lowered == "localhost" + || lowered + .strip_prefix("localhost:") + .is_some_and(|port| !port.is_empty() && port.parse::().is_ok()) + { + return true; + } + if let Ok(addr) = host.parse::() { + return addr.ip().is_loopback(); + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +/// Map socket timeout and transport failures to redacted API errors. +pub(crate) fn map_io_error(error: &std::io::Error) -> ApiError { + match error.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ApiError::LimitExceeded, + _ => ApiError::InvalidWirePayload, + } +} diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 5f4c5ffa5..eb4428440 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -116,7 +116,7 @@ pub fn naruon_may_claim_tepp_inference(method_code: &str) -> Result<(), ApiError } } -fn compose_https_target(origin: &str, path: &str) -> Result { +pub(crate) fn compose_https_target(origin: &str, path: &str) -> Result { require_nonempty(origin)?; if !origin.starts_with("https://") { return Err(ApiError::InvalidWirePayload); @@ -176,7 +176,7 @@ fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiEr Ok(()) } -fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> { +pub(crate) fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> { vec![ ("content-type".into(), "application/json".into()), ("tepp-consumer".into(), "naruon".into()), @@ -248,6 +248,11 @@ mod tests { compose_https_target("https://ho\u{0001}st", "/v1/x"), Err(ApiError::InvalidWirePayload) ); + let c1_control_origin = format!("https://host{}example", char::from_u32(0x80).unwrap()); + assert_eq!( + compose_https_target(&c1_control_origin, "/v1/x"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( compose_https_target("https://db.postgres.example", "/v1/x"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index b068fedd1..f9b4ca327 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -2,24 +2,35 @@ use std::collections::HashMap; use std::io::{Read, Write}; -use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; -use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, header_is_credential}; +use crate::lineageweave_http::NARUON_CONSUMER_CODE; +use crate::live_http::{ + header_value, map_io_error, parse_headers, parse_request_line, read_http_request, + split_request, validate_common_headers, +}; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH}; use crate::wire::{from_json, to_json}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, - ErrorEnvelope, requests_are_idempotent_matches, + AnalysisRunAccepted, AnalysisRunRequest, ApiError, ErrorEnvelope, + requests_are_idempotent_matches, }; -/// Maximum request-line plus header bytes accepted before the body. -pub const NARUON_LIVE_HEADER_BYTE_LIMIT: usize = 8 * 1024; +#[cfg(test)] +use crate::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; +#[cfg(test)] +use crate::live_http::{ + declared_content_length, host_implies_table_access, host_is_loopback, split_header_line, +}; -/// Maximum number of HTTP header lines on one live request. -pub const NARUON_LIVE_HEADER_COUNT_LIMIT: usize = 32; +/// Maximum live HTTP header-block bytes. +pub use crate::live_http::NARUON_LIVE_HEADER_BYTE_LIMIT; +/// Maximum live HTTP header count. +pub use crate::live_http::NARUON_LIVE_HEADER_COUNT_LIMIT; /// Read and write deadline installed on every accepted stream. pub const NARUON_LIVE_IO_TIMEOUT: Duration = Duration::from_secs(1); @@ -164,37 +175,7 @@ impl NaruonLiveService { /// [`NARUON_LIVE_HEADER_BYTE_LIMIT`]. Other read/framing failures are /// [`ApiError::InvalidWirePayload`]. pub fn read_http_request(reader: &mut R) -> Result { - let mut header_bytes = Vec::new(); - let mut byte = [0_u8; 1]; - loop { - if header_bytes.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { - return Err(ApiError::LimitExceeded); - } - let read = reader - .read(&mut byte) - .map_err(|error| map_io_error(&error))?; - if read == 0 { - return Err(ApiError::InvalidWirePayload); - } - header_bytes.push(byte[0]); - if header_bytes.ends_with(b"\r\n\r\n") { - break; - } - } - let header_text = - std::str::from_utf8(&header_bytes).map_err(|_| ApiError::InvalidWirePayload)?; - let content_length = declared_content_length(header_text)?; - if content_length > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { - return Err(ApiError::LimitExceeded); - } - let mut body = vec![0_u8; content_length]; - if content_length > 0 { - reader - .read_exact(&mut body) - .map_err(|error| map_io_error(&error))?; - } - let body_text = std::str::from_utf8(&body).map_err(|_| ApiError::InvalidWirePayload)?; - Ok(format!("{header_text}{body_text}")) + read_http_request(reader) } /// Write one HTTP/1.1 response to `writer`. @@ -342,123 +323,12 @@ fn status_for(error: ApiError) -> (u16, &'static str) { } } -fn map_io_error(error: &std::io::Error) -> ApiError { - match error.kind() { - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ApiError::LimitExceeded, - _ => ApiError::InvalidWirePayload, - } -} - -fn split_request(request: &str) -> Result<(&str, &str), ApiError> { - let Some(index) = request.find("\r\n\r\n") else { - if request.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { - return Err(ApiError::LimitExceeded); - } - return Err(ApiError::InvalidWirePayload); - }; - if index > NARUON_LIVE_HEADER_BYTE_LIMIT { - return Err(ApiError::LimitExceeded); - } - let header_block = &request[..index]; - let body = &request[index + 4..]; - let declared = declared_content_length(&format!("{header_block}\r\n\r\n"))?; - if declared != body.len() { - return Err(ApiError::InvalidWirePayload); - } - if declared > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { - return Err(ApiError::LimitExceeded); - } - Ok((header_block, body)) -} - -fn declared_content_length(header_text: &str) -> Result { - let header_block = header_text - .strip_suffix("\r\n\r\n") - .ok_or(ApiError::InvalidWirePayload)?; - let mut found = None; - for line in header_block.split("\r\n").skip(1) { - let (name, value) = split_header_line(line)?; - if name.eq_ignore_ascii_case("content-length") { - if found.is_some() { - return Err(ApiError::InvalidWirePayload); - } - if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(ApiError::InvalidWirePayload); - } - found = Some(value.parse().map_err(|_| ApiError::InvalidWirePayload)?); - } - } - found.ok_or(ApiError::InvalidWirePayload) -} - -fn parse_request_line(line: &str) -> Result<(&str, &str), ApiError> { - let mut parts = line.split(' '); - let method = parts.next().ok_or(ApiError::InvalidWirePayload)?; - let path = parts.next().ok_or(ApiError::InvalidWirePayload)?; - let version = parts.next().ok_or(ApiError::InvalidWirePayload)?; - if parts.next().is_some() || version != "HTTP/1.1" { - return Err(ApiError::InvalidWirePayload); - } - if !path.starts_with('/') || path.contains('?') || path.contains('#') || path.contains("://") { - return Err(ApiError::InvalidWirePayload); - } - Ok((method, path)) -} - -fn parse_headers<'a, I>(lines: I) -> Result, ApiError> -where - I: Iterator, -{ - let mut headers = HashMap::new(); - let mut count = 0_usize; - for line in lines { - count += 1; - if count > NARUON_LIVE_HEADER_COUNT_LIMIT { - return Err(ApiError::LimitExceeded); - } - let (name, value) = split_header_line(line)?; - let key = name.to_ascii_lowercase(); - if headers.contains_key(&key) { - return Err(ApiError::InvalidWirePayload); - } - headers.insert(key, value.to_owned()); - } - Ok(headers) -} - -fn split_header_line(line: &str) -> Result<(&str, &str), ApiError> { - let Some((name, value)) = line.split_once(':') else { - return Err(ApiError::InvalidWirePayload); - }; - if name.is_empty() || name.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { - return Err(ApiError::InvalidWirePayload); - } - Ok((name, value.trim())) -} - fn refuse_live_headers( headers: &HashMap, bound_addr: Option, ) -> Result<(), ApiError> { - for name in headers.keys() { - if header_is_credential(name) { - return Err(ApiError::AuthorizationDenied); - } - } - if headers.contains_key("transfer-encoding") { - return Err(ApiError::InvalidWirePayload); - } - let host = header_value(headers, "host")?; - if host_implies_table_access(host) { - return Err(ApiError::InvalidWirePayload); - } - if !host_is_loopback(host, bound_addr) { - return Err(ApiError::AuthorizationDenied); - } - if header_value(headers, "content-type")? != "application/json" { - return Err(ApiError::InvalidWirePayload); - } - if header_value(headers, "tepp-consumer")? != "naruon" { + validate_common_headers(headers, bound_addr)?; + if header_value(headers, "tepp-consumer")? != NARUON_CONSUMER_CODE { return Err(ApiError::InvalidWirePayload); } if header_value(headers, "tepp-contract-version")? != "1" { @@ -468,50 +338,6 @@ fn refuse_live_headers( Ok(()) } -fn header_value<'a>(headers: &'a HashMap, name: &str) -> Result<&'a str, ApiError> { - let value = headers.get(name).ok_or(ApiError::InvalidWirePayload)?; - if value.is_empty() { - return Err(ApiError::InvalidWirePayload); - } - Ok(value.as_str()) -} - -fn host_implies_table_access(host: &str) -> bool { - let lowered = host.to_ascii_lowercase(); - lowered.contains("postgres") - || lowered.contains("jdbc") - || lowered.contains("/sql") - || lowered.contains("/tables/") - || lowered.contains('\'') - || lowered.contains(';') - || lowered.contains('\\') - || lowered.contains(' ') - || lowered.chars().any(char::is_control) -} - -fn host_is_loopback(host: &str, bound_addr: Option) -> bool { - if let Some(bound) = bound_addr - && (host == bound.to_string() || host == bound.ip().to_string()) - { - return true; - } - let lowered = host.to_ascii_lowercase(); - if lowered == "localhost" - || lowered - .strip_prefix("localhost:") - .is_some_and(|port| !port.is_empty() && port.parse::().is_ok()) - { - return true; - } - if let Ok(addr) = host.parse::() { - return addr.ip().is_loopback(); - } - if let Ok(ip) = host.parse::() { - return ip.is_loopback(); - } - false -} - #[cfg(test)] mod tests { use super::{ diff --git a/crates/tepp_api/src/project_history.rs b/crates/tepp_api/src/project_history.rs new file mode 100644 index 000000000..433fab458 --- /dev/null +++ b/crates/tepp_api/src/project_history.rs @@ -0,0 +1,850 @@ +//! Cutoff-safe project-history projection for `LineageWeave` buyer surfaces. +//! +//! TEPP owns temporal validation and deterministic ordering. `LineageWeave` owns +//! authorization and selects the bounded source evidence supplied here. The +//! projection reports explicit temporal associations only; it never upgrades +//! sequence into causality or emits a psychometric score. + +use std::collections::{BTreeSet, HashSet}; +use std::net::{IpAddr, Ipv6Addr}; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use temporal_core::{KnowledgeCutoff, TemporalInstant}; + +use crate::ApiError; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json_with_limit, +}; + +/// Supported project-history request and response contract version. +pub const PROJECT_HISTORY_CONTRACT_VERSION: u16 = 1; + +/// Versioned project-history path exposed by a TEPP service adapter. +pub const PROJECT_HISTORY_PATH: &str = "/v1/project-histories"; + +/// Maximum serialized request size accepted by the project-history contract. +pub const DEFAULT_PROJECT_HISTORY_BYTE_LIMIT: usize = 256 * 1024; + +/// Maximum event count accepted in one project-history request. +pub const DEFAULT_PROJECT_HISTORY_EVENT_LIMIT: usize = 128; + +/// Explicit event evidence supplied by an authorized modular consumer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryEvent { + /// Consumer-owned opaque event identity. + pub event_id: String, + /// Bounded machine event type, such as `voc_received`. + pub event_type_code: String, + /// Buyer-readable event title grounded in the source evidence. + pub event_title: String, + /// Event occurrence instant as RFC 3339. + pub occurred_at: String, + /// Instant at which this evidence was available to the analysis. + pub available_at: String, + /// Authorized `LineageWeave` source-post identity. + pub source_post_id: String, + /// Bounded evidence excerpt; never an instruction or causal conclusion. + pub evidence_text: String, + /// Opaque actor identities explicitly attached to this event. + pub actor_ids: Vec, +} + +/// Versioned request for a deterministic TEPP project-history projection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryRequest { + /// Semantic contract version. + pub contract_version: u16, + /// Caller-supplied opaque idempotency key. + pub idempotency_key: String, + /// Authorized tenant or workspace identity. + pub tenant_workspace_id: String, + /// Consumer-owned stable project key. + pub project_key: String, + /// Buyer-readable project label. + pub project_name: String, + /// Maximum evidence-availability instant as RFC 3339. + pub knowledge_cutoff: String, + /// Event around which before/after findings are evaluated. + pub focus_event_id: String, + /// Explicit source-grounded events. + pub events: Vec, +} + +/// One evidence-grounded temporal association in a project history. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryFinding { + /// Stable finding code interpreted by consumer UI copy. + pub finding_code: String, + /// Non-causal explanation of the explicit event ordering. + pub summary: String, + /// Event identities supporting this finding. + pub related_event_ids: Vec, + /// Source-post identities supporting this finding. + pub evidence_post_ids: Vec, +} + +/// Deterministically ordered project-history response. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryProjection { + /// Semantic contract version. + pub contract_version: u16, + /// Consumer-owned stable project key. + pub project_key: String, + /// Buyer-readable project label. + pub project_name: String, + /// Focus event echoed after validation. + pub focus_event_id: String, + /// Knowledge cutoff applied to every event in the response. + pub knowledge_cutoff: String, + /// Earliest event instant in the response. + pub history_span_start: String, + /// Latest event instant in the response. + pub history_span_end: String, + /// Distinct explicit actor count across the supplied events. + pub participant_count: usize, + /// Fixed claim boundary: sequence is association, not causation. + pub inference_status: String, + /// Events ordered by occurrence instant and stable identity. + pub events: Vec, + /// Findings derived only from explicit known event types. + pub findings: Vec, +} + +/// HTTP exchange for a project-history request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectHistoryHttpExchange { + /// HTTP method, always `POST`. + pub method: &'static str, + /// Absolute HTTPS target ending in [`PROJECT_HISTORY_PATH`]. + pub target_url: String, + /// Exact version, consumer, content, and idempotency headers. + pub headers: Vec<(String, String)>, + /// Validated JSON request body. + pub body: String, +} + +impl ProjectHistoryRequest { + /// Parse and validate a project-history request using the default limit. + /// + /// # Errors + /// + /// Returns a version, size, JSON, timestamp, leakage, or field error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + /// Parse and validate a project-history request using a caller limit. + /// + /// # Errors + /// + /// Returns a version, size, JSON, timestamp, leakage, or field error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let request: Self = from_json(payload)?; + request.validate()?; + Ok(request) + } + + /// Serialize a validated project-history request. + /// + /// # Errors + /// + /// Returns a field-validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, PROJECT_HISTORY_CONTRACT_VERSION)?; + validate_bounded_text(&self.idempotency_key, 256)?; + validate_bounded_text(&self.tenant_workspace_id, 256)?; + validate_bounded_text(&self.project_key, 256)?; + validate_bounded_text(&self.project_name, 512)?; + validate_bounded_text(&self.focus_event_id, 256)?; + if self.events.is_empty() || self.events.len() > DEFAULT_PROJECT_HISTORY_EVENT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let cutoff = parse_knowledge_cutoff(&self.knowledge_cutoff)?; + if cutoff_is_in_future(cutoff)? { + return Err(ApiError::InvalidWirePayload); + } + let mut event_ids = HashSet::with_capacity(self.events.len()); + let mut focus_found = false; + for event in &self.events { + validate_event(event, cutoff.instant())?; + if !event_ids.insert(event.event_id.as_str()) { + return Err(ApiError::InvalidWirePayload); + } + focus_found |= event.event_id == self.focus_event_id; + } + if !focus_found { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +impl ProjectHistoryProjection { + /// Parse and validate a serialized TEPP projection. + /// + /// # Errors + /// + /// Returns a JSON, version, field, or claim-boundary error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + /// Parse and validate a serialized TEPP projection with a caller limit. + /// + /// # Errors + /// + /// Returns a size, JSON, version, field, or claim-boundary error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let projection: Self = from_json(payload)?; + projection.validate()?; + Ok(projection) + } + + /// Serialize a validated TEPP projection. + /// + /// # Errors + /// + /// Returns a validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, PROJECT_HISTORY_CONTRACT_VERSION)?; + validate_bounded_text(&self.project_key, 256)?; + validate_bounded_text(&self.project_name, 512)?; + validate_bounded_text(&self.focus_event_id, 256)?; + if self.inference_status != "temporal_association_only" || self.events.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + if self.events.len() > DEFAULT_PROJECT_HISTORY_EVENT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let cutoff = parse_knowledge_cutoff(&self.knowledge_cutoff)?; + if cutoff_is_in_future(cutoff)? { + return Err(ApiError::InvalidWirePayload); + } + let mut event_ids = HashSet::with_capacity(self.events.len()); + let mut focus_index = None; + for (index, event) in self.events.iter().enumerate() { + validate_event(event, cutoff.instant())?; + if !event_ids.insert(event.event_id.as_str()) { + return Err(ApiError::InvalidWirePayload); + } + if event.event_id == self.focus_event_id { + focus_index = Some(index); + } + } + let focus_index = focus_index.ok_or(ApiError::InvalidWirePayload)?; + let mut previous = None; + for event in &self.events { + let occurred_at = parse_timestamp(&event.occurred_at)?; + if let Some((previous_time, previous_id)) = previous + && (occurred_at < previous_time + || (occurred_at == previous_time && event.event_id.as_str() <= previous_id)) + { + return Err(ApiError::InvalidWirePayload); + } + previous = Some((occurred_at, event.event_id.as_str())); + } + let start = parse_timestamp(&self.history_span_start)?; + let end = parse_timestamp(&self.history_span_end)?; + let first_event_time = parse_timestamp(&self.events[0].occurred_at)?; + // The non-empty guard above makes this index safe and removes an + // unreachable second empty-events error path from the response contract. + let last_event_time = parse_timestamp(&self.events[self.events.len() - 1].occurred_at)?; + if start > end || start != first_event_time || end != last_event_time { + return Err(ApiError::InvalidWirePayload); + } + let participant_count = self + .events + .iter() + .flat_map(|event| event.actor_ids.iter().map(String::as_str)) + .collect::>() + .len(); + if self.participant_count != participant_count + || self.findings != build_findings(&self.events, focus_index) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Build a deterministic, cutoff-safe project-history projection. +/// +/// Findings are created only from explicit event type codes around the focus +/// event. The function does not infer causality, missing events, or latent +/// scores. +/// +/// # Errors +/// +/// Returns a fail-closed request validation error. +pub fn project_history_projection( + request: &ProjectHistoryRequest, +) -> Result { + request.validate()?; + let mut ordered = request.events.clone(); + ordered.sort_by_key(|event| { + ( + parse_timestamp(&event.occurred_at).ok(), + event.event_id.clone(), + ) + }); + let focus_index = ordered + .iter() + .position(|event| event.event_id == request.focus_event_id) + .ok_or(ApiError::InvalidWirePayload)?; + let findings = build_findings(&ordered, focus_index); + let participant_count = ordered + .iter() + .flat_map(|event| event.actor_ids.iter().map(String::as_str)) + .collect::>() + .len(); + let history_span_start = ordered + .first() + .map(|event| event.occurred_at.clone()) + .ok_or(ApiError::InvalidWirePayload)?; + let history_span_end = ordered + .last() + .map(|event| event.occurred_at.clone()) + .ok_or(ApiError::InvalidWirePayload)?; + let projection = ProjectHistoryProjection { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + project_key: request.project_key.clone(), + project_name: request.project_name.clone(), + focus_event_id: request.focus_event_id.clone(), + knowledge_cutoff: request.knowledge_cutoff.clone(), + history_span_start, + history_span_end, + participant_count, + inference_status: "temporal_association_only".into(), + events: ordered, + findings, + }; + projection.to_json()?; + Ok(projection) +} + +pub(crate) fn build_project_history_exchange( + origin: &str, + consumer_code: &str, + request: &ProjectHistoryRequest, +) -> Result { + validate_bounded_text(consumer_code, 64)?; + let target_url = compose_https_target(origin)?; + let body = request.to_json()?; + Ok(ProjectHistoryHttpExchange { + method: "POST", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), consumer_code.to_owned()), + ( + "tepp-contract-version".into(), + PROJECT_HISTORY_CONTRACT_VERSION.to_string(), + ), + ("idempotency-key".into(), request.idempotency_key.clone()), + ], + body, + }) +} + +fn validate_event(event: &ProjectHistoryEvent, cutoff: TemporalInstant) -> Result<(), ApiError> { + validate_bounded_text(&event.event_id, 256)?; + validate_code(&event.event_type_code)?; + validate_bounded_text(&event.event_title, 512)?; + validate_bounded_text(&event.source_post_id, 256)?; + validate_bounded_text(&event.evidence_text, 4096)?; + if event.actor_ids.len() > 64 { + return Err(ApiError::LimitExceeded); + } + for actor_id in &event.actor_ids { + validate_bounded_text(actor_id, 256)?; + } + parse_timestamp(&event.occurred_at)?; + let available_at = parse_timestamp(&event.available_at)?; + if available_at > cutoff { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +fn validate_bounded_text(value: &str, maximum_bytes: usize) -> Result<(), ApiError> { + require_nonempty(value)?; + if value.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(()) +} + +fn validate_code(value: &str) -> Result<(), ApiError> { + validate_bounded_text(value, 64)?; + if !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +fn parse_knowledge_cutoff(value: &str) -> Result { + KnowledgeCutoff::parse_rfc3339(value).map_err(|_| ApiError::InvalidWirePayload) +} + +fn parse_timestamp(value: &str) -> Result { + TemporalInstant::parse_rfc3339(value).map_err(|_| ApiError::InvalidWirePayload) +} + +fn cutoff_is_in_future(cutoff: KnowledgeCutoff) -> Result { + let now = KnowledgeCutoff::parse_rfc3339(&Timestamp::now().to_string()) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(cutoff > now) +} + +fn build_findings( + ordered: &[ProjectHistoryEvent], + focus_index: usize, +) -> Vec { + let before = &ordered[..focus_index]; + let after = &ordered[focus_index + 1..]; + let specification = first_type(before, "specification_changed"); + let handoff = first_type(before, "handoff_recorded"); + let mut findings = Vec::new(); + append_single_finding( + &mut findings, + first_type(before, "contract_awarded"), + "contract_award_before_focus", + "An explicit contract-award event precedes the focus event.", + ); + append_single_finding( + &mut findings, + specification, + "specification_change_before_focus", + "An explicit specification-change event precedes the focus event.", + ); + append_single_finding( + &mut findings, + first_type(before, "delivered"), + "delivery_before_focus", + "An explicit delivery event precedes the focus event.", + ); + append_single_finding( + &mut findings, + handoff, + "handoff_before_focus", + "An explicit operational-handoff event precedes the focus event.", + ); + append_single_finding( + &mut findings, + first_type(after, "rebid_started"), + "rebid_after_focus", + "An explicit rebid event follows the focus event.", + ); + if let (Some(specification), Some(handoff)) = (specification, handoff) { + findings.push(combined_finding(specification, handoff)); + } + findings +} + +fn first_type<'a>( + events: &'a [ProjectHistoryEvent], + event_type_code: &str, +) -> Option<&'a ProjectHistoryEvent> { + events + .iter() + .find(|event| event.event_type_code == event_type_code) +} + +fn append_single_finding( + findings: &mut Vec, + event: Option<&ProjectHistoryEvent>, + finding_code: &str, + summary: &str, +) { + if let Some(event) = event { + findings.push(ProjectHistoryFinding { + finding_code: finding_code.to_owned(), + summary: summary.to_owned(), + related_event_ids: vec![event.event_id.clone()], + evidence_post_ids: vec![event.source_post_id.clone()], + }); + } +} + +fn combined_finding( + specification: &ProjectHistoryEvent, + handoff: &ProjectHistoryEvent, +) -> ProjectHistoryFinding { + let evidence_post_ids = [ + specification.source_post_id.clone(), + handoff.source_post_id.clone(), + ] + .into_iter() + .collect::>() + .into_iter() + .collect(); + ProjectHistoryFinding { + finding_code: "specification_change_and_handoff_before_focus".into(), + summary: "Explicit specification-change and handoff events precede the focus event; this is a temporal association, not a causal conclusion.".into(), + related_event_ids: vec![ + specification.event_id.clone(), + handoff.event_id.clone(), + ], + evidence_post_ids, + } +} + +fn compose_https_target(origin: &str) -> Result { + validate_bounded_text(origin, 2048)?; + let authority = origin + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + validate_https_authority(authority)?; + let lowered = authority.to_ascii_lowercase(); + if lowered.contains("postgres") || lowered.contains("jdbc") { + return Err(ApiError::InvalidWirePayload); + } + Ok(format!("{origin}{PROJECT_HISTORY_PATH}")) +} + +fn validate_https_authority(authority: &str) -> Result<(), ApiError> { + if authority.is_empty() + || authority.contains('@') + || authority.contains('/') + || authority.contains('?') + || authority.contains('#') + || authority + .chars() + .any(|character| matches!(character, '\'' | ';' | '\\' | ' ') || character.is_control()) + { + return Err(ApiError::InvalidWirePayload); + } + + if let Some(bracketed) = authority.strip_prefix('[') { + let close = bracketed.find(']').ok_or(ApiError::InvalidWirePayload)?; + let host = &bracketed[..close]; + host.parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let suffix = &bracketed[close + 1..]; + if suffix.is_empty() { + return Ok(()); + } + let port = suffix + .strip_prefix(':') + .ok_or(ApiError::InvalidWirePayload)?; + return validate_https_port(port); + } + + if authority.contains(']') || authority.matches(':').count() > 1 { + return Err(ApiError::InvalidWirePayload); + } + let (host, port) = authority + .rsplit_once(':') + .map_or((authority, None), |(host, port)| (host, Some(port))); + validate_https_host(host)?; + if let Some(port) = port { + validate_https_port(port)?; + } + Ok(()) +} + +fn validate_https_host(host: &str) -> Result<(), ApiError> { + if host.is_empty() || host.len() > 253 { + return Err(ApiError::InvalidWirePayload); + } + if host.parse::().is_ok() { + return Ok(()); + } + for label in host.split('.') { + if label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(ApiError::InvalidWirePayload); + } + } + Ok(()) +} + +fn validate_https_port(port: &str) -> Result<(), ApiError> { + if port.is_empty() || port.parse::().map_or(true, |value| value == 0) { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + PROJECT_HISTORY_CONTRACT_VERSION, ProjectHistoryEvent, ProjectHistoryProjection, + ProjectHistoryRequest, build_project_history_exchange, compose_https_target, + project_history_projection, validate_code, + }; + use crate::ApiError; + + fn request_with_single_event() -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: "idem".into(), + tenant_workspace_id: "tenant".into(), + project_key: "project".into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } + } + + #[test] + fn projection_round_trip_preserves_the_non_causal_claim_boundary() { + let request = request_with_single_event(); + let projection = project_history_projection(&request).expect("projection"); + let json = projection.to_json().expect("json"); + assert_eq!( + ProjectHistoryProjection::from_json(&json).expect("decode"), + projection + ); + assert!(projection.findings.is_empty()); + assert_eq!(projection.participant_count, 0); + } + + #[test] + fn projection_counts_memberships_and_emits_explicit_findings() { + let mut request = request_with_single_event(); + let event = |event_id: &str, event_type_code: &str, occurred_at: &str, actor_id: &str| { + ProjectHistoryEvent { + event_id: event_id.into(), + event_type_code: event_type_code.into(), + event_title: event_type_code.into(), + occurred_at: occurred_at.into(), + available_at: occurred_at.into(), + source_post_id: format!("post-{event_id}"), + evidence_text: "explicit evidence".into(), + actor_ids: vec![actor_id.into()], + } + }; + request.events = vec![ + event( + "award", + "contract_awarded", + "2026-08-19T08:00:00Z", + "actor-1", + ), + event( + "specification", + "specification_changed", + "2026-08-19T09:00:00Z", + "actor-1", + ), + event("delivery", "delivered", "2026-08-19T10:00:00Z", "actor-2"), + event( + "handoff", + "handoff_recorded", + "2026-08-19T11:00:00Z", + "actor-2", + ), + event("focus", "voc_received", "2026-08-19T12:00:00Z", "actor-3"), + event("rebid", "rebid_started", "2026-08-19T13:00:00Z", "actor-3"), + ]; + let projection = project_history_projection(&request).expect("projection"); + assert_eq!(projection.participant_count, 3); + assert_eq!(projection.findings.len(), 6); + let payload = projection.to_json().expect("projection json"); + assert_eq!( + ProjectHistoryProjection::from_json(&payload), + Ok(projection) + ); + } + + #[test] + fn request_refuses_missing_focus_bad_codes_and_excess_events() { + let mut missing_focus = request_with_single_event(); + missing_focus.focus_event_id = "missing".into(); + assert_eq!( + project_history_projection(&missing_focus), + Err(ApiError::InvalidWirePayload) + ); + + let mut bad_code = request_with_single_event(); + bad_code.events[0].event_type_code = "VOC Received".into(); + assert_eq!( + project_history_projection(&bad_code), + Err(ApiError::InvalidWirePayload) + ); + + let mut excess = request_with_single_event(); + excess.events = + vec![excess.events[0].clone(); super::DEFAULT_PROJECT_HISTORY_EVENT_LIMIT + 1]; + assert_eq!( + project_history_projection(&excess), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn validation_edges_cover_cutoffs_bounds_projection_and_origins() { + let mut empty = request_with_single_event(); + empty.events.clear(); + assert_eq!( + project_history_projection(&empty), + Err(ApiError::LimitExceeded) + ); + + let mut future_cutoff = request_with_single_event(); + future_cutoff.knowledge_cutoff = "2999-01-01T00:00:00Z".into(); + assert_eq!( + project_history_projection(&future_cutoff), + Err(ApiError::InvalidWirePayload) + ); + + let mut occurred_after_cutoff = request_with_single_event(); + occurred_after_cutoff.events[0].occurred_at = "2026-08-20T00:00:00Z".into(); + assert!(project_history_projection(&occurred_after_cutoff).is_ok()); + + let mut available_after_cutoff = request_with_single_event(); + available_after_cutoff.events[0].available_at = "2026-08-20T00:00:00Z".into(); + assert_eq!( + project_history_projection(&available_after_cutoff), + Err(ApiError::InvalidWirePayload) + ); + + let mut too_many_actors = request_with_single_event(); + too_many_actors.events[0].actor_ids = vec!["actor".into(); 65]; + assert_eq!( + project_history_projection(&too_many_actors), + Err(ApiError::LimitExceeded) + ); + + let mut oversized_title = request_with_single_event(); + oversized_title.events[0].event_title = "x".repeat(513); + assert_eq!( + project_history_projection(&oversized_title), + Err(ApiError::LimitExceeded) + ); + assert_eq!(validate_code("_"), Ok(())); + assert_eq!(validate_code("1"), Ok(())); + + let projection = + project_history_projection(&request_with_single_event()).expect("projection"); + let mut wrong_status = projection.clone(); + wrong_status.inference_status = "causal_score".into(); + assert_eq!(wrong_status.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut empty_projection = projection.clone(); + empty_projection.events.clear(); + assert_eq!( + empty_projection.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut inverted_span = projection; + inverted_span.history_span_start = "2026-08-20T00:00:00Z".into(); + inverted_span.history_span_end = "2026-08-19T00:00:00Z".into(); + assert_eq!(inverted_span.to_json(), Err(ApiError::InvalidWirePayload)); + + let request = request_with_single_event(); + assert_eq!( + build_project_history_exchange("", "lineageweave", &request), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + build_project_history_exchange("https://example.test", &"x".repeat(65), &request), + Err(ApiError::LimitExceeded) + ); + assert!( + build_project_history_exchange("https://example.test", "lineageweave", &request) + .is_ok() + ); + } + + #[test] + fn malformed_https_authorities_are_rejected() { + for origin in [ + "http://example.test", + "https://", + "https://:", + "https:///path", + "https://user@example.test", + "https://example.test/path", + "https://example.test?query", + "https://example.test#fragment", + "https://example.test:", + "https://example.test:not-a-port", + "https://example.test:65536", + "https://[::1", + "https://[not-ipv6]", + "https://::1", + "https://example]test", + "https://example test", + "https://example'test", + "https://example;test", + "https://example\\test", + "https://example\ntest", + "https://postgres.example.test", + "https://jdbc.example.test", + ] { + assert_eq!( + compose_https_target(origin), + Err(ApiError::InvalidWirePayload), + "origin must be rejected: {origin:?}" + ); + } + assert_eq!( + compose_https_target("https://example.test").expect("origin"), + "https://example.test/v1/project-histories" + ); + assert!(compose_https_target("https://example.test:443").is_ok()); + assert!(compose_https_target("https://127.0.0.1").is_ok()); + assert!(compose_https_target("https://[::1]").is_ok()); + assert!(compose_https_target("https://[::1]:443").is_ok()); + for origin in [ + "https://-example.test", + "https://example-.test", + "https://example..test", + "https://example_.test", + ] { + assert_eq!( + compose_https_target(origin), + Err(ApiError::InvalidWirePayload) + ); + } + let long_host = format!("https://{}", "a.".repeat(127) + "a"); + assert_eq!( + compose_https_target(&long_host), + Err(ApiError::InvalidWirePayload) + ); + let long_label = format!("https://{}.test", "a".repeat(64)); + assert_eq!( + compose_https_target(&long_label), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/src/temporal_context.rs b/crates/tepp_api/src/temporal_context.rs new file mode 100644 index 000000000..f1c6792a3 --- /dev/null +++ b/crates/tepp_api/src/temporal_context.rs @@ -0,0 +1,414 @@ +//! Versioned, cutoff-safe temporal evidence context for modular consumers. + +use crate::ApiError; +use crate::lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json_with_limit, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff, TemporalInstant}; + +/// Supported temporal-context contract version. +pub const TEMPORAL_CONTEXT_CONTRACT_VERSION: u16 = 1; + +/// Versioned temporal-context HTTP path. +pub const TEMPORAL_CONTEXT_PATH: &str = "/v1/temporal-context"; + +/// Claim boundary for temporal association output. +pub const TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY: &str = "association_not_causal"; + +const DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT: usize = 64 * 1024; +const MAXIMUM_TEMPORAL_CONTEXT_EVENTS: usize = 1024; + +/// One opaque event offered for cutoff-safe temporal ordering. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextEvent { + /// Opaque event identity. + pub event_id: String, + /// Opaque source-post identity. + pub source_post_id: String, + /// Stable event-type code. + pub event_type_code: String, + /// Bounded display label for the event. + pub event_label: String, + /// Event or valid time. + pub event_time: String, + /// Availability time for historical eligibility. + pub available_time: String, + /// Opaque project identity, when known. + pub project_reference: Option, + /// Opaque actor identities participating in the event. + pub actor_references: Vec, +} + +/// Request for one bounded temporal evidence context. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextRequest { + /// Semantic contract version. + pub contract_version: u16, + /// Published modular-consumer identity. + pub consumer_code: String, + /// Latest availability time permitted in the context. + pub knowledge_cutoff: String, + /// Optional opaque post identity whose event is the subject. + pub subject_post_id: Option, + /// Bounded source events. + pub events: Vec, +} + +/// One ordered event in a temporal context response. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextTimelineEvent { + /// Opaque event identity. + pub event_id: String, + /// Opaque source-post identity. + pub source_post_id: String, + /// Stable event-type code. + pub event_type_code: String, + /// Bounded display label for the event. + pub event_label: String, + /// Original event-time representation. + pub event_time: String, + /// Optional opaque project identity. + pub project_reference: Option, + /// Opaque actor identities. + pub actor_references: Vec, + /// Zero-based deterministic temporal sequence position. + pub sequence_ordinal: usize, + /// Whether this event is associated with the requested subject post. + pub is_subject: bool, +} + +/// One non-causal forward temporal relation in a context response. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextRelation { + /// Earlier event identity. + pub from_event_id: String, + /// Later event identity. + pub to_event_id: String, + /// Stable relation code. + pub relation_code: String, +} + +/// One candidate transition gap that is not a causal claim. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalTransitionGapCandidate { + /// Earlier event identity. + pub from_event_id: String, + /// Later event identity. + pub to_event_id: String, + /// Explicit non-causal evidence-status code. + pub evidence_status_code: String, +} + +/// Ordered temporal evidence context returned to a modular consumer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextResponse { + /// Semantic contract version. + pub contract_version: u16, + /// Explicit claim boundary for every relation and gap candidate. + pub claim_boundary: String, + /// Events ordered by absolute event time. + pub timeline_events: Vec, + /// Adjacent forward-only temporal relations. + pub temporal_relations: Vec, + /// Adjacent candidate gaps that are not causal claims. + pub transition_gap_candidates: Vec, + /// Source-post identities in response order. + pub source_post_ids: Vec, +} + +impl TemporalContextRequest { + /// Parse and validate a temporal-context request with the default limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, limit, consumer, time, or shape + /// error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT) + } + + /// Parse and validate a temporal-context request with a caller limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, limit, consumer, time, or shape + /// error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let request: Self = from_json(payload)?; + request.validate()?; + Ok(request) + } + + /// Serialize a temporal-context request after validation. + /// + /// # Errors + /// + /// Returns a fail-closed validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + self.validated_ordered_events().map(|_| ()) + } + + fn validated_ordered_events( + &self, + ) -> Result, ApiError> { + require_contract_version(self.contract_version, TEMPORAL_CONTEXT_CONTRACT_VERSION)?; + if self.consumer_code != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let cutoff = KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) + .map_err(|_| ApiError::InvalidWirePayload)?; + if self.events.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + if self.events.len() > MAXIMUM_TEMPORAL_CONTEXT_EVENTS { + return Err(ApiError::LimitExceeded); + } + if let Some(subject_post_id) = &self.subject_post_id { + require_nonempty(subject_post_id)?; + } + let mut event_ids = HashSet::with_capacity(self.events.len()); + let mut ordered = Vec::with_capacity(self.events.len()); + for event in &self.events { + let event_time = validate_event(event, cutoff.instant(), &mut event_ids)?; + ordered.push((event.clone(), event_time)); + } + if let Some(subject_post_id) = &self.subject_post_id + && !self + .events + .iter() + .any(|event| event.source_post_id == *subject_post_id) + { + return Err(ApiError::InvalidWirePayload); + } + ordered.sort_by(|left, right| { + left.1 + .cmp(&right.1) + .then_with(|| left.0.event_id.cmp(&right.0.event_id)) + }); + Ok(ordered) + } +} + +impl TemporalContextResponse { + /// Parse and validate a temporal-context response. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, limit, or response-shape error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT) + } + + /// Parse and validate a temporal-context response with a caller limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, version, limit, or response-shape error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let response: Self = from_json(payload)?; + response.validate()?; + Ok(response) + } + + /// Serialize a temporal-context response after validation. + /// + /// # Errors + /// + /// Returns a fail-closed validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, TEMPORAL_CONTEXT_CONTRACT_VERSION)?; + if self.claim_boundary != TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY { + return Err(ApiError::InvalidWirePayload); + } + if self.timeline_events.is_empty() + || self.timeline_events.len() != self.source_post_ids.len() + || self.temporal_relations.len().checked_add(1) != Some(self.timeline_events.len()) + || self.transition_gap_candidates.len().checked_add(1) + != Some(self.timeline_events.len()) + { + return Err(ApiError::InvalidWirePayload); + } + let mut event_ids = HashSet::with_capacity(self.timeline_events.len()); + let mut previous_key: Option<(TemporalInstant, &str)> = None; + for (ordinal, event) in self.timeline_events.iter().enumerate() { + if event.sequence_ordinal != ordinal + || event.event_id.is_empty() + || event.source_post_id.is_empty() + || event.event_type_code.is_empty() + || event.event_label.is_empty() + || event.event_time.is_empty() + || event.actor_references.is_empty() + || self.source_post_ids[ordinal] != event.source_post_id + { + return Err(ApiError::InvalidWirePayload); + } + if !event_ids.insert(event.event_id.clone()) { + return Err(ApiError::InvalidWirePayload); + } + let event_time = EventTime::parse_rfc3339(&event.event_time) + .map_err(|_| ApiError::InvalidWirePayload)? + .instant(); + if let Some((previous_time, previous_id)) = previous_key + && (event_time < previous_time + || (event_time == previous_time && event.event_id.as_str() <= previous_id)) + { + return Err(ApiError::InvalidWirePayload); + } + previous_key = Some((event_time, event.event_id.as_str())); + if let Some(project_reference) = &event.project_reference { + require_nonempty(project_reference)?; + } + for actor_reference in &event.actor_references { + require_nonempty(actor_reference)?; + } + } + for (index, relation) in self.temporal_relations.iter().enumerate() { + if relation.from_event_id != self.timeline_events[index].event_id + || relation.to_event_id != self.timeline_events[index + 1].event_id + || relation.relation_code != "before" + { + return Err(ApiError::InvalidWirePayload); + } + } + for (index, candidate) in self.transition_gap_candidates.iter().enumerate() { + if candidate.from_event_id != self.timeline_events[index].event_id + || candidate.to_event_id != self.timeline_events[index + 1].event_id + || candidate.evidence_status_code != "candidate_not_causal" + { + return Err(ApiError::InvalidWirePayload); + } + } + Ok(()) + } +} + +/// Build an ordered, cutoff-safe temporal context without causal inference. +/// +/// # Errors +/// +/// Returns a fail-closed error when the request contains future-available, +/// duplicate, malformed, or otherwise invalid evidence. +pub fn build_temporal_context( + request: &TemporalContextRequest, +) -> Result { + let ordered = request.validated_ordered_events()?; + let timeline_events = ordered + .iter() + .enumerate() + .map( + |(sequence_ordinal, (event, _))| TemporalContextTimelineEvent { + event_id: event.event_id.clone(), + source_post_id: event.source_post_id.clone(), + event_type_code: event.event_type_code.clone(), + event_label: event.event_label.clone(), + event_time: event.event_time.clone(), + project_reference: event.project_reference.clone(), + actor_references: event.actor_references.clone(), + sequence_ordinal, + is_subject: request + .subject_post_id + .as_ref() + .is_some_and(|subject| subject == &event.source_post_id), + }, + ) + .collect::>(); + let temporal_relations = adjacent_relations(&ordered); + let transition_gap_candidates = adjacent_gaps(&ordered); + let response = TemporalContextResponse { + contract_version: TEMPORAL_CONTEXT_CONTRACT_VERSION, + claim_boundary: TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY.into(), + source_post_ids: timeline_events + .iter() + .map(|event| event.source_post_id.clone()) + .collect(), + timeline_events, + temporal_relations, + transition_gap_candidates, + }; + response.validate()?; + Ok(response) +} + +fn validate_event( + event: &TemporalContextEvent, + cutoff: TemporalInstant, + event_ids: &mut HashSet, +) -> Result { + for value in [ + &event.event_id, + &event.source_post_id, + &event.event_type_code, + &event.event_label, + &event.event_time, + &event.available_time, + ] { + require_nonempty(value)?; + } + if !event_ids.insert(event.event_id.clone()) { + return Err(ApiError::InvalidWirePayload); + } + if let Some(project_reference) = &event.project_reference { + require_nonempty(project_reference)?; + } + if event.actor_references.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + for actor_reference in &event.actor_references { + require_nonempty(actor_reference)?; + } + let event_time = + EventTime::parse_rfc3339(&event.event_time).map_err(|_| ApiError::InvalidWirePayload)?; + let available_time = AvailableTime::parse_rfc3339(&event.available_time) + .map_err(|_| ApiError::InvalidWirePayload)?; + if available_time.instant() > cutoff { + return Err(ApiError::InvalidWirePayload); + } + Ok(event_time.instant()) +} + +fn adjacent_relations( + ordered: &[(TemporalContextEvent, TemporalInstant)], +) -> Vec { + ordered + .windows(2) + .map(|events| TemporalContextRelation { + from_event_id: events[0].0.event_id.clone(), + to_event_id: events[1].0.event_id.clone(), + relation_code: "before".into(), + }) + .collect() +} + +fn adjacent_gaps( + ordered: &[(TemporalContextEvent, TemporalInstant)], +) -> Vec { + ordered + .windows(2) + .map(|events| TemporalTransitionGapCandidate { + from_event_id: events[0].0.event_id.clone(), + to_event_id: events[1].0.event_id.clone(), + evidence_status_code: "candidate_not_causal".into(), + }) + .collect() +} diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index 13b2a3fbe..6a5ee6d5c 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -2,6 +2,28 @@ use crate::ApiError; use serde::{Deserialize, Serialize}; +use std::io::{self, Write}; + +struct LimitedWriter { + bytes: Vec, + maximum_bytes: usize, + limit_exceeded: bool, +} + +impl Write for LimitedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if self.bytes.len().saturating_add(buffer.len()) > self.maximum_bytes { + self.limit_exceeded = true; + return Err(io::Error::other("JSON byte limit exceeded")); + } + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} /// Serialize a wire DTO to canonical JSON. /// @@ -12,6 +34,31 @@ pub fn to_json(value: &T) -> Result { serde_json::to_string(value).map_err(|_| ApiError::InvalidWirePayload) } +/// Serialize a wire DTO without buffering more than `maximum_bytes`. +/// +/// # Errors +/// +/// Returns [`ApiError::LimitExceeded`] when serialization crosses the limit, +/// or [`ApiError::InvalidWirePayload`] for another serialization failure. +pub fn to_json_with_limit( + value: &T, + maximum_bytes: usize, +) -> Result { + let mut writer = LimitedWriter { + bytes: Vec::with_capacity(maximum_bytes.min(4096)), + maximum_bytes, + limit_exceeded: false, + }; + if serde_json::to_writer(&mut writer, value).is_err() { + return Err(if writer.limit_exceeded { + ApiError::LimitExceeded + } else { + ApiError::InvalidWirePayload + }); + } + String::from_utf8(writer.bytes).map_err(|_| ApiError::InvalidWirePayload) +} + /// Deserialize a strict wire DTO from JSON text. /// /// # Errors @@ -63,6 +110,7 @@ pub fn require_contract_version(version: u16, expected: u16) -> Result<(), ApiEr mod tests { use super::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, + to_json_with_limit, }; use crate::ApiError; use serde::Serialize; @@ -85,6 +133,12 @@ mod tests { to_json(&SerializationFailure), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + to_json_with_limit(&SerializationFailure, 8), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(to_json_with_limit(&"abc", 5), Ok("\"abc\"".into())); + assert_eq!(to_json_with_limit(&"abc", 4), Err(ApiError::LimitExceeded)); assert_eq!( from_json::("not-json"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/tests/example_contracts.rs b/crates/tepp_api/tests/example_contracts.rs index a96c65a3d..a2ffd789a 100644 --- a/crates/tepp_api/tests/example_contracts.rs +++ b/crates/tepp_api/tests/example_contracts.rs @@ -1,7 +1,10 @@ //! Example payloads under `/examples` must parse through the live contracts. use std::path::PathBuf; -use tepp_api::{AnalysisRunRequest, CorpusSplitManifest, ReproducibilityManifest}; +use tepp_api::{ + AnalysisRunLiveService, AnalysisRunRequest, CorpusSplitManifest, NARUON_ANALYSIS_RUN_PATH, + NaruonLiveService, ReproducibilityManifest, +}; fn repo_example(name: &str) -> String { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -34,3 +37,81 @@ fn committed_examples_parse_through_live_contracts() { .expect("split example"); assert_eq!(split.excluded_unavailable_at_cutoff_count, 1); } + +#[test] +fn example_contracts_prove_live_idempotency_and_bound_loopback_identity() { + let mut analysis_service = AnalysisRunLiveService::new(); + let run = AnalysisRunRequest::from_json(&repo_example("analysis_run_request_v1.json")) + .expect("analysis example"); + let body = run.to_json().expect("run json"); + let request = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ); + let accepted = analysis_service.handle_http_request(&request); + assert_eq!(accepted.status_code, 202); + assert_eq!( + analysis_service.handle_http_request(&request).body, + accepted.body + ); + let mut conflict = run.clone(); + conflict.snapshot_id.push_str("-changed"); + let conflict_body = conflict.to_json().expect("conflict json"); + let conflict_request = request + .replace( + &format!("content-length: {}", body.len()), + &format!("content-length: {}", conflict_body.len()), + ) + .replace(&body, &conflict_body); + assert_eq!( + analysis_service + .handle_http_request(&conflict_request) + .status_code, + 400 + ); + + let service = NaruonLiveService::bind_loopback().expect("loopback bind"); + let bound = service.local_addr().expect("bound address"); + let bound_request = |host: &str| { + format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ) + }; + let mut bound_service = service; + assert_eq!( + bound_service + .handle_http_request(&bound_request(&bound.to_string())) + .status_code, + 202 + ); + assert_eq!( + bound_service + .handle_http_request(&bound_request(&bound.ip().to_string())) + .status_code, + 202 + ); + let mut conflicting = run.clone(); + conflicting.snapshot_id.push_str("-changed"); + let conflicting_body = conflicting.to_json().expect("bound conflict json"); + let conflicting_request = bound_request(&bound.to_string()) + .replace( + &format!("content-length: {}", body.len()), + &format!("content-length: {}", conflicting_body.len()), + ) + .replace(&body, &conflicting_body); + assert_eq!( + bound_service + .handle_http_request(&conflicting_request) + .status_code, + 400 + ); + assert_eq!( + bound_service + .handle_http_request(&bound_request("8.8.8.8")) + .status_code, + 403 + ); +} diff --git a/crates/tepp_api/tests/lineageweave_http_contract.rs b/crates/tepp_api/tests/lineageweave_http_contract.rs new file mode 100644 index 000000000..19b3e352e --- /dev/null +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -0,0 +1,155 @@ +//! `LineageWeave` uses the published asynchronous TEPP analysis-run boundary. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::thread; +use std::time::Duration; + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + NARUON_LIVE_HEADER_BYTE_LIMIT, lineageweave_analysis_run_exchange, +}; + +fn sample_run() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "shared-idempotency-key".into(), + tenant_workspace_id: "shared-tenant-workspace".into(), + snapshot_id: "lineageweave-snapshot-001".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + } +} + +fn http_request(consumer: &str, run: &AnalysisRunRequest) -> String { + let body = run.to_json().expect("run json"); + let mut request = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\n"); + for (name, value) in [ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", consumer), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ] { + write!(request, "{name}: {value}\r\n").expect("header"); + } + write!(request, "content-length: {}\r\n\r\n{body}", body.len()).expect("body"); + request +} + +#[test] +fn lineageweave_exchange_uses_the_published_consumer_header_without_credentials() { + let run = sample_run(); + let exchange = lineageweave_analysis_run_exchange("https://tepp.example.test", &run) + .expect("lineageweave exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + exchange + .headers + .contains(&("idempotency-key".into(), run.idempotency_key.clone())) + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "proxy-authorization" | "cookie" | "x-api-key" + ) + })); +} + +#[test] +fn live_listener_accepts_lineageweave_and_isolates_consumer_idempotency() { + let loopback = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); + assert!( + loopback + .local_addr() + .expect("loopback address") + .ip() + .is_loopback() + ); + assert_eq!( + AnalysisRunLiveService::bind("0.0.0.0:0".parse().expect("non-loopback address")) + .expect_err("non-loopback bind must fail"), + ApiError::AuthorizationDenied + ); + + let run = sample_run(); + let mut service = AnalysisRunLiveService::new(); + + let naruon = service.handle_http_request(&http_request(NARUON_CONSUMER_CODE, &run)); + let lineageweave = service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &run)); + + assert_eq!(naruon.status_code, 202); + assert_eq!(lineageweave.status_code, 202); + let naruon_accepted = AnalysisRunAccepted::from_json(&naruon.body).expect("naruon ack"); + let lineageweave_accepted = + AnalysisRunAccepted::from_json(&lineageweave.body).expect("lineageweave ack"); + assert_ne!(naruon_accepted.run_id, lineageweave_accepted.run_id); + assert_eq!(lineageweave_accepted.run_state, "accepted"); + assert_eq!(lineageweave_accepted.idempotency_key, run.idempotency_key); + + let replay = service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &run)); + assert_eq!(replay.status_code, 202); + assert_eq!(replay.body, lineageweave.body); + + let mut conflict = run.clone(); + conflict.snapshot_id = "lineageweave-snapshot-conflict".into(); + let conflict_response = + service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &conflict)); + assert_eq!(conflict_response.status_code, 400); +} + +#[test] +fn live_listener_refuses_an_unpublished_consumer() { + let mut service = AnalysisRunLiveService::new(); + assert_eq!(service.handle_http_request("").status_code, 400); + assert_eq!( + service + .handle_http_request(&"x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT)) + .status_code, + 413 + ); + let duplicate_headers = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nhost: 127.0.0.1\r\ncontent-length: 0\r\n\r\n" + ); + assert_eq!( + service.handle_http_request(&duplicate_headers).status_code, + 400 + ); + let response = + service.handle_http_request(&http_request("unpublished-consumer", &sample_run())); + assert_eq!(response.status_code, 400); +} + +#[test] +fn live_listener_serves_lineageweave_over_loopback() { + let run = sample_run(); + let mut service = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); + let address = service.local_addr().expect("loopback address"); + let worker = thread::spawn(move || service.serve_one()); + let mut stream = TcpStream::connect(address).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .write_all(http_request(LINEAGEWEAVE_CONSUMER_CODE, &run).as_bytes()) + .expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + assert!(response.starts_with("HTTP/1.1 202 Accepted")); + assert_eq!( + worker.join().expect("join").expect("served").status_code, + 202 + ); +} diff --git a/crates/tepp_api/tests/lineageweave_project_history_contract.rs b/crates/tepp_api/tests/lineageweave_project_history_contract.rs new file mode 100644 index 000000000..9bfaf7ff9 --- /dev/null +++ b/crates/tepp_api/tests/lineageweave_project_history_contract.rs @@ -0,0 +1,569 @@ +//! `LineageWeave` project-history requests remain cutoff-safe and non-causal. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::thread; + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + PROJECT_HISTORY_CONTRACT_VERSION, PROJECT_HISTORY_PATH, ProjectHistoryEvent, + ProjectHistoryProjection, ProjectHistoryRequest, lineageweave_project_history_exchange, + project_history_projection, +}; + +fn event( + event_id: &str, + event_type_code: &str, + event_title: &str, + occurred_at: &str, + source_post_id: &str, + actor_ids: &[&str], +) -> ProjectHistoryEvent { + ProjectHistoryEvent { + event_id: event_id.into(), + event_type_code: event_type_code.into(), + event_title: event_title.into(), + occurred_at: occurred_at.into(), + available_at: occurred_at.into(), + source_post_id: source_post_id.into(), + evidence_text: format!("evidence for {event_title}"), + actor_ids: actor_ids.iter().map(|value| (*value).to_owned()).collect(), + } +} + +fn sample_request() -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: "lineageweave-project-acme-voc-1".into(), + tenant_workspace_id: "tenant-demo".into(), + project_key: "project-acme".into(), + project_name: "Acme renewal".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "event-voc".into(), + events: vec![ + event( + "event-rebid", + "rebid_started", + "Rebid", + "2026-08-10T09:00:00Z", + "post-rebid", + &["person-3"], + ), + event( + "event-award", + "contract_awarded", + "Contract award", + "2022-03-11T09:00:00Z", + "post-award", + &["person-1"], + ), + event( + "event-spec", + "specification_changed", + "Specification change", + "2023-06-15T09:00:00Z", + "post-spec", + &["person-1", "person-2"], + ), + event( + "event-delivery", + "delivered", + "Delivery", + "2024-02-20T09:00:00Z", + "post-delivery", + &["person-2"], + ), + event( + "event-handoff", + "handoff_recorded", + "Operational handoff", + "2024-03-01T09:00:00Z", + "post-handoff", + &["person-2", "person-3"], + ), + event( + "event-voc", + "voc_received", + "VOC received", + "2026-07-30T09:00:00Z", + "post-voc", + &["person-3"], + ), + ], + } +} + +fn live_request(consumer: &str, body: &str, idempotency_key: &str) -> String { + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: {PROJECT_HISTORY_CONTRACT_VERSION}\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn request_near_the_serialized_byte_limit() -> ProjectHistoryRequest { + fn build(evidence_bytes: usize) -> ProjectHistoryRequest { + let events = (0..64) + .map(|index| { + let month = index / 28 + 1; + let day = index % 28 + 1; + let occurred_at = format!("2026-{month:02}-{day:02}T09:00:00Z"); + ProjectHistoryEvent { + event_id: format!("event-{index:03}"), + event_type_code: if index == 63 { + "voc_received".into() + } else { + "note_recorded".into() + }, + event_title: format!("Event {index}"), + occurred_at: occurred_at.clone(), + available_at: occurred_at, + source_post_id: format!("post-{index:03}"), + evidence_text: "e".repeat(evidence_bytes), + actor_ids: Vec::new(), + } + }) + .collect(); + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: "near-limit-request".into(), + tenant_workspace_id: "tenant-demo".into(), + project_key: "project-demo".into(), + project_name: "Project demo".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "event-063".into(), + events, + } + } + + let mut low: usize = 0; + let mut high: usize = 4096; + while low < high { + let evidence_bytes = (low + high).div_ceil(2); + let request = build(evidence_bytes); + let payload = serde_json::to_string(&request).expect("request json"); + if payload.len() <= tepp_api::DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + low = evidence_bytes; + } else { + high = evidence_bytes - 1; + } + } + build(low) +} + +#[test] +fn projection_orders_the_cycle_and_explains_only_explicit_temporal_evidence() { + let projection = project_history_projection(&sample_request()).expect("projection"); + + assert_eq!( + projection.contract_version, + PROJECT_HISTORY_CONTRACT_VERSION + ); + assert_eq!(projection.focus_event_id, "event-voc"); + assert_eq!( + projection.knowledge_cutoff, + sample_request().knowledge_cutoff + ); + assert_eq!(projection.inference_status, "temporal_association_only"); + assert_eq!(projection.participant_count, 3); + assert_eq!( + projection + .events + .iter() + .map(|item| item.event_type_code.as_str()) + .collect::>(), + vec![ + "contract_awarded", + "specification_changed", + "delivered", + "handoff_recorded", + "voc_received", + "rebid_started", + ] + ); + let finding_codes = projection + .findings + .iter() + .map(|finding| finding.finding_code.as_str()) + .collect::>(); + assert!(finding_codes.contains(&"specification_change_before_focus")); + assert!(finding_codes.contains(&"handoff_before_focus")); + assert!(finding_codes.contains(&"rebid_after_focus")); + assert!(finding_codes.contains(&"specification_change_and_handoff_before_focus")); + assert!( + projection + .findings + .iter() + .all(|finding| !finding.evidence_post_ids.is_empty()) + ); +} + +#[test] +fn projection_rejects_future_evidence_duplicates_and_unknown_json_fields() { + let mut future = sample_request(); + future.events[0].available_at = "2026-08-20T00:00:00Z".into(); + assert_eq!( + project_history_projection(&future), + Err(ApiError::InvalidWirePayload) + ); + + let mut known_future_occurrence = sample_request(); + known_future_occurrence.events[0].occurred_at = "2026-08-20T00:00:00Z".into(); + assert!(project_history_projection(&known_future_occurrence).is_ok()); + + let mut duplicate = sample_request(); + duplicate.events[1].event_id = duplicate.events[0].event_id.clone(); + assert_eq!( + project_history_projection(&duplicate), + Err(ApiError::InvalidWirePayload) + ); + + let json = sample_request().to_json().expect("json"); + let hostile = json.replacen('{', "{\"unpublished_causal_score\":1,", 1); + assert_eq!( + ProjectHistoryRequest::from_json(&hostile), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn generated_projection_rejects_output_that_exceeds_the_wire_limit() { + let request = request_near_the_serialized_byte_limit(); + let request_payload = request.to_json().expect("request remains within the limit"); + assert!(request_payload.len() <= tepp_api::DEFAULT_PROJECT_HISTORY_BYTE_LIMIT); + + assert_eq!( + project_history_projection(&request), + Err(ApiError::LimitExceeded) + ); +} + +#[test] +fn project_history_rejects_unknown_offset_temporal_values() { + let mut request = sample_request(); + request.knowledge_cutoff = "2026-08-19T23:59:59-00:00".into(); + assert_eq!( + project_history_projection(&request), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn request_json_with_explicit_limit_round_trips_a_valid_contract() { + let request = sample_request(); + let payload = request.to_json().expect("request json"); + let parsed = ProjectHistoryRequest::from_json_with_limit(&payload, payload.len() + 1) + .expect("request with explicit limit"); + assert_eq!(parsed, request); +} + +#[test] +fn live_project_history_route_is_cutoff_safe_and_idempotent() { + let request = sample_request(); + let body = request.to_json().expect("request json"); + let mut service = AnalysisRunLiveService::new(); + + let first = service.handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &body, + &request.idempotency_key, + )); + assert_eq!(first.status_code, 200); + let projection = ProjectHistoryProjection::from_json(&first.body).expect("projection"); + assert_eq!(projection.project_key, request.project_key); + assert_eq!(projection.inference_status, "temporal_association_only"); + + let replay = service.handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &body, + &request.idempotency_key, + )); + assert_eq!(replay.status_code, 200); + assert_eq!(replay.body, first.body); + + let mut conflict = request.clone(); + conflict.project_name = "Conflicting project".into(); + let conflict_body = conflict.to_json().expect("conflicting request json"); + assert_eq!( + service + .handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &conflict_body, + &conflict.idempotency_key, + )) + .status_code, + 400 + ); + + let unknown = body.replacen('{', "{\"unpublished_causal_score\":1,", 1); + assert_eq!( + service + .handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &unknown, + &request.idempotency_key, + )) + .status_code, + 400 + ); + + let mut unavailable = request.clone(); + unavailable.events[0].available_at = "2026-08-20T00:00:00Z".into(); + let unavailable_body = serde_json::to_string(&unavailable).expect("unavailable json"); + assert_eq!( + service + .handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &unavailable_body, + &unavailable.idempotency_key, + )) + .status_code, + 400 + ); + + let mut oversized = request.clone(); + oversized.project_name = "x".repeat(513); + let oversized_body = serde_json::to_string(&oversized).expect("oversized json"); + assert_eq!( + service + .handle_http_request(&live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &oversized_body, + &oversized.idempotency_key, + )) + .status_code, + 413 + ); + + let credentialed = live_request(LINEAGEWEAVE_CONSUMER_CODE, &body, &request.idempotency_key) + .replace( + "content-length:", + "authorization: Bearer secret\r\ncontent-length:", + ); + let credentialed_response = service.handle_http_request(&credentialed); + assert_eq!(credentialed_response.status_code, 403); + assert!(!credentialed_response.body.contains("Bearer secret")); + + assert_eq!( + service + .handle_http_request(&live_request( + NARUON_CONSUMER_CODE, + &body, + &request.idempotency_key, + )) + .status_code, + 400 + ); + + let mismatched_header = live_request( + LINEAGEWEAVE_CONSUMER_CODE, + &body, + "different-header-idempotency-key", + ); + assert_eq!( + service.handle_http_request(&mismatched_header).status_code, + 400 + ); +} + +#[test] +fn live_project_history_route_serves_over_loopback() { + let request = sample_request(); + let body = request.to_json().expect("request json"); + let mut service = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); + let address = service.local_addr().expect("loopback address"); + let worker = thread::spawn(move || service.serve_one()); + let mut stream = TcpStream::connect(address).expect("connect"); + stream + .write_all( + live_request(LINEAGEWEAVE_CONSUMER_CODE, &body, &request.idempotency_key).as_bytes(), + ) + .expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert_eq!( + worker.join().expect("join").expect("served").status_code, + 200 + ); +} + +#[test] +fn lineageweave_exchange_uses_the_versioned_credential_free_tepp_path() { + let exchange = + lineageweave_project_history_exchange("https://tepp.example.test", &sample_request()) + .expect("exchange"); + + assert_eq!( + exchange.target_url, + format!("https://tepp.example.test{PROJECT_HISTORY_PATH}") + ); + assert_eq!(exchange.method, "POST"); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + exchange + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("authorization")) + ); + assert_eq!( + lineageweave_project_history_exchange("http://tepp.example.test", &sample_request()), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn projection_response_revalidates_cutoff_order_findings_and_payload_size() { + let projection = project_history_projection(&sample_request()).expect("projection"); + let payload = projection.to_json().expect("projection json"); + assert_eq!( + ProjectHistoryProjection::from_json_with_limit(&payload, payload.len() - 1), + Err(ApiError::LimitExceeded) + ); + + let mut too_many: serde_json::Value = serde_json::from_str(&payload).expect("value"); + let events = too_many["events"].as_array_mut().expect("events"); + let template = events[0].clone(); + while events.len() <= 128 { + events.push(template.clone()); + } + let too_many_json = serde_json::to_string(&too_many).expect("too many json"); + assert_eq!( + ProjectHistoryProjection::from_json(&too_many_json), + Err(ApiError::LimitExceeded) + ); + + let mut future_cutoff: serde_json::Value = serde_json::from_str(&payload).expect("value"); + future_cutoff["knowledge_cutoff"] = serde_json::Value::String("2999-01-01T00:00:00Z".into()); + let future_cutoff_json = serde_json::to_string(&future_cutoff).expect("future cutoff json"); + assert_eq!( + ProjectHistoryProjection::from_json(&future_cutoff_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut duplicate: serde_json::Value = serde_json::from_str(&payload).expect("value"); + duplicate["events"][1]["event_id"] = duplicate["events"][0]["event_id"].clone(); + let duplicate_json = serde_json::to_string(&duplicate).expect("duplicate json"); + assert_eq!( + ProjectHistoryProjection::from_json(&duplicate_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut reversed: serde_json::Value = serde_json::from_str(&payload).expect("value"); + reversed["events"][1]["occurred_at"] = serde_json::Value::String("2020-01-01T00:00:00Z".into()); + let reversed_json = serde_json::to_string(&reversed).expect("reversed json"); + assert_eq!( + ProjectHistoryProjection::from_json(&reversed_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut equal_time: serde_json::Value = serde_json::from_str(&payload).expect("value"); + equal_time["events"][1]["occurred_at"] = equal_time["events"][0]["occurred_at"].clone(); + equal_time["events"][1]["available_at"] = equal_time["events"][0]["available_at"].clone(); + let equal_time_json = serde_json::to_string(&equal_time).expect("equal time json"); + assert!(ProjectHistoryProjection::from_json(&equal_time_json).is_ok()); + + equal_time["events"][1]["event_id"] = serde_json::Value::String("event-aaa".into()); + let equal_id_regression = serde_json::to_string(&equal_time).expect("equal id json"); + assert_eq!( + ProjectHistoryProjection::from_json(&equal_id_regression), + Err(ApiError::InvalidWirePayload) + ); + + let mut value: serde_json::Value = serde_json::from_str(&payload).expect("value"); + value["events"][0]["available_at"] = serde_json::Value::String("2026-08-20T00:00:00Z".into()); + let future = serde_json::to_string(&value).expect("future json"); + assert_eq!( + ProjectHistoryProjection::from_json(&future), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_span_start: serde_json::Value = + serde_json::from_str(&payload).expect("value"); + mismatched_span_start["history_span_start"] = + serde_json::Value::String("2022-03-10T09:00:00Z".into()); + let mismatched_span_start_json = + serde_json::to_string(&mismatched_span_start).expect("span start json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_span_start_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_span_end: serde_json::Value = serde_json::from_str(&payload).expect("value"); + mismatched_span_end["history_span_end"] = + serde_json::Value::String("2026-08-11T09:00:00Z".into()); + let mismatched_span_end_json = + serde_json::to_string(&mismatched_span_end).expect("span end json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_span_end_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_participant_count: serde_json::Value = + serde_json::from_str(&payload).expect("value"); + mismatched_participant_count["participant_count"] = serde_json::Value::from(99); + let mismatched_participant_count_json = + serde_json::to_string(&mismatched_participant_count).expect("participant count json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_participant_count_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut value: serde_json::Value = serde_json::from_str(&payload).expect("value"); + value["findings"] = serde_json::json!([{ + "finding_code": "causal_score", + "summary": "causal", + "related_event_ids": ["event-award"], + "evidence_post_ids": ["post-award"] + }]); + let fabricated = serde_json::to_string(&value).expect("fabricated json"); + assert_eq!( + ProjectHistoryProjection::from_json(&fabricated), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn projection_response_rejects_inconsistent_span_and_participant_count() { + let projection = project_history_projection(&sample_request()).expect("projection"); + let payload = projection.to_json().expect("projection json"); + + let mut reversed_span: serde_json::Value = serde_json::from_str(&payload).expect("value"); + reversed_span["history_span_start"] = serde_json::Value::String("2999-01-01T00:00:00Z".into()); + let reversed_span_json = serde_json::to_string(&reversed_span).expect("reversed span json"); + assert_eq!( + ProjectHistoryProjection::from_json(&reversed_span_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_start: serde_json::Value = serde_json::from_str(&payload).expect("value"); + mismatched_start["history_span_start"] = + serde_json::Value::String("2022-03-10T00:00:00Z".into()); + let mismatched_start_json = + serde_json::to_string(&mismatched_start).expect("mismatched start json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_start_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_end: serde_json::Value = serde_json::from_str(&payload).expect("value"); + mismatched_end["history_span_end"] = serde_json::Value::String("2026-08-09T00:00:00Z".into()); + let mismatched_end_json = serde_json::to_string(&mismatched_end).expect("mismatched end json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_end_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_participants: serde_json::Value = + serde_json::from_str(&payload).expect("value"); + mismatched_participants["participant_count"] = serde_json::Value::Number(0.into()); + let mismatched_participants_json = + serde_json::to_string(&mismatched_participants).expect("participants json"); + assert_eq!( + ProjectHistoryProjection::from_json(&mismatched_participants_json), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/crates/tepp_api/tests/lineageweave_temporal_context_contract.rs b/crates/tepp_api/tests/lineageweave_temporal_context_contract.rs new file mode 100644 index 000000000..66502d352 --- /dev/null +++ b/crates/tepp_api/tests/lineageweave_temporal_context_contract.rs @@ -0,0 +1,444 @@ +//! Contract tests for TEPP temporal evidence used by `LineageWeave` Ask surfaces. + +use std::fmt::Write as _; + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + NaruonHttpExchange, TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY, TEMPORAL_CONTEXT_CONTRACT_VERSION, + TEMPORAL_CONTEXT_PATH, TemporalContextEvent, TemporalContextRequest, TemporalContextResponse, + build_temporal_context, lineageweave_temporal_context_exchange, +}; + +fn event( + event_id: &str, + post_id: &str, + event_type: &str, + label: &str, + event_time: &str, + available_time: &str, + actors: &[&str], +) -> TemporalContextEvent { + TemporalContextEvent { + event_id: event_id.into(), + source_post_id: post_id.into(), + event_type_code: event_type.into(), + event_label: label.into(), + event_time: event_time.into(), + available_time: available_time.into(), + project_reference: Some("project-alpha".into()), + actor_references: actors.iter().map(|value| (*value).to_owned()).collect(), + } +} + +fn request() -> TemporalContextRequest { + TemporalContextRequest { + contract_version: TEMPORAL_CONTEXT_CONTRACT_VERSION, + consumer_code: LINEAGEWEAVE_CONSUMER_CODE.into(), + knowledge_cutoff: "2026-08-20T00:00:00Z".into(), + subject_post_id: Some("post-voc".into()), + events: vec![ + event( + "event-voc", + "post-voc", + "voc_received", + "VOC 접수", + "2026-08-01T09:00:00Z", + "2026-08-01T10:00:00Z", + &["actor-support"], + ), + event( + "event-order", + "post-order", + "order_awarded", + "수주", + "2022-03-01T09:00:00Z", + "2022-03-01T10:00:00Z", + &["actor-sales"], + ), + event( + "event-delivery", + "post-delivery", + "delivered", + "납품", + "2024-02-01T09:00:00Z", + "2024-02-01T10:00:00Z", + &["actor-operations"], + ), + event( + "event-spec", + "post-spec", + "specification_changed", + "사양 변경", + "2023-06-01T09:00:00Z", + "2023-06-01T10:00:00Z", + &["actor-engineering"], + ), + ], + } +} + +fn http_request_for_consumer(body: &str, consumer: &str) -> String { + let mut value = format!("POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\n"); + for (name, header_value) in [ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", consumer), + ("tepp-contract-version", "1"), + ("idempotency-key", "timeline-001"), + ] { + write!(value, "{name}: {header_value}\r\n").expect("header"); + } + write!(value, "content-length: {}\r\n\r\n{body}", body.len()).expect("body"); + value +} + +fn http_request_from_exchange(exchange: &NaruonHttpExchange) -> String { + let mut value = format!("POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\n"); + for (name, header_value) in &exchange.headers { + write!(value, "{name}: {header_value}\r\n").expect("header"); + } + write!( + value, + "content-length: {}\r\n\r\n{}", + exchange.body.len(), + exchange.body + ) + .expect("body"); + value +} + +#[test] +fn temporal_context_orders_events_and_marks_only_candidate_gaps() { + let response = build_temporal_context(&request()).expect("context"); + assert_eq!(response.contract_version, TEMPORAL_CONTEXT_CONTRACT_VERSION); + assert_eq!(response.claim_boundary, TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY); + assert_eq!( + response + .timeline_events + .iter() + .map(|item| item.event_id.as_str()) + .collect::>(), + vec!["event-order", "event-spec", "event-delivery", "event-voc"] + ); + assert_eq!( + response + .timeline_events + .iter() + .map(|item| item.sequence_ordinal) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert!(response.timeline_events[3].is_subject); + assert_eq!(response.temporal_relations.len(), 3); + assert!( + response + .temporal_relations + .iter() + .all(|item| item.relation_code == "before") + ); + assert!( + response + .transition_gap_candidates + .iter() + .all(|item| item.evidence_status_code == "candidate_not_causal") + ); + assert!( + response + .transition_gap_candidates + .iter() + .any(|item| item.from_event_id == "event-spec" && item.to_event_id == "event-delivery") + ); + assert_eq!( + response.source_post_ids, + vec!["post-order", "post-spec", "post-delivery", "post-voc"] + ); +} + +#[test] +fn temporal_context_rejects_leakage_duplicates_and_unpublished_consumers() { + let mut future = request(); + future.events[0].available_time = "2026-09-01T00:00:00Z".into(); + assert_eq!( + build_temporal_context(&future), + Err(ApiError::InvalidWirePayload) + ); + + let mut duplicate = request(); + duplicate.events[1].event_id = duplicate.events[0].event_id.clone(); + assert_eq!( + build_temporal_context(&duplicate), + Err(ApiError::InvalidWirePayload) + ); + + let mut hostile = request(); + hostile.consumer_code = "unpublished-consumer".into(); + assert_eq!( + build_temporal_context(&hostile), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn lineageweave_exchange_and_live_listener_return_the_same_context() { + let request = request(); + let exchange = lineageweave_temporal_context_exchange("https://tepp.example.test", &request) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/temporal-context" + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !name.eq_ignore_ascii_case("authorization") && !name.to_ascii_lowercase().contains("token") + })); + assert!( + exchange + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("idempotency-key")) + ); + + let mut service = AnalysisRunLiveService::new(); + let response = service.handle_http_request(&http_request_from_exchange(&exchange)); + assert_eq!(response.status_code, 200); + let live = TemporalContextResponse::from_json(&response.body).expect("response"); + assert_eq!(live, build_temporal_context(&request).expect("direct")); +} + +fn single_event_response() -> TemporalContextResponse { + let mut value = request(); + value.events.truncate(1); + value.subject_post_id = None; + build_temporal_context(&value).expect("single event") +} + +fn two_event_response() -> TemporalContextResponse { + let mut value = request(); + value.events.truncate(2); + value.subject_post_id = None; + build_temporal_context(&value).expect("two events") +} + +#[test] +fn temporal_context_rejects_invalid_requests() { + let mut empty_events = request(); + empty_events.events.clear(); + assert_eq!( + build_temporal_context(&empty_events), + Err(ApiError::InvalidWirePayload) + ); + + let mut too_many_events = request(); + too_many_events.events = (0..1025) + .map(|index| { + let mut value = too_many_events.events[0].clone(); + value.event_id = format!("event-{index}"); + value + }) + .collect(); + assert_eq!( + build_temporal_context(&too_many_events), + Err(ApiError::LimitExceeded) + ); + + let mut empty_subject = request(); + empty_subject.subject_post_id = Some(String::new()); + assert_eq!( + build_temporal_context(&empty_subject), + Err(ApiError::InvalidWirePayload) + ); + + let mut unknown_subject = request(); + unknown_subject.subject_post_id = Some("missing-post".into()); + assert_eq!( + build_temporal_context(&unknown_subject), + Err(ApiError::InvalidWirePayload) + ); + + let mut empty_project = request(); + empty_project.events[0].project_reference = Some(" ".into()); + assert_eq!( + build_temporal_context(&empty_project), + Err(ApiError::InvalidWirePayload) + ); + + let mut empty_actors = request(); + empty_actors.events[0].actor_references.clear(); + assert_eq!( + build_temporal_context(&empty_actors), + Err(ApiError::InvalidWirePayload) + ); + + let mut no_project = request(); + no_project.events[0].project_reference = None; + assert!(build_temporal_context(&no_project).is_ok()); +} + +#[test] +fn temporal_context_serialization_enforces_the_shared_wire_limit() { + let mut oversized_request = request(); + oversized_request.events[0].event_label = "x".repeat(64 * 1024); + assert_eq!(oversized_request.to_json(), Err(ApiError::LimitExceeded)); + + let mut oversized_response = single_event_response(); + oversized_response.timeline_events[0].event_label = "x".repeat(64 * 1024); + assert_eq!(oversized_response.to_json(), Err(ApiError::LimitExceeded)); +} + +#[test] +fn temporal_context_rejects_invalid_response_shapes() { + let single_response = single_event_response(); + + let mut invalid_claim = single_response.clone(); + invalid_claim.claim_boundary = "causal".into(); + assert_eq!(invalid_claim.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut empty_timeline = single_response.clone(); + empty_timeline.timeline_events.clear(); + assert_eq!(empty_timeline.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut missing_source = single_response.clone(); + missing_source.source_post_ids.clear(); + assert_eq!(missing_source.to_json(), Err(ApiError::InvalidWirePayload)); + + let two_response = two_event_response(); + + let mut missing_relation = two_response.clone(); + missing_relation.temporal_relations.clear(); + assert_eq!( + missing_relation.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut missing_gap = two_response.clone(); + missing_gap.transition_gap_candidates.clear(); + assert_eq!(missing_gap.to_json(), Err(ApiError::InvalidWirePayload)); +} + +#[test] +fn temporal_context_rejects_invalid_response_fields_and_edges() { + let single_response = single_event_response(); + for invalid in [ + { + let mut value = single_response.clone(); + value.timeline_events[0].sequence_ordinal = 1; + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].event_id.clear(); + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].source_post_id.clear(); + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].event_type_code.clear(); + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].event_label.clear(); + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].event_time.clear(); + value + }, + { + let mut value = single_response.clone(); + value.timeline_events[0].actor_references.clear(); + value + }, + { + let mut value = single_response.clone(); + value.source_post_ids[0] = "different-post".into(); + value + }, + ] { + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + } + + let two_response = two_event_response(); + for invalid in [ + { + let mut value = two_response.clone(); + value.timeline_events[0].event_time = "2026-08-02T09:00:00Z".into(); + value + }, + { + let mut value = two_response.clone(); + value.timeline_events[1].event_id = value.timeline_events[0].event_id.clone(); + value + }, + { + let mut value = two_response.clone(); + value.timeline_events[0].project_reference = Some(" ".into()); + value + }, + { + let mut value = two_response.clone(); + value.timeline_events[0].actor_references = vec![" ".into()]; + value + }, + { + let mut value = two_response.clone(); + value.temporal_relations[0].from_event_id = "wrong".into(); + value + }, + { + let mut value = two_response.clone(); + value.temporal_relations[0].to_event_id = "wrong".into(); + value + }, + { + let mut value = two_response.clone(); + value.temporal_relations[0].relation_code = "after".into(); + value + }, + { + let mut value = two_response.clone(); + value.transition_gap_candidates[0].from_event_id = "wrong".into(); + value + }, + { + let mut value = two_response.clone(); + value.transition_gap_candidates[0].to_event_id = "wrong".into(); + value + }, + { + let mut value = two_response.clone(); + value.transition_gap_candidates[0].evidence_status_code = "causal".into(); + value + }, + ] { + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + } +} + +#[test] +fn temporal_context_rejects_equal_timestamp_id_regressions() { + let two_response = two_event_response(); + let mut equal_time = two_response.clone(); + equal_time.timeline_events[1].event_time = equal_time.timeline_events[0].event_time.clone(); + assert!(equal_time.to_json().is_ok()); + + let mut equal_time_out_of_order = equal_time; + equal_time_out_of_order.timeline_events[1].event_id = "event-aaa".into(); + assert_eq!( + equal_time_out_of_order.to_json(), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn temporal_context_requires_matching_lineageweave_header() { + let body = request().to_json().expect("request json"); + let response = AnalysisRunLiveService::new() + .handle_http_request(&http_request_for_consumer(&body, NARUON_CONSUMER_CODE)); + assert_eq!(response.status_code, 400); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs new file mode 100644 index 000000000..20e475647 --- /dev/null +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -0,0 +1,31 @@ +//! The packaged loopback binary serves the published temporal-context wire. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; + +#[test] +fn binary_serves_one_bounded_temporal_context_request() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "1"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback service"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + let body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":"post-1","events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"health_probe","event_label":"Health probe","event_time":"2026-08-20T00:00:00Z","available_time":"2026-08-20T00:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + let request = format!( + "POST /v1/temporal-context HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{body}", + address.trim(), + body.len() + ); + let mut stream = TcpStream::connect(address.trim()).expect("connect"); + stream.write_all(request.as_bytes()).expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert!(response.contains("association_not_causal")); + assert!(child.wait().expect("wait").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4198a1c08..59e16ffc3 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -7,7 +7,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active PR adds a loopback HTTP/1.1 listener for naruon analysis-run and export POSTs. That listener is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` remain target interface shapes. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run and LineageWeave temporal-context POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families @@ -22,7 +22,8 @@ Current protected main exposes Rust library/domain contracts. The active PR adds | model/artifact/export API | `tepp_api` export envelopes + future HTTP service | standalone UI/CWL consumers | partial | | analysis-run request/accepted contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | implemented-main | | corpus-split leakage-audit manifest | `tepp_api` `CorpusSplitManifest` v1 | naruon, auditors, future UI | active-PR | -| analysis-run request/accepted contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | implemented-main (merged PR #21) | +| temporal-context ordering contract | `tepp_api` v1 wire DTOs | LineageWeave | active-PR | +| project-history projection contract | `tepp_api` v1 wire DTOs | LineageWeave | active-PR | ## 3. Versioning @@ -46,6 +47,7 @@ When the service layer is introduced, use resources such as: POST /v1/evidence-imports GET /v1/evidence-imports/{import_id} POST /v1/analysis-runs +POST /v1/temporal-context GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} @@ -54,6 +56,12 @@ GET /v1/exports/{export_id} Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. +`POST /v1/temporal-context` is a bounded LineageWeave read contract. It accepts +only events whose availability time is at or before `knowledge_cutoff`, orders +them by event time and opaque event ID, and emits adjacent forward temporal +associations plus `candidate_not_causal` transition gaps. It does not infer +causality, mutate TEPP state, or return a completed psychometric result. + ## 5. Analysis request authority An analysis request cannot supply arbitrary facts that bypass validated domain state. The service resolves and validates: diff --git a/docs/DOCUMENTATION_ASSESSMENT.md b/docs/DOCUMENTATION_ASSESSMENT.md index adec4ac21..8dbc1bf8e 100644 --- a/docs/DOCUMENTATION_ASSESSMENT.md +++ b/docs/DOCUMENTATION_ASSESSMENT.md @@ -1,7 +1,7 @@ # TEPP Whole-Conversation Documentation Assessment -**Assessment date:** 2026-08-19 -**Scope:** Durable TEPP decisions established in the product conversation, approved PRD v0.4/planning pack, and protected-main implementation through the current TRACEABILITY matrix. Merged PRs #8 and #9 are the temporal lineage; superseded PRs #5 and #6 are historical only. +**Assessment date:** 2026-08-19 +**Scope:** Durable TEPP decisions established in the product conversation, approved PRD v0.4/planning pack, and protected-main implementation through the current TRACEABILITY matrix. Merged PRs #8 and #9 are the temporal lineage; superseded PRs #5 and #6 are historical only. **Verdict:** **The canonical documentation/ADR graph is protected-main authority after PR #7; implementation maturity remains independently tracked and is not promoted by documentation completeness.** ## 1. Assessment rule @@ -28,7 +28,7 @@ Decision status and implementation maturity are separate. ADR `Accepted` means t | Architecture | PRESENT-CURRENT | root `ARCHITECTURE.md` owns service/crate boundaries and scientific/compute invariants | | UML / system flows | PRESENT-CURRENT | `docs/UML.md` covers component, sequence, clock state, relation authority, membership, compute and implementation lineage | | ERD / logical data model | PRESENT-CURRENT | `docs/ERD.md` distinguishes current domain objects from planned PostgreSQL entities and preserves uncertain time/membership/provenance | -| ADR index / core decisions | PRESENT-CURRENT | ADR 0001–0016 cover numerical authority, clocks, event/membership, multilingual semantics, ESEM/DSEM, GPU, quality, evidence, PII, LLM orchestration, MSA, topic measurement, persistence/manifests/splits, claim promotion/release, autonomous-development authority, and TDT/CHRONOS boundaries | +| ADR index / core decisions | PRESENT-CURRENT | Numbered ADRs in the canonical index cover numerical authority, clocks, event/membership, multilingual semantics, ESEM/DSEM, GPU, quality, evidence, PII, LLM orchestration, MSA, topic measurement, persistence/manifests/splits, claim promotion/release, autonomous-development authority, TDT/CHRONOS boundaries, and consumer-scoped modular ingress | | ADR status/maturity/supersession policy | PRESENT-CURRENT | `docs/adr/ADR_POLICY.md` makes `Accepted` vs implemented/released explicit and requires exact partial-supersession scope | | API / modular integration | PRESENT-CURRENT | `docs/API_CONTRACT.md` defines versioning, target async lifecycle, authority and naruon/contextual-orchestrator boundaries | | Security | PRESENT-CURRENT | `SECURITY.md` plus `docs/THREAT_MODEL.md` | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 41661e40f..8f64c2a69 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -21,6 +21,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | +| 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); merged PR #158 supplies the LineageWeave cutoff-safe temporal-context DTO and PR #155 carries its current loopback consumer boundary; production TLS remaining | partial | +| immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest and corpus-split leakage-audit wire (`CorpusSplitManifest` v1) on the active stack; `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 | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | future topic lineage/activity state | accepted-target | diff --git a/docs/adr/0002-six-clock-temporal-semantics.md b/docs/adr/0002-six-clock-temporal-semantics.md index 866ffcb84..cfe4c7ed2 100644 --- a/docs/adr/0002-six-clock-temporal-semantics.md +++ b/docs/adr/0002-six-clock-temporal-semantics.md @@ -1,19 +1,8 @@ # ADR 0002 — Six-clock temporal semantics and leakage prevention -**Decision status:** Accepted -**Implementation maturity:** partial — typed clocks/intervals are implemented-main (PR #8); input-process-outcome event-time order is `outcome_order` on the active PR; remaining clock-identity and split enforcement stay accepted-target -**Implementation maturity:** partial — typed clocks/intervals implemented-main via `temporal_core`; retrospective-reporting identity in `retrospective_edge` on the active PR; downstream transition/split enforcement remains accepted-target -**Implementation maturity:** active-PR — evidential-vs-transition gate in `support_edge` on the active PR; remaining graph/split enforcement stays accepted-target -**Implementation maturity:** active-PR — system-clock identity in `system_clock` on the active PR; remaining graph/split enforcement stays accepted-target -**Implementation maturity:** active-PR — event-clock identity in `event_clock` on the active PR; remaining graph/split enforcement stays accepted-target -**Implementation maturity:** active-PR — assertion-clock identity in `assertion_clock` on the active PR; remaining graph/split enforcement stays accepted-target -**Implementation maturity:** active-PR — knowledge-cutoff identity in `cutoff_clock` on the active PR; remaining graph/split enforcement stays accepted-target -**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). -**Implementation maturity:** active-PR — unmerged PR #8 is the canonical Task 3 replacement implementing typed clocks/intervals against the current protected-main lineage; superseded/conflicted PR #5 is historical lineage only; downstream transition/split enforcement remains accepted-target -**Date:** 2026-08-05 +**Decision status:** Accepted +**Implementation maturity:** partial — typed clocks, interval algebra, event/assertion/system/cutoff/availability identity, complete document clocks, revision ordering, retrospective/provenance-vs-transition gates, bounded payload validation, and strict input-process-outcome ordering are implemented-main; downstream graph/split enforcement remains accepted-target. +**Date:** 2026-08-05 **Supersedes:** None. ADR 0013 owns persistence/split representation; ADR 0016 owns event-intelligence reasoning above these temporal primitives. ## Context diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index abb44063e..49d473bf7 100644 --- a/docs/adr/0003-relational-event-multiple-membership.md +++ b/docs/adr/0003-relational-event-multiple-membership.md @@ -1,5 +1,8 @@ # ADR 0003 — Relational event ontology and time-varying multiple membership +**Decision status:** Accepted +**Implementation maturity:** partial — membership network, event mention/instance separation, inferred/evidential/retrospective status gates, summary/source identity separation, template-copy/source identity separation, typed forward-only relation graph, strict input-process-outcome ordering, nested ICC refusal, and subevent parent-window containment are implemented-main; full multilevel/MMMC estimators and remaining persistence remain accepted-target. +**Date:** 2026-08-05 **Decision status:** Accepted **Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; location-versus-entity/language identity in `location_membership` 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; 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 diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 7551baaed..8219c6f8b 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -1,9 +1,8 @@ # ADR 0005 — Posterior-aware ESEM/DSEM and structural interpretation -**Decision status:** Accepted -**Implementation maturity:** active-PR — CPU `f64` ESEM loading recovery and event-time DSEM lag gates in `psychometric_fit` on the active PR; `psychometric_core` input gates remain #49; longitudinal invariance and multilevel estimators remain accepted-target -**Implementation maturity:** active-PR — `longitudinal_core` separates unit means from within residuals and refuses between-as-within change; remaining ESEM/DSEM fit remains accepted-target -**Date:** 2026-08-05 +**Decision status:** Accepted +**Implementation maturity:** active-PR — `longitudinal_core` separates unit means from within residuals and refuses between-as-within change; CPU `f64` ESEM/DSEM fit and event-time lag gates are active-PR; remaining invariance and multilevel estimator work remains accepted-target. +**Date:** 2026-08-05 **Supersedes:** None. ADR 0012 governs upstream topic measurement/network coordinates; this ADR governs higher-order psychometric structure and longitudinal interpretation. ## Context diff --git a/docs/adr/0009-purpose-bound-pii-governance.md b/docs/adr/0009-purpose-bound-pii-governance.md index 13503b579..a48e2cd05 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -5,10 +5,10 @@ **Implementation maturity:** active-PR — `encrypted_mapping` seals source identity with AES-256-GCM authenticated encryption and an operating-system-generated nonce; persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; persistence/KMS and remaining authorization adapters stay accepted-target; deployment/provider-region evidence remains accepted-target **Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; provider-disclosure receipts with field-code-only evidence are on this active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target **Implementation maturity:** active-PR — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; `operational_log::try_record` and inspected `audit_event` inserts are on this active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target -**Date:** 2026-08-10 +**Date:** 2026-08-10 **Implementation maturity:** active-PR — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; `derived_sensitivity` inheritance is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target **Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is implemented-main; purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target -**Decision status:** Accepted +**Decision status:** Accepted **Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) are implemented-main; deployment/provider-region evidence remains accepted-target **Date:** 2026-08-10 diff --git a/docs/adr/0010-adaptive-llm-orchestration.md b/docs/adr/0010-adaptive-llm-orchestration.md index 5855e8cd2..e1e6b2841 100644 --- a/docs/adr/0010-adaptive-llm-orchestration.md +++ b/docs/adr/0010-adaptive-llm-orchestration.md @@ -3,9 +3,9 @@ **Decision status:** Accepted **Implementation maturity:** partial — `tepp_api` governed router, comparable-budget ablation record, and credential-free contextual-orchestrator binding are implemented on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; live NIM execution, learned conductor calibration, and production ablation evidence remain accepted-target **Date:** 2026-08-10 -**Decision status:** Accepted +**Decision status:** Accepted **Implementation maturity:** partial — `tepp_api` governed router, comparable-budget ablation record, credential-free contextual-orchestrator binding, and evidence-bounded `interpretation_gateway` are on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; live NIM execution, learned conductor calibration, and production ablation evidence remain accepted-target -**Date:** 2026-08-10 +**Date:** 2026-08-10 **Supersedes:** The LLM orchestration-selection/ablation clauses previously co-located in ADR 0006. ADR 0006 remains authoritative for GPU/VRAM and model-credential separation; ADR 0015 governs autonomous repository-write/review/merge authority. ## Context diff --git a/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md b/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md index c43bfbde8..d93b7d21c 100644 --- a/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md +++ b/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md @@ -1,5 +1,8 @@ # ADR 0012 — Temporal Relational Shared-Latent Topic Measurement +**Decision status:** Accepted +**Implementation maturity:** partial — `corpus_background`, `topic_lineage`, `network_analysis`, `model_selection`, `stopword_deletion`, `style_source`, `copied_text`, and `modality_source` implement bounded identity and recovery gates; the TRSL-TM estimator, method effects, global topic identity, and backend interchange remain accepted-target. +**Date:** 2026-08-12 **Decision status:** Accepted **Implementation maturity:** accepted-target — prompt-versus-unique-content identity in `prompt_source` on the active PR; estimator-side method model remains accepted-target **Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; estimator-side method model remains accepted-target diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index 8815625b1..57b46e22e 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,9 +1,9 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority -**Decision status:** Accepted -**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; remaining physical ERD and DR-runbook depth accepted-target -**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; typed `text_segment` SQL on the active PR -**Date:** 2026-08-12 +**Decision status:** Accepted +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; remaining physical ERD and DR-runbook depth accepted-target +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; typed `text_segment` SQL on the active PR +**Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership). ## Context diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index 49d1acdd6..18e206981 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -1,10 +1,10 @@ # ADR 0016 — TDT, CHRONOS, and Event Ontology intelligence boundary -**Decision status:** Accepted -**Implementation maturity:** active-PR — bounded predicted-vs-observed Allen promotion gate, including coverage before unmatched predicted mass may be authorized for promotion; TDT detection/tracking, CHRONOS schema extraction, prediction calibration, and path-consistency laws remain accepted-target +**Decision status:** Accepted +**Implementation maturity:** active-PR — bounded predicted-vs-observed Allen promotion gate, including coverage before unmatched predicted mass may be authorized for promotion; TDT detection/tracking, CHRONOS schema extraction, prediction calibration, and path-consistency laws remain accepted-target **Implementation maturity:** active-PR — evidence-layer admission and first-story detection rates are implemented in `event_core` on the active PR; full TDT tracking/calibration and CHRONOS schema extraction remain accepted-target. -**Date:** 2026-08-12 +**Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 temporal semantics and ADR 0003 event ontology/membership. ## Context diff --git a/docs/adr/0018-consumer-scoped-analysis-run-ingress.md b/docs/adr/0018-consumer-scoped-analysis-run-ingress.md new file mode 100644 index 000000000..3fcc61633 --- /dev/null +++ b/docs/adr/0018-consumer-scoped-analysis-run-ingress.md @@ -0,0 +1,104 @@ +# ADR 0018 — Consumer-scoped modular analysis-run ingress + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-20 +**Supersedes:** None; narrows ADR 0011 for shared modular analysis-run ingress and leaves production TLS/deployment authority unchanged. + +## Context + +TEPP must operate standalone and as a modular CWL service. Its first live loopback analysis-run ingress admitted only `naruon`. LineageWeave already owns authorized source-post selection, lineage reconstruction, and Buyer navigation, and needs to submit a bounded TEPP analysis-run request without sharing application tables or forwarding browser, reviewer, model-provider, or database credentials. + +Admitting another consumer by duplicating the listener would create divergent validation, idempotency, error, and security behavior. Reusing one tenant-scoped idempotency namespace without the consumer identity would also allow two legitimate products to collide when they independently choose the same tenant and caller key. + +## Decision + +TEPP publishes one consumer-neutral `/v1/analysis-runs` ingress and a closed modular-consumer registry. The initial admitted identities are: + +- `naruon`; +- `lineageweave`. + +The request body remains the versioned `AnalysisRunRequest`. The transport requires a matching `idempotency-key`, `tepp-contract-version`, `tepp-consumer`, JSON content type, and a loopback host in the current live proof. Accepted-run replay identity is scoped by: + +```text +consumer_code + tenant_workspace_id + idempotency_key +``` + +A retry from the same consumer returns the original accepted run only when the complete validated request is semantically identical. The same tenant/key used by a different consumer has a separate namespace. A changed payload under the same consumer/tenant/key fails closed. + +Consumer-specific client builders may set only the published consumer identity. They reuse the shared request validation and must not add credentials. The Naruon compatibility listener remains available while new consumers use `AnalysisRunLiveService`. + +An HTTP `202 Accepted` response means only that TEPP accepted an analysis-run identity for later execution. In the current loopback proof the accepted-run registry is in-memory and is not yet durable across restarts; persistence remains separate work. The response is not a completed temporal model, calibrated score, theta estimate, uncertainty statement, or scientific claim. + +## Non-goals + +- This ADR does not authorize direct access to another product's tables or object store. +- It does not define production TLS termination, public routing, service discovery, or tenant authentication; those remain separate deployment/security work. +- It does not make arbitrary consumer strings self-registering. +- It does not authorize a consumer to submit raw credentials, prompt text, provider secrets, or unrestricted PII. +- It does not define the completed-result contract. + +## Alternatives considered + +1. **One listener per consumer** — rejected because validation and security behavior would drift and every new CWL product would require another transport implementation. +2. **Tenant plus caller key only** — rejected because distinct modular consumers can legitimately reuse a key and must not replay or conflict with each other's accepted run. +3. **Trust any `tepp-consumer` value** — rejected because an open consumer namespace defeats purpose-bound admission and weakens auditability. +4. **Forward the caller's bearer token or provider credential** — rejected because TEPP should receive a bounded service contract, not inherit browser, reviewer, or model-provider authority. +5. **Closed consumer registry plus shared ingress and consumer-scoped idempotency** — accepted. + +## Consequences + +- LineageWeave and Naruon can use one validated analysis-run boundary without sharing databases. +- Adding another consumer requires a reviewed code change, contract tests, and an ADR/index update when the authority boundary changes. +- Idempotent retries remain deterministic within one product while cross-product collisions are prevented. +- The accepted acknowledgement remains operational evidence only and cannot be promoted to a measurement result. +- The shared listener carries a larger compatibility responsibility and therefore must preserve the strictest existing size, header, host, timeout, and error-redaction behavior. + +## Failure and recovery + +Unknown consumers, credential-bearing headers, malformed or duplicate headers, transfer encoding, non-loopback hosts, invalid content length, oversized payloads, unsupported contract versions, idempotency mismatches, and changed replay payloads fail closed with a redacted versioned error envelope. + +Socket timeout or malformed I/O does not create an accepted run. A retry is safe when it reuses the same consumer, tenant, key, and semantically identical request. Recovery from a deployment outage replays the original bounded request; callers must not fabricate a succeeded run or infer that a missing acknowledgement means the computation failed after acceptance. + +## Security, privacy, scientific-integrity, and governance impact + +- No authorization, review, Copilot, NIM, OpenAI, database, or browser credential crosses the consumer boundary. +- The closed consumer registry is purpose-bound; consumer identity is included in replay/audit identity. +- Host validation, bounded header/body parsing, read/write deadlines, and content-redacting errors limit SSRF-style, request-smuggling, resource-exhaustion, and data-disclosure risks in the current loopback proof. +- Tenant/workspace and snapshot identities remain opaque service references. +- `202 Accepted` cannot be used as evidence of convergence, calibration, uncertainty, validity, or production release readiness. + +## Compatibility and migration + +The existing Naruon listener and `naruon_analysis_run_exchange` remain compatibility surfaces. LineageWeave uses `lineageweave_analysis_run_exchange`, which changes only the consumer header and preserves the shared payload contract. Existing Naruon idempotent retries retain their result within the new consumer-qualified namespace. + +Production HTTP/TLS adapters may replace the loopback transport while preserving the same consumer registry, request semantics, credential prohibition, idempotency namespace, and redacted error contract. Consumer removal requires a deprecation window and retained historical audit interpretation. + +## Verification + +The falsifiable acceptance evidence is: + +- LineageWeave receives HTTP `202` with a valid `AnalysisRunAccepted` response; +- Naruon remains accepted through the compatibility listener; +- same-consumer, semantically identical retries return the original run identity; +- Naruon and LineageWeave using the same tenant/key do not replay each other; +- a changed request under the same consumer/tenant/key is rejected; +- unpublished consumers are rejected; +- credential headers, non-loopback hosts, table-access-like hosts, malformed framing, unsupported versions, and oversized inputs are rejected; +- the LineageWeave exchange contains no credential header; +- formatting, Clippy with warnings denied, all-target Rust tests, public rustdoc, production line/branch coverage, documentation validation, and dependency policy pass on the exact PR head; +- independent current-head review remains required before merge. + +## Rollback and supersession + +Rollback returns callers to the last validated consumer-specific ingress while preserving accepted-run audit identities. It must not collapse existing consumer-qualified replay keys into a shared tenant/key namespace. + +A superseding ADR is required to open dynamic consumer registration, change idempotency identity, permit credential delegation, remove the closed registry, or promote the accepted acknowledgement into a completed-result claim. Production TLS/deployment changes may complement this ADR but must preserve its authority and credential boundaries unless explicitly superseded. + +## Related authority + +- ADR 0011 owns standalone/modular CWL service and persistence boundaries. +- ADR 0002 owns knowledge-cutoff temporal eligibility. +- ADR 0008 owns immutable evidence and strict wire reconstruction. +- ADR 0009 owns purpose-bound PII governance. +- ADR 0014 owns scientific claim and release promotion. diff --git a/docs/adr/0019-project-history-wire-size-symmetry.md b/docs/adr/0019-project-history-wire-size-symmetry.md new file mode 100644 index 000000000..dd01aaea4 --- /dev/null +++ b/docs/adr/0019-project-history-wire-size-symmetry.md @@ -0,0 +1,70 @@ +# ADR 0019 — Symmetric LineageWeave wire-size enforcement + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-21 +**Supersedes:** None; narrows ADR 0008 for the project-history and temporal-context DTO boundaries. + +## Context + +The LineageWeave project-history and temporal-context request/response pairs +use symmetric wire-size ceilings when parsing JSON. A request can be valid and +close to its ceiling while its deterministic projection adds response +metadata. Without output guards, TEPP can construct a response that its own +parser rejects, leaving callers with an internally inconsistent success path. + +## Decision + +`ProjectHistoryRequest::to_json` and `ProjectHistoryProjection::to_json` both +enforce `DEFAULT_PROJECT_HISTORY_BYTE_LIMIT`. The +`project_history_projection` builder serializes and validates the generated +projection before returning it. A projection that cannot be represented by the +published wire contract fails closed with `ApiError::LimitExceeded`. + +`TemporalContextRequest::to_json` and `TemporalContextResponse::to_json` +likewise enforce `DEFAULT_TEMPORAL_CONTEXT_BYTE_LIMIT`. The live adapter cannot +return a success body that the published response parser rejects. + +## Alternatives considered + +1. **Only increase the response limit** — rejected because it silently changes + the published boundary and allows asymmetric resource consumption. +2. **Reserve an undocumented request headroom** — rejected because the + request-to-response size delta depends on event content and findings. +3. **Guard only the HTTP adapter** — rejected because callers can use the + standalone DTO builder and bypass that adapter. +4. **Validate every serialized request and generated projection at the shared + DTO boundary** — accepted. + +## Consequences and failure recovery + +Valid small projections are unchanged. Near-limit requests that would produce +an oversized response now fail deterministically before a success is exposed; +the caller can submit a smaller authorized evidence bundle. No event is +silently dropped and no truncation is introduced. + +## Security, privacy, and scientific integrity + +The shared bound limits memory and transport amplification without exposing +payload contents in errors. The complete explicit evidence set remains the +scientific input; rejecting an unrepresentable projection is safer than +silently changing temporal associations or findings. + +## Verification + +Contract tests construct project-history and temporal-context payloads whose +serialized forms exceed their response ceilings and assert `LimitExceeded`. +Existing round-trip, unknown-field, cutoff, ordering, and finding-invariant +tests remain required. + +## Rollback + +Rollback requires a superseding ADR because removing the guard would +reintroduce a self-rejecting success path. + +## Related authority + +- ADR 0008 owns strict versioned wire reconstruction and bounded evidence. +- ADR 0011 owns standalone and modular service boundaries. +- `docs/doctoring/LINEAGEWEAVE_PROJECT_HISTORY_REFERENCES.md` records the + standards and APA 7th sources for this contract. diff --git a/docs/adr/0020-lineageweave-project-history-boundary.md b/docs/adr/0020-lineageweave-project-history-boundary.md new file mode 100644 index 000000000..6128b5eba --- /dev/null +++ b/docs/adr/0020-lineageweave-project-history-boundary.md @@ -0,0 +1,91 @@ +# ADR 0020 — LineageWeave project-history service boundary + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-21 +**Supersedes:** None; narrows ADR 0011 for the project-history projection. + +## Context + +LineageWeave owns authorization, source-post selection, and buyer navigation; +TEPP owns temporal eligibility and deterministic project-history projection. +The products need a versioned boundary that preserves this ownership split +without sharing application tables, provider credentials, or psychometric +claims. + +## Decision + +TEPP publishes the credential-free `POST /v1/project-histories` contract and +the `lineageweave_project_history_exchange` builder. LineageWeave supplies a +bounded, already-authorized set of explicit source events, an opaque tenant and +project identity, and a knowledge cutoff. TEPP validates the cutoff, orders +events deterministically, recomputes only explicit non-causal findings, and +returns a `temporal_association_only` projection. The contract is versioned, +strict JSON, bounded to 256 KiB, and contains no provider or caller +credentials. + +## Non-goals + +- TEPP does not authorize or discover LineageWeave source records. +- The projection is not a causal conclusion, psychometric score, theta, + confidence value, or completed model result. +- The boundary does not grant cross-service database access or production TLS + deployment authority. + +## Alternatives considered + +1. **Shared LineageWeave/TEPP tables** — rejected because it couples + authorization, migrations, retention, and service ownership. +2. **A TEPP endpoint that fetches LineageWeave records by name** — rejected + because authorization and evidence selection belong to LineageWeave. +3. **A credential-bearing provider request** — rejected because the boundary + needs only an evidence contract, not browser, reviewer, or model authority. +4. **A versioned bounded evidence-in/projection-out contract** — accepted. + +## Consequences + +The services can run independently and compose through a stable API. Every +event and finding remains traceable to opaque submitted identities. Consumers +must reduce the authorized evidence bundle when the bounded response cannot be +represented; TEPP never silently truncates evidence or upgrades temporal order +to causation. + +## Failure and recovery + +Malformed, future-leaking, duplicate, oversized, credential-bearing, or +unsupported payloads fail closed with content-redacting errors. A retry may +reuse the same validated evidence and cutoff. An unavailable TEPP service does +not become a fabricated buyer result; the consumer records deferred/unavailable +state and retries through its own controlled adapter. + +## Security, privacy, and scientific integrity + +Only authorized bounded evidence and opaque identities cross the boundary. +Purpose-bound identity disclosure remains governed by ADR 0009. Deterministic +ordering and explicit finding recomputation preserve the distinction between +observed evidence, temporal association, and scientific inference. + +## Verification + +The project-history contract tests cover strict JSON, unknown fields, cutoff +leakage, deterministic ordering, finding recomputation, credential-free +headers, request/response size limits, and the near-limit generated-response +failure path. Documentation validation, Rust quality gates, and independent +current-head review remain required before merge. + +## Rollback + +Disable the modular adapter while retaining standalone TEPP operation. Do not +replace the contract with direct table access or reinterpret historical +projection records. A changed endpoint, ownership boundary, evidence meaning, +or credential policy requires a superseding ADR. + +## Related authority + +- ADR 0002 owns knowledge-cutoff and temporal eligibility. +- ADR 0008 owns strict bounded wire reconstruction. +- ADR 0009 owns purpose-bound PII governance. +- ADR 0011 owns standalone and modular service authority. +- ADR 0019 owns symmetric project-history wire-size enforcement. +- `docs/doctoring/LINEAGEWEAVE_PROJECT_HISTORY_REFERENCES.md` records the + contract sources and APA 7th references. diff --git a/docs/adr/README.md b/docs/adr/README.md index db4d279da..5300018fc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -79,7 +79,9 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | active-PR | Bounded predicted-vs-observed Allen promotion gate: `refuse_promotion` requires observed coverage; remaining TDT/CHRONOS tasks stay accepted-target. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | | [0017](0017-hourly-contextual-orchestrator-gateway.md) | Hourly contextual-orchestrator gateway and all-provider model discovery | Accepted | active-PR | Keeps proposal-model execution behind a pinned loopback gateway while preserving independent verifier, publisher, reviewer, and merge authority. | -| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | active-PR | Evidence-layer admission and first-story rates are on the active PR; full TDT tracking/calibration and CHRONOS schema extraction remain accepted-target. | +| [0018](0018-consumer-scoped-analysis-run-ingress.md) | Consumer-scoped modular analysis-run ingress | Accepted | active-PR | Narrows ADR 0011 for the closed consumer registry, credential-free exchange, and consumer-qualified idempotency namespace; production TLS remains separate. | +| [0019](0019-project-history-wire-size-symmetry.md) | Symmetric project-history wire-size enforcement | Accepted | active-PR | Narrows ADR 0008 for request serialization and generated LineageWeave project-history projections. | +| [0020](0020-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Narrows ADR 0011 for the credential-free bounded project-history API and preserves LineageWeave authorization ownership. | ## Decision ownership summary @@ -100,8 +102,11 @@ Use the narrowest owning ADR when decisions overlap: - **persistence / manifests / leakage-safe split:** ADR 0013; - **claim maturity / release evidence:** ADR 0014; - **autonomous development/review/merge authority:** ADR 0015; -- **TDT/CHRONOS event intelligence:** ADR 0016. +- **TDT/CHRONOS event intelligence:** ADR 0016; - **hourly proposal gateway and provider discovery:** ADR 0017. +- **modular consumer admission / replay identity:** ADR 0018. +- **project-history wire-size symmetry:** ADR 0019. +- **LineageWeave project-history service boundary:** ADR 0020. ## Change and supersession rule diff --git a/docs/doctoring/LINEAGEWEAVE_PROJECT_HISTORY_REFERENCES.md b/docs/doctoring/LINEAGEWEAVE_PROJECT_HISTORY_REFERENCES.md new file mode 100644 index 000000000..55c0c3d77 --- /dev/null +++ b/docs/doctoring/LINEAGEWEAVE_PROJECT_HISTORY_REFERENCES.md @@ -0,0 +1,46 @@ +# LineageWeave project-history contract references + +This doctoring record documents the authorities used by TEPP's versioned LineageWeave project-history projection. The projection validates and orders explicitly supplied evidence. It does not infer a missing event, identify a hidden actor, estimate theta, calculate confidence, or promote temporal order to causation. + +## Contract decisions + +| Authority | TEPP decision | +|---|---| +| ISO 8601-1:2019 and RFC 3339 | Parse event, availability, and knowledge-cutoff timestamps as absolute clocks and reject malformed or future-leaking evidence. | +| W3C Time Ontology in OWL and Allen interval algebra | Represent temporal relations separately from causal or psychometric authority. The response contract exposes `temporal_association_only`. | +| W3C PROV-O / PROV-DM | Preserve source identities and evidence references; findings may cite only event and post identities contained in the submitted authorized bundle. | +| RFC 8259 | Use strict versioned JSON DTOs with unknown-field rejection and bounded collections. | +| RFC 9110 | Publish an explicit POST resource path, media type, idempotency key, and fail-closed error behavior. | +| Allen (1983) | Apply deterministic qualitative temporal ordering without claiming that succession establishes cause. | + +## Invariants + +1. `available_at` must not exceed the request `knowledge_cutoff`. +2. Event identities must be unique and the focus event must belong to the request. +3. The response echoes the applied `knowledge_cutoff` and revalidates every event against it. +4. The response must preserve every submitted event and its evidence fields in deterministic occurrence-time/event-ID order. +5. Participant count must equal the distinct opaque actor identities present in the supplied events. +6. Findings are recomputed from explicit event types; fabricated causal or unsupported findings fail closed. +7. Findings may cite only supplied event IDs and source-post IDs. +8. LineageWeave and Naruon use consumer-scoped idempotency namespaces. +9. No caller credential or cross-service database access is part of the project-history contract. +10. Loopback HTTP is a local modular boundary; a non-loopback deployment requires HTTPS/TLS at the service edge. +11. Request serialization, projection serialization, and generated projections all enforce the same 256 KiB wire limit; TEPP never returns a projection that its own response parser must reject. + +## APA 7th references + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Cox, S., & Little, C. (Eds.). (2017). *Time ontology in OWL*. World Wide Web Consortium. https://www.w3.org/TR/owl-time/ + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +International Organization for Standardization. (2019). *Date and time—Representations for information interchange—Part 1: Basic rules* (ISO Standard No. 8601-1:2019). https://www.iso.org/standard/70907.html + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). Internet Engineering Task Force. https://doi.org/10.17487/RFC3339 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/research/rust-quality-tooling.md b/docs/research/rust-quality-tooling.md index 2cab9f702..3627f416a 100644 --- a/docs/research/rust-quality-tooling.md +++ b/docs/research/rust-quality-tooling.md @@ -29,10 +29,9 @@ surface. - `cargo-nextest` 0.9.140 runs process-isolated tests without retries. - Doctests run separately because nextest does not currently execute doctests. - `cargo-llvm-cov` 0.8.6 produces stable line coverage. -- Branch coverage uses the same tool on `nightly-2026-08-18` because the +- Branch coverage uses the same tool on `nightly-2026-08-21` because the upstream project identifies Rust branch coverage as unstable and - nightly-only. Every branch-coverage command uses that same reproducible date - pin, and a later update requires exact-head coverage evidence. + nightly-only (Endo, 2026). - Coverage thresholds are evaluated from LLVM JSON totals. A nonzero line or branch denominator passes only when all units are covered. - Coverage.py 7.15.2 measures the repository-quality Python scripts at 100% diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 776cd3d15..15578cc1a 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -87,17 +87,34 @@ International Organization for Standardization. (2012). *Language resource manag Hobbs, J. R., & Pan, F. (2017). *Time ontology in OWL* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/owl-time/ +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 -TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Input→process→outcome transitions require a strict event-time partial order; `outcome_of` may point at an earlier producer and is not a reverse state transition (Allen, 1983). +International Organization for Standardization. (2019). *Date and time—Representations for information interchange—Part 1: Basic rules* (ISO Standard No. 8601-1:2019). https://www.iso.org/standard/70907.html +`temporal_core` owns the thirteen elementary relations and composition; +`prediction_contradiction` uses `before`/`after` as contradiction, +`meets`/`met_by` as unsupported adjacency, and +`during`/`starts`/`finishes`/`equals` as the only coverage relations that can +authorize promotion. +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. + +Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: An analysis and review. *International Journal of Forecasting, 16*(4), 437–450. https://doi.org/10.1016/S0169-2070(00)00065-0 + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Valid time, transaction time, and a document's availability are not substitutes for `cutoff_clock` knowledge cutoff. ## Input-process-outcome order Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 -Allen (1983) classifies interval relations; it does **not** authorize treating a later outcome as an earlier input, nor treating `outcome_of` provenance as `input_to` or `process_to`. +Allen interval relations do not authorize treating a later outcome as an earlier input, or treating `outcome_of` provenance as `input_to` or `process_to`. The `outcome_order` contract therefore requires strict event-time rank for forward transitions while retaining provenance edges as non-transitions. + +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 `support_edge`; they do not make support, contradiction, summary, or `outcome_of` a state transition. +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Retrospective reporting may point at earlier event time; it is not a state transition and not a translation (Allen, 1983). ## Unicode, language tags, and multilingual structure @@ -135,7 +152,9 @@ 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 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). +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, template, or pasted copy is a PROV derivation of the source document, not a state transition and not a reuse of the source identity; documents, serialized records, checkpoints, and LLM outputs remain untrusted until identity, provenance, size, and nesting depth validate (Bray, 2017; 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. ## Privacy lifecycle, retention, and legal hold diff --git a/docs/verification/consumer-ingress-main-base.md b/docs/verification/consumer-ingress-main-base.md new file mode 100644 index 000000000..fe4df8a0d --- /dev/null +++ b/docs/verification/consumer-ingress-main-base.md @@ -0,0 +1,30 @@ +# Consumer ingress base stabilization + +## Scope + +The modular LineageWeave consumer-admission change is reviewed and merged as a direct successor to the already-merged loopback ingress in PR #107. + +## Exact base correction + +- Protected target branch: `main` +- `main` at the correction point: `c45be17a9dbce95ef81cee230e9d128abc7160ac` +- Product head before this evidence-only commit: `17f06e814e943ebd9bf592549e2d218a4efed112` +- Superseded target: the historical PR #107 feature branch + +Retargeting does not remove or reimplement any admitted-consumer behavior. It prevents a successful merge from updating only the already-consumed feature branch instead of advancing TEPP `main`. + +## Preserved product boundary + +The change continues to preserve: + +- one consumer-neutral `/v1/analysis-runs` ingress; +- a closed `naruon` and `lineageweave` consumer registry; +- credential-free request construction; +- consumer-qualified tenant/idempotency namespaces; +- deterministic replay and changed-payload rejection; +- the existing Naruon compatibility listener; +- the distinction between `202 Accepted` and a completed psychometric result. + +## Merge evidence rule + +Retargeting invalidates remembered branch-base assumptions. Merge requires fresh terminal checks and an independent approval on the exact current head. Cancelled, predecessor-head, child-PR, or author-only evidence is not transferable. diff --git a/docs/verification/project-history-parent-restack.md b/docs/verification/project-history-parent-restack.md new file mode 100644 index 000000000..af05d7432 --- /dev/null +++ b/docs/verification/project-history-parent-restack.md @@ -0,0 +1,22 @@ +# Project-history parent restack evidence + +## Exact parents + +This stacked branch preserves both reviewed lines through an ordinary two-parent merge commit: + +- project-history child before restack: `855c6c7153c2f66a1c14e842ad700f571592dd35`; +- current modular-consumer parent: `cbb3dc0aa657c8d95f18be512ae33d0a1263f2ca`. +- resulting merge commit: `c9103d1e4becb597af98470dfe54ec7d1603762c`, with parents `855c6c7153c2f66a1c14e842ad700f571592dd35` and `cbb3dc0aa657c8d95f18be512ae33d0a1263f2ca`; +- current PR head at verification: `18c0fcd43a35fa934505065f1465c5b825f30e84`; +- protected remote `main` head at verification: `c45be17a9dbce95ef81cee230e9d128abc7160ac` (the local `main` ref was `3810bb73e3606431e1e19497b9746a8335e5d379`). + +`git merge-base --is-ancestor` confirms the merge commit is an ancestor of this +branch head, but not of either observed local or protected-remote `main` ref. + +The merge retains the parent’s current analysis-run parsing, consumer-aware live ingress, wire validation, and regression tests. It retains the child’s `/v1/project-histories` DTOs, credential-free LineageWeave exchange, deterministic projection logic, scientific-claim boundary, and contract tests. + +## Conflict disposition + +The two branches both touched the crate root and root changelog. The crate root keeps the child’s superset exports, including all parent analysis-run exports plus the project-history module. The root changelog keeps the parent entry, while the child release note is retained as `CHANGELOG.d/lineageweave-project-history.md` rather than deleting the parent record. + +No branch history is rewritten, no force update is used, and no product capability is removed. Fresh exact-head Rust, documentation, security, SAST, coverage, and independent-review evidence remains mandatory after this merge. diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 02722ebd5..3b1205b2c 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -38,12 +38,14 @@ def load_totals(path: Path) -> Mapping[str, Any]: def load_union_branch_totals(files: Sequence[object]) -> Mapping[str, int | float]: """Merge LLVM branch outcomes by source coordinate across test binaries.""" - outcomes: dict[tuple[str, int, int, int, int], list[int | float]] = {} + outcomes: dict[tuple[str, int, int, int, int], list[int]] = {} for file_record in files: if not isinstance(file_record, Mapping): raise ValueError("coverage file record must be an object") filename = file_record.get("filename") - branches = file_record.get("branches", []) + if "branches" not in file_record: + raise ValueError("coverage file record must contain branches") + branches = file_record["branches"] if not isinstance(filename, str) or not filename: raise ValueError("coverage file record must contain a filename") if not isinstance(branches, list): @@ -53,12 +55,13 @@ def load_union_branch_totals(files: Sequence[object]) -> Mapping[str, int | floa raise ValueError("coverage branch record is malformed") coordinates = branch[:4] counts = branch[4:6] - if not all(isinstance(value, int) and value >= 0 for value in coordinates): + if not all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 + for value in coordinates + ): raise ValueError("coverage branch coordinates are invalid") if not all( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and value >= 0 + isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in counts ): raise ValueError("coverage branch counts are invalid") @@ -131,8 +134,24 @@ def is_executable_source_line( return False if text.startswith("#[") or text.startswith("#!["): return False - if text in {"{", "}", "},", ");", "];", "();", "};"}: + if text in { + "{", + "}", + "(", + ")", + "},", + ");", + "];", + "();", + "};", + "});", + "Ok(())", + }: return False + if text.endswith(" {"): + type_name = text[:-2] + if type_name and all(character.isalnum() or character in "_:" for character in type_name): + return False if text.startswith("use ") or text.startswith("pub use "): return False if text.startswith("mod ") or text.startswith("pub mod "): diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index 07a71efc2..a09d71dc1 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -51,7 +51,6 @@ "psychometric_fit", "subevent_containment", "prediction_contradiction", - "provider_receipt", "operational_log", "service_tls", "derived_sensitivity", diff --git a/scripts/validate_documentation.py b/scripts/validate_documentation.py index d8c543b6b..3050b1f4a 100644 --- a/scripts/validate_documentation.py +++ b/scripts/validate_documentation.py @@ -41,6 +41,10 @@ "docs/adr/0014-scientific-claim-promotion-and-release-evidence.md", "docs/adr/0015-autonomous-development-review-and-merge-authority.md", "docs/adr/0016-tdt-chronos-event-intelligence-boundary.md", + "docs/adr/0017-hourly-contextual-orchestrator-gateway.md", + "docs/adr/0018-consumer-scoped-analysis-run-ingress.md", + "docs/adr/0019-project-history-wire-size-symmetry.md", + "docs/adr/0020-lineageweave-project-history-boundary.md", "docs/product/prd-v0.4-approved.md", PRODUCT_TECHNICAL_GAP_BASELINE, "docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md", @@ -338,7 +342,6 @@ def validate_promotion_authority_pointers() -> None: raise AssertionError("\n".join(failures)) -def validate_documentation_map() -> None: def validate_documentation_map(root: Path = ROOT) -> None: """Require cross-cutting canonical documents to be discoverable from the root map.""" diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 1b3153f19..98fbe58af 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -123,14 +123,11 @@ def test_full_branch_reports_merge_duplicate_instrumented_copies(self) -> None: payload["data"][0]["files"] = [ # type: ignore[index] { "filename": "src/live.rs", - "branches": [ - [10, 4, 10, 12, 1, 0, 0, 0, 4], - [10, 4, 10, 12, 0, 1, 0, 0, 4], - ], + "branches": [[10, 4, 10, 12, 1, 0, 0, 0, 4]], }, { "filename": "src/live.rs", - "branches": [[10, 4, 10, 12, 0, 0, 0, 0, 4]], + "branches": [[10, 4, 10, 12, 0, 1, 0, 0, 4]], }, ] with tempfile.TemporaryDirectory() as temporary: @@ -150,12 +147,29 @@ def test_full_branch_reports_fail_closed_on_malformed_records(self) -> None: malformed_reports = ( ([None], "file record must be an object"), ([{"filename": "", "branches": []}], "must contain a filename"), + ([{"filename": "src.rs"}], "must contain branches"), ([{"filename": "src.rs", "branches": {}}], "branches must be a list"), ([{"filename": "src.rs", "branches": [[1, 2]]}], "record is malformed"), + ( + [{"filename": "src.rs", "branches": [[True, 2, 3, 4, 1, 0]]}], + "coordinates are invalid", + ), + ( + [{"filename": "src.rs", "branches": [[1.5, 2, 3, 4, 1, 0]]}], + "coordinates are invalid", + ), + ( + [{"filename": "src.rs", "branches": [[1, 2, 3, 4, True, 0]]}], + "counts are invalid", + ), ( [{"filename": "src.rs", "branches": [[-1, 2, 3, 4, 1, 0]]}], "coordinates are invalid", ), + ( + [{"filename": "src.rs", "branches": [[1, 2, 3, 4, 0.5, 0]]}], + "counts are invalid", + ), ( [{"filename": "src.rs", "branches": [[1, 2, 3, 4, -1, 0]]}], "counts are invalid", @@ -371,6 +385,17 @@ def test_executable_source_line_filters_noise_records(self) -> None: " }", # 55 "}", # 56 " executable_statement();", # 57 executable + " append_value(", # 58 multiline call opener + " value,", # 59 trailing comma noise + " );", # 60 call close + " values", # 61 + " .iter()", # 62 method-chain continuation + " .collect::>()", # 63 method-chain continuation + " });", # 64 closure call close + "(", # 65 structural call opener + ")", # 66 structural call close + " Ok(())", # 67 structural unit result + " NaruonLiveResponse {", # 68 structural struct literal ] source.write_text("\n".join(source_lines) + "\n", encoding="utf-8") path = str(source) @@ -385,7 +410,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: coverage_contract.is_executable_source_line(path, len(source_lines) + 5) ) - expected_executable = {13, 40, 44, 57} + expected_executable = {13, 40, 44, 57, 58, 61, 62, 63} for line_number in range(1, len(source_lines) + 1): is_exec = coverage_contract.is_executable_source_line(path, line_number) if line_number in expected_executable: @@ -405,6 +430,9 @@ def test_executable_source_line_filters_noise_records(self) -> None: [ f"SF:{path}", "DA:57,1", + "DA:58,0", + "DA:62,0", + "DA:63,0", "DA:1,0", "DA:2,0", "DA:48,0", @@ -417,7 +445,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: coverage_contract.load_lcov_line_totals( lcov, repository_root=Path(temporary) ), - {"lines": {"count": 1, "covered": 1}}, + {"lines": {"count": 4, "covered": 1}}, ) def test_lcov_rejects_source_paths_outside_repository(self) -> None: diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 46cbb1585..df20ccbb9 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -10,10 +10,8 @@ from pathlib import Path from unittest import mock -from scripts import check_workspace_contract as contract from scripts import check_docstrings as docstrings from scripts import check_workspace_contract as contract -from scripts import check_workspace_contract as workspace_contract REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -27,6 +25,11 @@ 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(set(contract.EXPECTED_CRATES)), + len(contract.EXPECTED_CRATES), + "workspace crate inventory must not contain duplicate entries", + ) self.assertEqual(len(crate_roots), 11) self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertEqual(len(crate_roots), 11) @@ -36,8 +39,7 @@ def test_live_repository_is_documented(self) -> None: for path in contract.expected_member_paths() } self.assertEqual(set(crate_roots), expected_crate_roots) - self.assertEqual(len(crate_roots), 11) - self.assertEqual(len(crate_roots), len(workspace_contract.EXPECTED_CRATES)) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), []) diff --git a/tests/quality/test_ci_coverage_diagnostics.py b/tests/quality/test_ci_coverage_diagnostics.py index 6023d8096..4b83b73ef 100644 --- a/tests/quality/test_ci_coverage_diagnostics.py +++ b/tests/quality/test_ci_coverage_diagnostics.py @@ -31,7 +31,7 @@ def test_line_and_branch_failures_print_exact_missing_locations(self) -> None: self.assertIn("steps.line-report.outcome == 'success'", workflow) self.assertIn("id: branch-report", workflow) self.assertIn( - "cargo +nightly-2026-08-18 llvm-cov report --branch --text --show-missing-lines", + "cargo +nightly-2026-08-21 llvm-cov report --branch --text --show-missing-lines", workflow, ) self.assertIn("steps.branch-report.outcome == 'success'", workflow) @@ -87,7 +87,7 @@ def test_rust_toolchains_are_reproducible_and_reviewably_updated(self) -> None: self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") workflow = CI_WORKFLOW.read_text(encoding="utf-8") - self.assertEqual(workflow.count("nightly-2026-08-18"), 3) + self.assertEqual(workflow.count("nightly-2026-08-21"), 3) self.assertNotIn("nightly-2026-08-01", workflow) dependabot = DEPENDABOT.read_text(encoding="utf-8") diff --git a/tests/quality/test_hourly_nim_product_development.py b/tests/quality/test_hourly_nim_product_development.py index fa685d80e..aa6b41cb9 100644 --- a/tests/quality/test_hourly_nim_product_development.py +++ b/tests/quality/test_hourly_nim_product_development.py @@ -210,7 +210,9 @@ def test_hourly_prompt_and_verifier_keep_commercial_quality_gates(self) -> None: "cargo deny check", 'line_coverage="$RUNNER_TEMP/coverage.lcov"', 'branch_coverage="$RUNNER_TEMP/coverage-branches.json"', + "cargo llvm-cov --workspace --all-features --lcov --output-path \"$line_coverage\" --ignore-filename-regex 'sqlx_live\\.rs'", 'python3 scripts/check_coverage.py "$line_coverage" --kind lines --format lcov', + "cargo +nightly-2026-08-21 llvm-cov --branch --workspace --all-features --json --output-path \"$branch_coverage\" --ignore-filename-regex 'sqlx_live\\.rs'", 'python3 scripts/check_coverage.py "$branch_coverage" --kind branches', ): self.assertIn(command, verifier)