Skip to content
Draft
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-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` loopback `tepp-project-histories list` enumerates metric-free accepted LineageWeave project-history projections (ADR 0065). Collection CLI stdout refuses RMSE/bias/coverage/SE-gate/scientific-acceptance/evidence/`causal_score` keys, non-200 bodies, and pages not bound to the requested cursor/limit. Naruon refused. Not project-history POST CLI, not collection GET listener, not persistence.
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.
2 changes: 2 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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) |
| Project-history collection CLI doctoring | [`docs/research/project-history-collection-cli.md`](docs/research/project-history-collection-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) |
Expand Down
6 changes: 6 additions & 0 deletions crates/tepp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs"
test = false
bench = false

[[bin]]
name = "tepp-project-histories"
path = "src/bin/tepp_project_histories.rs"
test = false
bench = false

[lints]
workspace = true
145 changes: 141 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,48 @@ 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 = match headers.get("tepp-page-limit") {
Some(value) => parse_project_history_collection_page_limit(Some(value.as_str()))?,
None => parse_project_history_collection_page_limit(None)?,
};
let cursor = match headers.get("tepp-page-cursor") {
Some(value) => parse_project_history_collection_page_cursor(Some(value.as_str()))?,
None => parse_project_history_collection_page_cursor(None)?,
};
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 +367,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 +988,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
29 changes: 29 additions & 0 deletions crates/tepp_api/src/bin/tepp_project_histories.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//! Operator CLI for loopback `LineageWeave` project-history collection GET.

use std::io::{self, IsTerminal};
use std::process::ExitCode;

use tepp_api::{
ApiError, ProjectHistoryCollectionCliInvocation, execute_project_history_collection_cli,
read_project_history_collection_cli_stdin, render_project_history_collection_cli_stdout,
};

fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}

fn run() -> Result<(), ApiError> {
let args: Vec<String> = std::env::args().skip(1).collect();
let body = read_project_history_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?;
let invocation = ProjectHistoryCollectionCliInvocation::from_args(&args, body)?;
let response = execute_project_history_collection_cli(&invocation)?;
let stdout = render_project_history_collection_cli_stdout(&invocation, &response)?;
println!("{stdout}");
Ok(())
}
44 changes: 44 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ mod naruon_http;
mod naruon_live;
mod orchestration;
mod project_history;
mod project_history_collection_cli;
mod project_history_collection_http;
mod project_journey;
mod provider_payload;
mod temporal_context;
Expand Down Expand Up @@ -216,6 +218,8 @@ pub use project_history::DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
pub use project_history::DEFAULT_PROJECT_HISTORY_EVENT_LIMIT;
/// Supported project-history contract version.
pub use project_history::PROJECT_HISTORY_CONTRACT_VERSION;
/// Maximum opaque idempotency-key size shared by project-history APIs.
pub use project_history::PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN;
/// Versioned project-history path.
pub use project_history::PROJECT_HISTORY_PATH;
/// Explicit source-grounded project event.
Expand All @@ -230,6 +234,46 @@ pub use project_history::ProjectHistoryProjection;
pub use project_history::ProjectHistoryRequest;
/// Build a cutoff-safe project-history projection.
pub use project_history::project_history_projection;
/// Loopback project-history collection CLI invocation.
pub use project_history_collection_cli::ProjectHistoryCollectionCliInvocation;
/// Loopback project-history collection CLI verb.
pub use project_history_collection_cli::ProjectHistoryCollectionCliVerb;
/// Compose HTTP/1.1 collection GET from a CLI invocation.
pub use project_history_collection_cli::compose_project_history_collection_cli_http;
/// Dispatch a collection CLI invocation against an in-process listener.
pub use project_history_collection_cli::dispatch_project_history_collection_cli;
/// Execute a collection CLI invocation over loopback TCP.
pub use project_history_collection_cli::execute_project_history_collection_cli;
/// Render a typed collection GET exchange as loopback HTTP/1.1.
pub use project_history_collection_cli::loopback_http1_from_project_history_collection_exchange;
/// Read leftover stdin for the project-history collection CLI.
pub use project_history_collection_cli::read_project_history_collection_cli_stdin;
/// Filter collection CLI stdout so the page stays metric-free.
pub use project_history_collection_cli::render_project_history_collection_cli_stdout;
/// 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
6 changes: 5 additions & 1 deletion crates/tepp_api/src/project_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub const DEFAULT_PROJECT_HISTORY_BYTE_LIMIT: usize = 256 * 1024;
/// Maximum event count accepted in one project-history request.
pub const DEFAULT_PROJECT_HISTORY_EVENT_LIMIT: usize = 128;

/// Maximum opaque idempotency-key size shared by creation and collection cursors.
pub const PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN: usize = 256;

/// Explicit event evidence supplied by an authorized modular consumer.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -162,7 +165,8 @@ impl ProjectHistoryRequest {

fn validate(&self) -> Result<(), ApiError> {
require_contract_version(self.contract_version, PROJECT_HISTORY_CONTRACT_VERSION)?;
validate_bounded_text(&self.idempotency_key, 256)?;
let maximum_key_len = PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN;
validate_bounded_text(&self.idempotency_key, maximum_key_len)?;
validate_bounded_text(&self.tenant_workspace_id, 256)?;
validate_bounded_text(&self.project_key, 256)?;
validate_bounded_text(&self.project_name, 512)?;
Expand Down
Loading