diff --git a/CHANGELOG.d/analysis-run-lifecycle-consumer-parity.md b/CHANGELOG.d/analysis-run-lifecycle-consumer-parity.md new file mode 100644 index 000000000..703069995 --- /dev/null +++ b/CHANGELOG.d/analysis-run-lifecycle-consumer-parity.md @@ -0,0 +1,6 @@ +- `tepp_api` adds `lineageweave_analysis_run_running_exchange` / + `lineageweave_analysis_run_terminal_exchange`, Naruon compatibility-listener + lifecycle POST, and a `tepp-loopback` TCP running proof (ADR 0029). Metric-free + running and request-bound terminal semantics are unchanged from ADR 0028. + `NaruonLiveService` stays POST-only and Naruon-only. Not GET status, not + cancel, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbe8b94a..b9e116cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `tepp_api` adds LineageWeave running/terminal exchange builders, Naruon compatibility-listener lifecycle POST, and a `tepp-loopback` TCP create-then-running proof so both published consumers can record metric-free running status without minting a foreign consumer header. `NaruonLiveService` stays POST-only and still refuses LineageWeave. Reverse transitions, mutating a terminal run, GET, receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer mismatch fail closed. This is the GAP-003A lifecycle consumer-parity slice for issue #166 stacked on #360; it does not duplicate the shared-listener lifecycle POST (#360), GET status (#359), status consumer-parity (#383), cancel (#361/#373), the terminal-result DTO (#358), or the `analysis_engine` library bind (#356); persistence remains GAP-003B. + - `tepp_api` loopback `AnalysisRunLiveService` now serves production `POST /v1/analysis-runs/{run_id}/running` and `POST /v1/analysis-runs/{run_id}/terminal` so accepted/running stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1` after a lifecycle POST. Canonical artifact bytes travel as `scientific_acceptance_json`. Reverse transitions, mutating a terminal run, failed-plus-artifact emission, receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer mismatch fail closed. This is the GAP-003A HTTP lifecycle slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356), the terminal-result DTO wire (#358), or the GET status slice (#359); persistence remains GAP-003B. - `tepp_api` loopback `AnalysisRunLiveService` now serves `GET /v1/analysis-runs/{run_id}` so accepted/running statuses stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. Receipt RMSE/bias/coverage/SE-gate keys, a GET body, failed-plus-artifact emission, an all-zero digest, and digest mismatch fail closed. This is the GAP-003A HTTP status slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356) or the terminal-result DTO wire (#358); persistence remains GAP-003B. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..a37274f24 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -11,6 +11,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| Analysis-run lifecycle consumer-parity doctoring | [`docs/research/analysis-run-lifecycle-consumer-parity.md`](docs/research/analysis-run-lifecycle-consumer-parity.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) | @@ -109,6 +110,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| Analysis-run lifecycle consumer-parity doctoring | [`docs/research/analysis-run-lifecycle-consumer-parity.md`](docs/research/analysis-run-lifecycle-consumer-parity.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index facecf471..973f67de8 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -167,6 +167,10 @@ pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; 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` running-status exchange without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_running_exchange; +/// Build a `LineageWeave` terminal-status exchange without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_terminal_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. diff --git a/crates/tepp_api/src/lineageweave_http.rs b/crates/tepp_api/src/lineageweave_http.rs index 6094760ed..13eb72750 100644 --- a/crates/tepp_api/src/lineageweave_http.rs +++ b/crates/tepp_api/src/lineageweave_http.rs @@ -3,9 +3,10 @@ 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, + AnalysisRunLifecycleTransition, AnalysisRunRequest, ApiError, NaruonHttpExchange, + ProjectHistoryHttpExchange, ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, + TEMPORAL_CONTEXT_PATH, TemporalContextRequest, naruon_analysis_run_exchange, + naruon_analysis_run_running_exchange, naruon_analysis_run_terminal_exchange, }; /// Stable consumer identity used by the Naruon adapter. @@ -29,13 +30,54 @@ pub fn lineageweave_analysis_run_exchange( request: &AnalysisRunRequest, ) -> Result { let mut exchange = naruon_analysis_run_exchange(origin, request)?; + swap_consumer_header(&mut exchange)?; + Ok(exchange) +} + +/// Build a `LineageWeave` → TEPP running-status POST without credentials. +/// +/// The function reuses TEPP's existing origin, body, and header validation, +/// then replaces only the published modular-consumer identity. Running stays +/// a metric-free lifecycle command, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_running_exchange`]. +pub fn lineageweave_analysis_run_running_exchange( + origin: &str, + transition: &AnalysisRunLifecycleTransition, +) -> Result { + let mut exchange = naruon_analysis_run_running_exchange(origin, transition)?; + swap_consumer_header(&mut exchange)?; + Ok(exchange) +} + +/// Build a `LineageWeave` → TEPP terminal-status POST without credentials. +/// +/// The function reuses TEPP's existing origin, body, and header validation, +/// then replaces only the published modular-consumer identity. Terminal +/// scientific-acceptance bytes remain request-bound and fail closed. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_terminal_exchange`]. +pub fn lineageweave_analysis_run_terminal_exchange( + origin: &str, + transition: &AnalysisRunLifecycleTransition, +) -> Result { + let mut exchange = naruon_analysis_run_terminal_exchange(origin, transition)?; + swap_consumer_header(&mut exchange)?; + Ok(exchange) +} + +fn swap_consumer_header(exchange: &mut NaruonHttpExchange) -> Result<(), ApiError> { 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) + Ok(()) } /// Build a credential-free `LineageWeave` temporal-context exchange. @@ -94,9 +136,12 @@ pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool { mod tests { use super::{ LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, - lineageweave_analysis_run_exchange, + lineageweave_analysis_run_exchange, lineageweave_analysis_run_running_exchange, + lineageweave_analysis_run_terminal_exchange, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunLifecycleTransition, AnalysisRunRequest, ApiError, }; - use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; fn sample_run() -> AnalysisRunRequest { AnalysisRunRequest { @@ -132,4 +177,63 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_lifecycle_exchanges_swap_only_the_consumer_header() { + let running = + AnalysisRunLifecycleTransition::running("tepp-run-1", "idem-1").expect("running"); + let running_exchange = + lineageweave_analysis_run_running_exchange("https://tepp.example.test", &running) + .expect("running exchange"); + assert_eq!(running_exchange.method, "POST"); + assert_eq!( + running_exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-1/running" + ); + assert!( + running_exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + !running_exchange + .headers + .contains(&("tepp-consumer".into(), NARUON_CONSUMER_CODE.into())) + ); + assert!(!running_exchange.body.contains("rmse")); + assert_eq!( + lineageweave_analysis_run_running_exchange("http://tepp.example.test", &running), + Err(ApiError::InvalidWirePayload) + ); + + let failed = crate::AnalysisRunTerminalResult::failed( + &sample_run(), + &crate::AnalysisRunAccepted::new("tepp-run-1", "accepted", "idem-1").expect("accepted"), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let terminal = + AnalysisRunLifecycleTransition::terminal("tepp-run-1", "idem-1", failed, None) + .expect("terminal"); + let terminal_exchange = + lineageweave_analysis_run_terminal_exchange("https://tepp.example.test", &terminal) + .expect("terminal exchange"); + assert_eq!(terminal_exchange.method, "POST"); + assert!( + terminal_exchange + .target_url + .ends_with("/v1/analysis-runs/tepp-run-1/terminal") + ); + assert!( + terminal_exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert_eq!( + lineageweave_analysis_run_terminal_exchange("https://tepp.example.test", &running) + .expect_err("running on terminal"), + ApiError::InvalidWirePayload + ); + } } diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index f9b4ca327..ee5fe195b 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -5,6 +5,8 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; +use crate::analysis_run_lifecycle_http::AnalysisRunLifecycleTransition; +use crate::analysis_run_status_http::{AnalysisRunLiveRoute, parse_analysis_run_live_route}; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; @@ -14,10 +16,11 @@ use crate::live_http::{ split_request, validate_common_headers, }; use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH}; +use crate::scientific_acceptance_http::{refuse_metrics_on_receipt, status_http_json}; use crate::wire::{from_json, to_json}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, ApiError, ErrorEnvelope, - requests_are_idempotent_matches, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, ApiError, + ErrorEnvelope, requests_are_idempotent_matches, require_status_binding, }; #[cfg(test)] @@ -51,14 +54,26 @@ pub struct NaruonLiveResponse { /// Production interchange origins remain `https` only. This listener binds /// loopback TCP so tests and local standalone operation can prove request /// handling without claiming TLS termination or cross-service table access. -/// This port only accepts versioned naruon POSTs. +/// This port only accepts versioned naruon POSTs, including +/// `POST /v1/analysis-runs/{run_id}/running` and +/// `POST /v1/analysis-runs/{run_id}/terminal` for metric-free lifecycle. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, bound_addr: Option, next_run_serial: u64, next_request_serial: u64, - accepted_runs: HashMap, + accepted_runs: HashMap, + runs_by_id: HashMap, +} + +/// One naruon-only accepted run and its current lifecycle status. +#[derive(Clone, Debug, Eq, PartialEq)] +struct NaruonLiveRun { + request: AnalysisRunRequest, + accepted: AnalysisRunAccepted, + status: AnalysisRunStatus, + scientific_acceptance_json: Option, } impl Default for NaruonLiveService { @@ -77,6 +92,7 @@ impl NaruonLiveService { next_run_serial: 1, next_request_serial: 1, accepted_runs: HashMap::new(), + runs_by_id: HashMap::new(), } } @@ -201,11 +217,24 @@ impl NaruonLiveService { if method != "POST" { return Err(ApiError::InvalidWirePayload); } + let headers = parse_headers(lines)?; + refuse_live_headers(&headers, self.bound_addr)?; + match parse_analysis_run_live_route(path) { + Ok(AnalysisRunLiveRoute::Running { run_id }) => { + return self.post_running_status(&run_id, &headers, body); + } + Ok(AnalysisRunLiveRoute::Terminal { run_id }) => { + return self.post_terminal_status(&run_id, &headers, body); + } + Ok(AnalysisRunLiveRoute::Status { .. }) => { + return Err(ApiError::InvalidWirePayload); + } + Err(ApiError::LimitExceeded) => return Err(ApiError::LimitExceeded), + Err(_) => {} + } if path != NARUON_ANALYSIS_RUN_PATH && path != NARUON_EXPORT_PATH { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(lines)?; - refuse_live_headers(&headers, self.bound_addr)?; self.dispatch_path(path, &headers, body) } @@ -233,12 +262,12 @@ impl NaruonLiveService { return Err(ApiError::InvalidWirePayload); } let replay_key = tenant_idempotency_key(&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) { + if let Some(stored) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(&stored.request, &request) { return Ok(NaruonLiveResponse::json( 202, "Accepted", - stored_accepted.to_json()?, + stored.accepted.to_json()?, )); } return Err(ApiError::InvalidWirePayload); @@ -248,10 +277,132 @@ impl NaruonLiveService { let accepted = AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; let body = accepted.to_json()?; - self.accepted_runs.insert(replay_key, (request, accepted)); + let status = AnalysisRunStatus::accepted(&accepted)?; + self.runs_by_id + .insert(accepted.run_id.clone(), replay_key.clone()); + self.accepted_runs.insert( + replay_key, + NaruonLiveRun { + request, + accepted, + status, + scientific_acceptance_json: None, + }, + ); Ok(NaruonLiveResponse::json(202, "Accepted", body)) } + fn post_running_status( + &mut self, + run_id: &str, + headers: &HashMap, + body: &str, + ) -> Result { + refuse_metrics_on_receipt(body)?; + let transition = AnalysisRunLifecycleTransition::from_json(body)?; + if transition.run_state != AnalysisRunStatusState::Running { + return Err(ApiError::InvalidWirePayload); + } + self.commit_lifecycle_transition(run_id, headers, &transition) + } + + fn post_terminal_status( + &mut self, + run_id: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let transition = AnalysisRunLifecycleTransition::from_json(body)?; + if !matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) { + return Err(ApiError::InvalidWirePayload); + } + self.commit_lifecycle_transition(run_id, headers, &transition) + } + + fn commit_lifecycle_transition( + &mut self, + path_run_id: &str, + headers: &HashMap, + transition: &AnalysisRunLifecycleTransition, + ) -> Result { + if transition.run_id != path_run_id { + return Err(ApiError::InvalidWirePayload); + } + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != transition.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = self + .runs_by_id + .get(path_run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.accepted.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let status = match transition.run_state { + AnalysisRunStatusState::Running => AnalysisRunStatus::running(&stored.accepted)?, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + let result = transition + .terminal_result + .clone() + .ok_or(ApiError::InvalidWirePayload)?; + AnalysisRunStatus::terminal(&stored.request, &stored.accepted, result)? + } + AnalysisRunStatusState::Accepted => return Err(ApiError::InvalidWirePayload), + }; + if stored.status == status + && stored.scientific_acceptance_json == transition.scientific_acceptance_json + { + let response_body = status_http_json( + &stored.status, + &stored.request, + stored.scientific_acceptance_json.as_deref(), + )?; + return Ok(NaruonLiveResponse::json(200, "OK", response_body)); + } + match stored.status.run_state { + AnalysisRunStatusState::Accepted => {} + AnalysisRunStatusState::Running + if matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) => {} + AnalysisRunStatusState::Running + | AnalysisRunStatusState::Succeeded + | AnalysisRunStatusState::Failed => { + return Err(ApiError::InvalidWirePayload); + } + } + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + require_status_binding(&stored.request, &stored.accepted, &status)?; + let _ = status_http_json( + &status, + &stored.request, + transition.scientific_acceptance_json.as_deref(), + )?; + stored.status = status; + stored + .scientific_acceptance_json + .clone_from(&transition.scientific_acceptance_json); + let response_body = status_http_json( + &stored.status, + &stored.request, + stored.scientific_acceptance_json.as_deref(), + )?; + Ok(NaruonLiveResponse::json(200, "OK", response_body)) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -531,4 +682,125 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_records_running_and_terminal_lifecycle() { + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLifecycleTransition, + AnalysisRunRequest, AnalysisRunTerminalResult, + }; + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-lifecycle-idem".into(), + tenant_workspace_id: "naruon-lifecycle-tenant".into(), + snapshot_id: "naruon-lifecycle-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "topic-measurement-v1".into(), + output_profile: "naruon-consumer-validation-report".into(), + }; + let body = run.to_json().expect("json"); + let create = format!( + "POST /v1/analysis-runs 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 mut service = NaruonLiveService::new(); + let accepted = service.handle_http_request(&create); + assert_eq!(accepted.status_code, 202); + let run_id = AnalysisRunAccepted::from_json(&accepted.body) + .expect("accepted") + .run_id; + let first_run_id = run_id.clone(); + let running = AnalysisRunLifecycleTransition::running(&run_id, run.idempotency_key.clone()) + .expect("running") + .to_json() + .expect("running json"); + let running_post = format!( + "POST /v1/analysis-runs/{run_id}/running 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{running}", + run.idempotency_key, + running.len() + ); + let recorded = service.handle_http_request(&running_post); + assert_eq!(recorded.status_code, 200); + assert!(recorded.body.contains("\"run_state\":\"running\"")); + assert!(!recorded.body.contains("rmse")); + assert!(!recorded.body.contains("scientific_acceptance")); + let replay = service.handle_http_request(&running_post); + assert_eq!(replay.body, recorded.body); + + let accepted_dto = + AnalysisRunAccepted::new(run_id.clone(), "accepted", run.idempotency_key.clone()) + .expect("accepted dto"); + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted_dto, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let terminal = AnalysisRunLifecycleTransition::terminal( + run_id.clone(), + run.idempotency_key.clone(), + failed, + None, + ) + .expect("terminal") + .to_json() + .expect("terminal json"); + let terminal_post = format!( + "POST /v1/analysis-runs/{run_id}/terminal 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{terminal}", + run.idempotency_key, + terminal.len() + ); + let finished = service.handle_http_request(&terminal_post); + assert_eq!(finished.status_code, 200); + assert!(finished.body.contains("\"run_state\":\"failed\"")); + assert!(!finished.body.contains("rmse")); + let replay_terminal = service.handle_http_request(&terminal_post); + assert_eq!(replay_terminal.body, finished.body); + assert_eq!(service.handle_http_request(&running_post).status_code, 400); + + let lineageweave = format!( + "POST /v1/analysis-runs/{first_run_id}/running HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{running}", + run.idempotency_key, + running.len() + ); + assert_eq!(service.handle_http_request(&lineageweave).status_code, 400); + let get = format!( + "GET /v1/analysis-runs/{first_run_id}/running 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: 0\r\n\r\n", + run.idempotency_key + ); + assert_eq!(service.handle_http_request(&get).status_code, 400); + let metrics = format!( + "POST /v1/analysis-runs/{first_run_id}/running 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: 12\r\n\r\n{{\"rmse\":0.1}}", + run.idempotency_key + ); + assert_eq!(service.handle_http_request(&metrics).status_code, 400); + assert_eq!( + service.handle_http_request( + "POST /v1/analysis-runs/missing/running 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: naruon-lifecycle-idem\r\ncontent-length: 0\r\n\r\n" + ).status_code, + 400 + ); + let oversized = format!( + "POST /v1/analysis-runs/{}/running 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: k\r\ncontent-length: 0\r\n\r\n", + "a".repeat(129) + ); + assert_eq!(service.handle_http_request(&oversized).status_code, 413); + let mut dangling = NaruonLiveService::new(); + dangling + .runs_by_id + .insert("ghost".into(), "missing-replay".into()); + let ghost_running = + AnalysisRunLifecycleTransition::running("ghost", "naruon-lifecycle-idem") + .expect("ghost") + .to_json() + .expect("ghost json"); + let ghost = format!( + "POST /v1/analysis-runs/ghost/running 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: naruon-lifecycle-idem\r\ncontent-length: {}\r\n\r\n{ghost_running}", + ghost_running.len() + ); + assert_eq!(dangling.handle_http_request(&ghost).status_code, 400); + } } diff --git a/crates/tepp_api/tests/lineageweave_http_contract.rs b/crates/tepp_api/tests/lineageweave_http_contract.rs index 19b3e352e..861197c4f 100644 --- a/crates/tepp_api/tests/lineageweave_http_contract.rs +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -7,9 +7,11 @@ 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, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLifecycleTransition, + AnalysisRunLiveService, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + lineageweave_analysis_run_exchange, lineageweave_analysis_run_running_exchange, + lineageweave_analysis_run_terminal_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -68,6 +70,41 @@ fn lineageweave_exchange_uses_the_published_consumer_header_without_credentials( })); } +#[test] +fn lineageweave_lifecycle_exchanges_post_without_credentials() { + let running = AnalysisRunLifecycleTransition::running("tepp-run-9", "shared-idempotency-key") + .expect("running"); + let exchange = + lineageweave_analysis_run_running_exchange("https://tepp.example.test", &running) + .expect("lineageweave running"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-9/running" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "proxy-authorization" | "cookie" | "x-api-key" + ) + })); + assert!(!exchange.body.contains("rmse")); + assert_eq!( + lineageweave_analysis_run_running_exchange("http://tepp.example.test", &running), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_terminal_exchange("https://tepp.example.test", &running) + .expect_err("running on terminal"), + ApiError::InvalidWirePayload + ); +} + #[test] fn live_listener_accepts_lineageweave_and_isolates_consumer_idempotency() { let loopback = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); @@ -108,6 +145,31 @@ fn live_listener_accepts_lineageweave_and_isolates_consumer_idempotency() { let conflict_response = service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &conflict)); assert_eq!(conflict_response.status_code, 400); + + let running = AnalysisRunLifecycleTransition::running( + &lineageweave_accepted.run_id, + run.idempotency_key.clone(), + ) + .expect("running") + .to_json() + .expect("running json"); + let post_running = service.handle_http_request(&format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{}/running 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\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{running}", + lineageweave_accepted.run_id, + run.idempotency_key, + running.len() + )); + assert_eq!(post_running.status_code, 200); + assert!(post_running.body.contains("\"running\"")); + assert!(!post_running.body.contains("rmse")); + assert!(!post_running.body.contains("scientific_acceptance")); + let naruon_running = service.handle_http_request(&format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{}/running 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\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{running}", + lineageweave_accepted.run_id, + run.idempotency_key, + running.len() + )); + assert_eq!(naruon_running.status_code, 400); } #[test] diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..41ab57d57 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,48 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_records_a_lineageweave_running_transition_over_tcp() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "2"]) + .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 host = address.trim(); + let body = r#"{"contract_version":1,"idempotency_key":"loopback-lifecycle-idem","tenant_workspace_id":"loopback-lifecycle-tenant","snapshot_id":"loopback-lifecycle-snapshot","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#; + let create = format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-lifecycle-idem\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(host).expect("connect create"); + stream.write_all(create.as_bytes()).expect("create"); + let mut created = String::new(); + stream.read_to_string(&mut created).expect("created"); + assert!(created.starts_with("HTTP/1.1 202 Accepted")); + let json_start = created.find("{\"contract_version\"").expect("json"); + let accepted: serde_json::Value = + serde_json::from_str(&created[json_start..]).expect("accepted json"); + let run_id = accepted["run_id"].as_str().expect("run_id"); + assert!(!created[json_start..].contains("rmse")); + let running = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","run_state":"running","idempotency_key":"loopback-lifecycle-idem"}}"# + ); + let post = format!( + "POST /v1/analysis-runs/{run_id}/running HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-lifecycle-idem\r\ncontent-length: {}\r\n\r\n{running}", + running.len() + ); + let mut stream = TcpStream::connect(host).expect("connect running"); + stream.write_all(post.as_bytes()).expect("running"); + let mut status = String::new(); + stream.read_to_string(&mut status).expect("running body"); + assert!(status.starts_with("HTTP/1.1 200 OK")); + assert!(status.contains("\"run_state\":\"running\"")); + assert!(!status.contains("rmse")); + assert!(!status.contains("scientific_acceptance")); + assert!(child.wait().expect("wait").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 87a7a4a87..12bc7d6be 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -105,7 +105,11 @@ bodies stay metric-free, and only a succeeded status with profile `POST /v1/analysis-runs/{run_id}/running` and `POST /v1/analysis-runs/{run_id}/terminal` are the production loopback status-update path that records those statuses; they do not persist and do not -execute psychometric estimation. Production TLS remains a later adapter. +execute psychometric estimation. LineageWeave mints those POSTs through +`lineageweave_analysis_run_running_exchange` and +`lineageweave_analysis_run_terminal_exchange`. `NaruonLiveService` serves the +same running/terminal POSTs for Naruon only and still refuses GET. +Production TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b90843a85..0ccb1e3b1 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,7 +54,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); 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 analysis-run scientific-acceptance GET | ADR 0027; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `GET /v1/analysis-runs/{run_id}` on `AnalysisRunLiveService` (#359): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | -| loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (this PR): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (#360): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run lifecycle consumer parity | ADR 0029; API contract; RFC 9110; FIPS 180-4 | `tepp_api` LineageWeave running/terminal exchanges, Naruon compatibility-listener lifecycle POST, and `tepp-loopback` TCP running proof (this PR): stacked on #360; `NaruonLiveService` stays POST-only and Naruon-only; not implemented-main | 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/0029-analysis-run-lifecycle-consumer-parity.md b/docs/adr/0029-analysis-run-lifecycle-consumer-parity.md new file mode 100644 index 000000000..77b9af58d --- /dev/null +++ b/docs/adr/0029-analysis-run-lifecycle-consumer-parity.md @@ -0,0 +1,103 @@ +# ADR 0029 — Analysis-run lifecycle consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0028 and ADR 0018. Does not supersede ADR 0014. ADR 0029 on the cancel-HTTP lineage is a different stack; this ADR number is unique on the lifecycle-POST lineage. + +## Context + +ADR 0028 added `POST /v1/analysis-runs/{run_id}/running` and +`POST /v1/analysis-runs/{run_id}/terminal` on `AnalysisRunLiveService` plus +Naruon running/terminal exchange builders. The Naruon compatibility listener +(`NaruonLiveService`) still refused every lifecycle path. `LineageWeave` had a +create-exchange builder but no running/terminal exchange, so a published +consumer would have to mint a Naruon-labelled lifecycle POST. The packaged +`tepp-loopback` binary had no TCP proof that lifecycle POST works for +LineageWeave on the shared listener. + +Duplicating the GET status listener, the lifecycle POST listener, cancel, +collection GET, retry, or engine-library slices would not close this +consumer-parity gap. `NaruonLiveService` stays POST-only: lifecycle is POST, so +the compatibility listener can serve it without adding GET. + +## Decision + +- `lineageweave_analysis_run_running_exchange` and + `lineageweave_analysis_run_terminal_exchange` reuse the Naruon builders and + replace only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free running/terminal path for the + Naruon-only compatibility listener. LineageWeave consumers remain refused + there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-running over loopback TCP. +- Accepted and running responses stay metric-free. Reverse transitions, + mutating a terminal run, unknown runs, consumer/idempotency mismatch, and + receipt metric keys fail closed. + +## Non-goals + +- GET status on `NaruonLiveService`. +- Duplicating the shared-listener lifecycle POST (ADR 0028). +- Cancel, collection GET, retry, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave lifecycle only on `AnalysisRunLiveService`** — rejected because the + compatibility listener would silently refuse a documented POST path. +2. **Admit LineageWeave on `NaruonLiveService`** — rejected because that + listener is Naruon-only (ADR 0011/0018). +3. **Mint a second lifecycle DTO** — rejected as a duplicate of ADR 0028. +4. **Consumer-parity lifecycle on the existing typed transition** — accepted. + +## Consequences + +- Both published consumers can build a credential-free running/terminal + exchange. +- Naruon local proofs can record lifecycle on either listener. +- Operators can observe LineageWeave running through `tepp-loopback` without a + second HTTP stack. + +## Failure and recovery + +Unknown runs, consumer mismatch, idempotency mismatch, metric keys, reverse +transitions, mutating a terminal run, GET, and oversized identities fail closed +with a redacted envelope. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Lifecycle remains loopback-only. Running stays metric-free. +- HTTP `200` running/terminal is not measurement or release evidence. + +## Compatibility and migration + +ADR 0028 create/running/terminal semantics are unchanged. Production adapters +may replace loopback while preserving consumer identity, metric-free running +status, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave running/terminal exchanges carry `tepp-consumer: lineageweave` + and no credentials; +- NaruonLiveService records accepted→running→terminal for Naruon and refuses + LineageWeave, GET, metrics, unknown runs, and reverse transitions; +- `tepp-loopback` create-then-running over TCP returns metric-free `running`; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the LineageWeave builders, compatibility-listener lifecycle, +and binary TCP proof; ADR 0028 shared-listener lifecycle remains. A superseding +ADR is required to persist lifecycle, bind a public address, add GET to +`NaruonLiveService`, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0028 owns the shared-listener lifecycle POST path. +- ADR 0027 owns GET status. +- ADR 0018 owns consumer-scoped ingress. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. diff --git a/docs/adr/README.md b/docs/adr/README.md index 421caeadd..1e4395e4a 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. | | [0027](0027-scientific-acceptance-http-status.md) | Scientific-acceptance loopback HTTP status path | Accepted | active-PR | GET `/v1/analysis-runs/{run_id}` stays metric-free on accepted/running; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0028](0028-scientific-acceptance-http-lifecycle.md) | Scientific-acceptance loopback HTTP lifecycle POST | Accepted | active-PR | POST `/running` and `/terminal` are the production status-update path; GET remains ADR 0027. Persistence remains GAP-003B. | +| [0029](0029-analysis-run-lifecycle-consumer-parity.md) | Analysis-run lifecycle consumer parity | Accepted | active-PR | LineageWeave running/terminal exchanges plus Naruon compatibility-listener lifecycle POST; `NaruonLiveService` stays POST-only. | | [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. | @@ -142,6 +143,9 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **scientific-acceptance loopback GET:** ADR 0027. +- **scientific-acceptance loopback lifecycle POST:** ADR 0028. +- **analysis-run lifecycle consumer parity:** ADR 0029. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 9c6f6d185..9ce1e08e0 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -28,6 +28,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | +| Live loopback lifecycle POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs/{run_id}/running` and `/terminal` (Naruon-only; LineageWeave uses `AnalysisRunLiveService`) | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/analysis-run-lifecycle-consumer-parity.md b/docs/research/analysis-run-lifecycle-consumer-parity.md new file mode 100644 index 000000000..4aadf3e9d --- /dev/null +++ b/docs/research/analysis-run-lifecycle-consumer-parity.md @@ -0,0 +1,51 @@ +# Analysis-run lifecycle consumer parity (GAP-003A) + +## Scope + +This note doctors the LineageWeave running/terminal exchange builders and the +Naruon compatibility-listener lifecycle POST stacked on ADR 0028 / issue #166: + +1. `lineageweave_analysis_run_running_exchange` and + `lineageweave_analysis_run_terminal_exchange` reuse the Naruon builders and + replace only `tepp-consumer`; +2. `NaruonLiveService` records metric-free running and request-bound terminal + status for Naruon only; +3. LineageWeave remains refused on `NaruonLiveService` and uses + `AnalysisRunLiveService`; +4. `tepp-loopback` proves create-then-running over loopback TCP; +5. GET is still refused on `NaruonLiveService`. + +This slice does not duplicate the shared-listener lifecycle POST (#360), GET +status (#359), status consumer-parity (#383), cancel (#361/#373), collection +GET, retry, the terminal-result DTO (#358), or the engine library (#356). +PostgreSQL persistence remains GAP-003B. HTTP success does not promote an +ADR 0014 claim. + +## Authoritative sources + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +Peng, R. D. (2011). Reproducible research in computational science. +*Science, 334*(6060), 1226–1227. https://doi.org/10.1126/science.1213847 + +National Academies of Sciences, Engineering, and Medicine. (2019). +*Reproducibility and replicability in science*. The National Academies Press. +https://doi.org/10.17226/25303 + +## Application + +RFC 9110 §9.3.3 keeps POST as the lifecycle write. The compatibility listener +already admits Naruon POSTs; extending it to `/running` and `/terminal` does +not require GET. Consumer identity stays a header swap so LineageWeave cannot +be forced to mint a Naruon-labelled transition. FIPS 180-4 digest binding on +succeeded scientific-acceptance terminals is unchanged from ADR 0028. + +## Non-application + +This note does not authorize public bind, TLS termination, durable status +storage, opening `NaruonLiveService` to LineageWeave, or treating HTTP `200` +as a scientific claim.