1use std::collections::BTreeMap;
11
12use lean_ctx_protocol::AcceptanceState;
13use lean_ctx_protocol::context_gateway::ContextDecisionReceiptV1;
14use serde::Serialize;
15
16use crate::core::context_admission::receipt_store::{LoadError, StoredReceipt, TaskReceipts};
17use crate::core::execution_ledger::ExecutionEvent;
18
19pub(crate) const LINEAGE_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct LineageStepV1 {
24 pub sequence: u64,
25 pub kind: &'static str,
26 pub timestamp: String,
27 pub fields: BTreeMap<&'static str, String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct LineageDeliveryV1 {
34 pub digest: String,
35 pub verified: bool,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub error: Option<&'static str>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub summary: Option<DeliverySummaryV1>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub egress: Option<EgressLineageV1>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct EgressLineageV1 {
47 pub admission: Option<lean_ctx_protocol::context_gateway::EgressAdmissionReceiptV1>,
48 pub observations: Vec<lean_ctx_protocol::context_gateway::EgressTransportObservationV1>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub error: Option<&'static str>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct DeliverySummaryV1 {
55 pub outcome: String,
56 pub delivery_semantics: &'static str,
58 pub destination: String,
59 pub policy_digest: Option<String>,
60 pub inspected: u32,
61 pub delivered: u32,
62 pub withheld: u32,
63 pub redactions: u32,
64 pub tokens_original: u64,
65 pub tokens_delivered: u64,
66 pub final_context: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct TaskLineageV1 {
73 pub schema_version: u32,
74 pub task_id: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub scope: Option<String>,
78 pub steps: Vec<LineageStepV1>,
79 pub deliveries: Vec<LineageDeliveryV1>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub ledger_error: Option<String>,
83 pub outcome: String,
84 pub gaps: Vec<&'static str>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum LedgerScope {
91 Verified,
93 Foreign,
95 Unverified,
97}
98
99pub fn build(
100 task_id: &str,
101 events: Result<Vec<ExecutionEvent>, String>,
102 ledger_scope: LedgerScope,
103 receipts: &TaskReceipts,
104) -> TaskLineageV1 {
105 let (events, mut ledger_error) = match events {
106 Ok(events) => (events, None),
107 Err(error) => (Vec::new(), Some(error)),
108 };
109 let events = match ledger_scope {
110 LedgerScope::Foreign => {
111 ledger_error = Some("the ledger's task belongs to another project scope".to_owned());
112 Vec::new()
113 }
114 LedgerScope::Unverified if !events.is_empty() => {
115 ledger_error = Some("no task envelope proves the ledger's project scope".to_owned());
116 Vec::new()
117 }
118 LedgerScope::Verified | LedgerScope::Unverified => events,
119 };
120 let steps: Vec<LineageStepV1> = events.iter().map(step).collect();
121 let deliveries: Vec<LineageDeliveryV1> = receipts.entries.iter().map(delivery).collect();
122
123 let has = |kind: &str| steps.iter().any(|step| step.kind == kind);
124 let outcome = events
126 .iter()
127 .rev()
128 .find_map(|event| match event {
129 ExecutionEvent::OutcomeRecorded { accepted, .. } => Some(*accepted),
130 _ => None,
131 })
132 .map_or_else(|| "unknown".to_owned(), acceptance_name);
133
134 let mut gaps = Vec::new();
135 if ledger_error.is_some() {
136 gaps.push("ledger_unverified");
137 }
138 if !has("plan_created") && !has("decision_recorded") {
139 gaps.push("no_plan_recorded");
140 }
141 if deliveries.is_empty() && !has("context_delivered") {
142 gaps.push("no_delivery_recorded");
143 }
144 if deliveries.iter().any(|delivery| !delivery.verified) {
145 gaps.push("delivery_unverified");
146 }
147 if !receipts.index_complete {
148 gaps.push("deliveries_incomplete");
149 }
150 if !has("outcome_recorded") {
151 gaps.push("no_outcome_recorded");
152 }
153
154 TaskLineageV1 {
155 schema_version: LINEAGE_SCHEMA_VERSION,
156 task_id: task_id.to_owned(),
157 scope: None,
158 steps,
159 deliveries,
160 ledger_error,
161 outcome,
162 gaps,
163 }
164}
165
166fn acceptance_name(value: AcceptanceState) -> String {
167 serde_json::to_value(value)
168 .ok()
169 .and_then(|v| v.as_str().map(str::to_owned))
170 .unwrap_or_else(|| "unknown".to_owned())
171}
172
173fn step(event: &ExecutionEvent) -> LineageStepV1 {
174 let mut fields = BTreeMap::new();
175 let kind = match event {
176 ExecutionEvent::TaskStarted { envelope_ref, .. } => {
177 fields.insert("envelope_ref", envelope_ref.clone());
178 "task_started"
179 }
180 ExecutionEvent::PlanCreated {
181 plan_id, plan_ref, ..
182 } => {
183 fields.insert("plan_id", plan_id.clone());
184 fields.insert("plan_ref", plan_ref.clone());
185 "plan_created"
186 }
187 ExecutionEvent::ContextDelivered {
188 context_balance, ..
189 } => {
190 fields.insert(
191 "original_tokens",
192 context_balance.original_tokens.to_string(),
193 );
194 fields.insert(
195 "materialized_tokens",
196 context_balance.materialized_tokens.to_string(),
197 );
198 fields.insert(
199 "delivered_tokens",
200 context_balance.delivered_tokens.to_string(),
201 );
202 fields.insert(
203 "provider_billed_tokens",
204 context_balance.provider_billed_tokens.to_string(),
205 );
206 "context_delivered"
207 }
208 ExecutionEvent::ModelInvoked {
209 plan_id,
210 invocation_id,
211 model,
212 provider,
213 tokens_in,
214 tokens_out,
215 latency_ms,
216 ..
217 } => {
218 fields.insert("plan_id", plan_id.clone());
219 fields.insert("invocation_id", invocation_id.clone());
220 fields.insert("model", model.clone());
221 fields.insert("provider", provider.clone());
222 fields.insert("tokens_in", tokens_in.to_string());
223 fields.insert("tokens_out", tokens_out.to_string());
224 fields.insert("latency_ms", latency_ms.to_string());
225 "model_invoked"
226 }
227 ExecutionEvent::EngineInvoked {
228 plan_id,
229 invocation_id,
230 capability_id,
231 capability_version,
232 ..
233 } => {
234 fields.insert("plan_id", plan_id.clone());
235 fields.insert("invocation_id", invocation_id.clone());
236 fields.insert("capability_id", capability_id.clone());
237 fields.insert("capability_version", capability_version.clone());
238 "engine_invoked"
239 }
240 ExecutionEvent::ReceiptSigned {
241 receipt_id,
242 receipt_hash,
243 ..
244 } => {
245 fields.insert("receipt_id", receipt_id.clone());
246 fields.insert("receipt_hash", receipt_hash.clone());
247 "receipt_signed"
248 }
249 ExecutionEvent::CanonicalReceiptRecorded {
250 invocation_id,
251 receipt_id,
252 receipt_ref,
253 receipt_digest,
254 ..
255 } => {
256 fields.insert("invocation_id", invocation_id.clone());
257 fields.insert("receipt_id", receipt_id.clone());
258 fields.insert("receipt_ref", receipt_ref.clone());
259 fields.insert("receipt_digest", receipt_digest.clone());
260 "canonical_receipt_recorded"
261 }
262 ExecutionEvent::OutcomeRecorded {
263 outcome_id,
264 receipt_id,
265 accepted,
266 ..
267 } => {
268 fields.insert("outcome_id", outcome_id.clone());
269 fields.insert("receipt_id", receipt_id.clone());
270 fields.insert("accepted", acceptance_name(*accepted));
271 "outcome_recorded"
272 }
273 ExecutionEvent::DecisionRecorded {
274 decision_id,
275 kind,
276 selected,
277 ..
278 } => {
279 fields.insert("decision_id", decision_id.clone());
280 fields.insert("decision_kind", kind.clone());
281 fields.insert("selected", selected.clone());
282 "decision_recorded"
283 }
284 };
285 LineageStepV1 {
286 sequence: event.sequence_number(),
287 kind,
288 timestamp: event.timestamp().to_owned(),
289 fields,
290 }
291}
292
293fn delivery((hex, loaded): &(String, Result<StoredReceipt, LoadError>)) -> LineageDeliveryV1 {
294 match loaded {
295 Ok(stored) => LineageDeliveryV1 {
296 digest: stored.digest.as_str().to_owned(),
297 verified: true,
298 error: None,
299 summary: Some(summarize(&stored.receipt)),
300 egress: None,
301 },
302 Err(error) => LineageDeliveryV1 {
303 digest: format!("sha256:{hex}"),
304 verified: false,
305 error: Some(match error {
306 LoadError::Missing => "missing",
307 LoadError::Unreadable => "unreadable",
308 LoadError::Tampered => "tampered",
309 }),
310 summary: None,
311 egress: None,
312 },
313 }
314}
315
316fn summarize(receipt: &ContextDecisionReceiptV1) -> DeliverySummaryV1 {
317 let name = |value: serde_json::Value| value.as_str().map(str::to_owned).unwrap_or_default();
318 let destination = &receipt.destination;
319 let is_egress = destination.provider.as_str() != "mcp-host";
320 DeliverySummaryV1 {
321 outcome: if is_egress
322 && receipt.outcome == lean_ctx_protocol::context_gateway::DeliveryOutcomeV1::Delivered
323 {
324 "prepared".to_owned()
325 } else {
326 name(serde_json::to_value(receipt.outcome).unwrap_or_default())
327 },
328 delivery_semantics: if is_egress {
329 "prepared_for_dispatch"
330 } else {
331 "returned_to_host"
332 },
333 destination: destination.model.as_ref().map_or_else(
334 || destination.provider.as_str().to_owned(),
335 |model| format!("{} / {}", destination.provider.as_str(), model.as_str()),
336 ),
337 policy_digest: receipt
338 .policy
339 .as_ref()
340 .map(|policy| policy.digest.as_str().to_owned()),
341 inspected: receipt.sources.inspected,
342 delivered: receipt.sources.selected,
343 withheld: receipt.sources.blocked,
344 redactions: receipt.security.redactions,
345 tokens_original: receipt.tokens.original,
346 tokens_delivered: receipt.tokens.delivered,
347 final_context: receipt
348 .final_context
349 .as_ref()
350 .map(|digest| digest.as_str().to_owned()),
351 }
352}
353
354pub(crate) fn ledger_scope(events: &[ExecutionEvent], scope: &str) -> LedgerScope {
361 if events.is_empty() {
362 return LedgerScope::Verified;
363 }
364 let mut verdict = LedgerScope::Unverified;
365 for envelope_ref in events.iter().filter_map(|event| match event {
366 ExecutionEvent::TaskStarted { envelope_ref, .. } => Some(envelope_ref.as_str()),
367 _ => None,
368 }) {
369 let envelope = envelope_ref
370 .strip_prefix("sha256:")
371 .and_then(|hex| {
372 crate::core::engine_artifact::read_content("execution/evidence", hex, "json").ok()
373 })
374 .and_then(|bytes| {
375 serde_json::from_slice::<lean_ctx_protocol::TaskEnvelopeV1>(&bytes).ok()
376 });
377 match envelope {
378 Some(envelope)
379 if super::task_scope(envelope.tenant_id.as_ref(), &envelope.project_id)
380 == scope =>
381 {
382 if verdict == LedgerScope::Unverified {
383 verdict = LedgerScope::Verified;
384 }
385 }
386 Some(_) => return LedgerScope::Foreign,
387 None => return LedgerScope::Unverified,
389 }
390 }
391 verdict
392}
393
394pub fn load(scope: &str, task_id: &str) -> TaskLineageV1 {
397 let events = crate::core::execution_ledger::ExecutionLedgerStore::from_default()
398 .and_then(|store| store.by_task_verified(task_id))
399 .map_err(|error| error.to_string());
400 let checked = events
401 .as_ref()
402 .map_or(LedgerScope::Verified, |events| ledger_scope(events, scope));
403 let receipts = crate::core::context_admission::receipt_store::for_task(scope, task_id);
404 let mut lineage = build(task_id, events, checked, &receipts);
405 lineage.scope = Some(scope.to_owned());
406 for delivery in &mut lineage.deliveries {
407 if !delivery.verified {
408 continue;
409 }
410 let evidence = lean_ctx_protocol::Sha256Digest::new(delivery.digest.clone())
411 .map_err(|_| "invalid decision receipt digest")
412 .and_then(|digest| {
413 crate::core::context_admission::egress::load_egress_for_decision(&digest)
414 });
415 delivery.egress = match evidence {
416 Ok(None) => None,
417 Ok(Some(view)) => Some(EgressLineageV1 {
418 admission: view.admission.map(|stored| stored.admission),
419 observations: view
420 .observations
421 .into_iter()
422 .map(|stored| stored.observation)
423 .collect(),
424 error: None,
425 }),
426 Err(error) => Some(EgressLineageV1 {
427 admission: None,
428 observations: Vec::new(),
429 error: Some(error),
430 }),
431 };
432 }
433 if lineage
434 .deliveries
435 .iter()
436 .filter_map(|delivery| delivery.egress.as_ref())
437 .any(|egress| egress.error.is_some() || egress.admission.is_none())
438 {
439 lineage.gaps.push("egress_admission_unverified");
440 }
441 if lineage
442 .deliveries
443 .iter()
444 .filter_map(|delivery| delivery.egress.as_ref())
445 .any(|egress| {
446 egress.admission.as_ref().is_some_and(|admission| {
447 admission.outcome
448 == lean_ctx_protocol::context_gateway::EgressAdmissionOutcomeV1::Prepared
449 }) && egress.observations.is_empty()
450 })
451 {
452 lineage.gaps.push("no_transport_observation");
453 }
454 lineage
455}
456
457#[cfg(test)]
458#[path = "lineage_tests.rs"]
459mod tests;