Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
782ca57
test(api): define LineageWeave temporal context contract
seonghobae Aug 20, 2026
e646f77
feat(api): add temporal context contract
seonghobae Aug 20, 2026
1245716
fix(api): declare temporal core workspace version
seonghobae Aug 20, 2026
a101440
Merge consumer contract base updates
seonghobae Aug 20, 2026
70cef63
Merge branch 'feat/lineageweave-live-consumer-contract' of https://gi…
seonghobae Aug 20, 2026
c1a020d
fix(api): share strict loopback host validation
seonghobae Aug 20, 2026
f915a4e
Merge remote-tracking branch 'origin/feat/lineageweave-live-consumer-…
seonghobae Aug 20, 2026
7494f5e
test(api): close live contract branch coverage
seonghobae Aug 20, 2026
5afc104
Merge remote-tracking branch 'origin/feat/lineageweave-live-consumer-…
seonghobae Aug 20, 2026
1a3a3e0
Merge branch 'feat/lineageweave-live-consumer-contract' of https://gi…
seonghobae Aug 20, 2026
5bf86c6
Merge remote-tracking branch 'origin/feat/lineageweave-live-consumer-…
seonghobae Aug 20, 2026
f7e9ef0
test(api): adapt temporal parser coverage to shared iterator
seonghobae Aug 20, 2026
cb1396b
Merge latest LineageWeave live contract coverage
seonghobae Aug 20, 2026
99c8d4c
Merge remote-tracking branch 'origin/feat/lineageweave-temporal-conte…
seonghobae Aug 20, 2026
0073e83
fix(api): enforce temporal context trust boundaries
seonghobae Aug 20, 2026
6d28d23
test(api): cover temporal tie ordering branches
seonghobae Aug 20, 2026
6264f5e
Merge current consumer ingress into temporal context stack
seonghobae Aug 21, 2026
b98e26d
fix: remove fabricated temporal context idempotency
seonghobae Aug 21, 2026
1c521a8
fix(api): allow temporal context reads without idempotency
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `tepp_api` LineageWeave temporal-context contract (v1): cutoff-safe event eligibility, deterministic event-time ordering, explicit non-causal association/gap boundaries, HTTPS interchange construction, and loopback listener handling at `POST /v1/temporal-context`; read-only context requests no longer require the write-only idempotency header, and no causal inference or completed-result service is included.
- `tepp_api` LineageWeave consumer-scoped analysis-run ingress: versioned, credential-free requests use a published consumer identity and isolate idempotency by consumer, tenant workspace, and opaque caller key; the one-shot restack workflow is removed after the protected-main merge is verified.
- ADR 0017 records the consumer-scoped analysis-run ingress, its in-memory loopback maturity, and the persistence boundary required before production use.
- `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011).
Expand Down Expand Up @@ -77,6 +78,9 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Changed

- The LineageWeave temporal-context read exchange no longer emits a fabricated
`idempotency-key`; that header remains reserved for retryable write/export
operations with a caller-owned operation key.
- Removed the temporary PR-155 review-repair workflows and source-fix helper after the bounded repair; subsequent changes use the normal reviewed branch path.
- Pinned Rust branch-coverage workflows to `nightly-2026-08-21`, which is newer than the workspace Rust 1.97.1 MSRV and avoids the previous nightly/MSRV mismatch.
- Applied the documented `sqlx_live.rs` authored-coverage exclusion to the hourly release gate so live-PostgreSQL success-path coverage is not reported as a false source failure.
Expand Down
122 changes: 97 additions & 25 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! Consumer-neutral live analysis-run ingress for modular CWL services.
//!
//! This module keeps the Naruon compatibility listener intact while providing
//! the shared `/v1/analysis-runs` boundary needed by Naruon and `LineageWeave`.
//! It accepts transport acknowledgements only; completed psychometric results
//! remain outside this crate.
//! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context`
//! boundaries needed by Naruon and `LineageWeave`. It accepts transport
//! acknowledgements and temporal evidence context only; completed psychometric
//! results remain outside this crate.

