Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.d/temporal-context-retrieval-get.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `GET /v1/temporal-context/{idempotency_key}` returns one accepted LineageWeave temporal-context identity on `tepp-loopback` (ADR 0083). Metric-free `inference_status=temporal_association_only`. Event labels and actor lists never appear. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open collection GET or cancel lineages. Not GAP-010 Figma/export, not persistence.
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) |
| 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) |
| Temporal-context GET-by-id doctoring | [`docs/research/temporal-context-retrieval-get.md`](docs/research/temporal-context-retrieval-get.md) |
| UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) |
| Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) |
| Security policy | [`SECURITY.md`](SECURITY.md) |
Expand Down
125 changes: 117 additions & 8 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH;
use crate::{
AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT,
ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH,
ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest,
ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH,
TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS, TemporalContextRequest, TemporalContextRetrieved,
build_temporal_context, project_history_projection, requests_are_idempotent_matches,
temporal_context_retrieval_path_id,
};

const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
Expand All @@ -41,6 +43,7 @@ pub struct AnalysisRunLiveService {
next_request_serial: u64,
accepted_runs: HashMap<String, (AnalysisRunRequest, AnalysisRunAccepted)>,
accepted_project_histories: HashMap<String, (ProjectHistoryRequest, ProjectHistoryProjection)>,
accepted_temporal_contexts: HashMap<String, TemporalContextRetrieved>,
}

impl Default for AnalysisRunLiveService {
Expand All @@ -60,6 +63,7 @@ impl AnalysisRunLiveService {
next_request_serial: 1,
accepted_runs: HashMap::new(),
accepted_project_histories: HashMap::new(),
accepted_temporal_contexts: HashMap::new(),
}
}

Expand Down Expand Up @@ -143,33 +147,85 @@ impl AnalysisRunLiveService {
let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?;
let mut lines = header_block.split("\r\n");
let (method, path) = parse_request_line(lines.next().unwrap_or(""))?;
let headers = parse_headers(&mut lines)?;
if method == "GET" {
return self.get_temporal_context(path, &headers, body);
}
Comment on lines +151 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: GET routing stays endpoint-scoped

temporal_context_retrieval_path_id admits only one temporal-context child segment. Collection, extra-segment, and unrelated GET routes remain closed.

Devin Review

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

if method != "POST"
|| (path != NARUON_ANALYSIS_RUN_PATH
&& path != TEMPORAL_CONTEXT_PATH
&& path != PROJECT_HISTORY_PATH)
{
return Err(ApiError::InvalidWirePayload);
}
let headers = parse_headers(&mut lines)?;
let consumer = require_headers(
&headers,
self.bound_addr,
path == NARUON_ANALYSIS_RUN_PATH || path == PROJECT_HISTORY_PATH,
)?;
if path == TEMPORAL_CONTEXT_PATH {
if consumer != LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
let context_request = TemporalContextRequest::from_json(body)?;
let response = build_temporal_context(&context_request)?;
return Ok(json_response(200, "OK", response.to_json()?));
return self.accept_temporal_context(consumer, &headers, body);
}
if path == PROJECT_HISTORY_PATH {
return self.accept_project_history(consumer, &headers, body);
}
self.accept_analysis_run(consumer, &headers, body)
}

fn accept_temporal_context(
&mut self,
consumer: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if consumer != LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
let context_request = TemporalContextRequest::from_json(body)?;
if let Some(idempotency_key) = headers.get("idempotency-key") {
let item = TemporalContextRetrieved::new(
idempotency_key.clone(),
context_request.knowledge_cutoff.clone(),
TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS,
)?;
Comment on lines +185 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Canonical POST builder cannot mint identities

lineageweave_temporal_context_exchange cannot add an idempotency key. Its callers cannot use the new retrieval flow without manually modifying the exchange.

Devin Review

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

let replay_key = format!("{consumer}\u{1f}{idempotency_key}");
if let Some(stored) = self.accepted_temporal_contexts.get(&replay_key) {
if stored.knowledge_cutoff != item.knowledge_cutoff {
return Err(ApiError::InvalidWirePayload);
}
Comment on lines +192 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Conflicting retries share one identity

When a key is reused with different events but the same cutoff, accept_temporal_context treats both submissions as matching. The retry returns different context under one identity instead of failing closed.

Prompt for agents
The temporal-context replay registry in crates/tepp_api/src/analysis_run_live.rs stores only TemporalContextRetrieved and compares only knowledge_cutoff. Store enough validated request state to compare the complete TemporalContextRequest on a repeated consumer/idempotency key. Return the original result for an exact replay and reject any changed request, including changes to events or subject_post_id.
Devin Review

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

} else {
self.accepted_temporal_contexts.insert(replay_key, item);
Comment on lines +196 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Failed submissions become retrievable

When response serialization fails, accepted_temporal_contexts.insert has already registered the identity. The failed submission then becomes retrievable as accepted.

Prompt for agents
In accept_temporal_context, complete temporal-context construction and response serialization before mutating accepted_temporal_contexts. Only commit the identity after every fallible response step succeeds, while preserving exact replay and conflict behavior.
Devin Review

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

}
}
let response = build_temporal_context(&context_request)?;
Ok(json_response(200, "OK", response.to_json()?))
}

