From c7e02968f147157aedbe805a8ffac823bdb716bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:40:19 +0000 Subject: [PATCH] feat(api): retrieve stored project-history requests by extra-segment GET GAP-003A unique slice: GET /v1/project-histories/{key}/request returns the accepted LineageWeave create request on AnalysisRunLiveService. Metric-free; inference_status remains temporal_association_only. Naruon refused. Does not re-open cancel lineages. ADR 0087. --- .../project-history-stored-request-get.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 88 +++++- crates/tepp_api/src/lib.rs | 9 + .../project_history_stored_request_http.rs | 280 ++++++++++++++++++ ...ct_history_stored_request_http_contract.rs | 42 +++ docs/API_CONTRACT.md | 2 + docs/TRACEABILITY.md | 1 + ...0087-project-history-stored-request-get.md | 61 ++++ docs/adr/README.md | 1 + .../project-history-stored-request-get.md | 14 + 11 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/project-history-stored-request-get.md create mode 100644 crates/tepp_api/src/project_history_stored_request_http.rs create mode 100644 crates/tepp_api/tests/project_history_stored_request_http_contract.rs create mode 100644 docs/adr/0087-project-history-stored-request-get.md create mode 100644 docs/research/project-history-stored-request-get.md diff --git a/CHANGELOG.d/project-history-stored-request-get.md b/CHANGELOG.d/project-history-stored-request-get.md new file mode 100644 index 000000000..7c36b8a5d --- /dev/null +++ b/CHANGELOG.d/project-history-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/project-histories/{idempotency_key}/request` returns the accepted LineageWeave create request on `tepp-loopback` (ADR 0087). Metric-free; stored projection `inference_status` remains `temporal_association_only`. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index eb183395e..2d4cbb984 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.md) | | Project-history GET-by-id doctoring | [`docs/research/project-history-retrieval-http.md`](docs/research/project-history-retrieval-http.md) | +| Project-history stored-request GET doctoring | [`docs/research/project-history-stored-request-get.md`](docs/research/project-history-stored-request-get.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 8807ed1d6..611836235 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -25,7 +25,8 @@ use crate::{ is_project_history_collection_path, page_project_history_collection_items, parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, project_history_projection, project_history_retrieval_path_id, - refuse_metrics_on_project_history_retrieval_payload, requests_are_idempotent_matches, + project_history_stored_request_path_id, refuse_metrics_on_project_history_retrieval_payload, + refuse_metrics_on_project_history_stored_request_payload, requests_are_idempotent_matches, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -153,6 +154,12 @@ impl AnalysisRunLiveService { if is_project_history_collection_path(path) { return self.list_project_histories(&headers, body); } + if matches!( + project_history_stored_request_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.get_project_history_stored_request(path, &headers, body); + } if matches!( project_history_retrieval_path_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -321,6 +328,40 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn get_project_history_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_stored_request_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("tepp-page-limit") || headers.contains_key("tepp-page-cursor") { + return Err(ApiError::InvalidWirePayload); + } + let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?; + crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?; + let idempotency_key = project_history_stored_request_path_id(path)?; + let replay_key = + consumer_tenant_idempotency_key(consumer, tenant_workspace_id, &idempotency_key); + let (stored_request, projection) = self + .accepted_project_histories + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if projection.inference_status != "temporal_association_only" { + return Err(ApiError::InvalidWirePayload); + } + let response_body = stored_request.to_json()?; + refuse_metrics_on_project_history_stored_request_payload(&response_body)?; + 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; @@ -1237,6 +1278,51 @@ mod tests { assert!(!collection.body.contains("evidence_text")); } + #[test] + fn project_history_stored_request_get_returns_create_request_and_fails_closed() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_project_history("idem-a", "project-a"); + assert_eq!( + service + .handle_http_request(&project_history_post(&first)) + .status_code, + 200 + ); + let got = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored = ProjectHistoryRequest::from_json(&got.body).expect("stored"); + assert_eq!(stored, first); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("causal_score")); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/missing/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 68d4a85ac..3d15a69d7 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -30,6 +30,7 @@ mod orchestration; mod project_history; mod project_history_collection_http; mod project_history_retrieval_http; +mod project_history_stored_request_http; mod project_journey; mod provider_payload; mod temporal_context; @@ -266,6 +267,14 @@ pub use project_history_retrieval_http::lineageweave_project_history_retrieval_e pub use project_history_retrieval_http::project_history_retrieval_path_id; /// Refuse scientific-metric and causal-score keys on retrieval JSON. pub use project_history_retrieval_http::refuse_metrics_on_project_history_retrieval_payload; +/// Whether a path is the project-history stored-request extra-segment. +pub use project_history_stored_request_http::is_project_history_stored_request_path; +/// `LineageWeave` GET exchange for one stored project-history create request. +pub use project_history_stored_request_http::lineageweave_project_history_stored_request_exchange; +/// Extract the opaque idempotency key from a stored-request GET path. +pub use project_history_stored_request_http::project_history_stored_request_path_id; +/// Refuse scientific-metric and causal-score keys on stored-request JSON. +pub use project_history_stored_request_http::refuse_metrics_on_project_history_stored_request_payload; /// Maximum posterior Project Journey artifact size. pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT; /// Exact posterior Project Journey schema identity. diff --git a/crates/tepp_api/src/project_history_stored_request_http.rs b/crates/tepp_api/src/project_history_stored_request_http.rs new file mode 100644 index 000000000..ef4ed65f8 --- /dev/null +++ b/crates/tepp_api/src/project_history_stored_request_http.rs @@ -0,0 +1,280 @@ +//! Provider-owned project-history stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/project-histories/{idempotency_key}/request` +//! returns the accepted `LineageWeave` create request on `AnalysisRunLiveService` +//! / `tepp-loopback` so operators who hold a retrieval identity do not replay +//! POST. `inference_status` on the live projection remains +//! `temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. +//! This module does not duplicate GET-by-id (#429), retrieval CLI (#431), +//! collection GET/CLI (#424/#428), POST CLI (#420), interpretation-run +//! stored-request GET (#453), cancel lineages (closed), Leiden, or GAP-010 +//! Figma/export. Persistence remains GAP-003B. Naruon is refused. +//! `NaruonLiveService` stays POST-only. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::project_history::validate_project_history_registry_identity; +use crate::project_history_retrieval_http::{ + PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, +}; +use crate::wire::require_nonempty; +use crate::{ApiError, PROJECT_HISTORY_PATH}; + +const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "causal_score", +]; + +/// Extract the opaque idempotency key from +/// `GET /v1/project-histories/{key}/request`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for collection, GET-by-id, extra +/// segments, a hostile encoding, or an empty identity, and +/// [`ApiError::LimitExceeded`] when oversized. +pub fn project_history_stored_request_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(PROJECT_HISTORY_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + let (encoded_id, rest) = encoded + .split_once('/') + .ok_or(ApiError::InvalidWirePayload)?; + if rest != "request" || encoded_id.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded_id)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Whether `path` is the stored-request extra-segment resource. +#[must_use] +pub fn is_project_history_stored_request_path(path: &str) -> bool { + project_history_stored_request_path_id(path).is_ok() +} + +/// Refuse stored-request JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric or causal +/// key is present. +pub fn refuse_metrics_on_project_history_stored_request_payload( + payload: &str, +) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + if !value.is_object() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { + match value { + serde_json::Value::Object(object) => { + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + == Some("tepp.scientific_acceptance.v1") + { + return Err(ApiError::InvalidWirePayload); + } + if FORBIDDEN_STORED_REQUEST_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + for nested in object.values() { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + serde_json::Value::Array(items) => { + for nested in items { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Build a credential-free `LineageWeave` stored-request GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin, tenant, or identity error. +pub fn lineageweave_project_history_stored_request_exchange( + origin: &str, + tenant_workspace_id: &str, + idempotency_key: &str, +) -> Result { + validate_project_history_registry_identity(tenant_workspace_id)?; + validate_project_history_registry_identity(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + let target_path = format!("{PROJECT_HISTORY_PATH}/{encoded_id}/request"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ( + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + tenant_workspace_id.into(), + ), + ], + body: String::new(), + }) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len() + value.len() / 2); + let hex = b"0123456789ABCDEF"; + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(ApiError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(ApiError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + is_project_history_stored_request_path, + lineageweave_project_history_stored_request_exchange, + project_history_stored_request_path_id, + }; + use crate::ApiError; + + #[test] + fn stored_request_exchange_is_lineageweave_get_without_credentials() { + let exchange = lineageweave_project_history_stored_request_exchange( + "https://tepp.example.test", + "history-tenant", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/project-histories/idem-a/request") + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); + assert!(is_project_history_stored_request_path( + "/v1/project-histories/idem-a/request" + )); + assert!(!is_project_history_stored_request_path( + "/v1/project-histories/idem-a" + )); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/request") + .expect("id"), + "idem-a" + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_stored_request_exchange( + "http://tepp.example.test", + "history-tenant", + "idem-a" + ), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/project_history_stored_request_http_contract.rs b/crates/tepp_api/tests/project_history_stored_request_http_contract.rs new file mode 100644 index 000000000..b6d930ec6 --- /dev/null +++ b/crates/tepp_api/tests/project_history_stored_request_http_contract.rs @@ -0,0 +1,42 @@ +//! Contract tests for `LineageWeave` project-history stored-request GET. + +use tepp_api::{ + lineageweave_project_history_stored_request_exchange, project_history_stored_request_path_id, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, +}; + +#[test] +fn stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = lineageweave_project_history_stored_request_exchange( + "https://tepp.example.test", + "history-tenant", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/project-histories/idem-a/request")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == LINEAGEWEAVE_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/request").expect("id"), + "idem-a" + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 819f70fa3..2af503e07 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -70,6 +70,8 @@ POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} GET /v1/project-histories +GET /v1/project-histories/{idempotency_key} +GET /v1/project-histories/{idempotency_key}/request ``` 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. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index dbc6936c6..b92d8e2c9 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,6 +55,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback LineageWeave project-history collection GET | ADR 0028; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories` on `tepp-loopback`; metric-free `temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | | loopback LineageWeave project-history GET-by-id | ADR 0066; API contract; RFC 9110; ADR 0028/0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}` on `tepp-loopback`; stored `temporal_association_only` projection; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history stored-request GET | ADR 0087; ADR 0066; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}/request` on `tepp-loopback`; returns stored create request; projection `inference_status` remains `temporal_association_only`; naruon refused | active-PR | | executable cutoff-safe analysis-run readiness | ADR 0021; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | delayed-reporting cutoff eligibility in truth corpora | ADR 0002; research | `tepp_simulation` eligible-at-cutoff filter on the active PR | active-PR | | 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 | diff --git a/docs/adr/0087-project-history-stored-request-get.md b/docs/adr/0087-project-history-stored-request-get.md new file mode 100644 index 000000000..bb1b84eab --- /dev/null +++ b/docs/adr/0087-project-history-stored-request-get.md @@ -0,0 +1,61 @@ +# ADR 0087 — Loopback project-history stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0066. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0086 occupied. + +## Context + +ADR 0066 retrieves one accepted project-history projection. Operators still +had no extra-segment GET for the stored LineageWeave create request. +Interpretation-run stored-request GET (#453) is orchestrator-owned. Duplicating +GET-by-id (#429), retrieval CLI (#431), collection GET/CLI, POST CLI, Leiden, +or GAP-010 would collide with live PRs. Cancel lineages stay closed. Naruon is +refused on this LineageWeave-owned adapter. + +## Decision + +Publish `GET /v1/project-histories/{idempotency_key}/request` on +`AnalysisRunLiveService`. Extra-segment parse. Slash/NUL fail closed. Empty +body. LineageWeave-only. Tenant header required. `inference_status` on the +stored projection remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. Cancel extra-segment stays +refused. `NaruonLiveService` stays POST-only. + +## Alternatives considered + +1. Re-open cancel HTTP — rejected. +2. Return GET-by-id projection — rejected (ADR 0066). +3. Loopback stored-request GET — accepted. + +## Consequences + +HTTP 200 is not measurement evidence and is not an ADR 0014 claim. Sequence +remains association, not causation. + +## Failure and recovery + +Naruon, nonempty bodies, extra segments, slash/NUL, missing keys, missing +tenant, and metric keys fail closed. + +## Verification + +- `GET /v1/project-histories/{idempotency_key}/request` of an accepted history + returns the stored create request without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, GET-by-id path, extra segments, slash/NUL, nonempty body, and missing + keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the extra-segment GET; POST and GET-by-id remain valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open naruon on this adapter, add +GET to `NaruonLiveService`, or treat retrieval success as an ADR 0014 claim. + +## Related authority + +ADR 0066, ADR 0028, ADR 0021, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index c865f298e..b7544f2cd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0028](0028-project-history-collection-get.md) | Loopback `GET /v1/project-histories` enumerates accepted LineageWeave projections | Accepted | active-PR | Complements ADR 0021/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0066](0066-project-history-retrieval-get.md) | Loopback `GET /v1/project-histories/{idempotency_key}` retrieves one accepted LineageWeave projection | Accepted | active-PR | Complements ADR 0028; unique vs protected main. Does not infer causality. | +| [0087](0087-project-history-stored-request-get.md) | Loopback project-history stored-request GET | Accepted | active-PR | Complements ADR 0066; `GET /v1/project-histories/{idempotency_key}/request` returns the stored create request. Unique versus protected main (0026–0086 occupied). Does not re-open cancel lineages. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/research/project-history-stored-request-get.md b/docs/research/project-history-stored-request-get.md new file mode 100644 index 000000000..9b7f9cf11 --- /dev/null +++ b/docs/research/project-history-stored-request-get.md @@ -0,0 +1,14 @@ +# Project-history stored-request GET (doctoring) + +`GET /v1/project-histories/{idempotency_key}/request` returns one accepted +LineageWeave create request on `tepp-loopback`. HTTP semantics follow RFC 9110 +(Fielding, Nottingham, & Reschke, 2022). Fail-closed naruon, extra segments, +slash/NUL, leftover bodies, credential flags, and scientific-authority +promotion are repository contract (ADR 0087; ADR 0014). + +`inference_status` on the stored projection remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific +claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package.