use std::collections::HashMap;
use std::io::Write;
Expand All @@ -17,7 +18,8 @@ use crate::live_http::{
use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH;
use crate::{
AnalysisRunAccepted, AnalysisRunRequest, ApiError, ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT,
NaruonLiveResponse, requests_are_idempotent_matches,
NaruonLiveResponse, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, build_temporal_context,
requests_are_idempotent_matches,
};

#[cfg(test)]
Expand Down Expand Up @@ -134,9 +136,21 @@ impl AnalysisRunLiveService {
fn dispatch_http_request(&mut self, request: &str) -> Result<NaruonLiveResponse, ApiError> {
let (header_block, body) = split_request(request)?;
let mut lines = header_block.split("\r\n");
require_request_line(lines.next().unwrap_or(""))?;
let (method, path) = parse_request_line(lines.next().unwrap_or(""))?;
if method != "POST" || (path != NARUON_ANALYSIS_RUN_PATH && path != TEMPORAL_CONTEXT_PATH) {
return Err(ApiError::InvalidWirePayload);
}
let headers = parse_headers(&mut lines)?;
let consumer = require_headers(&headers, self.bound_addr)?;
let consumer =
require_headers(&headers, self.bound_addr, path == NARUON_ANALYSIS_RUN_PATH)?;
if path == TEMPORAL_CONTEXT_PATH {
if consumer != crate::lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
let context_request = TemporalContextRequest::from_json(body)?;
let response = build_temporal_context(&context_request)?;
return Ok(json_response(200, "OK", response.to_json()?));
}
Comment on lines +139 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Temporal-context read path skips idempotency key

require_idempotency_key is set only for the analysis-run path (analysis_run_live.rs), and the temporal-context branch gates on the LineageWeave consumer (analysis_run_live.rs). The body consumer_code is independently re-validated in from_json, so a mismatched header/body pair still fails closed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

self.accept_analysis_run(consumer, &headers, body)
}

Expand Down Expand Up @@ -175,25 +189,15 @@ impl AnalysisRunLiveService {
let request_id = format!("analysis-run-live-{}", self.next_request_serial);
self.next_request_serial += 1;
let (status_code, reason_phrase) = status_for(error);
json_response(
status_code,
reason_phrase,
error_envelope_json(error, request_id),
)
let body = error_envelope_json(error, request_id);
json_response(status_code, reason_phrase, body)
}
}

fn require_request_line(line: &str) -> Result<(), ApiError> {
let (method, path) = parse_request_line(line)?;
if method != "POST" || path != NARUON_ANALYSIS_RUN_PATH {
return Err(ApiError::InvalidWirePayload);
}
Ok(())
}

fn require_headers(
headers: &HashMap<String, String>,
bound_addr: Option<SocketAddr>,
require_idempotency_key: bool,
) -> Result<&str, ApiError> {
validate_common_headers(headers, bound_addr)?;
if header_value(headers, "tepp-contract-version")? != "1" {
Expand All @@ -203,7 +207,9 @@ fn require_headers(
if !consumer_is_supported(consumer) {
return Err(ApiError::InvalidWirePayload);
}
let _idempotency_key = header_value(headers, "idempotency-key")?;
if require_idempotency_key {
let _idempotency_key = header_value(headers, "idempotency-key")?;
}
Ok(consumer)
}

Expand Down Expand Up @@ -246,6 +252,7 @@ fn json_response(

#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read, Write};
use std::net::TcpStream;
Expand All @@ -255,14 +262,14 @@ mod tests {
use super::{
AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length,
error_envelope_json, host_implies_table_access, map_io_error, parse_headers,
read_http_request, require_request_line, split_header_line, split_request, status_for,
read_http_request, require_headers, split_header_line, split_request, status_for,
};
use crate::live_http::host_is_loopback;
use crate::{
ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError,
DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE,
NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT,
NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT,
NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH,
};

fn sample_run() -> AnalysisRunRequest {
Expand Down Expand Up @@ -605,15 +612,80 @@ mod tests {
}

#[test]
fn parser_helpers_cover_framing_header_and_limit_edges() {
fn temporal_read_headers_and_defensive_write_edges_are_covered() {
let run = sample_run();
let body = run.to_json().expect("body");
let mut service = AnalysisRunLiveService::new();

for missing_header in ["tepp-contract-version", "tepp-consumer"] {
let headers = [
("Host", "127.0.0.1"),
("content-type", "application/json"),
("tepp-consumer", NARUON_CONSUMER_CODE),
("tepp-contract-version", "1"),
("idempotency-key", run.idempotency_key.as_str()),
]
.into_iter()
.filter(|(name, _)| *name != missing_header)
.collect::<Vec<_>>();
assert_eq!(
service
.handle_http_request(&http_request(&body, &headers))
.status_code,
400,
"missing={missing_header}"
);
}

let temporal_body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#;
let temporal_request = format!(
"POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{temporal_body}",
temporal_body.len()
);
assert_eq!(
service.handle_http_request(&temporal_request).status_code,
200
);

let mut headers = HashMap::from([
("host".to_owned(), "127.0.0.1".to_owned()),
("content-type".to_owned(), "application/json".to_owned()),
("tepp-consumer".to_owned(), NARUON_CONSUMER_CODE.to_owned()),
("tepp-contract-version".to_owned(), "1".to_owned()),
]);
assert_eq!(
service.accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body),
Err(ApiError::InvalidWirePayload)
);
headers.insert("idempotency-key".to_owned(), run.idempotency_key.clone());
assert_eq!(
require_headers(&headers, None, true),
Ok(NARUON_CONSUMER_CODE)
);
headers.insert("tepp-contract-version".to_owned(), "2".to_owned());
assert_eq!(
require_request_line("POST"),
require_headers(&headers, None, true),
Err(ApiError::InvalidWirePayload)
);
headers.insert("tepp-contract-version".to_owned(), "1".to_owned());
headers.insert("tepp-consumer".to_owned(), "unpublished".to_owned());
assert_eq!(
require_request_line("POST /v1/analysis-runs"),
require_headers(&headers, None, true),
Err(ApiError::InvalidWirePayload)
);
headers.insert("tepp-consumer".to_owned(), NARUON_CONSUMER_CODE.to_owned());
let accepted = service
.accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body)
.expect("accepted");
assert_eq!(accepted.status_code, 202);
let replay = service
.accept_analysis_run(NARUON_CONSUMER_CODE, &headers, &body)
.expect("replay");
assert_eq!(replay.body, accepted.body);
}

#[test]
fn parser_helpers_cover_framing_header_and_limit_edges() {
assert_eq!(
split_request("").expect_err("empty"),
ApiError::InvalidWirePayload
Expand Down
23 changes: 23 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod naruon_http;
mod naruon_live;
mod orchestration;
mod provider_payload;
mod temporal_context;
mod wire;

/// Analysis-run contract version constant.
Expand Down Expand Up @@ -65,6 +66,8 @@ pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE;
pub use lineageweave_http::NARUON_CONSUMER_CODE;
/// Build a credential-free `LineageWeave` analysis-run exchange.
pub use lineageweave_http::lineageweave_analysis_run_exchange;
/// Build a credential-free `LineageWeave` temporal-context exchange.
pub use lineageweave_http::lineageweave_temporal_context_exchange;
/// Versioned analysis-run path modular consumers may call.
pub use naruon_http::NARUON_ANALYSIS_RUN_PATH;
/// Versioned export path Naruon may call.
Expand Down Expand Up @@ -149,3 +152,23 @@ pub use provider_payload::ReidentificationAuditSink;
pub use provider_payload::disclose_identity_mapping;
/// Minimize evidence for a model provider.
pub use provider_payload::minimize_provider_payload;
/// Temporal association claim boundary.
pub use temporal_context::TEMPORAL_ASSOCIATION_CLAIM_BOUNDARY;
/// Temporal-context contract version constant.
pub use temporal_context::TEMPORAL_CONTEXT_CONTRACT_VERSION;
/// Versioned temporal-context HTTP path.
pub use temporal_context::TEMPORAL_CONTEXT_PATH;
/// One opaque event in a temporal-context request.
pub use temporal_context::TemporalContextEvent;
/// One adjacent temporal relation.
pub use temporal_context::TemporalContextRelation;
/// Temporal-context request.
pub use temporal_context::TemporalContextRequest;
/// Temporal-context response.
pub use temporal_context::TemporalContextResponse;
/// One ordered event in a temporal-context response.
pub use temporal_context::TemporalContextTimelineEvent;
/// One non-causal transition-gap candidate.
pub use temporal_context::TemporalTransitionGapCandidate;
/// Build a cutoff-safe, non-causal temporal context.
pub use temporal_context::build_temporal_context;
34 changes: 33 additions & 1 deletion crates/tepp_api/src/lineageweave_http.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
//! Published modular-consumer identity and `LineageWeave` analysis-run exchange.

use crate::{AnalysisRunRequest, ApiError, NaruonHttpExchange, naruon_analysis_run_exchange};
use crate::naruon_http::compose_https_target;
use crate::{
AnalysisRunRequest, ApiError, NaruonHttpExchange, TEMPORAL_CONTEXT_CONTRACT_VERSION,
TEMPORAL_CONTEXT_PATH, TemporalContextRequest, naruon_analysis_run_exchange,
};

/// Stable consumer identity used by the Naruon adapter.
pub const NARUON_CONSUMER_CODE: &str = "naruon";
Expand Down Expand Up @@ -32,6 +36,34 @@ pub fn lineageweave_analysis_run_exchange(
Ok(exchange)
}

/// Build a credential-free `LineageWeave` temporal-context exchange.
///
/// # Errors
///
/// Returns a fail-closed error for a hostile origin or invalid temporal-context
/// request.
pub fn lineageweave_temporal_context_exchange(
origin: &str,
request: &TemporalContextRequest,
) -> Result<NaruonHttpExchange, ApiError> {
let target_url = compose_https_target(origin, TEMPORAL_CONTEXT_PATH)?;
let body = request.to_json()?;
let headers = vec![
("content-type".into(), "application/json".into()),
("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()),
(
"tepp-contract-version".into(),
TEMPORAL_CONTEXT_CONTRACT_VERSION.to_string(),
),
];
Ok(NaruonHttpExchange {
method: "POST",
target_url,
headers,
body,
})
}

/// Return whether a modular analysis-run consumer is published by TEPP.
pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool {
matches!(
Expand Down
4 changes: 2 additions & 2 deletions crates/tepp_api/src/naruon_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ pub fn naruon_may_claim_tepp_inference(method_code: &str) -> Result<(), ApiError
}
}

fn compose_https_target(origin: &str, path: &str) -> Result<String, ApiError> {
pub(crate) fn compose_https_target(origin: &str, path: &str) -> Result<String, ApiError> {
require_nonempty(origin)?;
if !origin.starts_with("https://") {
return Err(ApiError::InvalidWirePayload);
Expand Down Expand Up @@ -174,7 +174,7 @@ fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiEr
Ok(())
}

fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> {
pub(crate) fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> {
vec![
("content-type".into(), "application/json".into()),
("tepp-consumer".into(), "naruon".into()),
Expand Down
Loading