Skip to main content

kranz_engine/outcomes/
reasons.rs

1//! Versioned display mapping over recorded causes. Never an execution policy.
2use 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    /// The existing reducer's status, never a replacement outcome taxonomy.
175    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        // A workspace check or generic validation failure can be infrastructure
201        // or defective work. Neither the owner nor its prose establishes which.
202        _ => 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
219/// Inputs already belong to one mission and are in log order. Unknown reducer
220/// state makes attribution untrusted; the projection still exposes provenance.
221pub(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                    // In particular, a native command-assertion failure does
257                    // not distinguish a broken implementation from setup.
258                    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
476/// Cohort by latest recorded activity; retain the full history of each selected
477/// mission so a repair never erases an earlier cause or inflates the denominator.
478pub 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}