1use crate::events::{Event, EventKind};
3use crate::gate_evaluation::{lifecycle, protocol};
4use crate::live_permission::Actor;
5use crate::types::{BlockCause, BlockContext, BlockOwner, MissionStatus, RunResult};
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10pub const MAPPING_VERSION: u32 = 1;
11
12pub fn render_text(report: &Report) -> String {
13 use std::fmt::Write as _;
14 let safe = |text: &str| {
15 text.chars()
16 .map(|c| if c.is_control() { ' ' } else { c })
17 .collect::<String>()
18 };
19 let mut out = format!(
20 "Recorded outcome reasons (mapping v{})\n{}\n",
21 report.mapping_version, report.selection
22 );
23 let _ = writeln!(
24 out,
25 "Window: {} → {}",
26 report
27 .from
28 .map(|d| d.to_rfc3339())
29 .unwrap_or_else(|| "all history".into()),
30 report
31 .through
32 .map(|d| d.to_rfc3339())
33 .unwrap_or_else(|| "latest recorded event".into())
34 );
35 if report.missions.is_empty() {
36 out.push_str("No missions in this activity window.\n");
37 }
38 for class in &report.task_classes {
39 let _ = writeln!(
40 out,
41 " {}: {} missions; {} mixed; {} with unresolved requests or blocks",
42 safe(&class.task_class),
43 class.missions,
44 class.mixed_missions,
45 class.unresolved_missions
46 );
47 for count in &class.counts {
48 let _ = writeln!(
49 out,
50 " {}: {}/{} missions, {} observations",
51 count.category.label(),
52 count.missions,
53 class.missions,
54 count.observations
55 );
56 }
57 }
58 for mission in &report.missions {
59 let _ = writeln!(
60 out,
61 " {}: current state {}",
62 safe(&mission.mission_id),
63 mission
64 .current_status
65 .map(|s| serde_json::to_string(&s).unwrap_or_default())
66 .unwrap_or_else(|| "unknown".into())
67 );
68 for row in &mission.observations {
69 let _ = writeln!(
70 out,
71 " event #{} [{}; {:?}] {}: {}",
72 row.seq,
73 row.category.label(),
74 row.state,
75 row.event_type,
76 safe(&row.detail)
77 );
78 let _ = writeln!(out, " milestone={:?} feature={:?} run={:?} attempt={:?} stage={:?} permission={:?} actor={:?} resolution-event={:?}", row.milestone_id, row.feature_id, row.run_id, row.attempt_id, row.stage, row.permission_request_id, row.actor, row.resolution_seq);
79 }
80 }
81 if !report.unavailable_logs.is_empty() {
82 let _ = writeln!(
83 out,
84 "Unavailable logs (excluded): {}",
85 safe(&report.unavailable_logs.join(", "))
86 );
87 }
88 out
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum Category {
94 ReportedDefect,
95 EnvironmentPrerequisite,
96 HumanPolicyBoundary,
97 Interrupted,
98 Cancelled,
99 Unknown,
100}
101impl Category {
102 pub fn label(self) -> &'static str {
103 match self {
104 Self::ReportedDefect => "reported defect",
105 Self::EnvironmentPrerequisite => "environment prerequisite",
106 Self::HumanPolicyBoundary => "human/policy boundary",
107 Self::Interrupted => "interrupted",
108 Self::Cancelled => "cancelled",
109 Self::Unknown => "unknown",
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "kebab-case")]
116pub enum ResolutionState {
117 Recorded,
118 Unresolved,
119 Resolved,
120 Closed,
121 Expired,
122 Superseded,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct Observation {
128 pub seq: u64,
129 pub ts: DateTime<Utc>,
130 pub event_type: String,
131 pub category: Category,
132 pub detail: String,
133 pub state: ResolutionState,
134 pub resolution_seq: Option<u64>,
135 pub milestone_id: Option<String>,
136 pub feature_id: Option<String>,
137 pub run_id: Option<String>,
138 pub attempt_id: Option<String>,
139 pub permission_request_id: Option<String>,
140 pub stage: Option<protocol::Stage>,
141 pub actor: Option<Actor>,
142 pub block_context: Option<BlockContext>,
143 pub deadline: Option<DateTime<Utc>>,
144}
145impl Observation {
146 fn new(event: &Event, category: Category, detail: &str) -> Self {
147 Self {
148 seq: event.seq,
149 ts: event.ts,
150 event_type: event.kind.type_name().into(),
151 category,
152 detail: crate::scrub::scrub_and_truncate(detail, 4096),
153 state: ResolutionState::Recorded,
154 resolution_seq: None,
155 milestone_id: None,
156 feature_id: None,
157 run_id: None,
158 attempt_id: None,
159 permission_request_id: None,
160 stage: None,
161 actor: None,
162 block_context: None,
163 deadline: None,
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct MissionReasons {
171 pub mapping_version: u32,
172 pub mission_id: String,
173 pub task_class: String,
174 pub current_status: Option<MissionStatus>,
176 pub latest_event_at: Option<DateTime<Utc>>,
177 pub latest_event_seq: Option<u64>,
178 pub observations: Vec<Observation>,
179}
180
181fn block_category(context: Option<BlockContext>) -> Category {
182 match context {
183 Some(BlockContext {
184 owner: BlockOwner::Engine,
185 cause: BlockCause::Authentication,
186 }) => Category::EnvironmentPrerequisite,
187 Some(BlockContext {
188 owner: BlockOwner::Operator,
189 cause: BlockCause::Operator,
190 }) => Category::HumanPolicyBoundary,
191 Some(BlockContext {
192 owner: BlockOwner::Engine,
193 cause:
194 BlockCause::Grant
195 | BlockCause::SecretScan
196 | BlockCause::UntrustedValidator
197 | BlockCause::ValidatorTamper
198 | BlockCause::ReviewerIndependence,
199 }) => Category::HumanPolicyBoundary,
200 _ => Category::Unknown,
203 }
204}
205
206fn resolve(
207 rows: &mut [Observation],
208 pending: &mut BTreeMap<String, usize>,
209 key: &str,
210 seq: u64,
211 state: ResolutionState,
212) {
213 if let Some(index) = pending.remove(key) {
214 rows[index].state = state;
215 rows[index].resolution_seq = Some(seq);
216 }
217}
218
219pub(super) fn mission(
222 mission_id: &str,
223 events: &[&Event],
224 status: Option<MissionStatus>,
225 task_class: Option<&str>,
226) -> MissionReasons {
227 let mut rows = Vec::new();
228 let mut pending = BTreeMap::new();
229 let mut attempts = BTreeMap::new();
230 let mut runs = BTreeMap::new();
231 let mut permissions = BTreeMap::new();
232 for event in events {
233 let mut key = None;
234 let mut row = match &event.kind {
235 EventKind::WorkerSpawned { run_id, feature_id, milestone_id, role, .. } => {
236 runs.insert(run_id.as_str(), (feature_id.clone(), milestone_id.clone(), *role));
237 continue;
238 }
239 EventKind::MilestoneBlocked { milestone_id, reason, block_context } => {
240 key = Some(format!("block:{milestone_id}"));
241 let mut row = Observation::new(event, block_category(*block_context), reason);
242 row.milestone_id = Some(milestone_id.clone());
243 row.block_context = *block_context;
244 row
245 }
246 EventKind::MilestoneUnblocked { milestone_id, .. } | EventKind::MilestoneCompleted { milestone_id, .. } => {
247 resolve(&mut rows, &mut pending, &format!("block:{milestone_id}"), event.seq, ResolutionState::Resolved);
248 continue;
249 }
250 EventKind::ValidationFinding { milestone_id, run_id, finding } => {
251 let category = if run_id == crate::reducer::ENGINE_RUN_ID && finding.class == "out-of-contract-write" {
252 Category::HumanPolicyBoundary
253 } else if runs.get(run_id.as_str()).is_some_and(|(_, _, role)| matches!(role, crate::types::Role::ValidatorScrutiny | crate::types::Role::ValidatorFunctional)) {
254 Category::ReportedDefect
255 } else {
256 Category::Unknown
259 };
260 let mut row = Observation::new(event, category, &format!("{}: {}", finding.subject, finding.evidence));
261 row.milestone_id = Some(milestone_id.clone());
262 row.run_id = Some(run_id.clone());
263 row.stage = Some(protocol::Stage::MilestoneValidation);
264 row
265 }
266 EventKind::ValidatorTamper { milestone_id, run_id, .. } => {
267 let mut row = Observation::new(event, Category::HumanPolicyBoundary, "Validator isolation tripwire recorded checkout or metadata drift.");
268 row.milestone_id = Some(milestone_id.clone());
269 row.run_id = Some(run_id.clone());
270 row
271 }
272 EventKind::WorkerCompleted { run_id, result, .. } if *result != RunResult::Pass => {
273 let mut row = Observation::new(event, Category::Unknown, "A non-passing worker result does not identify its cause.");
274 row.run_id = Some(run_id.clone());
275 row
276 }
277 EventKind::FeatureFailed { feature_id, reason, .. } => {
278 let mut row = Observation::new(event, Category::Unknown, reason);
279 row.feature_id = Some(feature_id.clone());
280 row
281 }
282 EventKind::GateResult { verdict: crate::gate::GateVerdict::Fail, surface, gate, .. } => {
283 let mut row = Observation::new(event, Category::Unknown, &format!("Non-passing gate {gate}; this event does not distinguish a defect from unavailable evidence or execution failure."));
284 row.stage = Some(match surface {
285 crate::gate::GateSurface::Approval => protocol::Stage::PlanApproval,
286 crate::gate::GateSurface::FinalGate => protocol::Stage::FinalGate,
287 });
288 row
289 }
290 EventKind::GrantRequested { milestone_id, command, .. } => {
291 key = Some("grant".into());
292 let mut row = Observation::new(event, Category::HumanPolicyBoundary, command);
293 row.milestone_id = Some(milestone_id.clone());
294 row
295 }
296 EventKind::GrantApproved { .. } => {
297 resolve(&mut rows, &mut pending, "grant", event.seq, ResolutionState::Resolved);
298 continue;
299 }
300 EventKind::GrantDenied { reason, .. } => {
301 resolve(&mut rows, &mut pending, "grant", event.seq, ResolutionState::Resolved);
302 Observation::new(event, Category::HumanPolicyBoundary, reason)
303 }
304 EventKind::PermissionRequested { request } => {
305 permissions.insert(request.proposal.id.as_str(), request.binding.run_id.as_str());
306 key = Some(format!("permission:{}", request.proposal.id));
307 let mut row = Observation::new(event, Category::HumanPolicyBoundary, request.proposal.prohibition.as_deref().unwrap_or("One-call permission requested; no decision recorded yet."));
308 row.run_id = Some(request.binding.run_id.clone());
309 row.permission_request_id = Some(request.proposal.id.clone());
310 row.stage = Some(protocol::Stage::CommandPermission);
311 row.deadline = Some(request.proposal.deadline);
312 row
313 }
314 EventKind::PermissionResolved { resolution } => {
315 resolve(&mut rows, &mut pending, &format!("permission:{}", resolution.request_id), event.seq, ResolutionState::Resolved);
316 let mut row = Observation::new(event, Category::HumanPolicyBoundary, &resolution.reason);
317 row.actor = Some(resolution.actor.clone());
318 row.permission_request_id = Some(resolution.request_id.clone());
319 row.run_id = permissions.get(resolution.request_id.as_str()).map(|s| (*s).into());
320 row.stage = Some(protocol::Stage::CommandPermission);
321 row
322 }
323 EventKind::PermissionClosed { request_id, .. } => {
324 resolve(&mut rows, &mut pending, &format!("permission:{request_id}"), event.seq, ResolutionState::Closed);
325 continue;
326 }
327 EventKind::PlanRevisionProposed { instructions, .. } => {
328 key = Some("revision".into());
329 Observation::new(event, Category::HumanPolicyBoundary, instructions)
330 }
331 EventKind::PlanRevised { .. } | EventKind::PlanRevisionRejected { .. } => {
332 resolve(&mut rows, &mut pending, "revision", event.seq, ResolutionState::Resolved);
333 continue;
334 }
335 EventKind::QuestionOpened { question_id, text, run_id, feature_id, milestone_id, .. } => {
336 key = Some(format!("question:{question_id}"));
337 let mut row = Observation::new(event, Category::HumanPolicyBoundary, text);
338 row.run_id = run_id.clone(); row.feature_id = feature_id.clone(); row.milestone_id = milestone_id.clone();
339 row
340 }
341 EventKind::QuestionAnswered { question_id, .. } | EventKind::QuestionCleared { question_id, .. } => {
342 resolve(&mut rows, &mut pending, &format!("question:{question_id}"), event.seq,
343 if matches!(event.kind, EventKind::QuestionAnswered { .. }) { ResolutionState::Resolved } else { ResolutionState::Closed });
344 continue;
345 }
346 EventKind::GateEvaluationRequested { evaluation } => {
347 attempts.insert(evaluation.request.params.attempt_id.as_str(), evaluation.as_ref());
348 continue;
349 }
350 EventKind::GateEvaluationFinished { evaluation } => {
351 let request = attempts.get(evaluation.attempt_id.as_str());
352 let (category, detail) = match &evaluation.outcome {
353 lifecycle::Outcome::Error { message } => (Category::Unknown, message.as_str()),
354 lifecycle::Outcome::Evaluated { result, .. } if result.status == protocol::Status::Escalate => (Category::HumanPolicyBoundary, result.rationale.as_str()),
355 lifecycle::Outcome::Evaluated { result, .. } if result.verdict == Some(protocol::Verdict::Fail) => {
356 let defect = request.is_some_and(|r| r.policy.kind == crate::pack::evaluator::Kind::Judgment)
357 && result.findings.as_ref().is_some_and(|f| !f.is_empty());
358 (if defect { Category::ReportedDefect } else { Category::Unknown }, result.rationale.as_str())
359 }
360 _ => continue,
361 };
362 let mut row = Observation::new(event, category, detail);
363 row.attempt_id = Some(evaluation.attempt_id.as_str().into());
364 row.stage = request.map(|r| r.request.params.stage);
365 row
366 }
367 EventKind::GateResolutionRecorded { resolution } if resolution.disposition != lifecycle::Disposition::Proceed => {
368 if resolution.disposition == lifecycle::Disposition::RequireHuman {
369 key = Some(format!("gate:{}", resolution.attempt_id.as_str()));
370 }
371 let mut row = Observation::new(event, Category::HumanPolicyBoundary, &resolution.rationale);
372 row.attempt_id = Some(resolution.attempt_id.as_str().into());
373 row.stage = attempts.get(resolution.attempt_id.as_str()).map(|r| r.request.params.stage);
374 row.actor = resolution.consent.as_ref().map(|c| c.actor.clone());
375 row
376 }
377 EventKind::GateResolutionConsumed { consumption } => {
378 resolve(&mut rows, &mut pending, &format!("gate:{}", consumption.attempt_id.as_str()), event.seq, ResolutionState::Resolved);
379 continue;
380 }
381 EventKind::GateEvaluationClosed { attempt_id, .. } => {
382 resolve(&mut rows, &mut pending, &format!("gate:{}", attempt_id.as_str()), event.seq, ResolutionState::Closed);
383 continue;
384 }
385 EventKind::MissionPaused {} | EventKind::UserMessage { interrupt: true, .. } => Observation::new(event, Category::Interrupted, "An explicit pause or interrupt was recorded; its underlying cause is not inferred."),
386 EventKind::MissionAbandoned { reason } => Observation::new(event, Category::Cancelled, reason),
387 EventKind::MissionFailed { reason } => Observation::new(event, Category::Unknown, reason),
388 EventKind::MissionCompleted {} => {
389 for index in std::mem::take(&mut pending).into_values() {
390 rows[index].state = ResolutionState::Closed;
391 rows[index].resolution_seq = Some(event.seq);
392 }
393 continue;
394 }
395 _ => continue,
396 };
397 if let Some((feature, milestone, _)) = row.run_id.as_deref().and_then(|id| runs.get(id)) {
398 row.feature_id = row.feature_id.or_else(|| feature.clone());
399 row.milestone_id = row.milestone_id.or_else(|| milestone.clone());
400 }
401 if let Some(key) = key {
402 row.state = ResolutionState::Unresolved;
403 if let Some(previous) = pending.insert(key, rows.len()) {
404 rows[previous].state = ResolutionState::Superseded;
405 rows[previous].resolution_seq = Some(event.seq);
406 }
407 }
408 if matches!(
409 event.kind,
410 EventKind::MissionFailed { .. } | EventKind::MissionAbandoned { .. }
411 ) {
412 for index in std::mem::take(&mut pending).into_values() {
413 rows[index].state = ResolutionState::Closed;
414 rows[index].resolution_seq = Some(event.seq);
415 }
416 }
417 rows.push(row);
418 }
419 if let Some(latest) = events.last() {
420 for row in &mut rows {
421 if row.state == ResolutionState::Unresolved
422 && row.deadline.is_some_and(|d| d <= latest.ts)
423 {
424 row.state = ResolutionState::Expired;
425 }
426 }
427 }
428 if status.is_none() {
429 for row in &mut rows {
430 row.category = Category::Unknown;
431 }
432 if let Some(event) = events.last() {
433 rows.push(Observation::new(event, Category::Unknown, "The existing reducer cannot establish mission state from this log; cause attribution is untrusted."));
434 }
435 }
436 MissionReasons {
437 mapping_version: MAPPING_VERSION,
438 mission_id: mission_id.into(),
439 task_class: task_class.unwrap_or(super::UNCLASSIFIED_TASK_CLASS).into(),
440 current_status: status,
441 latest_event_at: events.last().map(|e| e.ts),
442 latest_event_seq: events.last().map(|e| e.seq),
443 observations: rows,
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase")]
449pub struct Count {
450 pub category: Category,
451 pub missions: u64,
452 pub observations: u64,
453 pub share: Option<f64>,
454}
455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub struct ClassCounts {
458 pub task_class: String,
459 pub missions: u64,
460 pub mixed_missions: u64,
461 pub unresolved_missions: u64,
462 pub counts: Vec<Count>,
463}
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct Report {
467 pub mapping_version: u32,
468 pub from: Option<DateTime<Utc>>,
469 pub through: Option<DateTime<Utc>>,
470 pub selection: String,
471 pub missions: Vec<MissionReasons>,
472 pub task_classes: Vec<ClassCounts>,
473 pub unavailable_logs: Vec<String>,
474}
475
476pub fn report(
479 mut missions: Vec<MissionReasons>,
480 unavailable_logs: Vec<String>,
481 window: Option<(u64, DateTime<Utc>)>,
482) -> anyhow::Result<Report> {
483 let (from, through) = match window {
484 Some((days, now)) => {
485 anyhow::ensure!(
486 days <= super::MAX_MERGED_CHANGE_WINDOW_DAYS,
487 "outcome reason window exceeds {} days",
488 super::MAX_MERGED_CHANGE_WINDOW_DAYS
489 );
490 (
491 Some(
492 now.checked_sub_signed(chrono::Duration::days(days as i64))
493 .ok_or_else(|| anyhow::anyhow!("outcome reason window underflow"))?,
494 ),
495 Some(now),
496 )
497 }
498 None => (None, None),
499 };
500 missions.retain(|m| {
501 window.is_none()
502 || m.latest_event_at
503 .is_some_and(|at| Some(at) >= from && Some(at) <= through)
504 });
505 missions.sort_by(|a, b| a.mission_id.cmp(&b.mission_id));
506 let mut classes: BTreeMap<String, ClassCounts> = BTreeMap::new();
507 for mission in &mut missions {
508 if let Some(now) = through {
509 for row in &mut mission.observations {
510 if row.state == ResolutionState::Unresolved
511 && row.deadline.is_some_and(|d| d <= now)
512 {
513 row.state = ResolutionState::Expired;
514 }
515 }
516 }
517 let class = classes
518 .entry(mission.task_class.clone())
519 .or_insert_with(|| ClassCounts {
520 task_class: mission.task_class.clone(),
521 missions: 0,
522 mixed_missions: 0,
523 unresolved_missions: 0,
524 counts: vec![],
525 });
526 class.missions += 1;
527 let categories: BTreeSet<_> = mission.observations.iter().map(|r| r.category).collect();
528 class.mixed_missions += u64::from(categories.len() > 1);
529 class.unresolved_missions += u64::from(
530 mission
531 .observations
532 .iter()
533 .any(|r| r.state == ResolutionState::Unresolved),
534 );
535 for category in categories {
536 let index = class
537 .counts
538 .iter()
539 .position(|c| c.category == category)
540 .unwrap_or_else(|| {
541 class.counts.push(Count {
542 category,
543 missions: 0,
544 observations: 0,
545 share: None,
546 });
547 class.counts.len() - 1
548 });
549 class.counts[index].missions += 1;
550 class.counts[index].observations += mission
551 .observations
552 .iter()
553 .filter(|r| r.category == category)
554 .count() as u64;
555 }
556 }
557 let mut task_classes: Vec<_> = classes.into_values().collect();
558 task_classes.sort_by_key(|c| {
559 (
560 c.task_class == super::UNCLASSIFIED_TASK_CLASS,
561 c.task_class.clone(),
562 )
563 });
564 for class in &mut task_classes {
565 class.counts.sort_by_key(|c| c.category);
566 for count in &mut class.counts {
567 count.share = Some(count.missions as f64 / class.missions as f64);
568 }
569 }
570 Ok(Report { mapping_version: MAPPING_VERSION, from, through,
571 selection: "Missions whose latest recorded event is within the inclusive window; counts cover their full recorded history. Categories overlap. Unavailable logs are excluded; completion is not release or deployment.".into(),
572 missions, task_classes, unavailable_logs })
573}