Skip to main content

pointlock_store/projection/
timeline.rs

1//! `RunTimelineEntry` — the timeline projection of the RunLog (spine
2//! §10.1, 08 §4). Pinned decisions, all unchanged: monotonic `seq` order
3//! (never timestamps); the five-value filter vocabulary adopted verbatim
4//! from the DeviceRail live visualizer; the 50/page hard cap; evidence as
5//! pure references (never inlined bytes); bounded rendering with explicit
6//! `truncated` flags and a fail-closed placeholder for over-limit events
7//! (the full record stays reachable through `StepDossierView`).
8
9use pointlock_ir::{
10    ActionOutcome, HandlerHook, RunLogEvent, RunLogPayload, StepState, render_run_path,
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use super::ProjectionVersion;
17use crate::error::StoreError;
18use crate::store::Store;
19
20/// Hard page-size cap (08 §4.4, = `LIVE_TIMELINE_MAX_PAGE_SIZE`).
21pub const TIMELINE_MAX_PAGE_SIZE: u32 = 50;
22/// Text fields truncate at 4 KiB (08 §4.4).
23pub const TIMELINE_TEXT_MAX_BYTES: usize = 4 * 1024;
24/// JSON digests truncate at 16 KiB serialized (08 §4.4).
25pub const TIMELINE_JSON_MAX_BYTES: usize = 16 * 1024;
26/// JSON digests truncate at depth 12 (08 §4.4).
27pub const TIMELINE_JSON_MAX_DEPTH: usize = 12;
28/// At most 32 evidence references per entry (08 §4.4).
29pub const TIMELINE_EVIDENCE_MAX: usize = 32;
30
31/// The five-value filter vocabulary (08 §4.2, verbatim from the
32/// DeviceRail `TimelineFilter`).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "camelCase")]
35pub enum RunTimelineFilter {
36    /// Every event (structural events are ONLY here).
37    All,
38    /// `observationRecorded`.
39    Observations,
40    /// `actionIntent` + `actionSettled` (intent/terminal pairs).
41    Actions,
42    /// Failed/cancelled/timedOut settlements + `handlerTriggered(onError)`.
43    Errors,
44    /// `preflightProbed` + `assertionEvaluated` + `verdictRecorded`.
45    Verdicts,
46}
47
48impl RunTimelineFilter {
49    /// The pinned event→filter mapping (08 §4.2 table; UI and API share
50    /// this one function).
51    pub fn admits(self, payload: &RunLogPayload) -> bool {
52        match self {
53            RunTimelineFilter::All => true,
54            RunTimelineFilter::Observations => {
55                matches!(payload, RunLogPayload::ObservationRecorded { .. })
56            }
57            RunTimelineFilter::Actions => matches!(
58                payload,
59                RunLogPayload::ActionIntent { .. } | RunLogPayload::ActionSettled { .. }
60            ),
61            RunTimelineFilter::Errors => match payload {
62                RunLogPayload::ActionSettled { outcome, .. } => {
63                    !matches!(outcome, ActionOutcome::Succeeded { .. })
64                }
65                RunLogPayload::HandlerTriggered { hook, .. } => *hook == HandlerHook::OnError,
66                _ => false,
67            },
68            RunTimelineFilter::Verdicts => matches!(
69                payload,
70                RunLogPayload::PreflightProbed { .. }
71                    | RunLogPayload::AssertionEvaluated { .. }
72                    | RunLogPayload::VerdictRecorded { .. }
73            ),
74        }
75    }
76}
77
78/// One evidence reference on a timeline entry — reference ONLY, never
79/// bytes (08 §4.3); dereference goes through the evidence route.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct TimelineEvidenceRef {
83    /// Provider-side asset id.
84    pub id: String,
85    /// Media type of the referenced bytes.
86    pub media_type: String,
87    /// Content address of the localized bytes (bare lowercase hex).
88    pub sha256: String,
89}
90
91/// Typed error surface on error-class entries (08 §4.2: ErrorClass +
92/// `ErrorInfo{code, message, retryable}`).
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct TimelineErrorView {
96    /// Closed taxonomy class, when the settlement carried one.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub error_class: Option<String>,
99    /// Provider error code (open set).
100    pub code: String,
101    /// Provider error message (bounded).
102    pub message: String,
103    /// Whether the provider marked the error retryable.
104    pub retryable: bool,
105}
106
107/// The typed per-event detail of one entry. A closed enum mirroring the
108/// 17-variant RunLog union, carrying only bounded summaries — full
109/// payloads stay in the dossier (08 §4.4).
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
111#[serde(tag = "type", rename_all = "camelCase")]
112pub enum TimelineDetail {
113    /// Run opened.
114    #[serde(rename_all = "camelCase")]
115    RunStarted {
116        /// Supervision policy, explicit `null` when unsupervised (R13).
117        supervise_policy: Option<String>,
118    },
119    /// Step span opened.
120    #[serde(rename_all = "camelCase")]
121    StepEntered {
122        /// The entered step.
123        step_id: String,
124    },
125    /// Resume-drift probes evaluated.
126    #[serde(rename_all = "camelCase")]
127    PreflightProbed {
128        /// Probe outcome counts as `pass/fail/unknown`.
129        pass: u32,
130        /// Failed probes.
131        fail: u32,
132        /// Unknown probes.
133        unknown: u32,
134        /// The step declared NO probes and this resume re-touched the world
135        /// without checking it (07 §4.2 rule 1 / I3: 「没有探针的 resume 在
136        /// 报告中标 `unprobed`,而不是假装校验过」). Stated as a flag, not
137        /// left to be inferred from three zeroes — the whole point of the
138        /// rule is that a reader sees it said.
139        #[serde(default, skip_serializing_if = "core::ops::Not::not")]
140        unprobed: bool,
141    },
142    /// WAL intent (dispatch about to happen).
143    #[serde(rename_all = "camelCase")]
144    ActionIntent {
145        /// The dispatch correlation id.
146        call_id: String,
147        /// Bounded argument snapshot digest.
148        args: BoundedValue,
149    },
150    /// Action reached a terminal (four-way, never folded — 08 §2.5).
151    #[serde(rename_all = "camelCase")]
152    ActionSettled {
153        /// The dispatch correlation id.
154        call_id: String,
155        /// Terminal discriminant (`succeeded/failed/cancelled/timedOut`).
156        outcome: String,
157        /// Execution mode when reported (`coordinateFallback` highlighted
158        /// by renderers).
159        #[serde(skip_serializing_if = "Option::is_none")]
160        execution_mode: Option<String>,
161        /// Daemon-side degradation reason, when any.
162        #[serde(skip_serializing_if = "Option::is_none")]
163        fallback_reason: Option<String>,
164        /// Error surface on non-success terminals.
165        #[serde(skip_serializing_if = "Option::is_none")]
166        error: Option<TimelineErrorView>,
167    },
168    /// Observation captured (legal omissions shown as-is).
169    #[serde(rename_all = "camelCase")]
170    ObservationRecorded {
171        /// Observation id.
172        observation_id: String,
173        /// Capture wall-clock.
174        captured_at_ms: u64,
175        /// Screenshot omission reason, when omitted.
176        #[serde(skip_serializing_if = "Option::is_none")]
177        screenshot_omission: Option<String>,
178        /// uiSnapshot omission reason, when omitted.
179        #[serde(skip_serializing_if = "Option::is_none")]
180        ui_snapshot_omission: Option<String>,
181    },
182    /// One assertion evaluated.
183    #[serde(rename_all = "camelCase")]
184    AssertionEvaluated {
185        /// The assertion.
186        assert_id: String,
187        /// Three-valued result.
188        result: String,
189        /// Channel that completed evaluation, when one did.
190        #[serde(skip_serializing_if = "Option::is_none")]
191        channel: Option<String>,
192        /// Bounded reason text.
193        reason: String,
194    },
195    /// Step verdict recorded (re-judgements append, never replace).
196    #[serde(rename_all = "camelCase")]
197    VerdictRecorded {
198        /// Three-valued status.
199        status: String,
200        /// Degraded-verification marker.
201        degraded: bool,
202        /// Superseded verdict id, on re-judgement.
203        #[serde(skip_serializing_if = "Option::is_none")]
204        supersedes: Option<String>,
205        /// Bounded verdict summary.
206        summary: String,
207        /// Bounded `verdict.record` write-back failure — remote archival
208        /// failed; the local verdict is untouched (04 §5).
209        #[serde(skip_serializing_if = "Option::is_none")]
210        remote_archival_error: Option<String>,
211    },
212    /// Step span closed.
213    #[serde(rename_all = "camelCase")]
214    StepExited {
215        /// Exit state (closed vocabulary).
216        state: StepState,
217    },
218    /// Call frame pushed (subflow entered).
219    #[serde(rename_all = "camelCase")]
220    CallFramePushed {
221        /// Callee identity `flowId@sha256:…`.
222        callee: String,
223        /// The frame was RE-ENTERED under a repaired callee rather than
224        /// opened (07 §5.2 case (a) down-drill). Absent on every ordinary
225        /// push and on every pre-incorporation ledger.
226        #[serde(default, skip_serializing_if = "core::ops::Not::not")]
227        rebase: bool,
228    },
229    /// Call frame popped (subflow returned).
230    #[serde(rename_all = "camelCase")]
231    CallFramePopped {
232        /// Whether the callee returned outputs.
233        has_outputs: bool,
234    },
235    /// Handler consulted (hook fired).
236    #[serde(rename_all = "camelCase")]
237    HandlerTriggered {
238        /// The hook.
239        hook: String,
240        /// Trigger ordinal for the instance.
241        trigger: u64,
242        /// Declared disposition head (03 §1.8), when recorded.
243        #[serde(skip_serializing_if = "Option::is_none")]
244        disposition: Option<String>,
245    },
246    /// Human request opened.
247    #[serde(rename_all = "camelCase")]
248    HumanRequested {
249        /// Pairing id.
250        request_id: String,
251        /// `step` vs `supervision` (R13).
252        purpose: String,
253        /// Interaction mode on step requests.
254        #[serde(skip_serializing_if = "Option::is_none")]
255        mode: Option<String>,
256        /// Bounded prompt text.
257        prompt: String,
258    },
259    /// Human response recorded through the arbitration door.
260    #[serde(rename_all = "camelCase")]
261    HumanResponded {
262        /// Pairing id.
263        request_id: String,
264        /// `step` vs `supervision`.
265        purpose: String,
266        /// Responding actor.
267        actor: String,
268        /// Bounded response digest.
269        response: BoundedValue,
270    },
271    /// Run suspended (timeline shows the lineage divider — 08 §4.1).
272    #[serde(rename_all = "camelCase")]
273    RunSuspended {
274        /// Suspension reason, explicit `null` when unstated.
275        reason: Option<String>,
276    },
277    /// Run resumed with an alignment report.
278    #[serde(rename_all = "camelCase")]
279    RunResumed {
280        /// Per-class alignment counts, in class order.
281        alignment: BoundedValue,
282        /// Supervision policy of the new segment (explicit `null` when
283        /// unsupervised — never inherited, R13).
284        supervise_policy: Option<String>,
285    },
286    /// Run reached its terminal.
287    #[serde(rename_all = "camelCase")]
288    RunFinished {
289        /// Flow verdict status; absent = finished unverified/aborted.
290        #[serde(skip_serializing_if = "Option::is_none")]
291        status: Option<String>,
292        /// Degraded marker of the flow verdict.
293        #[serde(skip_serializing_if = "Option::is_none")]
294        degraded: Option<bool>,
295        /// Bounded `verdict.record` write-back failure of the flow
296        /// verdict (04 §5 — see `verdictRecorded`).
297        #[serde(skip_serializing_if = "Option::is_none")]
298        remote_archival_error: Option<String>,
299    },
300    /// Fail-closed placeholder: the event exceeded the bounded-rendering
301    /// limits even after truncation; the full record is in the dossier
302    /// (08 §4.4 — no half entries).
303    #[serde(rename_all = "camelCase")]
304    OverLimit {
305        /// The original event discriminant.
306        event_type: String,
307    },
308}
309
310/// A bounded JSON digest (08 §4.4): truncation is always explicit.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
312#[serde(rename_all = "camelCase", deny_unknown_fields)]
313pub struct BoundedValue {
314    /// The digest value (depth- and size-bounded).
315    pub value: Value,
316    /// Whether anything was cut while bounding.
317    pub truncated: bool,
318}
319
320/// One timeline entry (spine §10.1 `RunTimelineEntry`).
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
322#[serde(rename_all = "camelCase", deny_unknown_fields)]
323pub struct RunTimelineEntry {
324    /// Ledger sequence — the ordering authority (08 §4.1).
325    pub seq: u64,
326    /// Wall clock, informational only.
327    pub at_ms: u64,
328    /// Canonical run-path string of the event.
329    pub run_path: String,
330    /// Bounded typed detail (discriminant = RunLog event type).
331    pub detail: TimelineDetail,
332    /// Evidence references (≤ 32; pure references — 08 §4.3).
333    pub evidence: Vec<TimelineEvidenceRef>,
334    /// How many evidence references were dropped by the cap.
335    pub evidence_omitted: u32,
336    /// Whether any field of this entry was truncated while bounding.
337    pub truncated: bool,
338}
339
340/// One synchronous timeline page (08 §4.4: pagination never moves seen
341/// entries; `revision` tells the client when to re-pull).
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct TimelinePage {
345    /// Protocol version (spine §10.3).
346    pub projection_version: ProjectionVersion,
347    /// The projected run.
348    pub run_id: String,
349    /// Filter applied server-side.
350    pub filter: RunTimelineFilter,
351    /// 1-based page number.
352    pub page: u32,
353    /// Effective page size (≤ 50; requests can only shrink it).
354    pub page_size: u32,
355    /// Snapshot revision (= max ledger seq at query time).
356    pub revision: u64,
357    /// Total entries admitted by the filter.
358    pub total: u64,
359    /// The page.
360    pub entries: Vec<RunTimelineEntry>,
361}
362
363/// Projects one timeline page. `page` is 1-based; `page_size` is clamped
364/// to the hard cap (08 §4.4).
365pub fn timeline_page(
366    store: &Store,
367    run_id: &str,
368    filter: RunTimelineFilter,
369    page: u32,
370    page_size: u32,
371) -> Result<TimelinePage, StoreError> {
372    let events = store.events(run_id)?;
373    let revision = events.last().map(|event| event.seq).unwrap_or(0);
374
375    // The runner-recorded ErrorClass lives on the checkpoint's attempt
376    // records, keyed by callId (08 §4.2: error entries carry ErrorClass +
377    // ErrorInfo); joined here so the timeline never re-derives taxonomy.
378    let mut error_classes: std::collections::BTreeMap<String, String> =
379        std::collections::BTreeMap::new();
380    if let Some((_, view)) = store.materialized_checkpoint(run_id)? {
381        for record in &view.completed {
382            for attempt in &record.attempts {
383                if let Some(class) = &attempt.error_class {
384                    error_classes.insert(attempt.call_id.clone(), wire(class));
385                }
386            }
387        }
388    }
389
390    let admitted: Vec<&RunLogEvent> = events
391        .iter()
392        .filter(|event| filter.admits(&event.payload))
393        .collect();
394    let page = page.max(1);
395    let page_size = page_size.clamp(1, TIMELINE_MAX_PAGE_SIZE);
396    let start = (page as usize - 1).saturating_mul(page_size as usize);
397    let entries = admitted
398        .iter()
399        .skip(start)
400        .take(page_size as usize)
401        .map(|event| entry_of(event, &error_classes))
402        .collect();
403    Ok(TimelinePage {
404        projection_version: ProjectionVersion,
405        run_id: run_id.to_owned(),
406        filter,
407        page,
408        page_size,
409        revision,
410        total: admitted.len() as u64,
411        entries,
412    })
413}
414
415/// Serializes a unit-enum value to its wire literal.
416fn wire<T: Serialize>(value: &T) -> String {
417    serde_json::to_value(value)
418        .ok()
419        .and_then(|v| v.as_str().map(str::to_owned))
420        .unwrap_or_default()
421}
422
423/// Truncates a string at the byte cap on a char boundary; explicit flag.
424fn bound_text(text: &str) -> (String, bool) {
425    if text.len() <= TIMELINE_TEXT_MAX_BYTES {
426        return (text.to_owned(), false);
427    }
428    let mut cut = TIMELINE_TEXT_MAX_BYTES;
429    while !text.is_char_boundary(cut) {
430        cut -= 1;
431    }
432    (text[..cut].to_owned(), true)
433}
434
435/// Depth-bounds a JSON value; deeper structure collapses to a marker
436/// string (explicitly visible, never silent).
437fn bound_depth(value: &Value, depth: usize, truncated: &mut bool) -> Value {
438    if depth == 0 {
439        *truncated = true;
440        return Value::String("…depth truncated".to_owned());
441    }
442    match value {
443        Value::Object(map) => Value::Object(
444            map.iter()
445                .map(|(k, v)| (k.clone(), bound_depth(v, depth - 1, truncated)))
446                .collect(),
447        ),
448        Value::Array(items) => Value::Array(
449            items
450                .iter()
451                .map(|v| bound_depth(v, depth - 1, truncated))
452                .collect(),
453        ),
454        Value::String(text) => {
455            let (bounded, cut) = bound_text(text);
456            if cut {
457                *truncated = true;
458            }
459            Value::String(bounded)
460        }
461        other => other.clone(),
462    }
463}
464
465/// Bounds a JSON value by depth then by serialized size (08 §4.4). When
466/// even the depth-bounded form exceeds the byte cap, the whole value
467/// fails closed to a marker.
468fn bound_value(value: &Value) -> BoundedValue {
469    let mut truncated = false;
470    let bounded = bound_depth(value, TIMELINE_JSON_MAX_DEPTH, &mut truncated);
471    let size = serde_json::to_string(&bounded)
472        .map(|s| s.len())
473        .unwrap_or(0);
474    if size > TIMELINE_JSON_MAX_BYTES {
475        return BoundedValue {
476            value: Value::String("…over 16 KiB, see the step dossier".to_owned()),
477            truncated: true,
478        };
479    }
480    BoundedValue {
481        value: bounded,
482        truncated,
483    }
484}
485
486/// Builds one bounded entry from one ledger event. `error_classes` is
487/// the checkpoint's callId → ErrorClass join (08 §4.2).
488fn entry_of(
489    event: &RunLogEvent,
490    error_classes: &std::collections::BTreeMap<String, String>,
491) -> RunTimelineEntry {
492    let mut truncated = false;
493    let mut evidence: Vec<TimelineEvidenceRef> = Vec::new();
494    let mut evidence_omitted = 0u32;
495
496    let detail = match &event.payload {
497        RunLogPayload::RunStarted {
498            supervise_policy, ..
499        } => TimelineDetail::RunStarted {
500            supervise_policy: supervise_policy.as_ref().map(wire),
501        },
502        RunLogPayload::StepEntered { step_id, .. } => TimelineDetail::StepEntered {
503            step_id: step_id.to_string(),
504        },
505        RunLogPayload::PreflightProbed { outcomes } => {
506            let count = |status: pointlock_ir::VerdictStatus| {
507                outcomes.iter().filter(|o| o.result == status).count() as u32
508            };
509            TimelineDetail::PreflightProbed {
510                pass: count(pointlock_ir::VerdictStatus::Pass),
511                fail: count(pointlock_ir::VerdictStatus::Fail),
512                unknown: count(pointlock_ir::VerdictStatus::Unknown),
513                // `preflight` is `minItems: 1` in the schema, so a declared
514                // probe list never evaluates to zero outcomes — an empty one
515                // can only mean the step declared none.
516                unprobed: outcomes.is_empty(),
517            }
518        }
519        RunLogPayload::ActionIntent {
520            call_id,
521            args_snapshot,
522            ..
523        } => {
524            let args = bound_value(args_snapshot);
525            truncated |= args.truncated;
526            TimelineDetail::ActionIntent {
527                call_id: call_id.clone(),
528                args,
529            }
530        }
531        RunLogPayload::ActionSettled { call_id, outcome } => {
532            let (execution_mode, fallback_reason) = match outcome {
533                ActionOutcome::Succeeded { result } => {
534                    for asset in &result.evidence {
535                        push_evidence(asset, &mut evidence, &mut evidence_omitted);
536                    }
537                    match result.execution.as_ref() {
538                        Some(pointlock_ir::ActionExecution::NativeSemantic { .. }) => {
539                            (Some("nativeSemantic".to_owned()), None)
540                        }
541                        Some(pointlock_ir::ActionExecution::WebSemantic { .. }) => {
542                            (Some("webSemantic".to_owned()), None)
543                        }
544                        Some(pointlock_ir::ActionExecution::CoordinateFallback {
545                            fallback_reason,
546                            ..
547                        }) => (
548                            Some("coordinateFallback".to_owned()),
549                            Some(wire(fallback_reason)),
550                        ),
551                        None => (None, None),
552                    }
553                }
554                _ => (None, None),
555            };
556            let error = match outcome {
557                ActionOutcome::Succeeded { .. } => None,
558                ActionOutcome::Failed { error }
559                | ActionOutcome::Cancelled { error }
560                | ActionOutcome::TimedOut { error } => {
561                    let (message, cut) = bound_text(&error.message);
562                    truncated |= cut;
563                    Some(TimelineErrorView {
564                        error_class: error_classes.get(call_id).cloned(),
565                        code: error.code.clone(),
566                        message,
567                        retryable: error.retryable,
568                    })
569                }
570            };
571            TimelineDetail::ActionSettled {
572                call_id: call_id.clone(),
573                outcome: outcome.kind().to_owned(),
574                execution_mode,
575                fallback_reason,
576                error,
577            }
578        }
579        RunLogPayload::ObservationRecorded { observation } => {
580            if let Some(evidence_ref) = &observation.screenshot {
581                push_evidence(&evidence_ref.asset, &mut evidence, &mut evidence_omitted);
582            }
583            if let Some(evidence_ref) = &observation.ui_snapshot {
584                push_evidence(&evidence_ref.asset, &mut evidence, &mut evidence_omitted);
585            }
586            TimelineDetail::ObservationRecorded {
587                observation_id: observation.observation_id.clone(),
588                captured_at_ms: observation.captured_at_ms,
589                screenshot_omission: observation.screenshot_omission.as_ref().map(wire),
590                ui_snapshot_omission: observation.ui_snapshot_omission.as_ref().map(wire),
591            }
592        }
593        RunLogPayload::AssertionEvaluated { outcome } => {
594            let (reason, cut) = bound_text(&outcome.reason);
595            truncated |= cut;
596            TimelineDetail::AssertionEvaluated {
597                assert_id: outcome.assert_id.to_string(),
598                result: wire(&outcome.result),
599                channel: outcome.channel.as_ref().map(wire),
600                reason,
601            }
602        }
603        RunLogPayload::VerdictRecorded {
604            verdict,
605            remote_archival_error,
606            ..
607        } => {
608            let (summary, cut) = bound_text(&verdict.summary);
609            truncated |= cut;
610            for asset in &verdict.evidence {
611                push_evidence(asset, &mut evidence, &mut evidence_omitted);
612            }
613            let remote_archival_error = remote_archival_error.as_deref().map(|error| {
614                let (bounded, cut) = bound_text(error);
615                truncated |= cut;
616                bounded
617            });
618            TimelineDetail::VerdictRecorded {
619                status: wire(&verdict.status),
620                degraded: verdict.degraded,
621                supersedes: verdict.supersedes.clone(),
622                summary,
623                remote_archival_error,
624            }
625        }
626        RunLogPayload::StepExited { state, .. } => TimelineDetail::StepExited { state: *state },
627        RunLogPayload::CallFramePushed { frame, rebase } => TimelineDetail::CallFramePushed {
628            callee: format!("{}@{}", frame.flow_id, frame.ir_hash),
629            rebase: *rebase,
630        },
631        RunLogPayload::CallFramePopped { outputs } => TimelineDetail::CallFramePopped {
632            has_outputs: outputs.is_some(),
633        },
634        RunLogPayload::HandlerTriggered {
635            hook,
636            trigger,
637            disposition,
638        } => TimelineDetail::HandlerTriggered {
639            hook: wire(hook),
640            trigger: *trigger,
641            disposition: disposition.clone(),
642        },
643        RunLogPayload::HumanRequested {
644            request_id,
645            purpose,
646            mode,
647            prompt,
648            ..
649        } => {
650            let (prompt, cut) = bound_text(prompt);
651            truncated |= cut;
652            TimelineDetail::HumanRequested {
653                request_id: request_id.clone(),
654                purpose: wire(purpose),
655                mode: mode.as_ref().map(wire),
656                prompt,
657            }
658        }
659        RunLogPayload::HumanResponded {
660            request_id,
661            purpose,
662            response,
663            actor,
664        } => {
665            let response = bound_value(response);
666            truncated |= response.truncated;
667            TimelineDetail::HumanResponded {
668                request_id: request_id.clone(),
669                purpose: wire(purpose),
670                actor: actor.clone(),
671                response,
672            }
673        }
674        RunLogPayload::RunSuspended { reason, .. } => TimelineDetail::RunSuspended {
675            reason: reason.clone(),
676        },
677        RunLogPayload::RunResumed {
678            alignment_report,
679            supervise_policy,
680            ..
681        } => {
682            let counts = serde_json::json!({
683                "reusable": count_class(alignment_report, pointlock_ir::AlignmentClass::Reusable),
684                "judgeDirty": count_class(alignment_report, pointlock_ir::AlignmentClass::JudgeDirty),
685                "effectDirty": count_class(alignment_report, pointlock_ir::AlignmentClass::EffectDirty),
686                "new": count_class(alignment_report, pointlock_ir::AlignmentClass::New),
687                "orphaned": count_class(alignment_report, pointlock_ir::AlignmentClass::Orphaned),
688            });
689            TimelineDetail::RunResumed {
690                alignment: bound_value(&counts),
691                supervise_policy: supervise_policy.as_ref().map(wire),
692            }
693        }
694        RunLogPayload::RunFinished {
695            verdict,
696            remote_archival_error,
697        } => {
698            let remote_archival_error = remote_archival_error.as_deref().map(|error| {
699                let (bounded, cut) = bound_text(error);
700                truncated |= cut;
701                bounded
702            });
703            TimelineDetail::RunFinished {
704                status: verdict.as_ref().map(|v| wire(&v.status)),
705                degraded: verdict.as_ref().map(|v| v.degraded),
706                remote_archival_error,
707            }
708        }
709    };
710
711    // Fail-closed over-limit gate: a bounded entry that still serializes
712    // over the JSON cap collapses to a placeholder (08 §4.4).
713    let serialized = serde_json::to_string(&detail).map(|s| s.len()).unwrap_or(0);
714    let detail = if serialized > TIMELINE_JSON_MAX_BYTES {
715        truncated = true;
716        TimelineDetail::OverLimit {
717            event_type: event.payload.event_type().to_owned(),
718        }
719    } else {
720        detail
721    };
722
723    RunTimelineEntry {
724        seq: event.seq,
725        at_ms: event.at_ms,
726        run_path: render_run_path(&event.run_path),
727        detail,
728        evidence,
729        evidence_omitted,
730        truncated,
731    }
732}
733
734fn count_class(
735    report: &pointlock_ir::AlignmentReport,
736    class: pointlock_ir::AlignmentClass,
737) -> usize {
738    report
739        .entries
740        .iter()
741        .filter(|entry| entry.class == class)
742        .count()
743}
744
745fn push_evidence(
746    asset: &pointlock_ir::AssetRef,
747    evidence: &mut Vec<TimelineEvidenceRef>,
748    omitted: &mut u32,
749) {
750    if evidence.len() >= TIMELINE_EVIDENCE_MAX {
751        *omitted += 1;
752        return;
753    }
754    evidence.push(TimelineEvidenceRef {
755        id: asset.id.clone(),
756        media_type: asset.media_type.clone(),
757        sha256: asset.sha256.clone().unwrap_or_default(),
758    });
759}