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    /// Final exit state, or the frontier state while in flight.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub state: Option<StepState>,
245}
246
247/// Strips attempt/phase/assertion frames — the instance identity
248/// (these three only ever suffix a step segment, spine §9).
249fn instance_path(path: &[PathFrame]) -> Vec<PathFrame> {
250    path.iter()
251        .filter(|frame| {
252            !matches!(
253                frame,
254                PathFrame::Attempt { .. } | PathFrame::Phase { .. } | PathFrame::Assertion { .. }
255            )
256        })
257        .cloned()
258        .collect()
259}
260
261fn attempt_of(path: &[PathFrame]) -> Option<u64> {
262    path.iter().rev().find_map(|frame| match frame {
263        PathFrame::Attempt { n } => Some(*n),
264        _ => None,
265    })
266}
267
268/// Serializes a unit-enum value to its wire literal.
269fn wire<T: Serialize>(value: &T) -> String {
270    serde_json::to_value(value)
271        .ok()
272        .and_then(|v| v.as_str().map(str::to_owned))
273        .unwrap_or_default()
274}
275
276/// Enumerates every entered step instance of a run, in first-entry order
277/// (canonical string → structured path).
278fn entered_instances(events: &[RunLogEvent]) -> Vec<(String, RunPath)> {
279    let mut seen = Vec::new();
280    let mut keys = std::collections::BTreeSet::new();
281    for event in events {
282        if matches!(event.payload, RunLogPayload::StepEntered { .. }) {
283            let instance = instance_path(&event.run_path);
284            let key = render_run_path(&instance);
285            if keys.insert(key.clone()) {
286                seen.push((key, instance));
287            }
288        }
289    }
290    seen
291}
292
293/// Resolves a locate `--step` argument to one instance path: a canonical
294/// path string (spine §9 grammar, matched by rendered equality against
295/// recorded instances — parse yields hash prefixes only), a JSON
296/// `PathFrame[]` document (07 §2.3: the structured form is the
297/// authority), or a bare step id (unique-match convenience; ambiguity is
298/// a typed error).
299pub fn locate_step(store: &Store, run_id: &str, step: &str) -> Result<RunPath, StoreError> {
300    let events = store.events(run_id)?;
301    let instances = entered_instances(&events);
302
303    // JSON PathFrame[] input (07 §2.3): full hashes, exact identity.
304    if step.trim_start().starts_with('[') {
305        let frames: RunPath = serde_json::from_str(step).map_err(|err| StoreError::BadRunPath {
306            input: step.to_owned(),
307            message: format!("not a JSON PathFrame[] document: {err}"),
308        })?;
309        let wanted = render_run_path(&instance_path(&frames));
310        return instances
311            .into_iter()
312            .find(|(key, _)| *key == wanted)
313            .map(|(_, path)| path)
314            .ok_or_else(|| StoreError::UnknownStepInstance {
315                run_id: run_id.to_owned(),
316                path: wanted,
317            });
318    }
319
320    if step.contains('/') || step.contains('@') {
321        let parsed = parse_run_path(step).map_err(|err| StoreError::BadRunPath {
322            input: step.to_owned(),
323            message: format!("{} at offset {}", err.message, err.offset),
324        })?;
325        // `render_run_path` abbreviates hashes to the same 8-hex prefix
326        // the parser yields, so rendered equality is exact instance
327        // identity (spine §9 round-trip guarantee).
328        let wanted = render_parsed_run_path(&instance_parsed(&parsed));
329        return instances
330            .into_iter()
331            .find(|(key, _)| *key == wanted)
332            .map(|(_, path)| path)
333            .ok_or_else(|| StoreError::UnknownStepInstance {
334                run_id: run_id.to_owned(),
335                path: step.to_owned(),
336            });
337    }
338
339    let matches: Vec<(String, RunPath)> = instances
340        .into_iter()
341        .filter(|(_, path)| {
342            instance_step_id(path)
343                .map(|step_id| step_id.as_ref() == step)
344                .unwrap_or(false)
345        })
346        .collect();
347    match matches.len() {
348        0 => Err(StoreError::UnknownStepInstance {
349            run_id: run_id.to_owned(),
350            path: step.to_owned(),
351        }),
352        1 => Ok(matches.into_iter().next().expect("len checked").1),
353        _ => Err(StoreError::AmbiguousStep {
354            run_id: run_id.to_owned(),
355            step: step.to_owned(),
356            candidates: matches.into_iter().map(|(key, _)| key).collect(),
357        }),
358    }
359}
360
361/// The step id an instance path names: its last `Step` frame, or the
362/// call step of its last `Call` frame (the runner anchors call steps at
363/// the `Call` frame itself, with no separate `Step` frame — 07 §2.1).
364/// `None` for a handler-launched repair flow's own frame
365/// (`Call { step_id: None }` — it has no host step to name).
366fn instance_step_id(path: &[PathFrame]) -> Option<&pointlock_ir::StepId> {
367    path.iter().rev().find_map(|frame| match frame {
368        PathFrame::Step { step_id } => Some(step_id),
369        PathFrame::Call {
370            step_id: Some(step_id),
371            ..
372        } => Some(step_id),
373        _ => None,
374    })
375}
376
377/// Strips attempt/phase/assertion frames from a parsed path.
378fn instance_parsed(path: &[pointlock_ir::ParsedPathFrame]) -> Vec<pointlock_ir::ParsedPathFrame> {
379    use pointlock_ir::ParsedPathFrame as P;
380    path.iter()
381        .filter(|frame| {
382            !matches!(
383                frame,
384                P::Attempt { .. } | P::Phase { .. } | P::Assertion { .. }
385            )
386        })
387        .cloned()
388        .collect()
389}
390
391/// Builds the dossier of one instance. `artifacts` may carry the run's
392/// FlowIR and its subflow closure; when the governing IR is absent the
393/// dossier still delivers the complete run record (irNode/source absent).
394pub fn step_dossier(
395    store: &Store,
396    run_id: &str,
397    path: &[PathFrame],
398    artifacts: &[FlowIR],
399) -> Result<StepDossierView, StoreError> {
400    let events = store.events(run_id)?;
401    let instance = instance_path(path);
402    let key = render_run_path(&instance);
403
404    let step_id =
405        instance_step_id(&instance)
406            .cloned()
407            .ok_or_else(|| StoreError::UnknownStepInstance {
408                run_id: run_id.to_owned(),
409                path: key.clone(),
410            })?;
411
412    // ── Event fold for this instance ──────────────────────────────────
413    let mut entered: Option<(String, String, Value)> = None; // effect, judge, inputs
414    let mut preflight = Vec::new();
415    let mut attempts: Vec<AttemptView> = Vec::new();
416    let mut by_call: BTreeMap<String, usize> = BTreeMap::new();
417    let mut observations = Vec::new();
418    let mut assertion_outcomes = Vec::new();
419    let mut verdict_history = Vec::new();
420    let mut handler_triggers = Vec::new();
421    let mut evidence: Vec<EvidenceItemView> = Vec::new();
422    let mut evidence_gaps: Vec<EvidenceGapView> = Vec::new();
423    let mut output = None;
424    let mut state: Option<StepState> = None;
425    let mut step_summary: Option<pointlock_ir::ProviderStateSummary> = None;
426    let mut touched = false;
427
428    for event in &events {
429        if render_run_path(&instance_path(&event.run_path)) != key {
430            continue;
431        }
432        touched = true;
433        match &event.payload {
434            RunLogPayload::StepEntered {
435                effect_hash,
436                judge_hash,
437                resolved_inputs,
438                ..
439            } => {
440                entered = Some((
441                    effect_hash.to_string(),
442                    judge_hash.to_string(),
443                    resolved_inputs.clone(),
444                ));
445            }
446            RunLogPayload::PreflightProbed { outcomes } => {
447                preflight.extend(outcomes.iter().cloned());
448            }
449            RunLogPayload::ActionIntent {
450                call_id,
451                args_snapshot,
452                chain_index,
453                channel,
454                action_name,
455            } => {
456                by_call.insert(call_id.clone(), attempts.len());
457                attempts.push(AttemptView {
458                    n: attempt_of(&event.run_path),
459                    chain_index: *chain_index,
460                    channel: channel.as_ref().map(wire),
461                    action_name: action_name.as_ref().map(|name| name.as_str().to_owned()),
462                    call_id: call_id.clone(),
463                    outcome: None,
464                    error_class: None,
465                    execution_mode: None,
466                    fallback_reason: None,
467                    error: None,
468                    args_snapshot: args_snapshot.clone(),
469                    started_at_ms: None,
470                    finished_at_ms: None,
471                    intent_seq: event.seq,
472                    settled_seq: None,
473                    evidence: Vec::new(),
474                });
475            }
476            RunLogPayload::ActionSettled { call_id, outcome } => {
477                if let Some(&index) = by_call.get(call_id) {
478                    let attempt = &mut attempts[index];
479                    attempt.outcome = Some(outcome.kind().to_owned());
480                    if let ActionOutcome::Succeeded { result } = outcome {
481                        attempt.evidence = result.evidence.clone();
482                    }
483                    attempt.settled_seq = Some(event.seq);
484                    match outcome {
485                        ActionOutcome::Succeeded { result } => {
486                            attempt.started_at_ms = Some(result.started_at_ms);
487                            attempt.finished_at_ms = Some(result.finished_at_ms);
488                            attempt.execution_mode =
489                                result.execution.as_ref().map(|execution| match execution {
490                                    pointlock_ir::ActionExecution::NativeSemantic { .. } => {
491                                        "nativeSemantic".to_owned()
492                                    }
493                                    pointlock_ir::ActionExecution::WebSemantic { .. } => {
494                                        "webSemantic".to_owned()
495                                    }
496                                    pointlock_ir::ActionExecution::CoordinateFallback {
497                                        fallback_reason,
498                                        ..
499                                    } => {
500                                        attempt.fallback_reason = Some(wire(fallback_reason));
501                                        "coordinateFallback".to_owned()
502                                    }
503                                });
504                        }
505                        ActionOutcome::Failed { error }
506                        | ActionOutcome::Cancelled { error }
507                        | ActionOutcome::TimedOut { error } => {
508                            attempt.error = Some(error.clone());
509                        }
510                    }
511                }
512            }
513            RunLogPayload::ObservationRecorded { observation } => {
514                if let Some(evidence_ref) = &observation.screenshot {
515                    evidence.push(EvidenceItemView {
516                        reference: evidence_ref.clone(),
517                        source: format!("observation:{}/screenshot", observation.observation_id),
518                    });
519                }
520                if let Some(evidence_ref) = &observation.ui_snapshot {
521                    evidence.push(EvidenceItemView {
522                        reference: evidence_ref.clone(),
523                        source: format!("observation:{}/uiSnapshot", observation.observation_id),
524                    });
525                }
526                observations.push(observation.clone());
527            }
528            RunLogPayload::AssertionEvaluated { outcome } => {
529                assertion_outcomes.push(outcome.clone());
530            }
531            RunLogPayload::VerdictRecorded {
532                verdict,
533                localized,
534                localization_gaps,
535                remote_archival_error: _,
536            } => {
537                // The judgment's manifest merges into the gallery with
538                // the same (sha256, asset.id) first-wins rule the fold
539                // applies — the two surfaces cannot diverge.
540                for entry in localized {
541                    let duplicate = evidence.iter().any(|existing| {
542                        existing.reference.sha256 == entry.sha256
543                            && existing.reference.asset.id == entry.asset.id
544                    });
545                    if !duplicate {
546                        evidence.push(EvidenceItemView {
547                            reference: entry.clone(),
548                            source: format!("verdict@seq:{}", event.seq),
549                        });
550                    }
551                }
552                for gap in localization_gaps {
553                    evidence_gaps.push(EvidenceGapView {
554                        asset: gap.asset.clone(),
555                        reason: gap.reason.clone(),
556                        seq: event.seq,
557                    });
558                }
559                verdict_history.push(VerdictRecordView {
560                    seq: event.seq,
561                    at_ms: event.at_ms,
562                    verdict: verdict.clone(),
563                    localized: localized.clone(),
564                    localization_gaps: localization_gaps.clone(),
565                });
566            }
567            RunLogPayload::HandlerTriggered {
568                hook,
569                trigger,
570                disposition,
571            } => {
572                handler_triggers.push(HandlerTriggerView {
573                    hook: wire(hook),
574                    trigger: *trigger,
575                    disposition: disposition.clone(),
576                    seq: event.seq,
577                    at_ms: event.at_ms,
578                });
579            }
580            RunLogPayload::StepExited {
581                provider_state_summary,
582                state: exit_state,
583                output: exit_output,
584                localized,
585                localization_gaps,
586            } => {
587                state = Some(*exit_state);
588                if exit_output.is_some() {
589                    output = exit_output.clone();
590                }
591                if provider_state_summary.is_some() {
592                    step_summary = provider_state_summary.clone();
593                }
594                // An UNVERIFIED exit's manifest (item ③ review fix):
595                // same merge rule as the verdict-borne one.
596                for entry in localized {
597                    let duplicate = evidence.iter().any(|existing| {
598                        existing.reference.sha256 == entry.sha256
599                            && existing.reference.asset.id == entry.asset.id
600                    });
601                    if !duplicate {
602                        evidence.push(EvidenceItemView {
603                            reference: entry.clone(),
604                            source: format!("exit@seq:{}", event.seq),
605                        });
606                    }
607                }
608                for gap in localization_gaps {
609                    evidence_gaps.push(EvidenceGapView {
610                        asset: gap.asset.clone(),
611                        reason: gap.reason.clone(),
612                        seq: event.seq,
613                    });
614                }
615            }
616            _ => {}
617        }
618    }
619
620    if !touched || entered.is_none() {
621        return Err(StoreError::UnknownStepInstance {
622            run_id: run_id.to_owned(),
623            path: key.clone(),
624        });
625    }
626    let (effect_hash, judge_hash, resolved_inputs) = entered.expect("checked above");
627
628    // ── Checkpoint overlay: recorded error classes + frontier state ───
629    let checkpoint = store.materialized_checkpoint(run_id)?;
630    if let Some((_, view)) = &checkpoint {
631        if let Some(record) = view
632            .completed
633            .iter()
634            .find(|record: &&StepRecord| render_run_path(&record.run_path) == key)
635        {
636            for recorded in &record.attempts {
637                if let Some(&index) = by_call.get(&recorded.call_id) {
638                    let attempt = &mut attempts[index];
639                    attempt.error_class = recorded.error_class.as_ref().map(wire);
640                    if attempt.execution_mode.is_none() {
641                        attempt.execution_mode = recorded.execution_mode.as_ref().map(wire);
642                    }
643                    if attempt.fallback_reason.is_none() {
644                        attempt.fallback_reason = recorded.fallback_reason.as_ref().map(wire);
645                    }
646                }
647            }
648        }
649        if state.is_none() && render_run_path(&instance_path(&view.frontier.run_path)) == key {
650            state = Some(view.frontier.state);
651        }
652    }
653
654    // Current verdict = last recorded projection (append-only lineage).
655    let verdict = verdict_history.last().map(|record| StepVerdict {
656        status: record.verdict.status,
657        degraded: record.verdict.degraded,
658        supersedes: record.verdict.supersedes.clone(),
659    });
660
661    // ── IR resolution, when the governing artifact is supplied ────────
662    // The governing flow is the one whose body DECLARES the step: for a
663    // call step (instance ends with the Call frame itself) that is the
664    // HOST flow, not the callee — so the terminal Call frame is skipped
665    // before scanning for the innermost Flow/Call authority.
666    let scan = match instance.last() {
667        Some(PathFrame::Call { .. }) => &instance[..instance.len() - 1],
668        _ => instance.as_slice(),
669    };
670    let governing_hash = scan.iter().rev().find_map(|frame| match frame {
671        PathFrame::Flow { ir_hash, .. } => Some(ir_hash.clone()),
672        PathFrame::Call { callee_ir_hash, .. } => Some(callee_ir_hash.clone()),
673        _ => None,
674    });
675    let mut ir_node = None;
676    let mut source = None;
677    if let Some(hash) = governing_hash
678        && let Some(flow) = artifacts.iter().find(|flow| flow.ir_hash == hash)
679        && let Some((node, pointer)) = find_step(&flow.body, "/body", step_id.as_ref())
680    {
681        source = flow
682            .source_map
683            .iter()
684            .find(|entry| entry.ir_path.as_ref() == pointer)
685            .map(|entry| SourceLocation {
686                ir_path: entry.ir_path.clone(),
687                entry: entry.clone(),
688            });
689        ir_node = Some(node.clone());
690    }
691
692    // ── Frame environment from the checkpoint frame stack ─────────────
693    // `CheckpointView.frames` is the LIVE stack; it matches this instance
694    // only while the instance's own call chain is still open. Every
695    // frame of the chain is identity-checked (root by irHash, each Call
696    // by call step + callee identity) — on any mismatch the environment
697    // fails closed to absent rather than reporting another call's state.
698    let frame = checkpoint
699        .as_ref()
700        .and_then(|(_, view)| frame_environment(&instance, &view.frames))
701        .map(|mut environment| {
702            environment.provider_state_summary = step_summary.clone();
703            environment
704        });
705
706    Ok(StepDossierView {
707        projection_version: ProjectionVersion,
708        run_id: run_id.to_owned(),
709        run_path: key,
710        run_path_frames: instance,
711        step_id: step_id.to_string(),
712        effect_hash,
713        judge_hash,
714        ir_node,
715        source,
716        resolved_inputs,
717        preflight,
718        attempts,
719        output,
720        observations,
721        assertion_outcomes,
722        verdict,
723        verdict_history,
724        handler_triggers,
725        evidence,
726        evidence_gaps,
727        provider_state_summary: step_summary,
728        frame,
729        state,
730    })
731}
732
733/// The identity-verified frame-environment join (07 §2.3 part 4): walks
734/// the instance path's Flow/Call chain against the live frame stack and
735/// returns the enclosing frame only when EVERY level matches — the root
736/// by `irHash`, each call level by call step id + callee identity. A
737/// popped or re-pushed stack (completed calls, later siblings) fails the
738/// walk and the environment is reported absent, never mis-attributed.
739fn frame_environment(
740    instance: &[PathFrame],
741    frames: &[pointlock_ir::CallFrame],
742) -> Option<FrameEnvironment> {
743    let mut level = 0usize;
744    for (index, path_frame) in instance.iter().enumerate() {
745        match path_frame {
746            PathFrame::Flow { ir_hash, flow_id } => {
747                let frame = frames.first()?;
748                if frame.ir_hash != *ir_hash
749                    || frame.flow_id != *flow_id
750                    || frame.call_step_id.is_some()
751                {
752                    return None;
753                }
754            }
755            PathFrame::Call {
756                step_id,
757                callee_flow_id,
758                callee_ir_hash,
759            } => {
760                // The terminal Call frame IS the dossier target (the call
761                // step itself); its environment is the host frame already
762                // verified, so the callee frame is not required.
763                if index + 1 == instance.len() {
764                    break;
765                }
766                level += 1;
767                let frame = frames.get(level)?;
768                if frame.call_step_id != *step_id
769                    || frame.flow_id != *callee_flow_id
770                    || frame.ir_hash != *callee_ir_hash
771                {
772                    return None;
773                }
774            }
775            _ => {}
776        }
777    }
778    frames.get(level).map(|frame| FrameEnvironment {
779        inputs_snapshot: frame.inputs_snapshot.clone(),
780        vars: frame.vars.clone(),
781        provider_state_summary: None,
782    })
783}
784
785/// Depth-first search for a step id in a body; returns the node and its
786/// JSON Pointer inside the FlowIR document.
787fn find_step<'a>(body: &'a [StepIR], prefix: &str, step_id: &str) -> Option<(&'a StepIR, String)> {
788    for (index, step) in body.iter().enumerate() {
789        let pointer = format!("{prefix}/{index}");
790        if step.step_id().as_ref() == step_id {
791            return Some((step, pointer));
792        }
793        match step {
794            StepIR::If(nested) => {
795                if let Some(found) = find_step(&nested.then, &format!("{pointer}/then"), step_id) {
796                    return Some(found);
797                }
798                if let Some(else_body) = nested.r#else.as_deref()
799                    && let Some(found) = find_step(else_body, &format!("{pointer}/else"), step_id)
800                {
801                    return Some(found);
802                }
803            }
804            StepIR::Foreach(nested) => {
805                if let Some(found) = find_step(&nested.body, &format!("{pointer}/body"), step_id) {
806                    return Some(found);
807                }
808            }
809            _ => {}
810        }
811    }
812    None
813}