Skip to main content

kranz_cli/otel/
map.rs

1//! Pure, deterministic event-to-span mapping primitives.
2//!
3//! No OTLP or network types leak in here — this module is unit-testable in
4//! isolation.
5
6use chrono::{DateTime, Utc};
7use kranz_engine::events::{Event, EventKind};
8use kranz_engine::types::TokenUsage;
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11
12/// A single OTLP-agnostic span attribute value.
13#[derive(Debug, Clone, PartialEq)]
14pub enum AttrValue {
15    String(String),
16    I64(i64),
17    F64(f64),
18}
19
20/// Span outcome, independent of any OTLP status code encoding.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SpanStatus {
23    Unset,
24    Ok,
25    Error(String),
26}
27
28/// A neutral, transport-agnostic span record.
29///
30/// Attribute ordering is caller-determined and preserved (a `Vec`, not a
31/// map) so mapping code can emit attributes in a deterministic order.
32#[derive(Debug, Clone, PartialEq)]
33pub struct MissionSpan {
34    pub trace_id: [u8; 16],
35    pub span_id: [u8; 8],
36    pub parent_span_id: Option<[u8; 8]>,
37    pub name: String,
38    pub start: DateTime<Utc>,
39    pub end: DateTime<Utc>,
40    pub attributes: Vec<(String, AttrValue)>,
41    pub status: SpanStatus,
42}
43
44/// Deterministic trace id for a mission: first 16 bytes of sha256(mission_id).
45pub fn trace_id(mission_id: &str) -> [u8; 16] {
46    let digest = Sha256::digest(mission_id.as_bytes());
47    let mut out = [0u8; 16];
48    out.copy_from_slice(&digest[..16]);
49    out
50}
51
52/// Deterministic span id for a mission/opening-seq pair: first 8 bytes of
53/// sha256("{mission_id}:{open_seq}"), where `open_seq` is the seq of the
54/// span's opening event (mission.created / milestone.started /
55/// worker.spawned).
56pub fn span_id(mission_id: &str, open_seq: u64) -> [u8; 8] {
57    let digest = Sha256::digest(format!("{mission_id}:{open_seq}").as_bytes());
58    let mut out = [0u8; 8];
59    out.copy_from_slice(&digest[..8]);
60    out
61}
62
63fn tokens_attrs(tokens: &TokenUsage) -> [(String, AttrValue); 4] {
64    [
65        (
66            "kranz.tokens.input".to_string(),
67            AttrValue::I64(tokens.input as i64),
68        ),
69        (
70            "kranz.tokens.output".to_string(),
71            AttrValue::I64(tokens.output as i64),
72        ),
73        (
74            "kranz.tokens.cache_read".to_string(),
75            AttrValue::I64(tokens.cache_read as i64),
76        ),
77        (
78            "kranz.tokens.cache_write".to_string(),
79            AttrValue::I64(tokens.cache_write as i64),
80        ),
81    ]
82}
83
84/// Tracks a still-open (not-yet-closed) milestone span.
85struct OpenMilestone {
86    open_seq: u64,
87    start: DateTime<Utc>,
88    title: String,
89    fix_cycles: u32,
90    validating: bool,
91}
92
93/// Tracks a still-open (not-yet-closed) run span.
94struct OpenRun {
95    open_seq: u64,
96    start: DateTime<Utc>,
97    role: kranz_engine::types::Role,
98    model: String,
99    feature_id: Option<String>,
100    milestone_id: Option<String>,
101}
102
103/// Cap on any free-text string this module turns into a span attribute or a
104/// span name. Goals, milestone titles, and failure reasons are agent- or
105/// repo-authored and otherwise unbounded.
106pub const OTEL_TEXT_MAX_CHARS: usize = 512;
107
108/// Redact and bound one piece of free text before it becomes an attribute.
109///
110/// `EventLog::append_redacting` scrubs at write time, but the export's read
111/// path does not get to assume that writer: `otel::run` enumerates
112/// `<repo>/.kranz/missions/` with a bare `read_dir` and no provenance check,
113/// so a repo that commits a hand-written `events.jsonl` gets its strings
114/// into the operator's observability backend verbatim. Scrub here too, and
115/// cap the length while we are at it — an OTLP exporter is not the place to
116/// discover a megabyte attribute.
117fn clean_text(text: &str) -> String {
118    kranz_engine::scrub::truncate_chars(&kranz_engine::scrub::scrub(text), OTEL_TEXT_MAX_CHARS)
119}
120
121/// Cap on an IDENTIFIER attribute or span name (mission/run/feature/
122/// milestone ids). Ids are short by construction; a long one is a
123/// hand-written log getting creative.
124pub const OTEL_ID_MAX_CHARS: usize = 128;
125
126/// Redact and bound one identifier before it becomes an attribute or part of
127/// a span name.
128///
129/// Threat (follow-up review M-14): the module claimed every free-text field
130/// crossed [`clean_text`], and the ids did not. `otel::run` enumerates
131/// `<repo>/.kranz/missions/` with a bare `read_dir` and no provenance check,
132/// so a repo that commits a hand-written `events.jsonl` shipped its `runId`,
133/// `featureId`, `milestoneId` and mission id into the operator's
134/// observability backend verbatim and unbounded. Ids get the same scrub as
135/// prose with a tighter cap.
136///
137/// Deliberately NOT applied to `trace_id`/`span_id`: those hash the RAW id,
138/// and cleaning one side of that derivation would break parent linkage
139/// against every span already exported.
140fn clean_id(id: &str) -> String {
141    kranz_engine::scrub::truncate_chars(&kranz_engine::scrub::scrub(id), OTEL_ID_MAX_CHARS)
142}
143
144/// Fold a mission's event log into its finished spans (root, milestones,
145/// runs). Pure and deterministic: every timestamp comes from the events'
146/// `ts` fields, never from wall-clock time. A span is emitted only when its
147/// closing event is present in `events` — mirroring OTel, which exports on
148/// span end.
149///
150/// Every free-text field the log carries crosses [`clean_text`], and every
151/// identifier crosses [`clean_id`], on the way in, so no caller can build an
152/// unscrubbed or unbounded attribute or span name.
153pub fn map_mission(events: &[Event]) -> Vec<MissionSpan> {
154    let mut spans = Vec::new();
155
156    let mission_id = match events.first() {
157        Some(e) => e.mission_id.clone(),
158        None => return spans,
159    };
160
161    let mut goal = String::new();
162    let mut created_seq: Option<u64> = None;
163    let mut created_ts: Option<DateTime<Utc>> = None;
164
165    // milestone_id -> title, populated from plan.approved when present.
166    let mut milestone_titles: HashMap<String, String> = HashMap::new();
167    let mut open_milestones: HashMap<String, OpenMilestone> = HashMap::new();
168    let mut open_runs: HashMap<String, OpenRun> = HashMap::new();
169
170    let mut total_tokens = TokenUsage::default();
171    let mut total_cost = 0.0_f64;
172
173    for event in events {
174        match &event.kind {
175            EventKind::MissionCreated { goal: g, .. } => {
176                goal = clean_text(g);
177                created_seq = Some(event.seq);
178                created_ts = Some(event.ts);
179            }
180
181            EventKind::PlanApproved { plan, .. } => {
182                for (mi, pm) in plan.milestones.iter().enumerate() {
183                    milestone_titles.insert(format!("ms-{}", mi + 1), clean_text(&pm.title));
184                }
185            }
186
187            EventKind::MilestoneStarted { milestone_id, .. } => {
188                let title = milestone_titles
189                    .get(milestone_id)
190                    .cloned()
191                    .unwrap_or_else(|| clean_id(milestone_id));
192                open_milestones.insert(
193                    milestone_id.clone(),
194                    OpenMilestone {
195                        open_seq: event.seq,
196                        start: event.ts,
197                        title,
198                        fix_cycles: 0,
199                        validating: false,
200                    },
201                );
202            }
203
204            EventKind::MilestoneValidating { milestone_id } => {
205                if let Some(open) = open_milestones.get_mut(milestone_id) {
206                    open.validating = true;
207                }
208            }
209
210            EventKind::FixFeatureCreated { milestone_id, .. } => {
211                if let Some(open) = open_milestones.get_mut(milestone_id) {
212                    if open.validating {
213                        open.fix_cycles += 1;
214                        open.validating = false;
215                    }
216                }
217            }
218
219            EventKind::WorkerSpawned {
220                run_id,
221                role,
222                feature_id,
223                milestone_id,
224                model,
225                ..
226            } => {
227                open_runs.insert(
228                    run_id.clone(),
229                    OpenRun {
230                        open_seq: event.seq,
231                        start: event.ts,
232                        role: *role,
233                        model: model.clone(),
234                        feature_id: feature_id.clone(),
235                        milestone_id: milestone_id.clone(),
236                    },
237                );
238            }
239
240            EventKind::WorkerCompleted {
241                run_id,
242                result,
243                tokens,
244                cost_usd,
245                ..
246            } => {
247                total_tokens.add(tokens);
248                if let Some(c) = cost_usd {
249                    total_cost += c;
250                }
251
252                if let Some(open) = open_runs.remove(run_id) {
253                    let parent = if let Some(mid) = &open.milestone_id {
254                        open_milestones
255                            .get(mid)
256                            .map(|m| span_id(&mission_id, m.open_seq))
257                            .or_else(|| root_span_id(&mission_id, created_seq))
258                    } else if let Some(fid) = &open.feature_id {
259                        milestone_id_from_feature_id(fid)
260                            .and_then(|mid| open_milestones.get(&mid))
261                            .map(|m| span_id(&mission_id, m.open_seq))
262                            .or_else(|| root_span_id(&mission_id, created_seq))
263                    } else {
264                        root_span_id(&mission_id, created_seq)
265                    };
266
267                    let (status, result_str) = match result {
268                        kranz_engine::types::RunResult::Pass => (SpanStatus::Ok, "pass"),
269                        kranz_engine::types::RunResult::Fail => {
270                            (SpanStatus::Error("fail".to_string()), "fail")
271                        }
272                        kranz_engine::types::RunResult::Partial => {
273                            (SpanStatus::Error("partial".to_string()), "partial")
274                        }
275                    };
276
277                    let mut attrs = vec![
278                        (
279                            "kranz.run.id".to_string(),
280                            AttrValue::String(clean_id(run_id)),
281                        ),
282                        (
283                            "kranz.role".to_string(),
284                            AttrValue::String(role_str(open.role).to_string()),
285                        ),
286                        (
287                            "kranz.model".to_string(),
288                            AttrValue::String(clean_text(&open.model)),
289                        ),
290                        (
291                            "kranz.run.result".to_string(),
292                            AttrValue::String(result_str.to_string()),
293                        ),
294                    ];
295                    if let Some(c) = cost_usd {
296                        attrs.push(("kranz.cost.usd".to_string(), AttrValue::F64(*c)));
297                    }
298                    attrs.extend(tokens_attrs(tokens));
299                    if let Some(fid) = &open.feature_id {
300                        attrs.push((
301                            "kranz.feature.id".to_string(),
302                            AttrValue::String(clean_id(fid)),
303                        ));
304                    }
305                    if let Some(mid) = &open.milestone_id {
306                        attrs.push((
307                            "kranz.milestone.id".to_string(),
308                            AttrValue::String(clean_id(mid)),
309                        ));
310                    }
311
312                    spans.push(MissionSpan {
313                        trace_id: trace_id(&mission_id),
314                        span_id: span_id(&mission_id, open.open_seq),
315                        parent_span_id: parent,
316                        name: format!("{} {}", role_str(open.role), clean_id(run_id)),
317                        start: open.start,
318                        end: event.ts,
319                        attributes: attrs,
320                        status,
321                    });
322                }
323            }
324
325            EventKind::MilestoneCompleted { milestone_id, .. } => {
326                if let Some(open) = open_milestones.remove(milestone_id) {
327                    spans.push(finished_milestone_span(
328                        &mission_id,
329                        milestone_id,
330                        &open,
331                        event.ts,
332                        SpanStatus::Ok,
333                        created_seq,
334                    ));
335                }
336            }
337
338            EventKind::MilestoneBlocked {
339                milestone_id,
340                reason,
341                ..
342            } => {
343                if let Some(open) = open_milestones.remove(milestone_id) {
344                    spans.push(finished_milestone_span(
345                        &mission_id,
346                        milestone_id,
347                        &open,
348                        event.ts,
349                        SpanStatus::Error(clean_text(reason)),
350                        created_seq,
351                    ));
352                }
353            }
354
355            EventKind::MissionCompleted {} => {
356                if let (Some(seq), Some(start)) = (created_seq, created_ts) {
357                    spans.push(finished_root_span(
358                        &mission_id,
359                        &goal,
360                        seq,
361                        start,
362                        event.ts,
363                        SpanStatus::Ok,
364                        "complete",
365                        &total_tokens,
366                        total_cost,
367                    ));
368                }
369            }
370
371            EventKind::MissionFailed { reason } => {
372                if let (Some(seq), Some(start)) = (created_seq, created_ts) {
373                    spans.push(finished_root_span(
374                        &mission_id,
375                        &goal,
376                        seq,
377                        start,
378                        event.ts,
379                        SpanStatus::Error(clean_text(reason)),
380                        "failed",
381                        &total_tokens,
382                        total_cost,
383                    ));
384                }
385            }
386
387            EventKind::MissionAbandoned { reason } => {
388                if let (Some(seq), Some(start)) = (created_seq, created_ts) {
389                    spans.push(finished_root_span(
390                        &mission_id,
391                        &goal,
392                        seq,
393                        start,
394                        event.ts,
395                        SpanStatus::Error(clean_text(reason)),
396                        "abandoned",
397                        &total_tokens,
398                        total_cost,
399                    ));
400                }
401            }
402
403            _ => {}
404        }
405    }
406
407    spans
408}
409
410/// The root span id, if `mission.created` is among the events this fold saw.
411///
412/// A live tail may start mid-mission and observe a milestone or run's full
413/// open/close pair without ever having seen `mission.created` (it happened
414/// before the tail began) — such a span still gets exported, just parentless
415/// rather than panicking.
416fn root_span_id(mission_id: &str, created_seq: Option<u64>) -> Option<[u8; 8]> {
417    created_seq.map(|seq| span_id(mission_id, seq))
418}
419
420fn role_str(role: kranz_engine::types::Role) -> &'static str {
421    use kranz_engine::types::Role::*;
422    match role {
423        Orchestrator => "orchestrator",
424        Worker => "worker",
425        ValidatorScrutiny => "validator-scrutiny",
426        ValidatorFunctional => "validator-functional",
427    }
428}
429
430/// `f-<m>-<f>` -> `ms-<m>`.
431fn milestone_id_from_feature_id(feature_id: &str) -> Option<String> {
432    let rest = feature_id.strip_prefix("f-")?;
433    let m = rest.split('-').next()?;
434    Some(format!("ms-{m}"))
435}
436
437fn finished_milestone_span(
438    mission_id: &str,
439    milestone_id: &str,
440    open: &OpenMilestone,
441    end: DateTime<Utc>,
442    status: SpanStatus,
443    created_seq: Option<u64>,
444) -> MissionSpan {
445    let attrs = vec![
446        (
447            "kranz.milestone.id".to_string(),
448            AttrValue::String(clean_id(milestone_id)),
449        ),
450        (
451            "kranz.milestone.title".to_string(),
452            AttrValue::String(open.title.clone()),
453        ),
454        (
455            "kranz.milestone.status".to_string(),
456            AttrValue::String(if status == SpanStatus::Ok {
457                "complete".to_string()
458            } else {
459                "blocked".to_string()
460            }),
461        ),
462        (
463            "kranz.milestone.fix_cycles".to_string(),
464            AttrValue::I64(open.fix_cycles as i64),
465        ),
466    ];
467
468    MissionSpan {
469        trace_id: trace_id(mission_id),
470        span_id: span_id(mission_id, open.open_seq),
471        parent_span_id: root_span_id(mission_id, created_seq),
472        name: format!("milestone {}: {}", clean_id(milestone_id), open.title),
473        start: open.start,
474        end,
475        attributes: attrs,
476        status,
477    }
478}
479
480#[allow(clippy::too_many_arguments)]
481fn finished_root_span(
482    mission_id: &str,
483    goal: &str,
484    created_seq: u64,
485    start: DateTime<Utc>,
486    end: DateTime<Utc>,
487    status: SpanStatus,
488    status_str: &str,
489    tokens: &TokenUsage,
490    cost: f64,
491) -> MissionSpan {
492    let mut attrs = vec![
493        (
494            "kranz.mission.id".to_string(),
495            AttrValue::String(clean_id(mission_id)),
496        ),
497        (
498            "kranz.mission.goal".to_string(),
499            AttrValue::String(goal.to_string()),
500        ),
501        (
502            "kranz.mission.status".to_string(),
503            AttrValue::String(status_str.to_string()),
504        ),
505        ("kranz.cost.usd".to_string(), AttrValue::F64(cost)),
506    ];
507    attrs.extend(tokens_attrs(tokens));
508
509    MissionSpan {
510        trace_id: trace_id(mission_id),
511        span_id: span_id(mission_id, created_seq),
512        parent_span_id: None,
513        name: format!("mission {}", clean_id(mission_id)),
514        start,
515        end,
516        attributes: attrs,
517        status,
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn ids_are_deterministic_and_idempotent() {
527        let t1 = trace_id("m-01");
528        let t2 = trace_id("m-01");
529        assert_eq!(
530            t1, t2,
531            "trace_id must be idempotent for the same mission_id"
532        );
533
534        // Pin the exact derivation: first 16 bytes of sha256(mission_id),
535        // computed independently of `trace_id` via sha2 directly.
536        let expected_trace: [u8; 16] = {
537            let digest = Sha256::digest(b"m-01");
538            digest[..16].try_into().unwrap()
539        };
540        assert_eq!(t1, expected_trace);
541
542        // And against a hardcoded hex vector (`printf 'm-01' | shasum -a
543        // 256` => 71677172fe9630d25271b72337b5caa4...), so the test also
544        // guards against the hash algorithm itself changing.
545        let expected_trace_hex: [u8; 16] = [
546            0x71, 0x67, 0x71, 0x72, 0xfe, 0x96, 0x30, 0xd2, 0x52, 0x71, 0xb7, 0x23, 0x37, 0xb5,
547            0xca, 0xa4,
548        ];
549        assert_eq!(t1, expected_trace_hex);
550
551        let s1 = span_id("m-01", 42);
552        let s2 = span_id("m-01", 42);
553        assert_eq!(
554            s1, s2,
555            "span_id must be idempotent for the same (mission_id, seq)"
556        );
557
558        // Pin span_id to first 8 bytes of sha256("{mission_id}:{seq}"),
559        // independently computed via sha2 directly.
560        let expected_span: [u8; 8] = {
561            let digest = Sha256::digest(b"m-01:42");
562            digest[..8].try_into().unwrap()
563        };
564        assert_eq!(s1, expected_span);
565
566        let t_other = trace_id("m-02");
567        assert_ne!(t1, t_other, "distinct missions must get distinct trace ids");
568
569        let s_other_seq = span_id("m-01", 43);
570        assert_ne!(s1, s_other_seq, "distinct seqs must get distinct span ids");
571
572        let s_other_mission = span_id("m-02", 42);
573        assert_ne!(
574            s1, s_other_mission,
575            "distinct missions must get distinct span ids even with the same seq"
576        );
577    }
578
579    use chrono::TimeZone;
580    use kranz_engine::types::{MissionConfig, Role, RunResult, TokenUsage};
581
582    fn ts(secs: i64) -> DateTime<Utc> {
583        Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
584    }
585
586    fn ev(seq: u64, secs: i64, kind: EventKind) -> Event {
587        Event {
588            seq,
589            ts: ts(secs),
590            mission_id: "m-01".to_string(),
591            kind,
592        }
593    }
594
595    fn created(seq: u64, secs: i64) -> Event {
596        ev(
597            seq,
598            secs,
599            EventKind::MissionCreated {
600                goal: "ship the thing".to_string(),
601                base_branch: "main".to_string(),
602                mission_branch: "kranz/mission-m-01".to_string(),
603                config: MissionConfig::default(),
604            },
605        )
606    }
607
608    fn spawned(
609        seq: u64,
610        secs: i64,
611        run_id: &str,
612        role: Role,
613        feature_id: Option<&str>,
614        milestone_id: Option<&str>,
615        model: &str,
616    ) -> Event {
617        ev(
618            seq,
619            secs,
620            EventKind::WorkerSpawned {
621                backend: None,
622                run_id: run_id.to_string(),
623                role,
624                feature_id: feature_id.map(|s| s.to_string()),
625                milestone_id: milestone_id.map(|s| s.to_string()),
626                candidate: None,
627                executor_route: None,
628                sdk_session_id: "sdk-1".to_string(),
629                model: model.to_string(),
630                quant: "n/a".to_string(),
631                weight_hash: None,
632                prompt_hash: "hash".to_string(),
633                transcript_path: "path".to_string(),
634            },
635        )
636    }
637
638    fn completed(
639        seq: u64,
640        secs: i64,
641        run_id: &str,
642        result: RunResult,
643        tokens: TokenUsage,
644        cost_usd: Option<f64>,
645    ) -> Event {
646        ev(
647            seq,
648            secs,
649            EventKind::WorkerCompleted {
650                run_id: run_id.to_string(),
651                result,
652                tokens,
653                cost_usd,
654                report: None,
655            },
656        )
657    }
658
659    fn milestone_started(seq: u64, secs: i64, milestone_id: &str) -> Event {
660        ev(
661            seq,
662            secs,
663            EventKind::MilestoneStarted {
664                milestone_id: milestone_id.to_string(),
665                start_sha: "abc123".to_string(),
666            },
667        )
668    }
669
670    fn milestone_completed(seq: u64, secs: i64, milestone_id: &str) -> Event {
671        ev(
672            seq,
673            secs,
674            EventKind::MilestoneCompleted {
675                milestone_id: milestone_id.to_string(),
676                tag: None,
677            },
678        )
679    }
680
681    fn sample_tokens() -> TokenUsage {
682        TokenUsage {
683            input: 100,
684            output: 50,
685            cache_read: 10,
686            cache_write: 5,
687        }
688    }
689
690    #[test]
691    fn mission_maps_to_trace_root() {
692        let events = vec![
693            created(1, 0),
694            milestone_started(2, 10, "ms-1"),
695            spawned(3, 20, "run-1", Role::Worker, Some("f-1-1"), None, "sonnet"),
696            completed(4, 30, "run-1", RunResult::Pass, sample_tokens(), Some(0.5)),
697            milestone_completed(5, 40, "ms-1"),
698            ev(6, 50, EventKind::MissionCompleted {}),
699        ];
700
701        let spans = map_mission(&events);
702        let roots: Vec<_> = spans
703            .iter()
704            .filter(|s| s.parent_span_id.is_none())
705            .collect();
706        assert_eq!(
707            roots.len(),
708            1,
709            "expected exactly one root span, got: {spans:#?}"
710        );
711
712        let root = roots[0];
713        assert_eq!(root.trace_id, trace_id("m-01"));
714        assert_eq!(root.start, ts(0));
715        assert_eq!(root.end, ts(50));
716        assert_eq!(root.status, SpanStatus::Ok);
717    }
718
719    #[test]
720    fn mission_without_terminal_event_emits_no_root() {
721        let events = vec![created(1, 0), milestone_started(2, 10, "ms-1")];
722        let spans = map_mission(&events);
723        assert!(
724            spans.iter().all(|s| s.parent_span_id.is_some()),
725            "no root span should be emitted for a still-running mission"
726        );
727    }
728
729    #[test]
730    fn run_span_carries_cost_and_token_attributes() {
731        let events = vec![
732            created(1, 0),
733            spawned(2, 10, "run-1", Role::Worker, None, None, "sonnet"),
734            completed(3, 20, "run-1", RunResult::Pass, sample_tokens(), Some(1.25)),
735        ];
736
737        let spans = map_mission(&events);
738        let run_span = spans
739            .iter()
740            .find(|s| s.name.contains("run-1"))
741            .expect("run span expected");
742
743        assert_eq!(
744            run_span
745                .attributes
746                .iter()
747                .find(|(k, _)| k == "kranz.cost.usd")
748                .map(|(_, v)| v.clone()),
749            Some(AttrValue::F64(1.25))
750        );
751        assert_eq!(
752            run_span
753                .attributes
754                .iter()
755                .find(|(k, _)| k == "kranz.tokens.input")
756                .map(|(_, v)| v.clone()),
757            Some(AttrValue::I64(100))
758        );
759        assert_eq!(
760            run_span
761                .attributes
762                .iter()
763                .find(|(k, _)| k == "kranz.tokens.output")
764                .map(|(_, v)| v.clone()),
765            Some(AttrValue::I64(50))
766        );
767        assert_eq!(
768            run_span
769                .attributes
770                .iter()
771                .find(|(k, _)| k == "kranz.tokens.cache_read")
772                .map(|(_, v)| v.clone()),
773            Some(AttrValue::I64(10))
774        );
775        assert_eq!(
776            run_span
777                .attributes
778                .iter()
779                .find(|(k, _)| k == "kranz.tokens.cache_write")
780                .map(|(_, v)| v.clone()),
781            Some(AttrValue::I64(5))
782        );
783        assert_eq!(
784            run_span
785                .attributes
786                .iter()
787                .find(|(k, _)| k == "kranz.role")
788                .map(|(_, v)| v.clone()),
789            Some(AttrValue::String("worker".to_string()))
790        );
791        assert_eq!(
792            run_span
793                .attributes
794                .iter()
795                .find(|(k, _)| k == "kranz.model")
796                .map(|(_, v)| v.clone()),
797            Some(AttrValue::String("sonnet".to_string()))
798        );
799    }
800
801    #[test]
802    fn terminal_status_maps_to_span_status() {
803        // Run statuses.
804        for (result, expected) in [
805            (RunResult::Pass, SpanStatus::Ok),
806            (RunResult::Fail, SpanStatus::Error("fail".to_string())),
807            (RunResult::Partial, SpanStatus::Error("partial".to_string())),
808        ] {
809            let events = vec![
810                created(1, 0),
811                spawned(2, 10, "run-1", Role::Worker, None, None, "sonnet"),
812                completed(3, 20, "run-1", result, sample_tokens(), None),
813            ];
814            let spans = map_mission(&events);
815            let run_span = spans.iter().find(|s| s.name.contains("run-1")).unwrap();
816            assert_eq!(
817                run_span.status, expected,
818                "result {result:?} should map to {expected:?}"
819            );
820        }
821
822        // Mission statuses.
823        let complete = vec![created(1, 0), ev(2, 10, EventKind::MissionCompleted {})];
824        let root = map_mission(&complete)
825            .into_iter()
826            .find(|s| s.parent_span_id.is_none())
827            .unwrap();
828        assert_eq!(root.status, SpanStatus::Ok);
829
830        let failed = vec![
831            created(1, 0),
832            ev(
833                2,
834                10,
835                EventKind::MissionFailed {
836                    reason: "boom".to_string(),
837                },
838            ),
839        ];
840        let root = map_mission(&failed)
841            .into_iter()
842            .find(|s| s.parent_span_id.is_none())
843            .unwrap();
844        assert_eq!(root.status, SpanStatus::Error("boom".to_string()));
845
846        let abandoned = vec![
847            created(1, 0),
848            ev(
849                2,
850                10,
851                EventKind::MissionAbandoned {
852                    reason: "retired".to_string(),
853                },
854            ),
855        ];
856        let root = map_mission(&abandoned)
857            .into_iter()
858            .find(|s| s.parent_span_id.is_none())
859            .unwrap();
860        assert_eq!(root.status, SpanStatus::Error("retired".to_string()));
861
862        // Milestone statuses.
863        let ms_complete = vec![
864            created(1, 0),
865            milestone_started(2, 10, "ms-1"),
866            milestone_completed(3, 20, "ms-1"),
867        ];
868        let ms_span = map_mission(&ms_complete)
869            .into_iter()
870            .find(|s| s.name.contains("ms-1"))
871            .unwrap();
872        assert_eq!(ms_span.status, SpanStatus::Ok);
873
874        let ms_blocked = vec![
875            created(1, 0),
876            milestone_started(2, 10, "ms-1"),
877            ev(
878                3,
879                20,
880                EventKind::MilestoneBlocked {
881                    block_context: None,
882                    milestone_id: "ms-1".to_string(),
883                    reason: "too many fix cycles".to_string(),
884                },
885            ),
886        ];
887        let ms_span = map_mission(&ms_blocked)
888            .into_iter()
889            .find(|s| s.name.contains("ms-1"))
890            .unwrap();
891        assert_eq!(
892            ms_span.status,
893            SpanStatus::Error("too many fix cycles".to_string())
894        );
895    }
896
897    #[test]
898    fn spans_parent_correctly() {
899        let events = vec![
900            created(1, 0),
901            // orchestrator run — parents to root.
902            spawned(2, 5, "orch-1", Role::Orchestrator, None, None, "opus"),
903            completed(3, 6, "orch-1", RunResult::Pass, sample_tokens(), None),
904            // milestone ms-1, and a milestone-scoped validator run.
905            milestone_started(4, 10, "ms-1"),
906            spawned(
907                5,
908                15,
909                "val-1",
910                Role::ValidatorFunctional,
911                None,
912                Some("ms-1"),
913                "sonnet",
914            ),
915            completed(6, 16, "val-1", RunResult::Pass, sample_tokens(), None),
916            // milestone ms-2, and a worker run parented via featureId f-2-1.
917            milestone_started(7, 20, "ms-2"),
918            spawned(
919                8,
920                25,
921                "run-2-1",
922                Role::Worker,
923                Some("f-2-1"),
924                None,
925                "sonnet",
926            ),
927            completed(9, 26, "run-2-1", RunResult::Pass, sample_tokens(), None),
928            milestone_completed(10, 30, "ms-1"),
929            milestone_completed(11, 31, "ms-2"),
930            ev(12, 40, EventKind::MissionCompleted {}),
931        ];
932
933        let spans = map_mission(&events);
934        let root = spans
935            .iter()
936            .find(|s| s.parent_span_id.is_none())
937            .expect("root span");
938        let ms1 = spans
939            .iter()
940            .find(|s| s.name.contains("ms-1"))
941            .expect("ms-1 span");
942        let ms2 = spans
943            .iter()
944            .find(|s| s.name.contains("ms-2"))
945            .expect("ms-2 span");
946        let orch = spans
947            .iter()
948            .find(|s| s.name.contains("orch-1"))
949            .expect("orch span");
950        let val = spans
951            .iter()
952            .find(|s| s.name.contains("val-1"))
953            .expect("val span");
954        let run2 = spans
955            .iter()
956            .find(|s| s.name.contains("run-2-1"))
957            .expect("run-2-1 span");
958
959        assert_eq!(
960            orch.parent_span_id,
961            Some(root.span_id),
962            "orchestrator run parents to root"
963        );
964        assert_eq!(
965            ms1.parent_span_id,
966            Some(root.span_id),
967            "milestone parents to root"
968        );
969        assert_eq!(
970            ms2.parent_span_id,
971            Some(root.span_id),
972            "milestone parents to root"
973        );
974        assert_eq!(
975            val.parent_span_id,
976            Some(ms1.span_id),
977            "validator run with milestoneId parents to that milestone"
978        );
979        assert_eq!(
980            run2.parent_span_id,
981            Some(ms2.span_id),
982            "run with featureId f-2-1 parents to ms-2"
983        );
984    }
985
986    #[test]
987    fn replay_uses_event_timestamps() {
988        let events = vec![
989            created(1, 1000),
990            milestone_started(2, 1010, "ms-1"),
991            spawned(
992                3,
993                1020,
994                "run-1",
995                Role::Worker,
996                Some("f-1-1"),
997                None,
998                "sonnet",
999            ),
1000            completed(
1001                4,
1002                1030,
1003                "run-1",
1004                RunResult::Pass,
1005                sample_tokens(),
1006                Some(0.1),
1007            ),
1008            milestone_completed(5, 1040, "ms-1"),
1009            ev(6, 1050, EventKind::MissionCompleted {}),
1010        ];
1011
1012        let spans = map_mission(&events);
1013        assert_eq!(spans.len(), 3, "expected root, milestone, and run spans");
1014
1015        let root = spans.iter().find(|s| s.parent_span_id.is_none()).unwrap();
1016        assert_eq!(root.start, ts(1000));
1017        assert_eq!(root.end, ts(1050));
1018
1019        let ms = spans.iter().find(|s| s.name.contains("ms-1")).unwrap();
1020        assert_eq!(ms.start, ts(1010));
1021        assert_eq!(ms.end, ts(1040));
1022
1023        let run = spans.iter().find(|s| s.name.contains("run-1")).unwrap();
1024        assert_eq!(run.start, ts(1020));
1025        assert_eq!(run.end, ts(1030));
1026    }
1027
1028    /// Audit (backends MEDIUM): the export reads mission logs off disk with
1029    /// no provenance check, so goal/title/reason text is untrusted. It
1030    /// crosses the scrubber and a length cap before it becomes a span
1031    /// attribute or a span name — the observability backend is not a place
1032    /// to discover a repo's planted credentials.
1033    #[test]
1034    fn otel_mission_text_is_scrubbed_and_bounded() {
1035        let secret = "sk-ant-F00barBazQuux9_7";
1036        let long = "g".repeat(OTEL_TEXT_MAX_CHARS + 500);
1037        let events = vec![
1038            ev(
1039                1,
1040                0,
1041                EventKind::MissionCreated {
1042                    goal: format!("{}={secret} {long}", "api_key"),
1043                    base_branch: "main".to_string(),
1044                    mission_branch: "kranz/mission-m-01".to_string(),
1045                    config: MissionConfig::default(),
1046                },
1047            ),
1048            ev(
1049                2,
1050                10,
1051                EventKind::MissionFailed {
1052                    reason: format!("failed with {}={secret}", "api_key"),
1053                },
1054            ),
1055        ];
1056
1057        let root = map_mission(&events)
1058            .into_iter()
1059            .find(|s| s.parent_span_id.is_none())
1060            .unwrap();
1061        let goal = root
1062            .attributes
1063            .iter()
1064            .find(|(key, _)| key == "kranz.mission.goal")
1065            .map(|(_, value)| value.clone())
1066            .unwrap();
1067        let AttrValue::String(goal) = goal else {
1068            panic!("goal is a string attribute");
1069        };
1070        assert!(!goal.contains(secret), "goal leaked a secret: {goal}");
1071        assert!(goal.contains("[REDACTED]"));
1072        assert!(
1073            goal.chars().count() <= OTEL_TEXT_MAX_CHARS + 32,
1074            "goal is unbounded: {} chars",
1075            goal.chars().count()
1076        );
1077        match &root.status {
1078            SpanStatus::Error(reason) => {
1079                assert!(
1080                    !reason.contains(secret),
1081                    "reason leaked the fixture value: {reason}"
1082                );
1083                assert!(reason.contains("[REDACTED]"));
1084            }
1085            other => panic!("expected an error status, got {other:?}"),
1086        }
1087    }
1088
1089    /// M-14 (follow-up review): the module's own claim was that EVERY
1090    /// free-text field crosses the scrubber, and the ids did not: `run_id`
1091    /// (attribute and span name), `model`, `feature_id`, `milestone_id` and
1092    /// the mission id all shipped verbatim and uncapped from a hand-written
1093    /// `events.jsonl`.
1094    #[test]
1095    fn otel_run_ids_model_and_mission_id_are_scrubbed_and_bounded() {
1096        let secret = "sk-ant-F00barBazQuux9_7";
1097        let long = "z".repeat(OTEL_ID_MAX_CHARS + 200);
1098        let run_id = format!("run-{secret}-{long}");
1099        let events = vec![
1100            created(1, 0),
1101            spawned(
1102                2,
1103                5,
1104                &run_id,
1105                Role::Worker,
1106                Some(&format!("f-1-1-{secret}")),
1107                Some(&format!("ms-1-{secret}")),
1108                &format!("claude-{secret}"),
1109            ),
1110            completed(3, 9, &run_id, RunResult::Pass, TokenUsage::default(), None),
1111        ];
1112
1113        let run = map_mission(&events)
1114            .into_iter()
1115            .find(|s| s.name.starts_with("worker"))
1116            .expect("a finished run span");
1117
1118        assert!(
1119            !run.name.contains(secret),
1120            "span name leaked a secret: {}",
1121            run.name
1122        );
1123        assert!(
1124            run.name.chars().count() <= OTEL_ID_MAX_CHARS + 32,
1125            "span name is unbounded: {} chars",
1126            run.name.chars().count()
1127        );
1128
1129        let attr = |key: &str| {
1130            run.attributes
1131                .iter()
1132                .find(|(k, _)| k == key)
1133                .map(|(_, v)| match v {
1134                    AttrValue::String(s) => s.clone(),
1135                    other => panic!("{key} is not a string attribute: {other:?}"),
1136                })
1137                .unwrap_or_else(|| panic!("missing attribute {key}"))
1138        };
1139        for key in [
1140            "kranz.run.id",
1141            "kranz.model",
1142            "kranz.feature.id",
1143            "kranz.milestone.id",
1144        ] {
1145            let value = attr(key);
1146            assert!(!value.contains(secret), "{key} leaked a secret: {value}");
1147            assert!(
1148                value.contains("[REDACTED]"),
1149                "{key} was not scrubbed: {value}"
1150            );
1151        }
1152        let id = attr("kranz.run.id");
1153        assert!(
1154            id.chars().count() <= OTEL_ID_MAX_CHARS,
1155            "kranz.run.id is unbounded: {} chars",
1156            id.chars().count()
1157        );
1158
1159        // The mission id rides the root span's attribute and name.
1160        let secret_mission = format!("m-{secret}");
1161        let mut events = vec![created(1, 0), ev(2, 10, EventKind::MissionCompleted {})];
1162        for e in &mut events {
1163            e.mission_id = secret_mission.clone();
1164        }
1165        let root = map_mission(&events)
1166            .into_iter()
1167            .find(|s| s.parent_span_id.is_none())
1168            .expect("a root span");
1169        assert!(
1170            !root.name.contains(secret),
1171            "root name leaked: {}",
1172            root.name
1173        );
1174        let mission_attr = root
1175            .attributes
1176            .iter()
1177            .find(|(k, _)| k == "kranz.mission.id")
1178            .map(|(_, v)| format!("{v:?}"))
1179            .unwrap();
1180        assert!(
1181            !mission_attr.contains(secret),
1182            "kranz.mission.id leaked: {mission_attr}"
1183        );
1184    }
1185
1186    /// The milestone title travels the same way, into both the attribute and
1187    /// the span NAME.
1188    #[test]
1189    fn otel_milestone_title_is_scrubbed() {
1190        let secret = "sk-ant-F00barBazQuux9_7";
1191        let plan = kranz_engine::types::Plan {
1192            goal: "ship".to_string(),
1193            validation_contract: vec![],
1194            milestones: vec![kranz_engine::types::PlanMilestone {
1195                title: format!("do it with {}={secret}", "api_key"),
1196                features: vec![],
1197            }],
1198            considered_alternatives: None,
1199            command_grants: vec![],
1200            touch_set: vec![],
1201            standards_manifest: None,
1202            reviewer_independence: None,
1203        };
1204        let events = vec![
1205            created(1, 0),
1206            ev(
1207                2,
1208                5,
1209                EventKind::PlanApproved {
1210                    plan,
1211                    base_sha: None,
1212                },
1213            ),
1214            milestone_started(3, 10, "ms-1"),
1215            milestone_completed(4, 20, "ms-1"),
1216        ];
1217
1218        let ms = map_mission(&events)
1219            .into_iter()
1220            .find(|s| s.name.contains("ms-1"))
1221            .unwrap();
1222        assert!(!ms.name.contains(secret), "span name leaked: {}", ms.name);
1223        let title = ms
1224            .attributes
1225            .iter()
1226            .find(|(key, _)| key == "kranz.milestone.title")
1227            .map(|(_, value)| value.clone())
1228            .unwrap();
1229        assert_eq!(
1230            title,
1231            AttrValue::String(kranz_engine::scrub::scrub(&format!(
1232                "do it with {}={secret}",
1233                "api_key"
1234            )))
1235        );
1236    }
1237}