From 0c0341e39f0fb166c8fc37bb8368cf26652095f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:56:23 +0000 Subject: [PATCH] feat(api): authorize purpose-bound exports via loopback CLI Operators can POST /v1/exports through `tepp-exports authorize` against `tepp-naruon-live` without writing raw HTTP. Metric-free decision JSON only. tepp.scientific_acceptance.v1 never appears. tepp-loopback remains AnalysisRunLiveService and does not serve /v1/exports. Not GAP-010 Figma/export. Not analysis-run CLIs. Stacked on protected main. --- CHANGELOG.d/export-authorize-cli.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/Cargo.toml | 12 + crates/tepp_api/src/bin/tepp_exports.rs | 37 ++ crates/tepp_api/src/bin/tepp_naruon_live.rs | 24 + crates/tepp_api/src/export_authorize_cli.rs | 606 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 15 + .../tests/export_authorize_cli_contract.rs | 75 +++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + docs/adr/0026-export-authorize-cli.md | 68 ++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-authorize-cli.md | 54 ++ 14 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/export-authorize-cli.md create mode 100644 crates/tepp_api/src/bin/tepp_exports.rs create mode 100644 crates/tepp_api/src/bin/tepp_naruon_live.rs create mode 100644 crates/tepp_api/src/export_authorize_cli.rs create mode 100644 crates/tepp_api/tests/export_authorize_cli_contract.rs create mode 100644 docs/adr/0026-export-authorize-cli.md create mode 100644 docs/research/export-authorize-cli.md diff --git a/CHANGELOG.d/export-authorize-cli.md b/CHANGELOG.d/export-authorize-cli.md new file mode 100644 index 000000000..6081ebf61 --- /dev/null +++ b/CHANGELOG.d/export-authorize-cli.md @@ -0,0 +1 @@ +- `tepp_api` loopback `tepp-exports authorize` posts a purpose-bound export request to `tepp-naruon-live` (`NaruonLiveService` `POST /v1/exports`, ADR 0026). Metric-free decision JSON only. `tepp.scientific_acceptance.v1` never appears. Not `tepp-loopback`, not analysis-run CLIs, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..25c5d3d58 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) | +| Export-authorize CLI doctoring | [`docs/research/export-authorize-cli.md`](docs/research/export-authorize-cli.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/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..74103e85a 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,17 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-naruon-live" +path = "src/bin/tepp_naruon_live.rs" +test = false +bench = false + +[[bin]] +name = "tepp-exports" +path = "src/bin/tepp_exports.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_exports.rs b/crates/tepp_api/src/bin/tepp_exports.rs new file mode 100644 index 000000000..aaf1ce6c5 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_exports.rs @@ -0,0 +1,37 @@ +//! Operator CLI for loopback purpose-bound export authorization POST. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ExportAuthorizeCliInvocation, execute_export_authorize_cli, + read_export_authorize_cli_stdin, render_export_authorize_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("authorize") => run_authorize(&args), + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn run_authorize(args: &[String]) -> Result<(), ApiError> { + let body = read_export_authorize_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ExportAuthorizeCliInvocation::from_args(args, body)?; + let response = execute_export_authorize_cli(&invocation)?; + let stdout = render_export_authorize_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/bin/tepp_naruon_live.rs b/crates/tepp_api/src/bin/tepp_naruon_live.rs new file mode 100644 index 000000000..8e203b8d6 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_naruon_live.rs @@ -0,0 +1,24 @@ +//! Runnable loopback ingress for naruon analysis-run and export POSTs. + +use std::net::SocketAddr; + +use tepp_api::NaruonLiveService; + +const DEFAULT_BIND_ADDR: &str = "127.0.0.1:18082"; + +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 = NaruonLiveService::bind(bind_addr)?; + println!("{}", service.local_addr()?); + (0..request_limit).for_each(|_| drop(service.serve_one())); + Ok(()) +} diff --git a/crates/tepp_api/src/export_authorize_cli.rs b/crates/tepp_api/src/export_authorize_cli.rs new file mode 100644 index 000000000..5df4acb74 --- /dev/null +++ b/crates/tepp_api/src/export_authorize_cli.rs @@ -0,0 +1,606 @@ +//! Operator loopback CLI for purpose-bound export authorization POST. +//! +//! Operator-visible client of `POST /v1/exports` on `NaruonLiveService` +//! (ADR 0009 / ADR 0011). Operators run `tepp-exports authorize` against +//! `tepp-naruon-live` without writing raw HTTP. `tepp-loopback` is +//! `AnalysisRunLiveService` and does not serve `/v1/exports`. +//! `tepp.scientific_acceptance.v1` never appears. This module does not +//! duplicate analysis-run CLIs, GET-by-id, Leiden, or GAP-010 Figma/export. +//! Persistence remains GAP-003B. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::{NARUON_EXPORT_PATH, header_is_credential}; +use crate::wire::{from_json, require_nonempty, to_json}; +use crate::{ + AnalyticalPurpose, ApiError, ExportAuthorizationRequest, NARUON_CONSUMER_CODE, + NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, NaruonLiveService, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const FORBIDDEN_EXPORT_KEYS: [&str; 11] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "scientific_acceptance", + "terminal_result", +]; + +/// Supported operator verbs for the loopback export CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportAuthorizeCliVerb { + /// `POST /v1/exports`. + Authorize, +} + +impl ExportAuthorizeCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "authorize" => Ok(Self::Authorize), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Authorize => "authorize", + } + } +} + +/// One operator CLI invocation against a loopback export-authorize listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExportAuthorizeCliInvocation { + /// CLI verb to execute. + pub verb: ExportAuthorizeCliVerb, + /// Loopback `host:port` of `tepp-naruon-live`. + pub host: String, + /// Per-export operation key; never equal to `principal_id`. + pub idempotency_key: String, + /// Validated purpose-bound export request. + pub request: ExportAuthorizationRequest, +} + +impl ExportAuthorizeCliInvocation { + /// Parse argv plus stdin JSON into a validated loopback export invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing flags, a + /// non-loopback host, credential-shaped flags, a nonempty-incompatible + /// purpose, metric keys, or an idempotency key equal to `principal_id`. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ExportAuthorizeCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + let body = body.into(); + refuse_scientific_acceptance(&body)?; + refuse_metrics(&body)?; + let request: ExportAuthorizationRequest = from_json(&body)?; + let invocation = Self { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + request, + }; + invocation.validate()?; + Ok(invocation) + } + + /// Reject a non-loopback host or a purpose the live listener will not serve. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a + /// non-modular purpose, and [`ApiError::InvalidWirePayload`] when the + /// idempotency key equals `principal_id`. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.idempotency_key)?; + require_nonempty(&self.request.tenant_workspace_id)?; + require_nonempty(&self.request.principal_id)?; + require_nonempty(&self.request.artifact_id)?; + if self.idempotency_key == self.request.principal_id { + return Err(ApiError::InvalidWirePayload); + } + if self.request.purpose != AnalyticalPurpose::ModularServiceConsumer { + return Err(ApiError::AuthorizationDenied); + } + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Compose one HTTP/1.1 export-authorize POST for a validated CLI invocation. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ExportAuthorizeCliInvocation::validate`]. +pub fn compose_export_authorize_cli_http( + invocation: &ExportAuthorizeCliInvocation, +) -> Result { + invocation.validate()?; + let body = to_json(&invocation.request)?; + refuse_scientific_acceptance(&body)?; + refuse_metrics(&body)?; + Ok(format!( + "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: {}\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{body}", + invocation.host, + invocation.idempotency_key, + body.len() + )) +} + +/// Dispatch one export CLI invocation against an in-process naruon live service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_export_authorize_cli( + service: &mut NaruonLiveService, + invocation: &ExportAuthorizeCliInvocation, +) -> Result { + let request = compose_export_authorize_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one export CLI invocation over loopback TCP against `tepp-naruon-live`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_export_authorize_cli( + invocation: &ExportAuthorizeCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_export_authorize_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).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))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so export never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when the body is empty or carries +/// metric keys or the scientific-acceptance schema. +pub fn render_export_authorize_cli_stdout( + invocation: &ExportAuthorizeCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics(&response.body)?; + Ok(response.body.clone()) +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn refuse_metrics(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)?; + let Some(object) = value.as_object() else { + return Err(ApiError::InvalidWirePayload); + }; + if FORBIDDEN_EXPORT_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(ApiError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(ApiError::InvalidWirePayload)? + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for line in lines { + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(ApiError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; export authorize requires JSON. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_export_authorize_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(body) + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + ExportAuthorizeCliInvocation, ExportAuthorizeCliVerb, SCIENTIFIC_ACCEPTANCE_SCHEMA, + compose_export_authorize_cli_http, dispatch_export_authorize_cli, + execute_export_authorize_cli, parse_http_response, read_export_authorize_cli_stdin, + render_export_authorize_cli_stdout, static_reason, + }; + use crate::{ + AnalyticalPurpose, ApiError, ExportAuthorizationRequest, NaruonLiveResponse, + NaruonLiveService, + }; + + fn allowed_body() -> String { + serde_json::to_string(&ExportAuthorizationRequest { + tenant_workspace_id: "tenant-a".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-a".into(), + includes_source_text: false, + }) + .expect("json") + } + + fn authorize_args() -> [&'static str; 5] { + [ + "authorize", + "--host", + "127.0.0.1:18082", + "--idempotency-key", + "export-idem-1", + ] + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + ExportAuthorizeCliVerb::parse("authorize").expect("verb"), + ExportAuthorizeCliVerb::Authorize + ); + assert_eq!(ExportAuthorizeCliVerb::Authorize.as_str(), "authorize"); + assert_eq!( + ExportAuthorizeCliVerb::parse("AUTHORIZE"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportAuthorizeCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportAuthorizeCliVerb::parse("wait"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_host_credentials_purpose_and_metrics() { + assert_eq!( + ExportAuthorizeCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args(["authorize"], allowed_body()).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "8.8.8.8:80", + "--idempotency-key", + "export-idem-1" + ], + allowed_body() + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "127.0.0.1:18082", + "--idempotency-key", + "export-idem-1", + "--authorization", + "secret" + ], + allowed_body() + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + let monitoring = serde_json::to_string(&ExportAuthorizationRequest { + tenant_workspace_id: "tenant-a".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::OperationalMonitoring, + artifact_id: "artifact-a".into(), + includes_source_text: false, + }) + .expect("json"); + assert_eq!( + ExportAuthorizeCliInvocation::from_args(authorize_args(), monitoring).unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args(authorize_args(), r#"{"rmse":1.0}"#) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "127.0.0.1:18082", + "--idempotency-key", + "naruon-service" + ], + allowed_body() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args(authorize_args(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn compose_posts_exports_without_credentials_or_metrics() { + let invocation = + ExportAuthorizeCliInvocation::from_args(authorize_args(), allowed_body()).expect("inv"); + let http = compose_export_authorize_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/exports HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("idempotency-key: export-idem-1")); + assert!(!http.contains("authorization")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!http.contains("rmse")); + } + + #[test] + fn dispatch_allows_modular_export_without_scientific_acceptance() { + let mut service = NaruonLiveService::new(); + let invocation = + ExportAuthorizeCliInvocation::from_args(authorize_args(), allowed_body()).expect("inv"); + let got = dispatch_export_authorize_cli(&mut service, &invocation).expect("dispatch"); + assert_eq!(got.status_code, 200); + let stdout = render_export_authorize_cli_stdout(&invocation, &got).expect("stdout"); + assert!(stdout.contains("purpose_bound_export_allowed")); + assert!(stdout.contains("\"allowed\":true")); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("source_text")); + } + + #[test] + fn render_refuses_metrics_schema_and_empty_bodies() { + let invocation = + ExportAuthorizeCliInvocation::from_args(authorize_args(), allowed_body()).expect("inv"); + assert_eq!( + render_export_authorize_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_authorize_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"allowed":true,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_authorize_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"#), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn execute_over_tcp_and_parse_response_failures() { + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = + ExportAuthorizeCliInvocation::from_args(authorize_args(), allowed_body()).expect("inv"); + invocation.host = addr.to_string(); + let response = execute_export_authorize_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_export_authorize_cli(&invocation).unwrap_err(), + ApiError::InvalidWirePayload + ); + + let parsed = + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").expect("parse"); + assert_eq!(parsed.status_code, 200); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + assert_eq!(static_reason(403).expect("403"), "Forbidden"); + assert_eq!( + static_reason(500).unwrap_err(), + ApiError::InvalidWirePayload + ); + let empty = read_export_authorize_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_export_authorize_cli_stdin(false, std::io::Cursor::new(b"{}")).expect("piped"); + assert_eq!(piped, "{}"); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..3a4146d9f 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -20,6 +20,7 @@ mod corpus_split_manifest; mod envelope; mod error; mod export; +mod export_authorize_cli; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -95,6 +96,20 @@ pub use export::GraphMlExport; pub use export::JsonLdExport; /// Reproducibility manifest. pub use export::ReproducibilityManifest; +/// One validated export-authorize CLI invocation. +pub use export_authorize_cli::ExportAuthorizeCliInvocation; +/// Loopback export-authorize CLI verb. +pub use export_authorize_cli::ExportAuthorizeCliVerb; +/// Compose loopback export POST bytes for a CLI invocation. +pub use export_authorize_cli::compose_export_authorize_cli_http; +/// Dispatch an export CLI invocation against an in-process naruon listener. +pub use export_authorize_cli::dispatch_export_authorize_cli; +/// Execute an export CLI invocation over loopback TCP. +pub use export_authorize_cli::execute_export_authorize_cli; +/// Read leftover stdin for the export CLI. +pub use export_authorize_cli::read_export_authorize_cli_stdin; +/// Render export CLI stdout with metric-free gates. +pub use export_authorize_cli::render_export_authorize_cli_stdout; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_authorize_cli_contract.rs b/crates/tepp_api/tests/export_authorize_cli_contract.rs new file mode 100644 index 000000000..47516de85 --- /dev/null +++ b/crates/tepp_api/tests/export_authorize_cli_contract.rs @@ -0,0 +1,75 @@ +//! Contract tests for the purpose-bound export-authorize loopback CLI. + +use tepp_api::{ + AnalyticalPurpose, ApiError, ExportAuthorizationRequest, ExportAuthorizeCliInvocation, + ExportAuthorizeCliVerb, compose_export_authorize_cli_http, +}; + +fn allowed_body() -> String { + serde_json::to_string(&ExportAuthorizationRequest { + tenant_workspace_id: "tenant-a".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-a".into(), + includes_source_text: false, + }) + .expect("json") +} + +#[test] +fn export_cli_is_metric_free_post_without_credentials() { + assert_eq!( + ExportAuthorizeCliVerb::parse("authorize").expect("verb"), + ExportAuthorizeCliVerb::Authorize + ); + let invocation = ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "127.0.0.1:18082", + "--idempotency-key", + "export-idem-1", + ], + allowed_body(), + ) + .expect("invocation"); + let http = compose_export_authorize_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/exports HTTP/1.1")); + assert!(!http.contains("authorization")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert!(!http.contains("/analysis-runs")); +} + +#[test] +fn export_cli_refuses_non_loopback_unknown_verbs_and_metrics() { + assert_eq!( + ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "8.8.8.8:80", + "--idempotency-key", + "export-idem-1" + ], + allowed_body() + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + ExportAuthorizeCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportAuthorizeCliInvocation::from_args( + [ + "authorize", + "--host", + "127.0.0.1:18082", + "--idempotency-key", + "export-idem-1" + ], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..115186f2f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,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 stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export 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. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export 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. `tepp-naruon-live` binds `NaruonLiveService` on `127.0.0.1:18082` by default and is the operator-visible listener for `POST /v1/exports`. The loopback `tepp-exports authorize` CLI is the operator-visible client for that POST; stdout stays purpose-bound and `tepp.scientific_acceptance.v1` never appears. `tepp-loopback` remains `AnalysisRunLiveService` and does not serve `/v1/exports`. 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 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..ab5046b9a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,6 +53,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional session-affine `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), `revision_order` later-revision system-time ordering implemented-main, entity/project target SQL on PR #131; remaining physical ERD constraints | partial | | 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 purpose-bound export-authorize CLI | ADR 0026; API contract; RFC 9110; ADR 0009/0011 | `tepp_api` `tepp-exports authorize` CLI against `tepp-naruon-live` (`NaruonLiveService` `POST /v1/exports`); metric-free decision JSON; `tepp.scientific_acceptance.v1` never appears; `tepp-loopback` does not serve this path | 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/0026-export-authorize-cli.md b/docs/adr/0026-export-authorize-cli.md new file mode 100644 index 000000000..92589d54c --- /dev/null +++ b/docs/adr/0026-export-authorize-cli.md @@ -0,0 +1,68 @@ +# ADR 0026 — Purpose-bound export-authorize loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0009 and ADR 0011 for the operator-visible export client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; other live PRs may reuse 0026 on unrelated GAP-003A stacks. + +## Context + +Protected main already serves `POST /v1/exports` on `NaruonLiveService`, but operators still had to write raw HTTP/1.1. `tepp-loopback` is `AnalysisRunLiveService` and does not serve `/v1/exports`. Duplicating analysis-run CLIs (#362/#371/#378/#385/#392/#394/#395/#397/#400/#401/#403/#406), GET-by-id, Leiden, Driver p.16, or GAP-010 Figma/export would collide with live PRs. + +## Decision + +`tepp_api` publishes a loopback-only export client and the naruon live listener it targets: + +- `tepp-naruon-live` binds `NaruonLiveService` on `127.0.0.1:18082` by default. It is not `tepp-loopback`. +- `tepp-exports authorize` POSTs `/v1/exports` with `--host` and `--idempotency-key`. Stdin is `ExportAuthorizationRequest` JSON. +- Only `modular_service_consumer` is accepted. Other purposes fail closed before the wire. +- The idempotency key must not equal `principal_id`. +- Stdout is the purpose-bound decision JSON. `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, and SE-gate keys never appear. +- Non-loopback hosts, credential-shaped flags, unknown verbs, empty stdin, and metric keys fail closed. +- This slice does not implement export retrieval `GET /v1/exports/{export_id}`, analysis-run HTTP, or persistence. + +## Alternatives considered + +1. **Keep raw HTTP as the only export path** — rejected because operators still guess framing after ADR 0011. +2. **Add export onto `tepp-loopback`** — rejected because that binary is `AnalysisRunLiveService` and must not pretend to serve `/v1/exports`. +3. **Return scientific-acceptance on allowed export** — rejected because export bodies must stay purpose-bound and metric-free. +4. **Loopback export CLI against `NaruonLiveService`** — accepted. + +## Consequences + +- Operators can authorize a purpose-bound export without writing HTTP. +- Export stdout cannot be mistaken for a succeeded scientific-acceptance result. +- CLI success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, empty stdin, credential flags, non-modular purposes, and an idempotency key equal to `principal_id` fail closed. Denied source-text purposes remain refused by ADR 0009. The in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only. Free-text source bodies stay purpose-gated (ADR 0009). +- Process exit 0 on authorize is not measurement evidence. + +## Compatibility and migration + +`POST /v1/analysis-runs`, `tepp-loopback`, temporal-context, and project-history paths are unchanged. `GET /v1/exports/{export_id}` remains a later slice. + +## Verification + +Falsifiable evidence: + +- CLI authorize of a modular export returns `allowed`/`purpose_bound_export_allowed` with no RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1` keys; +- operational-monitoring purpose, non-loopback host, credential flags, empty stdin, and unknown verbs fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes `tepp-exports` and `tepp-naruon-live`; `NaruonLiveService` HTTP remains valid. A superseding ADR is required to persist exports, bind a public address, emit scientific-acceptance on export, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0009 owns purpose-bound PII governance. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns POST semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..51af555ca 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [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. | +| [0026](0026-export-authorize-cli.md) | Loopback `tepp-exports authorize` is purpose-bound export /v1/exports client | Accepted | active-PR | Complements ADR 0009/0011; does not supersede ADR 0014. Unique on protected main. `tepp-loopback` does not serve exports. | | [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. | @@ -140,6 +141,7 @@ 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. +- **purpose-bound export-authorize CLI:** ADR 0026. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 9c6f6d185..42ce790d4 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -27,6 +27,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | 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 | +| CLI export authorize | `tepp_api` `tepp-exports authorize` → loopback `POST /v1/exports` on `tepp-naruon-live` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/export-authorize-cli.md b/docs/research/export-authorize-cli.md new file mode 100644 index 000000000..23445625c --- /dev/null +++ b/docs/research/export-authorize-cli.md @@ -0,0 +1,54 @@ +# Export-authorize CLI (doctoring) + +## Scope + +`tepp-exports authorize` is the operator-visible client of loopback +`POST /v1/exports` on `NaruonLiveService`. `tepp-naruon-live` is the packaged +listener. `tepp-loopback` is `AnalysisRunLiveService` and does not serve this +path. HTTP method, path, and header semantics follow current HTTP semantics +(Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of non-loopback +hosts, non-modular purposes, review/Copilot/GitHub credential flags, and +scientific-authority promotion is repository contract authority (ADR 0026; +ADR 0009; ADR 0011; ADR 0014), not an RFC inference rule. + +CLI stdout is the purpose-bound `ExportAuthorizationDecision` JSON. +`tepp.scientific_acceptance.v1` never appears. Process exit 0 is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.3 describes POST as a method for processing the enclosed +representation. TEPP maps that processing onto a bounded, purpose-gated +export authorization. The RFC does not define psychometric acceptance, RMSE, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0026-export-authorize-cli.md` — this client +- `docs/adr/0009-purpose-bound-pii-governance.md` — purpose-bound disclosure +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `crates/tepp_api/tests/export_authorize_cli_contract.rs` — fail-closed + export CLI proofs + +## Verification + +- `tepp-exports authorize` of a modular export returns + `purpose_bound_export_allowed` without RMSE/bias/coverage/SE-gate keys or + `tepp.scientific_acceptance.v1`; +- operational-monitoring purpose, non-loopback hosts, credential flags, empty + stdin, and unknown verbs fail closed. + +## Non-claims + +This slice does not implement export retrieval GET, analysis-run CLIs, +GET-by-id, wait CLI, lookup CLI, persistence, production TLS, Leiden +consensus, GAP-010 Figma/export, or an ADR 0014 scientific claim-promotion +package.