Skip to main content

pointlock_store/projection/
dossier.rs

1//! `StepDossierView` — the adjudicable dossier of one step instance
2//! (spine §9 rule 3, §10.1; 07 §2.3): IR node + YAML span + the full run
3//! record (attempts, observations, evidence, assertion outcomes, verdict
4//! lineage). This IS the `pointlock locate` JSON output shape — the CLI
5//! and every renderer share this one query layer (08 §2.5).
6//!
7//! Unlike the timeline (bounded), the dossier is complete: attempts are
8//! enriched from the `actionSettled` payloads with the full
9//! `ErrorInfo`/timing that the discriminant-only `AttemptRecord` omits.
10
11use std::collections::BTreeMap;
12
13use pointlock_ir::{
14    ActionOutcome, AssertionOutcomeRecord, ErrorInfo, EvidenceRef, FlowIR, JsonPointer,
15    ObservationRecord, PathFrame, RunLogEvent, RunLogPayload, RunPath, SourceMapEntry, StepIR,
16    StepRecord, StepState, StepVerdict, Verdict, parse_run_path, render_parsed_run_path,
17    render_run_path,
18};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::ProjectionVersion;
24use crate::error::StoreError;
25use crate::store::Store;
26
27/// One attempt, enriched beyond the checkpoint's discriminant-only
28/// `AttemptRecord` by joining the `actionIntent`/`actionSettled` events
29/// (07 §2.2: dossier attempts carry the `ErrorInfo` verbatim + timing).
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct AttemptView {
33    /// Attempt ordinal from the intent path (`#n`), when recorded.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub n: Option<u64>,
36    /// 1-based `binding.attempts` chain position (item ②, 2026-07-18);
37    /// absent on pre-incorporation ledgers.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub chain_index: Option<u32>,
40    /// The dispatched attempt's locating channel (wire literal).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub channel: Option<String>,
43    /// The dispatched attempt's provider-native action name.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub action_name: Option<String>,
46    /// The dispatch correlation id (WAL pairing key).
47    pub call_id: String,
48    /// Terminal discriminant (`succeeded/failed/cancelled/timedOut`);
49    /// absent while the attempt is still in the crash window.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub outcome: Option<String>,
52    /// Closed taxonomy class, as the runner recorded it.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub error_class: Option<String>,
55    /// Execution mode the daemon reported.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub execution_mode: Option<String>,
58    /// Daemon-side degradation reason, when any.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub fallback_reason: Option<String>,
61    /// Full provider error on non-success terminals (verbatim).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub error: Option<ErrorInfo>,
64    /// Dispatch argument snapshot (from the WAL intent).
65    pub args_snapshot: Value,
66    /// Provider-reported start, on success.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub started_at_ms: Option<u64>,
69    /// Provider-reported finish, on success.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub finished_at_ms: Option<u64>,
72    /// Ledger seq of the intent.
73    pub intent_seq: u64,
74    /// Ledger seq of the terminal, once settled.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub settled_seq: Option<u64>,
77    /// Declared settlement evidence, verbatim from the terminal's
78    /// `result.evidence` (item ③ — the provenance join key; the
79    /// LOCALIZED copies live on the dossier's evidence gallery).
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub evidence: Vec<pointlock_ir::AssetRef>,
82}
83
84/// One recorded verdict in the append-only lineage (re-judgements
85/// append with `supersedes`; history is never deleted — 08 §2.5).
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88pub struct VerdictRecordView {
89    /// Ledger seq of the `verdictRecorded` event.
90    pub seq: u64,
91    /// Wall clock of the record.
92    pub at_ms: u64,
93    /// The full verdict as recorded.
94    pub verdict: Verdict,
95    /// This judgment's localized settlement/verdict/human evidence
96    /// (item ③, 2026-07-18); empty on offline re-judgements and
97    /// pre-incorporation records.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub localized: Vec<EvidenceRef>,
100    /// Typed localization failures of the same judgment (payload
101    /// mirror — the seq-bearing aggregate lives on the dossier).
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub localization_gaps: Vec<pointlock_ir::EvidenceGap>,
104}
105
106/// One aggregated localization gap of the dossier (item ③): the payload
107/// gap plus its verdict-event anchor.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct EvidenceGapView {
111    /// The declared asset, verbatim.
112    pub asset: pointlock_ir::AssetRef,
113    /// Why localization failed.
114    pub reason: String,
115    /// Ledger seq of the recording `verdictRecorded` event.
116    pub seq: u64,
117}
118
119/// One localized gallery item with its provenance (08 §2.5: the
120/// evidence tile says WHICH observation/judgment produced it).
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub struct EvidenceItemView {
124    /// The localized reference (asset + content address + local path).
125    #[serde(flatten)]
126    pub reference: EvidenceRef,
127    /// Provenance label: `observation:<id>/screenshot`,
128    /// `observation:<id>/uiSnapshot`, `verdict@seq:<n>` (that judgment's
129    /// settlement/human evidence), or `exit@seq:<n>` (an unverified
130    /// exit's manifest).
131    pub source: String,
132}
133
134/// One handler trigger against this instance.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct HandlerTriggerView {
138    /// The hook.
139    pub hook: String,
140    /// Trigger ordinal.
141    pub trigger: u64,
142    /// The consulted binding's declared disposition head (03 §1.8
143    /// closed set; 08 §2.5 handler row). Absent on pre-incorporation
144    /// ledgers.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub disposition: Option<String>,
147    /// Ledger seq.
148    pub seq: u64,
149    /// Wall clock.
150    pub at_ms: u64,
151}
152
153/// YAML source location resolved through the sourceMap (07 §2.3 part 2).
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
155#[serde(rename_all = "camelCase", deny_unknown_fields)]
156pub struct SourceLocation {
157    /// JSON Pointer of the IR node inside its `FlowIR`.
158    pub ir_path: JsonPointer,
159    /// The sourceMap entry: file + span + macro origin trace.
160    pub entry: SourceMapEntry,
161}
162
163/// The enclosing frame environment (07 §2.3 part 4).
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct FrameEnvironment {
167    /// Call-by-value inputs snapshot of the enclosing frame.
168    pub inputs_snapshot: Value,
169    /// `let` bindings visible in the frame.
170    pub vars: BTreeMap<String, Value>,
171    /// The failure-instant provider profile of this step's exit
172    /// (07 §2.2/§2.3 part 4, incorporated 2026-07-18): present only for
173    /// fail/unknown-verdict exits on post-incorporation ledgers.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub provider_state_summary: Option<pointlock_ir::ProviderStateSummary>,
176}
177
178/// The adjudicable dossier (spine §10.1; = `pointlock locate` JSON).
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct StepDossierView {
182    /// Protocol version (spine §10.3).
183    pub projection_version: ProjectionVersion,
184    /// The queried run.
185    pub run_id: String,
186    /// Canonical string of the instance path.
187    pub run_path: String,
188    /// The structured path (frames are the authority — spine §9).
189    pub run_path_frames: RunPath,
190    /// The step id.
191    pub step_id: String,
192    /// Effect-identity hash at entry.
193    pub effect_hash: String,
194    /// Judgement-identity hash at entry.
195    pub judge_hash: String,
196    /// The IR node in full, when the governing FlowIR artifact was
197    /// supplied (07 §2.3 part 1; the ledger alone cannot resolve IR).
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub ir_node: Option<StepIR>,
200    /// YAML source location, when the artifact was supplied.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub source: Option<SourceLocation>,
203    /// Inputs frozen at `ready`.
204    pub resolved_inputs: Value,
205    /// Resume-drift probe outcomes (07 §5), in ledger order.
206    pub preflight: Vec<AssertionOutcomeRecord>,
207    /// Enriched attempts, in intent order.
208    pub attempts: Vec<AttemptView>,
209    /// Step output, once exited with one.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub output: Option<Value>,
212    /// Recorded observations (omissions shown as-is).
213    pub observations: Vec<ObservationRecord>,
214    /// Assertion outcomes, in evaluation order.
215    pub assertion_outcomes: Vec<AssertionOutcomeRecord>,
216    /// The current verdict projection (last recorded), when judged.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub verdict: Option<StepVerdict>,
219    /// The full append-only verdict lineage (supersedes chain).
220    pub verdict_history: Vec<VerdictRecordView>,
221    /// Handler triggers anchored at this instance.
222    pub handler_triggers: Vec<HandlerTriggerView>,
223    /// Localized evidence references (localPath included — 07 §2.3):
224    /// observation assets plus every judgment's localized manifest,
225    /// deduped (sha256, asset.id) first-wins, each with its provenance.
226    pub evidence: Vec<EvidenceItemView>,
227    /// Typed localization failures across the judgments (item ③): the
228    /// honest-gap gallery — a cited-but-unlocalizable asset is a gap
229    /// tile, never a silent omission.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub evidence_gaps: Vec<EvidenceGapView>,
232    /// The failure-instant provider profile of this instance's exit
233    /// (07 §2.2/§2.3 part 4): step-anchored — it rides this instance's
234    /// own `stepExited`, so it surfaces here even when the live-frame
235    /// environment join fails closed (popped call frames, later
236    /// siblings — exactly the post-mortem cases the dossier serves).
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub provider_state_summary: Option<pointlock_ir::ProviderStateSummary>,
239    /// Enclosing frame environment, when reconstructable.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub frame: Option<FrameEnvironment>,
242    /// The 1-based execution life of this instance — the count of its
243    /// `stepEntered` events — present only when greater than 1, i.e. the
244    /// instance was re-executed (positional invalidation or a forced
245    /// re-execution, 07 §5.2). The honesty marker that every per-life
246    /// fact shown here (terminal, verdict, human request, handler
247    /// trigger budget) belongs to the latest life; the earlier lives'
248    /// events remain on the ledger and in `verdictHistory`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub execution_life: Option<u32>,
251    /// Final exit state, or the frontier state while in flight.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub state: Option<StepState>,
254}
255
256/// Strips attempt/phase/assertion frames — the instance identity
257/// (these three only ever suffix a step segment, spine §9).
258fn instance_path(path: &[PathFrame]) -> Vec<PathFrame> {
259    path.iter()
260        .filter(|frame| {
261            !matches!(
262                frame,
263                PathFrame::Attempt { .. } | PathFrame::Phase { .. } | PathFrame::Assertion { .. }
264            )
265        })
266        .cloned()
267        .collect()
268}
269
270fn attempt_of(path: &[PathFrame]) -> Option<u64> {
271    path.iter().rev().find_map(|frame| match frame {
272        PathFrame::Attempt { n } => Some(*n),
273        _ => None,
274    })
275}
276
277/// Serializes a unit-enum value to its wire literal.
278fn wire<T: Serialize>(value: &T) -> String {
279    serde_json::to_value(value)
280        .ok()
281        .and_then(|v| v.as_str().map(str::to_owned))
282        .unwrap_or_default()
283}
284
285/// Enumerates every entered step instance of a run, in first-entry order
286/// (canonical string → structured path).
287fn entered_instances(events: &[RunLogEvent]) -> Vec<(String, RunPath)> {
288    let mut seen = Vec::new();
289    let mut keys = std::collections::BTreeSet::new();
290    for event in events {
291        if matches!(event.payload, RunLogPayload::StepEntered { .. }) {
292            let instance = instance_path(&event.run_path);
293            let key = render_run_path(&instance);
294            if keys.insert(key.clone()) {
295                seen.push((key, instance));
296            }
297        }
298    }
299    seen
300}
301
302/// Resolves a locate `--step` argument to one instance path: a canonical
303/// path string (spine §9 grammar, matched by rendered equality against
304/// recorded instances — parse yields hash prefixes only), a JSON
305/// `PathFrame[]` document (07 §2.3: the structured form is the
306/// authority), or a bare step id (unique-match convenience; ambiguity is
307/// a typed error).
308pub fn locate_step(store: &Store, run_id: &str, step: &str) -> Result<RunPath, StoreError> {
309    let events = store.events(run_id)?;
310    let instances = entered_instances(&events);
311
312    // JSON PathFrame[] input (07 §2.3): full hashes, exact identity.
313    if step.trim_start().starts_with('[') {
314        let frames: RunPath = serde_json::from_str(step).map_err(|err| StoreError::BadRunPath {
315            input: step.to_owned(),
316            message: format!("not a JSON PathFrame[] document: {err}"),
317        })?;
318        let wanted = render_run_path(&instance_path(&frames));
319        return instances
320            .into_iter()
321            .find(|(key, _)| *key == wanted)
322            .map(|(_, path)| path)
323            .ok_or_else(|| StoreError::UnknownStepInstance {
324                run_id: run_id.to_owned(),
325                path: wanted,
326            });
327    }
328
329    if step.contains('/') || step.contains('@') {
330        let parsed = parse_run_path(step).map_err(|err| StoreError::BadRunPath {
331            input: step.to_owned(),
332            message: format!("{} at offset {}", err.message, err.offset),
333        })?;
334        // `render_run_path` abbreviates hashes to the same 8-hex prefix
335        // the parser yields, so rendered equality is exact instance
336        // identity (spine §9 round-trip guarantee).
337        let wanted = render_parsed_run_path(&instance_parsed(&parsed));
338        return instances
339            .into_iter()
340            .find(|(key, _)| *key == wanted)
341            .map(|(_, path)| path)
342            .ok_or_else(|| StoreError::UnknownStepInstance {
343                run_id: run_id.to_owned(),
344                path: step.to_owned(),
345            });
346    }
347
348    let matches: Vec<(String, RunPath)> = instances
349        .into_iter()
350        .filter(|(_, path)| {
351            instance_step_id(path)
352                .map(|step_id| step_id.as_ref() == step)
353                .unwrap_or(false)
354        })
355        .collect();
356    match matches.len() {
357        0 => Err(StoreError::UnknownStepInstance {
358            run_id: run_id.to_owned(),
359            path: step.to_owned(),
360        }),
361        1 => Ok(matches.into_iter().next().expect("len checked").1),
362        _ => Err(StoreError::AmbiguousStep {
363            run_id: run_id.to_owned(),
364            step: step.to_owned(),
365            candidates: matches.into_iter().map(|(key, _)| key).collect(),
366        }),
367    }
368}
369
370/// The step id an instance path names: its last `Step` frame, or the
371/// call step of its last `Call` frame (the runner anchors call steps at
372/// the `Call` frame itself, with no separate `Step` frame — 07 §2.1).
373/// `None` for a handler-launched repair flow's own frame
374/// (`Call { step_id: None }` — it has no host step to name).
375fn instance_step_id(path: &[PathFrame]) -> Option<&pointlock_ir::StepId> {
376    path.iter().rev().find_map(|frame| match frame {
377        PathFrame::Step { step_id } => Some(step_id),
378        PathFrame::Call {
379            step_id: Some(step_id),
380            ..
381        } => Some(step_id),
382        _ => None,
383    })
384}
385
386/// Strips attempt/phase/assertion frames from a parsed path.
387fn instance_parsed(path: &[pointlock_ir::ParsedPathFrame]) -> Vec<pointlock_ir::ParsedPathFrame> {
388    use pointlock_ir::ParsedPathFrame as P;
389    path.iter()
390        .filter(|frame| {
391            !matches!(
392                frame,
393                P::Attempt { .. } | P::Phase { .. } | P::Assertion { .. }
394            )
395        })
396        .cloned()
397        .collect()
398}
399
400/// Builds the dossier of one instance. `artifacts` may carry the run's
401/// FlowIR and its subflow closure; when the governing IR is absent the
402/// dossier still delivers the complete run record (irNode/source absent).
403pub fn step_dossier(
404    store: &Store,
405    run_id: &str,
406    path: &[PathFrame],
407    artifacts: &[FlowIR],
408) -> Result<StepDossierView, StoreError> {
409    let events = store.events(run_id)?;
410    let instance = instance_path(path);
411    let key = render_run_path(&instance);
412
413    let step_id =
414        instance_step_id(&instance)
415            .cloned()
416            .ok_or_else(|| StoreError::UnknownStepInstance {
417                run_id: run_id.to_owned(),
418                path: key.clone(),
419            })?;
420
421    // ── Event fold for this instance ──────────────────────────────────
422    let mut entered: Option<(String, String, Value)> = None; // effect, judge, inputs
423    let mut preflight = Vec::new();
424    let mut attempts: Vec<AttemptView> = Vec::new();
425    let mut by_call: BTreeMap<String, usize> = BTreeMap::new();
426    let mut observations = Vec::new();
427    let mut assertion_outcomes = Vec::new();
428    let mut verdict_history = Vec::new();
429    let mut handler_triggers = Vec::new();
430    let mut evidence: Vec<EvidenceItemView> = Vec::new();
431    let mut evidence_gaps: Vec<EvidenceGapView> = Vec::new();
432    let mut output = None;
433    let mut state: Option<StepState> = None;
434    let mut step_summary: Option<pointlock_ir::ProviderStateSummary> = None;
435    let mut touched = false;
436    let mut lives: u32 = 0;
437
438    for event in &events {
439        if render_run_path(&instance_path(&event.run_path)) != key {
440            continue;
441        }
442        touched = true;
443        match &event.payload {
444            RunLogPayload::StepEntered {
445                effect_hash,
446                judge_hash,
447                resolved_inputs,
448                ..
449            } => {
450                lives += 1;
451                entered = Some((
452                    effect_hash.to_string(),
453                    judge_hash.to_string(),
454                    resolved_inputs.clone(),
455                ));
456            }
457            RunLogPayload::PreflightProbed { outcomes } => {
458                preflight.extend(outcomes.iter().cloned());
459            }
460            RunLogPayload::ActionIntent {
461                call_id,
462                args_snapshot,
463                chain_index,
464                channel,
465                action_name,
466            } => {
467                by_call.insert(call_id.clone(), attempts.len());
468                attempts.push(AttemptView {
469                    n: attempt_of(&event.run_path),
470                    chain_index: *chain_index,
471                    channel: channel.as_ref().map(wire),
472                    action_name: action_name.as_ref().map(|name| name.as_str().to_owned()),
473                    call_id: call_id.clone(),
474                    outcome: None,
475                    error_class: None,
476                    execution_mode: None,
477                    fallback_reason: None,
478                    error: None,
479                    args_snapshot: args_snapshot.clone(),
480                    started_at_ms: None,
481                    finished_at_ms: None,
482                    intent_seq: event.seq,
483                    settled_seq: None,
484                    evidence: Vec::new(),
485                });
486            }
487            RunLogPayload::ActionSettled { call_id, outcome } => {
488                if let Some(&index) = by_call.get(call_id) {
489                    let attempt = &mut attempts[index];
490                    attempt.outcome = Some(outcome.kind().to_owned());
491                    if let ActionOutcome::Succeeded { result } = outcome {
492                        attempt.evidence = result.evidence.clone();
493                    }
494                    attempt.settled_seq = Some(event.seq);
495                    match outcome {
496                        ActionOutcome::Succeeded { result } => {
497                            attempt.started_at_ms = Some(result.started_at_ms);
498                            attempt.finished_at_ms = Some(result.finished_at_ms);
499                            attempt.execution_mode =
500                                result.execution.as_ref().map(|execution| match execution {
501                                    pointlock_ir::ActionExecution::NativeSemantic { .. } => {
502                                        "nativeSemantic".to_owned()
503                                    }
504                                    pointlock_ir::ActionExecution::WebSemantic { .. } => {
505                                        "webSemantic".to_owned()
506                                    }
507                                    pointlock_ir::ActionExecution::CoordinateFallback {
508                                        fallback_reason,
509                                        ..
510                                    } => {
511                                        attempt.fallback_reason = Some(wire(fallback_reason));
512                                        "coordinateFallback".to_owned()
513                                    }
514                                });
515                        }
516                        ActionOutcome::Failed { error }
517                        | ActionOutcome::Cancelled { error }
518                        | ActionOutcome::TimedOut { error } => {
519                            attempt.error = Some(error.clone());
520                        }
521                    }
522                }
523            }
524            RunLogPayload::ObservationRecorded { observation } => {
525                if let Some(evidence_ref) = &observation.screenshot {
526                    evidence.push(EvidenceItemView {
527                        reference: evidence_ref.clone(),
528                        source: format!("observation:{}/screenshot", observation.observation_id),
529                    });
530                }
531                if let Some(evidence_ref) = &observation.ui_snapshot {
532                    evidence.push(EvidenceItemView {
533                        reference: evidence_ref.clone(),
534                        source: format!("observation:{}/uiSnapshot", observation.observation_id),
535                    });
536                }
537                observations.push(observation.clone());
538            }
539            RunLogPayload::AssertionEvaluated { outcome } => {
540                assertion_outcomes.push(outcome.clone());
541            }
542            RunLogPayload::VerdictRecorded {
543                verdict,
544                localized,
545                localization_gaps,
546                remote_archival_error: _,
547            } => {
548                // The judgment's manifest merges into the gallery with
549                // the same (sha256, asset.id) first-wins rule the fold
550                // applies — the two surfaces cannot diverge.
551                for entry in localized {
552                    let duplicate = evidence.iter().any(|existing| {
553                        existing.reference.sha256 == entry.sha256
554                            && existing.reference.asset.id == entry.asset.id
555                    });
556                    if !duplicate {
557                        evidence.push(EvidenceItemView {
558                            reference: entry.clone(),
559                            source: format!("verdict@seq:{}", event.seq),
560                        });
561                    }
562                }
563                for gap in localization_gaps {
564                    evidence_gaps.push(EvidenceGapView {
565                        asset: gap.asset.clone(),
566                        reason: gap.reason.clone(),
567                        seq: event.seq,
568                    });
569                }
570                verdict_history.push(VerdictRecordView {
571                    seq: event.seq,
572                    at_ms: event.at_ms,
573                    verdict: verdict.clone(),
574                    localized: localized.clone(),
575                    localization_gaps: localization_gaps.clone(),
576                });
577            }
578            RunLogPayload::HandlerTriggered {
579                hook,
580                trigger,
581                disposition,
582            } => {
583                handler_triggers.push(HandlerTriggerView {
584                    hook: wire(hook),
585                    trigger: *trigger,
586                    disposition: disposition.clone(),
587                    seq: event.seq,
588                    at_ms: event.at_ms,
589                });
590            }
591            RunLogPayload::StepExited {
592                provider_state_summary,
593                state: exit_state,
594                output: exit_output,
595                localized,
596                localization_gaps,
597            } => {
598                state = Some(*exit_state);
599                if exit_output.is_some() {
600                    output = exit_output.clone();
601                }
602                if provider_state_summary.is_some() {
603                    step_summary = provider_state_summary.clone();
604                }
605                // An UNVERIFIED exit's manifest (item ③ review fix):
606                // same merge rule as the verdict-borne one.
607                for entry in localized {
608                    let duplicate = evidence.iter().any(|existing| {
609                        existing.reference.sha256 == entry.sha256
610                            && existing.reference.asset.id == entry.asset.id
611                    });
612                    if !duplicate {
613                        evidence.push(EvidenceItemView {
614                            reference: entry.clone(),
615                            source: format!("exit@seq:{}", event.seq),
616                        });
617                    }
618                }
619                for gap in localization_gaps {
620                    evidence_gaps.push(EvidenceGapView {
621                        asset: gap.asset.clone(),
622                        reason: gap.reason.clone(),
623                        seq: event.seq,
624                    });
625                }
626            }
627            _ => {}
628        }
629    }
630
631    if !touched || entered.is_none() {
632        return Err(StoreError::UnknownStepInstance {
633            run_id: run_id.to_owned(),
634            path: key.clone(),
635        });
636    }
637    let (effect_hash, judge_hash, resolved_inputs) = entered.expect("checked above");
638
639    // ── Checkpoint overlay: recorded error classes + frontier state ───
640    let checkpoint = store.materialized_checkpoint(run_id)?;
641    if let Some((_, view)) = &checkpoint {
642        if let Some(record) = view
643            .completed
644            .iter()
645            .find(|record: &&StepRecord| render_run_path(&record.run_path) == key)
646        {
647            for recorded in &record.attempts {
648                if let Some(&index) = by_call.get(&recorded.call_id) {
649                    let attempt = &mut attempts[index];
650                    attempt.error_class = recorded.error_class.as_ref().map(wire);
651                    if attempt.execution_mode.is_none() {
652                        attempt.execution_mode = recorded.execution_mode.as_ref().map(wire);
653                    }
654                    if attempt.fallback_reason.is_none() {
655                        attempt.fallback_reason = recorded.fallback_reason.as_ref().map(wire);
656                    }
657                }
658            }
659        }
660        if state.is_none() && render_run_path(&instance_path(&view.frontier.run_path)) == key {
661            state = Some(view.frontier.state);
662        }
663    }
664
665    // Current verdict = last recorded projection (append-only lineage).
666    let verdict = verdict_history.last().map(|record| StepVerdict {
667        status: record.verdict.status,
668        degraded: record.verdict.degraded,
669        supersedes: record.verdict.supersedes.clone(),
670    });
671
672    // ── IR resolution, when the governing artifact is supplied ────────
673    // The governing flow is the one whose body DECLARES the step: for a
674    // call step (instance ends with the Call frame itself) that is the
675    // HOST flow, not the callee — so the terminal Call frame is skipped
676    // before scanning for the innermost Flow/Call authority.
677    let scan = match instance.last() {
678        Some(PathFrame::Call { .. }) => &instance[..instance.len() - 1],
679        _ => instance.as_slice(),
680    };
681    let governing_hash = scan.iter().rev().find_map(|frame| match frame {
682        PathFrame::Flow { ir_hash, .. } => Some(ir_hash.clone()),
683        PathFrame::Call { callee_ir_hash, .. } => Some(callee_ir_hash.clone()),
684        _ => None,
685    });
686    let mut ir_node = None;
687    let mut source = None;
688    if let Some(hash) = governing_hash
689        && let Some(flow) = artifacts.iter().find(|flow| flow.ir_hash == hash)
690        && let Some((node, pointer)) = find_step(&flow.body, "/body", step_id.as_ref())
691    {
692        source = flow
693            .source_map
694            .iter()
695            .find(|entry| entry.ir_path.as_ref() == pointer)
696            .map(|entry| SourceLocation {
697                ir_path: entry.ir_path.clone(),
698                entry: entry.clone(),
699            });
700        ir_node = Some(node.clone());
701    }
702
703    // ── Frame environment from the checkpoint frame stack ─────────────
704    // `CheckpointView.frames` is the LIVE stack; it matches this instance
705    // only while the instance's own call chain is still open. Every
706    // frame of the chain is identity-checked (root by irHash, each Call
707    // by call step + callee identity) — on any mismatch the environment
708    // fails closed to absent rather than reporting another call's state.
709    let frame = checkpoint
710        .as_ref()
711        .and_then(|(_, view)| frame_environment(&instance, &view.frames))
712        .map(|mut environment| {
713            environment.provider_state_summary = step_summary.clone();
714            environment
715        });
716
717    Ok(StepDossierView {
718        projection_version: ProjectionVersion,
719        run_id: run_id.to_owned(),
720        run_path: key,
721        run_path_frames: instance,
722        step_id: step_id.to_string(),
723        effect_hash,
724        judge_hash,
725        ir_node,
726        source,
727        resolved_inputs,
728        preflight,
729        attempts,
730        output,
731        observations,
732        assertion_outcomes,
733        verdict,
734        verdict_history,
735        handler_triggers,
736        evidence,
737        evidence_gaps,
738        provider_state_summary: step_summary,
739        frame,
740        state,
741        execution_life: (lives > 1).then_some(lives),
742    })
743}
744
745/// The identity-verified frame-environment join (07 §2.3 part 4): walks
746/// the instance path's Flow/Call chain against the live frame stack and
747/// returns the enclosing frame only when EVERY level matches — the root
748/// by `irHash`, each call level by call step id + callee identity. A
749/// popped or re-pushed stack (completed calls, later siblings) fails the
750/// walk and the environment is reported absent, never mis-attributed.
751fn frame_environment(
752    instance: &[PathFrame],
753    frames: &[pointlock_ir::CallFrame],
754) -> Option<FrameEnvironment> {
755    let mut level = 0usize;
756    for (index, path_frame) in instance.iter().enumerate() {
757        match path_frame {
758            PathFrame::Flow { ir_hash, flow_id } => {
759                let frame = frames.first()?;
760                if frame.ir_hash != *ir_hash
761                    || frame.flow_id != *flow_id
762                    || frame.call_step_id.is_some()
763                {
764                    return None;
765                }
766            }
767            PathFrame::Call {
768                step_id,
769                callee_flow_id,
770                callee_ir_hash,
771            } => {
772                // The terminal Call frame IS the dossier target (the call
773                // step itself); its environment is the host frame already
774                // verified, so the callee frame is not required.
775                if index + 1 == instance.len() {
776                    break;
777                }
778                level += 1;
779                let frame = frames.get(level)?;
780                if frame.call_step_id != *step_id
781                    || frame.flow_id != *callee_flow_id
782                    || frame.ir_hash != *callee_ir_hash
783                {
784                    return None;
785                }
786            }
787            _ => {}
788        }
789    }
790    frames.get(level).map(|frame| FrameEnvironment {
791        inputs_snapshot: frame.inputs_snapshot.clone(),
792        vars: frame.vars.clone(),
793        provider_state_summary: None,
794    })
795}
796
797/// Depth-first search for a step id in a body; returns the node and its
798/// JSON Pointer inside the FlowIR document.
799fn find_step<'a>(body: &'a [StepIR], prefix: &str, step_id: &str) -> Option<(&'a StepIR, String)> {
800    for (index, step) in body.iter().enumerate() {
801        let pointer = format!("{prefix}/{index}");
802        if step.step_id().as_ref() == step_id {
803            return Some((step, pointer));
804        }
805        match step {
806            StepIR::If(nested) => {
807                if let Some(found) = find_step(&nested.then, &format!("{pointer}/then"), step_id) {
808                    return Some(found);
809                }
810                if let Some(else_body) = nested.r#else.as_deref()
811                    && let Some(found) = find_step(else_body, &format!("{pointer}/else"), step_id)
812                {
813                    return Some(found);
814                }
815            }
816            StepIR::Foreach(nested) => {
817                if let Some(found) = find_step(&nested.body, &format!("{pointer}/body"), step_id) {
818                    return Some(found);
819                }
820            }
821            _ => {}
822        }
823    }
824    None
825}