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/project-history-collection-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` loopback `GET /v1/project-histories` enumerates accepted LineageWeave project-history projections on `tepp-loopback` (ADR 0028). Metric-free `temporal_association_only` identities only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Not project-history CLI, not analysis-run collection GET, not persistence.
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.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) |
Expand Down
143 changes: 139 additions & 4 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ 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,
ProjectHistoryCollection, ProjectHistoryCollectionItem, ProjectHistoryProjection,
ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, build_temporal_context,
is_project_history_collection_path, page_project_history_collection_items,
parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit,
project_history_projection, requests_are_idempotent_matches,
};

const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
Expand Down Expand Up @@ -143,14 +146,17 @@ 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_project_histories(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,
Expand Down Expand Up @@ -235,6 +241,46 @@ impl AnalysisRunLiveService {
Ok(json_response(200, "OK", response_body))
}

fn list_project_histories(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if !is_project_history_collection_path(path) {
return Err(ApiError::InvalidWirePayload);
}
if !body.is_empty() {
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_project_history_collection_page_limit(
headers.get("tepp-page-limit").map(String::as_str),
)?;
let cursor = parse_project_history_collection_page_cursor(
headers.get("tepp-page-cursor").map(String::as_str),
)?;
let items = self
.accepted_project_histories
.values()
.map(|(request, projection)| {
ProjectHistoryCollectionItem::new(
request.project_key.clone(),
request.idempotency_key.clone(),
projection.knowledge_cutoff.clone(),
projection.inference_status.clone(),
)
})
.collect::<Result<Vec<_>, _>>()?;
let (page, next_cursor) =
page_project_history_collection_items(items, cursor.as_deref(), limit);
let collection = ProjectHistoryCollection::new(page, next_cursor)?;
Ok(json_response(200, "OK", collection.to_json()?))
}

fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse {
let request_id = format!("analysis-run-live-{}", self.next_request_serial);
self.next_request_serial += 1;
Expand Down Expand Up @@ -319,7 +365,9 @@ mod tests {
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, TEMPORAL_CONTEXT_PATH,
NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, PROJECT_HISTORY_CONTRACT_VERSION,
PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryEvent, ProjectHistoryRequest,
TEMPORAL_CONTEXT_PATH,
};

fn sample_run() -> AnalysisRunRequest {
Expand Down Expand Up @@ -938,6 +986,93 @@ mod tests {
);
}

fn sample_project_history(idempotency_key: &str, project_key: &str) -> ProjectHistoryRequest {
ProjectHistoryRequest {
contract_version: PROJECT_HISTORY_CONTRACT_VERSION,
idempotency_key: idempotency_key.into(),
tenant_workspace_id: "history-tenant".into(),
project_key: project_key.into(),
project_name: "Project".into(),
knowledge_cutoff: "2026-08-19T23:59:59Z".into(),
focus_event_id: "focus".into(),
events: vec![ProjectHistoryEvent {
event_id: "focus".into(),
event_type_code: "voc_received".into(),
event_title: "VOC".into(),
occurred_at: "2026-08-19T09:00:00Z".into(),
available_at: "2026-08-19T10:00:00Z".into(),
source_post_id: "post".into(),
evidence_text: "explicit evidence".into(),
actor_ids: Vec::new(),
}],
}
}

fn project_history_post(request: &ProjectHistoryRequest) -> String {
let body = request.to_json().expect("history json");
format!(
"POST {PROJECT_HISTORY_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: {}\r\ncontent-length: {}\r\n\r\n{body}",
request.idempotency_key,
body.len()
)
}

#[test]
fn project_history_collection_get_is_metric_free_and_fail_closed() {
let mut service = AnalysisRunLiveService::new();
let first = sample_project_history("idem-a", "project-a");
let second = sample_project_history("idem-b", "project-b");
assert_eq!(
service
.handle_http_request(&project_history_post(&first))
.status_code,
200
);
assert_eq!(
service
.handle_http_request(&project_history_post(&second))
.status_code,
200
);

let list = format!(
"GET {PROJECT_HISTORY_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"
);
let got = service.handle_http_request(&list);
assert_eq!(got.status_code, 200);
let page = ProjectHistoryCollection::from_json(&got.body).expect("page");
assert_eq!(page.histories.len(), 2);
assert_eq!(page.histories[0].idempotency_key, "idem-a");
assert_eq!(page.histories[1].project_key, "project-b");
assert!(!got.body.contains("rmse"));
assert!(!got.body.contains("tepp.scientific_acceptance.v1"));
assert!(!got.body.contains("evidence_text"));
assert!(!got.body.contains("findings"));
assert!(!got.body.contains("causal_score"));

let limited = format!(
"GET {PROJECT_HISTORY_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\ntepp-page-limit: 1\r\ncontent-length: 0\r\n\r\n"
);
let limited_got = service.handle_http_request(&limited);
let limited_page =
ProjectHistoryCollection::from_json(&limited_got.body).expect("limited page");
assert_eq!(limited_page.histories.len(), 1);
assert_eq!(limited_page.next_cursor.as_deref(), Some("idem-a"));

let analysis_get = format!(
"GET {NARUON_ANALYSIS_RUN_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!(service.handle_http_request(&analysis_get).status_code, 400);
let naruon_list = format!(
"GET {PROJECT_HISTORY_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"
);
assert_eq!(service.handle_http_request(&naruon_list).status_code, 400);
let nonempty = format!(
"GET {PROJECT_HISTORY_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{{}}"
);
assert_eq!(service.handle_http_request(&nonempty).status_code, 400);
}

struct ScriptedRead {
reader: Cursor<Vec<u8>>,
first_error: Option<std::io::ErrorKind>,
Expand Down
25 changes: 25 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod naruon_http;
mod naruon_live;
mod orchestration;
mod project_history;
mod project_history_collection_http;
mod project_journey;
mod provider_payload;
mod temporal_context;
Expand Down Expand Up @@ -230,6 +231,30 @@ pub use project_history::ProjectHistoryProjection;
pub use project_history::ProjectHistoryRequest;
/// Build a cutoff-safe project-history projection.
pub use project_history::project_history_projection;
/// Maximum opaque cursor length on project-history collection GET.
pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN;
/// Default page size for project-history collection GET.
pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT;
/// Fixed non-causal inference status on collection rows.
pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS;
/// Maximum page size for project-history collection GET.
pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_MAX_LIMIT;
/// Metric-free project-history collection page.
pub use project_history_collection_http::ProjectHistoryCollection;
/// One metric-free project-history collection row.
pub use project_history_collection_http::ProjectHistoryCollectionItem;
/// Whether a path is the project-history collection resource.
pub use project_history_collection_http::is_project_history_collection_path;
/// `LineageWeave` GET exchange for project-history collection.
pub use project_history_collection_http::lineageweave_project_history_collection_exchange;
/// Page stored project-history collection rows.
pub use project_history_collection_http::page_project_history_collection_items;
/// Parse the exclusive project-history collection cursor header.
pub use project_history_collection_http::parse_project_history_collection_page_cursor;
/// Parse the project-history collection page-limit header.
pub use project_history_collection_http::parse_project_history_collection_page_limit;
/// Refuse metric, evidence, and causal-score keys on collection JSON.
pub use project_history_collection_http::refuse_metrics_on_project_history_collection_payload;
/// Maximum posterior Project Journey artifact size.
pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT;
/// Exact posterior Project Journey schema identity.
Expand Down
Loading
Loading