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-collection-get.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `GET /v1/temporal-context` enumerates accepted LineageWeave temporal-context identities on `tepp-loopback` (ADR 0081). Metric-free `inference_status=temporal_association_only` rows. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Not temporal-context CLI, not project-history collection GET, 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 collection GET doctoring | [`docs/research/temporal-context-collection-get.md`](docs/research/temporal-context-collection-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
147 changes: 138 additions & 9 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ 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,
build_temporal_context, project_history_projection, requests_are_idempotent_matches,
ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS,
TEMPORAL_CONTEXT_PATH, TemporalContextCollection, TemporalContextCollectionItem,
TemporalContextRequest, build_temporal_context, is_temporal_context_collection_path,
page_temporal_context_collection_items, parse_temporal_context_collection_page_cursor,
parse_temporal_context_collection_page_limit, project_history_projection,
requests_are_idempotent_matches,
};

const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
Expand All @@ -41,6 +45,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, TemporalContextCollectionItem>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Registry growth lacks a bound

Each distinct POST permanently grows accepted_temporal_contexts. Add an entry limit or eviction policy before using this loopback service as a long-running process.

Devin Review

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

}

impl Default for AnalysisRunLiveService {
Expand All @@ -60,6 +65,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 +149,92 @@ 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.list_temporal_contexts(path, &headers, body);
}
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 = TemporalContextCollectionItem::new(
idempotency_key.clone(),
context_request.knowledge_cutoff.clone(),
TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS,
)?;
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 +194 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.

🟡 Reused keys return different results

Reusing an idempotency key with the same cutoff but different events passes accept_temporal_context. One accepted identity can therefore produce conflicting results.

Prompt for agents
The temporal-context registry stores only TemporalContextCollectionItem, so replay validation can compare only knowledge_cutoff. Store the complete validated TemporalContextRequest, or a canonical digest of every request field, alongside the collection item. Accept an existing idempotency key only when the full request matches; reject changed events, subject_post_id, consumer_code, or other semantics.
Devin Review

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

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

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 posts remain listed as accepted

When response serialization exceeds its limit, accept_temporal_context records the identity before returning 413. Later collection reads falsely list the request as accepted.

Prompt for agents
In crates/tepp_api/src/analysis_run_live.rs, accept_temporal_context mutates accepted_temporal_contexts before build_temporal_context and TemporalContextResponse::to_json complete. A request can fit the 64 KiB input limit yet expand beyond the 64 KiB response limit because event identities are repeated across timeline, relation, gap, and source arrays. Build and serialize the successful response before committing the collection identity. Preserve replay/conflict behavior without recording any request whose POST response fails.
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 list_temporal_contexts(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if !is_temporal_context_collection_path(path) {
return Err(ApiError::InvalidWirePayload);
}
if !body.is_empty() {
return Err(ApiError::InvalidWirePayload);
}
if headers.contains_key("idempotency-key") {
return Err(ApiError::InvalidWirePayload);
}
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
let limit = parse_temporal_context_collection_page_limit(
headers.get("tepp-page-limit").map(String::as_str),
)?;
let cursor = parse_temporal_context_collection_page_cursor(
headers.get("tepp-page-cursor").map(String::as_str),
)?;
let items = self.accepted_temporal_contexts.values().cloned().collect();
let (page, next_cursor) =
page_temporal_context_collection_items(items, cursor.as_deref(), limit);
let collection = TemporalContextCollection::new(page, next_cursor)?;
Ok(json_response(200, "OK", collection.to_json()?))
}

fn accept_analysis_run(
&mut self,
consumer: &str,
Expand Down Expand Up @@ -320,6 +385,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,
TemporalContextCollection,
};

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

#[test]
fn temporal_context_collection_get_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 listed = 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"
),
);
assert_eq!(listed.status_code, 200, "{}", listed.body);
assert!(!listed.body.contains("rmse"));
assert!(!listed.body.contains("event_label"));
assert!(!listed.body.contains("actor_references"));
assert!(!listed.body.contains("tepp.scientific_acceptance.v1"));
let page = TemporalContextCollection::from_json(&listed.body).expect("page");
assert_eq!(page.contexts.len(), 1);
assert_eq!(page.contexts[0].idempotency_key, "idem-a");
assert_eq!(page.contexts[0].inference_status, "temporal_association_only");
assert_eq!(
service
.handle_http_request(
"GET /v1/analysis-runs 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: 0\r\n\r\n"
)
.status_code,
400
);
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: {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} 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: 0\r\n\r\n"
)
)
.status_code,
400
);
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: 2\r\n\r\n{{}}"
)
)
.status_code,
400
);
}

#[test]
fn parser_helpers_cover_framing_header_and_limit_edges() {
assert_eq!(
Expand Down
27 changes: 27 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_collection_http;
mod wire;

/// Terminal analysis-result contract version constant.
Expand Down Expand Up @@ -282,3 +283,29 @@ 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;
/// Supported temporal-context collection contract version.
pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_CONTRACT_VERSION;
/// Default page size for loopback temporal-context collection GET.
pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_DEFAULT_LIMIT;
/// Maximum opaque cursor / idempotency-key length on the collection path.
pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN;
/// Fixed non-causal claim boundary echoed on every collection row.
pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS;
/// Maximum page size accepted on loopback temporal-context collection GET.
pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT;
/// Versioned metric-free temporal-context collection page.
pub use temporal_context_collection_http::TemporalContextCollection;
/// One metric-free collection row for an accepted temporal-context identity.
pub use temporal_context_collection_http::TemporalContextCollectionItem;
/// Return whether `path` is exactly the temporal-context collection resource.
pub use temporal_context_collection_http::is_temporal_context_collection_path;
/// Build a provider-owned `GET` temporal-context collection exchange.
pub use temporal_context_collection_http::lineageweave_temporal_context_collection_exchange;
/// Page stored rows after an exclusive cursor, sorted by idempotency key.
pub use temporal_context_collection_http::page_temporal_context_collection_items;
/// Parse the optional exclusive `tepp-page-cursor` header.
pub use temporal_context_collection_http::parse_temporal_context_collection_page_cursor;
/// Parse the optional `tepp-page-limit` header.
pub use temporal_context_collection_http::parse_temporal_context_collection_page_limit;
/// Refuse collection JSON that already carries scientific-metric or evidence keys.
pub use temporal_context_collection_http::refuse_metrics_on_temporal_context_collection_payload;
Loading
Loading