fn get_temporal_context(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if !body.is_empty() {
return Err(ApiError::InvalidWirePayload);
}
if headers.contains_key("idempotency-key") {
return Err(ApiError::InvalidWirePayload);
}
let idempotency_key = temporal_context_retrieval_path_id(path)?;
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
let replay_key = format!("{consumer}\u{1f}{idempotency_key}");
let stored = self
.accepted_temporal_contexts
.get(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
Ok(json_response(200, "OK", stored.to_json()?))
}

fn accept_analysis_run(
&mut self,
consumer: &str,
Expand Down Expand Up @@ -320,6 +376,7 @@ mod tests {
DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE,
NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT,
NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH,
TemporalContextRetrieved,
};

fn sample_run() -> AnalysisRunRequest {
Expand Down Expand Up @@ -734,6 +791,58 @@ mod tests {
assert_eq!(replay.body, accepted.body);
}

#[test]
fn temporal_context_get_by_id_is_metric_free_and_fail_closed() {
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 mut service = AnalysisRunLiveService::new();
let posted = format!(
"POST {TEMPORAL_CONTEXT_PATH} 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: idem-a\r\ncontent-length: {}\r\n\r\n{temporal_body}",
temporal_body.len()
);
assert_eq!(service.handle_http_request(&posted).status_code, 200);
let got = service.handle_http_request(
&format!(
"GET {TEMPORAL_CONTEXT_PATH}/idem-a 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\ncontent-length: 0\r\n\r\n"
),
);
assert_eq!(got.status_code, 200, "{}", got.body);
assert!(!got.body.contains("event_label"));
assert!(!got.body.contains("rmse"));
let row = TemporalContextRetrieved::from_json(&got.body).expect("row");
assert_eq!(row.idempotency_key, "idem-a");
assert_eq!(row.inference_status, "temporal_association_only");
assert_eq!(
service
.handle_http_request(
&format!(
"GET {TEMPORAL_CONTEXT_PATH} 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\ncontent-length: 0\r\n\r\n"
)
)
.status_code,
400
);
assert_eq!(
service
.handle_http_request(
&format!(
"GET {TEMPORAL_CONTEXT_PATH}/idem-a 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\ncontent-length: 0\r\n\r\n"
)
)
.status_code,
400
);
assert_eq!(
service
.handle_http_request(
&format!(
"GET {TEMPORAL_CONTEXT_PATH}/missing 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\ncontent-length: 0\r\n\r\n"
)
)
.status_code,
400
);
}

#[test]
fn parser_helpers_cover_framing_header_and_limit_edges() {
assert_eq!(
Expand Down
17 changes: 17 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod project_history;
mod project_journey;
mod provider_payload;
mod temporal_context;
mod temporal_context_retrieval_http;
mod wire;

/// Terminal analysis-result contract version constant.
Expand Down Expand Up @@ -282,3 +283,19 @@ pub use temporal_context::TemporalContextTimelineEvent;
pub use temporal_context::TemporalTransitionGapCandidate;
/// Build a cutoff-safe, non-causal temporal context.
pub use temporal_context::build_temporal_context;
/// Maximum opaque idempotency-key length on the retrieval path.
pub use temporal_context_retrieval_http::TEMPORAL_CONTEXT_RETRIEVAL_ID_MAX_LEN;
/// Supported temporal-context retrieval contract version.
pub use temporal_context_retrieval_http::TEMPORAL_CONTEXT_RETRIEVAL_CONTRACT_VERSION;
/// Fixed non-causal claim boundary echoed on every retrieval.
pub use temporal_context_retrieval_http::TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS;
/// One metric-free identity projection for an accepted temporal-context POST.
pub use temporal_context_retrieval_http::TemporalContextRetrieved;
/// Build a provider-owned `GET` temporal-context retrieval exchange.
pub use temporal_context_retrieval_http::lineageweave_temporal_context_retrieval_exchange;
/// Refuse retrieval JSON that already carries scientific-metric or evidence keys.
pub use temporal_context_retrieval_http::refuse_metrics_on_temporal_context_retrieval_payload;
/// Extract the opaque idempotency key from `GET /v1/temporal-context/{key}`.
pub use temporal_context_retrieval_http::temporal_context_retrieval_path_id;
/// Refuse an empty, oversized, slash, NUL, or control-bearing identity.
pub use temporal_context_retrieval_http::validate_temporal_context_registry_identity;
Loading
Loading