Skip to main content

kranz_engine/
review_packet.rs

1//! Read-only human review, derived from the sealed log and retained evidence.
2//! This is deliberately not part of MissionState or any validator input. A
3//! packet describes an observation, never grants consent or authorizes reuse.
4use crate::error::{EngineError, Result};
5use crate::events::{Event, EventKind};
6use crate::gate_evaluation::{
7    authority::Authority,
8    input_builder::ObservedCheck,
9    lifecycle::{Outcome, RetainedArtifact},
10    protocol::{Digest, Status, Subject, Verdict},
11    snapshot::{Identity, SourceSnapshot},
12};
13use crate::git_ops::GitRepo;
14use crate::paths::MissionPaths;
15use crate::types::{MissionState, MissionStatus, Plan, WorkerIsolation};
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::fmt::Write as _;
20use std::io::Read;
21use std::path::Path;
22
23#[derive(Debug, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ReviewPacket {
26    pub mission_id: String,
27    pub observed_at: DateTime<Utc>,
28    pub through_seq: u64,
29    pub approved_plan: Option<Plan>,
30    pub approval_seq: Option<u64>,
31    pub candidate: Option<Candidate>,
32    pub committed_change: Option<CommittedChange>,
33    pub decisions: Vec<Entry>,
34    pub evaluations: Vec<Evaluation>,
35    pub baseline_candidates: Vec<crate::contract_controls::pair::Review>,
36    pub history: Vec<Entry>,
37    pub unknowns: Vec<String>,
38}
39
40#[derive(Debug, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CommittedChange {
43    pub base: String,
44    pub head: String,
45    pub diff_stat: String,
46}
47
48#[derive(Debug, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct Candidate {
51    pub identity: Identity,
52    /// Committed changes; working-tree changes are identified separately.
53    pub diff_stat: String,
54    pub working_tree: String,
55    pub excluded_paths: Vec<String>,
56    pub scope_deviations: Vec<String>,
57}
58
59#[derive(Debug, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct Entry {
62    pub seq: u64,
63    pub title: String,
64    pub detail: String,
65}
66
67#[derive(Debug, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum EvidenceStatus {
70    CurrentPass,
71    Failed,
72    Escalated,
73    Historical,
74    Unavailable,
75}
76
77#[derive(Debug, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Evaluation {
80    pub attempt_id: String,
81    pub gate_id: String,
82    pub stage: crate::gate_evaluation::protocol::Stage,
83    pub kind: crate::pack::evaluator::Kind,
84    pub enforcement: crate::pack::evaluator::Enforcement,
85    pub requested_seq: u64,
86    pub result_seq: Option<u64>,
87    pub status: EvidenceStatus,
88    pub detail: String,
89    pub subject: Subject,
90    pub binding: crate::gate_evaluation::protocol::Binding,
91    pub checks: Vec<Check>,
92    pub findings: Vec<crate::gate_evaluation::protocol::Finding>,
93    pub artifacts: Vec<Artifact>,
94    pub preceding_attempt: Option<String>,
95    pub changes_since_review: String,
96    pub paths_changed_after_review: Option<Vec<String>>,
97}
98
99#[derive(Debug, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct Check {
102    pub id: String,
103    pub command: String,
104    pub status: EvidenceStatus,
105    pub receipt: Option<ObservedCheck>,
106    pub artifact: Option<String>,
107}
108
109#[derive(Debug, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct Artifact {
112    pub path: String,
113    pub digest: Digest,
114    pub available: bool,
115}
116
117/// A live operator read. Never writes a packet, creates a worktree, reads a
118/// transcript, or invokes a provider. Missing worktrees remain unavailable.
119pub fn compute_review_packet(repo_root: &Path, mission_id: &str) -> Result<ReviewPacket> {
120    if !MissionPaths::is_safe_id(mission_id) {
121        return Err(EngineError::InvalidState("invalid mission id".into()));
122    }
123    let paths = MissionPaths::new(repo_root, mission_id);
124    paths.require_no_follow()?;
125    let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
126    let state = crate::reducer::fold(&events)?;
127    let root = match state.config.isolation() {
128        WorkerIsolation::Worktree => {
129            crate::orchestrator::mission_worktree_path(repo_root, mission_id)
130        }
131        WorkerIsolation::Checkout => repo_root.to_path_buf(),
132    };
133    let repo = GitRepo::open(&root).ok().filter(|repo| {
134        repo.current_branch().ok().as_deref() == Some(state.mission.mission_branch.as_str())
135    });
136    let mut packet = project(
137        &paths.mission_dir(),
138        &state,
139        &events,
140        repo.as_ref(),
141        Utc::now(),
142    )?;
143    // Delivery can remove the execution worktree. Git still proves the
144    // committed diff, but cannot establish the missing working-tree bytes.
145    if let Ok(repository) = GitRepo::open(repo_root) {
146        if packet.candidate.is_none() {
147            if let Some((base, head)) = state
148                .mission
149                .base_sha
150                .as_ref()
151                .zip(repository.rev_parse(&state.mission.mission_branch).ok())
152            {
153                if let Ok(diff_stat) = repository.diff_stat(base, &head) {
154                    packet.committed_change = Some(CommittedChange {
155                        base: base.clone(),
156                        head,
157                        diff_stat,
158                    });
159                }
160            }
161        }
162        if state.mission.status == MissionStatus::Complete
163            && crate::merged::merged_bit(&repository, &state.mission) == Some(true)
164        {
165            packet.decisions.retain(|d| d.title != "Mission decision");
166        }
167    }
168    Ok(packet)
169}
170
171/// Shared by live CLI/API reads and the point-in-time mission report. The
172/// caller supplies only the mission's execution tree, never another checkout.
173pub fn project(
174    mission_dir: &Path,
175    state: &MissionState,
176    events: &[Event],
177    repo: Option<&GitRepo>,
178    now: DateTime<Utc>,
179) -> Result<ReviewPacket> {
180    let approved = events.iter().rev().find_map(|e| match &e.kind {
181        EventKind::PlanApproved { plan, .. } | EventKind::PlanRevised { plan, .. } => {
182            Some((e.seq, plan))
183        }
184        _ => None,
185    });
186    let mut packet = ReviewPacket {
187        mission_id: state.mission.id.clone(),
188        observed_at: now,
189        through_seq: state.last_seq,
190        approved_plan: approved.map(|(_, p)| p.clone()),
191        approval_seq: approved.map(|(s, _)| s),
192        candidate: None,
193        committed_change: None,
194        decisions: vec![],
195        evaluations: vec![],
196        baseline_candidates: vec![],
197        history: vec![],
198        unknowns: vec![],
199    };
200    if approved.is_none() {
201        packet
202            .unknowns
203            .push("Approved scope and criteria unavailable: no approved plan in the log.".into());
204    }
205    let authority = repo.and_then(|r| Authority::from_events(r, state, events).ok());
206    let mut snapshots: BTreeMap<String, (Identity, Vec<u8>)> = BTreeMap::new();
207    if let Some((repo, base)) = repo.zip(state.mission.base_sha.as_deref()) {
208        match SourceSnapshot::capture(repo, base) {
209            Ok(snapshot) => {
210                snapshots.insert(
211                    snapshot.identity.base.clone(),
212                    (snapshot.identity.clone(), snapshot.inventory.clone()),
213                );
214                packet.candidate = Some(Candidate {
215                    diff_stat: repo.diff_stat(base, &snapshot.identity.head)?,
216                    working_tree: repo.porcelain_status()?,
217                    identity: snapshot.identity,
218                    excluded_paths: snapshot.excluded_paths,
219                    scope_deviations: scope_deviations(repo, base, &state.mission.touch_set)?,
220                });
221            }
222            Err(_) => packet.unknowns.push(
223                "Candidate source snapshot unavailable; freshness cannot be established.".into(),
224            ),
225        }
226    } else {
227        packet.unknowns.push("Candidate unavailable: no pinned base or active mission checkout. Retained evidence is historical.".into());
228    }
229    let mut records: Vec<_> = state.gate_evaluations.values().collect();
230    records.sort_by_key(|r| r.requested_seq);
231    let mut budget = 128 * 1024 * 1024usize;
232    // Spend the bounded read budget on the newest evidence first.
233    for (index, record) in records.iter().enumerate().rev() {
234        let p = &record.requested.request.params;
235        let mut bytes = BTreeMap::new();
236        let artifacts: Vec<_> = record
237            .requested
238            .retained_inputs
239            .iter()
240            .chain(record.finished.iter().flat_map(|f| &f.artifacts))
241            .map(|artifact| {
242                let read = retained_bytes(mission_dir, artifact, &mut budget);
243                let available = read.is_some();
244                if let Some(read) = read {
245                    // Only these small, structured inputs inform the human projection.
246                    let leaf = artifact.path.as_str().rsplit('/').next().unwrap_or("");
247                    if matches!(
248                        leaf,
249                        "source-identity" | "snapshot-inventory" | "check-requirements"
250                    ) || leaf.starts_with("check-")
251                    {
252                        // A scrubbed transformation may not be used to reconstruct raw bindings.
253                        if Digest::of(&read) == artifact.raw_digest {
254                            bytes.insert(
255                                leaf.to_string(),
256                                (artifact.path.as_str().to_string(), read),
257                            );
258                        }
259                    }
260                }
261                Artifact {
262                    path: artifact.path.as_str().to_string(),
263                    digest: artifact.retained_digest.clone(),
264                    available,
265                }
266            })
267            .collect();
268        let retained_identity = bytes
269            .get("source-identity")
270            .and_then(|(_, b)| serde_json::from_slice::<Identity>(b).ok());
271        if let Some(identity) = &retained_identity {
272            if !snapshots.contains_key(&identity.base) && snapshots.len() < 32 {
273                if let Some(snapshot) =
274                    repo.and_then(|r| SourceSnapshot::capture(r, &identity.base).ok())
275                {
276                    snapshots.insert(
277                        identity.base.clone(),
278                        (snapshot.identity, snapshot.inventory),
279                    );
280                }
281            }
282        }
283        let current_source = match &p.subject {
284            Subject::Plan {
285                revision,
286                base_commit,
287                ..
288            } => {
289                *revision == u64::from(state.latest_plan_revision)
290                    && state.mission.base_sha.as_deref() == Some(base_commit.value.as_str())
291            }
292            // A permission can expire or be answered independently of candidate bytes.
293            Subject::Invocation { .. } => record
294                .requested
295                .permission_request_id
296                .as_ref()
297                .and_then(|id| state.permissions.get(id))
298                .is_some_and(|r| r.pending(now)),
299            _ => retained_identity.as_ref().is_some_and(|id| {
300                snapshots.get(&id.base).map(|(identity, _)| identity) == Some(id)
301                    && subject_snapshot(&p.subject) == Some(id.inventory_digest.clone())
302            }),
303        };
304        let current_integration = match &p.subject {
305            Subject::Integration {
306                live_base_commit,
307                candidate_commit,
308                ..
309            } => repo.is_some_and(|r| {
310                r.rev_parse(&state.mission.base_branch).ok().as_deref()
311                    == Some(live_base_commit.value.as_str())
312                    && r.rev_parse(&state.mission.mission_branch).ok().as_deref()
313                        == Some(candidate_commit.value.as_str())
314            }),
315            _ => true,
316        };
317        let paths_changed_after_review = retained_identity.as_ref().and_then(|identity| {
318            let (_, before) = bytes.get("snapshot-inventory")?;
319            let (_, after) = snapshots.get(&identity.base)?;
320            changed_snapshot_paths(before, after)
321        });
322        let workspace = match &p.subject {
323            Subject::Invocation { .. } => record
324                .requested
325                .permission_request_id
326                .as_ref()
327                .and_then(|id| state.permissions.get(id))
328                .map(|r| {
329                    crate::gate_evaluation::lifecycle::workspace_id(&r.request.binding.workspace)
330                }),
331            _ => mission_dir.ancestors().nth(3).map(|root| {
332                crate::gate_evaluation::lifecycle::workspace_id(&root.to_string_lossy())
333            }),
334        };
335        let superseded = records[index + 1..].iter().any(|r| {
336            let newer = &r.requested.request.params;
337            newer.stage == p.stage
338                && newer.gate_id == p.gate_id
339                && same_review_unit(&newer.subject, &p.subject)
340        });
341        let current = current_source
342            && current_integration
343            && workspace.as_ref() == Some(&p.binding.workspace_id)
344            && !superseded
345            && record.closed.is_none()
346            && record.consumed.is_none()
347            && DateTime::parse_from_rfc3339(&p.deadline).is_ok_and(|d| d > now)
348            && authority.as_ref().is_some_and(|a| {
349                Digest::of(&a.plan_bytes) == p.binding.plan_digest
350                    && a.policy == record.requested.policy.mission_policy_digest
351                    && a.registrations
352                        .iter()
353                        .any(|r| r.digest() == p.binding.registration_digest)
354            });
355        let checks_projection = project_checks(&bytes, current);
356        let all_available = !artifacts.is_empty() && artifacts.iter().all(|a| a.available);
357        let requirements_available = checks_projection.is_some();
358        let checks = checks_projection.unwrap_or_default();
359        let checks_pass = requirements_available
360            && checks
361                .iter()
362                .all(|c| matches!(c.status, EvidenceStatus::CurrentPass));
363        let (status, mut detail, findings) = match record.finished.as_ref().map(|f| &f.outcome) {
364            Some(Outcome::Error { message }) => (EvidenceStatus::Failed, message.clone(), vec![]),
365            Some(Outcome::Evaluated { result, .. }) => {
366                let status = if result.status == Status::Escalate {
367                    EvidenceStatus::Escalated
368                } else if result.verdict == Some(Verdict::Fail) {
369                    EvidenceStatus::Failed
370                } else if !all_available || !requirements_available {
371                    EvidenceStatus::Unavailable
372                } else if !current {
373                    EvidenceStatus::Historical
374                } else if !checks_pass
375                    || !record.requested.policy.mechanical_prerequisites_passed
376                    || !record
377                        .finished
378                        .as_ref()
379                        .is_some_and(|f| f.cleanup_confirmed && f.exit_code == Some(0))
380                {
381                    EvidenceStatus::Failed
382                } else {
383                    EvidenceStatus::CurrentPass
384                };
385                (
386                    status,
387                    result.rationale.clone(),
388                    result.findings.clone().unwrap_or_default(),
389                )
390            }
391            None => (
392                EvidenceStatus::Unavailable,
393                "Evaluation has no finished result.".into(),
394                vec![],
395            ),
396        };
397        if !current_source {
398            detail.push_str("\nThe live subject differs or cannot be observed.");
399        }
400        if !current && current_source {
401            detail.push_str("\nThe attempt is closed, consumed, expired, superseded, or its current approval/workspace/policy binding cannot be established.");
402        }
403        if !all_available {
404            detail.push_str(
405                "\nRetained evidence is missing, changed, unreadable or exceeds the read budget.",
406            );
407        }
408        let previous = records[..index].iter().rev().find(|r| {
409            let prior = &r.requested.request.params;
410            prior.gate_id == p.gate_id
411                && prior.stage == p.stage
412                && same_review_unit(&prior.subject, &p.subject)
413        });
414        let changes = previous.map(|r| {
415            let prior = &r.requested.request.params;
416            format!("Since event #{}: subject {}; plan {}; policy {}; registration {}. Review events #{}–#{} for intervening decisions and repairs.",
417                r.requested_seq, changed(prior.binding.subject_digest != p.binding.subject_digest),
418                changed(prior.binding.plan_digest != p.binding.plan_digest), changed(prior.binding.policy_digest != p.binding.policy_digest),
419                changed(prior.binding.registration_digest != p.binding.registration_digest), r.requested_seq + 1, record.requested_seq)
420        }).unwrap_or_else(|| "No preceding evaluation for this gate and stage is recorded.".into());
421        packet.evaluations.push(Evaluation {
422            attempt_id: p.attempt_id.as_str().into(),
423            gate_id: p.gate_id.as_str().into(),
424            stage: p.stage,
425            kind: record.requested.policy.kind,
426            enforcement: record.requested.policy.enforcement,
427            requested_seq: record.requested_seq,
428            result_seq: events.iter().find_map(|e| match &e.kind {
429                EventKind::GateEvaluationFinished { evaluation }
430                    if evaluation.attempt_id == p.attempt_id =>
431                {
432                    Some(e.seq)
433                }
434                _ => None,
435            }),
436            status,
437            detail,
438            subject: p.subject.clone(),
439            binding: p.binding.clone(),
440            checks,
441            findings,
442            artifacts,
443            preceding_attempt: previous
444                .map(|r| r.requested.request.params.attempt_id.as_str().into()),
445            changes_since_review: changes,
446            paths_changed_after_review,
447        });
448    }
449    packet.evaluations.reverse();
450    packet.baseline_candidates = crate::contract_controls::pair::reviews_with_budget(
451        mission_dir,
452        events,
453        repo,
454        approved
455            .map(|(_, p)| p.validation_contract.as_slice())
456            .unwrap_or(&[]),
457        &state.config,
458        &mut budget,
459    );
460    if records.is_empty() {
461        packet.unknowns.push("No source-bound external check receipts recorded. Native gate outcomes below are recorded results, not proof of a current pass.".into());
462    }
463    pending_decisions(&mut packet, state, events, now);
464    for event in events {
465        let entry = match &event.kind {
466            EventKind::GateResult { gate, surface, verdict, artefact_ref, artefact_detail, .. } => {
467                if packet.baseline_candidates.iter().any(|pair| pair.seq == event.seq) {
468                    continue;
469                }
470                let availability = match crate::gate_results::resolve_artefact(mission_dir, artefact_ref) {
471                    crate::gate_results::ArtefactResolution::Resolved { .. } => "artifact present (legacy reference, no digest verification)",
472                    crate::gate_results::ArtefactResolution::Inline => "inline evidence in this event",
473                    crate::gate_results::ArtefactResolution::Unresolved { .. } => "artifact unavailable",
474                };
475                Some((format!("Native check {gate} ({surface:?}) — recorded {verdict:?}"),
476                    format!("{artefact_ref}\n{availability}\n{}\nCurrent candidate binding unavailable for this record.", artefact_detail.as_deref().unwrap_or(""))))
477            },
478            EventKind::ValidationFinding { run_id, finding, .. } => Some((
479                format!("Finding: {} — {}", finding.subject, finding.severity),
480                format!("{}\nClass: {}. Run: {}. Resolution is not inferred; inspect later checks and explicit waivers.", finding.evidence, finding.class, run_id))),
481            EventKind::StandardsWaiverApproved { .. } => Some(("Explicit standards waiver (recorded; applicability must be rechecked)".into(), serde_json::to_string_pretty(&event.kind)?)),
482            EventKind::OrchestratorDecision { summary, detail } if summary.starts_with("waived") => Some(("Recorded orchestrator waiver (not human consent)".into(), detail.as_ref().unwrap_or(summary).clone())),
483            EventKind::GrantApproved { kind, command } => Some((format!("Approved capability extension: {kind:?}"), command.clone())),
484            _ => None,
485        };
486        if let Some((title, detail)) = entry {
487            packet.history.push(Entry {
488                seq: event.seq,
489                title,
490                detail,
491            });
492        }
493    }
494    packet.unknowns.push("This observation does not authorize work. Check environments are the recorded environments; approvals, deadlines and bindings are rechecked by the existing decision path.".into());
495    Ok(packet)
496}
497
498fn changed(value: bool) -> &'static str {
499    if value {
500        "changed"
501    } else {
502        "unchanged"
503    }
504}
505
506fn same_review_unit(a: &Subject, b: &Subject) -> bool {
507    match (a, b) {
508        (
509            Subject::Milestone {
510                milestone_id: a, ..
511            },
512            Subject::Milestone {
513                milestone_id: b, ..
514            },
515        ) => a == b,
516        // Permission attempts for another tool call/session cannot supersede
517        // one another, even when they share a gate and stage.
518        (Subject::Invocation { .. }, Subject::Invocation { .. }) => a == b,
519        (Subject::Plan { .. }, Subject::Plan { .. })
520        | (Subject::Deliverable { .. }, Subject::Deliverable { .. })
521        | (Subject::Integration { .. }, Subject::Integration { .. }) => true,
522        _ => false,
523    }
524}
525
526fn changed_snapshot_paths(before: &[u8], after: &[u8]) -> Option<Vec<String>> {
527    use crate::gate_evaluation::evidence::{SnapshotEntry, SnapshotInventory};
528    fn entries(bytes: &[u8]) -> Option<BTreeMap<String, (Option<Digest>, bool)>> {
529        let inventory: SnapshotInventory = serde_json::from_slice(bytes).ok()?;
530        Some(
531            inventory
532                .entries
533                .into_iter()
534                .map(|entry| match entry {
535                    SnapshotEntry::File {
536                        path,
537                        digest,
538                        executable,
539                        ..
540                    } => (path.as_str().to_string(), (Some(digest), executable)),
541                    SnapshotEntry::Deleted { path } => (path.as_str().to_string(), (None, false)),
542                })
543                .collect(),
544        )
545    }
546    let before = entries(before)?;
547    let after = entries(after)?;
548    let paths: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect();
549    Some(
550        paths
551            .into_iter()
552            .filter(|p| before.get(*p) != after.get(*p))
553            .cloned()
554            .collect(),
555    )
556}
557fn scope_deviations(repo: &GitRepo, base: &str, touch_set: &[String]) -> Result<Vec<String>> {
558    if touch_set.is_empty() {
559        return Ok(vec![]);
560    }
561    repo.review_changed_paths(base)?
562        .into_iter()
563        .filter(|p| !crate::gate_evaluation::snapshot::excluded(p))
564        .filter_map(
565            |path| match crate::contract_sweep::touch_set_includes(touch_set, &path) {
566                Ok(true) => None,
567                Ok(false) => Some(Ok(path)),
568                Err(e) => Some(Err(EngineError::InvalidState(format!(
569                    "invalid approved touch set: {e}"
570                )))),
571            },
572        )
573        .collect()
574}
575fn subject_snapshot(subject: &Subject) -> Option<Digest> {
576    match subject {
577        Subject::Milestone {
578            snapshot_digest, ..
579        }
580        | Subject::Deliverable {
581            snapshot_digest, ..
582        }
583        | Subject::Integration {
584            snapshot_digest, ..
585        } => Some(snapshot_digest.clone()),
586        _ => None,
587    }
588}
589
590/// Bounded, no-follow and digest-checked reads; never follow an artifact's
591/// contents as another path. The aggregate budget applies across the packet.
592fn retained_bytes(root: &Path, artifact: &RetainedArtifact, budget: &mut usize) -> Option<Vec<u8>> {
593    let len = usize::try_from(artifact.retained_bytes).ok()?;
594    if len > *budget || len > 64 * 1024 * 1024 {
595        return None;
596    }
597    *budget -= len;
598    let file = crate::paths::open_read_nofollow(&root.join(artifact.path.as_str())).ok()?;
599    let metadata = file.metadata().ok()?;
600    if !metadata.is_file() || metadata.len() != artifact.retained_bytes {
601        return None;
602    }
603    #[cfg(unix)]
604    {
605        use std::os::unix::fs::MetadataExt;
606        if metadata.nlink() != 1 {
607            return None;
608        }
609    }
610    let mut bytes = Vec::new();
611    file.take(artifact.retained_bytes + 1)
612        .read_to_end(&mut bytes)
613        .ok()?;
614    (bytes.len() == len && Digest::of(&bytes) == artifact.retained_digest).then_some(bytes)
615}
616
617#[derive(Deserialize)]
618#[serde(rename_all = "camelCase")]
619struct Requirements {
620    checked_content: Digest,
621    environment: Digest,
622    required: Vec<Required>,
623}
624#[derive(Deserialize)]
625#[serde(rename_all = "camelCase")]
626struct Required {
627    id: String,
628    command: String,
629    require_assertions: bool,
630}
631#[derive(Deserialize)]
632struct Receipt {
633    receipt: ObservedCheck,
634}
635
636fn project_checks(
637    bytes: &BTreeMap<String, (String, Vec<u8>)>,
638    current: bool,
639) -> Option<Vec<Check>> {
640    let requirements = bytes
641        .get("check-requirements")
642        .and_then(|(_, b)| serde_json::from_slice::<Requirements>(b).ok());
643    let requirements = requirements?;
644    let receipts: Vec<_> = bytes
645        .values()
646        .filter_map(|(path, b)| {
647            serde_json::from_slice::<Receipt>(b)
648                .ok()
649                .map(|r| (path, r.receipt))
650        })
651        .collect();
652    Some(
653        requirements
654            .required
655            .into_iter()
656            .map(|required| {
657                let latest = receipts
658                    .iter()
659                    .filter(|(_, r)| r.check_id.as_str() == required.id)
660                    .max_by_key(|(_, r)| r.sequence);
661                let status = match latest {
662                    None => EvidenceStatus::Unavailable,
663                    Some((_, r))
664                        if r.command != required.command
665                            || r.checked_content != requirements.checked_content
666                            || r.environment != requirements.environment
667                            || !current =>
668                    {
669                        EvidenceStatus::Historical
670                    }
671                    Some((_, r))
672                        if r.exit_code != Some(0)
673                            || r.assertions_executed == Some(0)
674                            || (required.require_assertions && r.assertions_executed.is_none()) =>
675                    {
676                        EvidenceStatus::Failed
677                    }
678                    _ => EvidenceStatus::CurrentPass,
679                };
680                Check {
681                    id: required.id,
682                    command: required.command,
683                    status,
684                    receipt: latest.map(|(_, r)| r.clone()),
685                    artifact: latest.map(|(p, _)| (*p).clone()),
686                }
687            })
688            .collect(),
689    )
690}
691
692fn pending_decisions(
693    packet: &mut ReviewPacket,
694    state: &MissionState,
695    events: &[Event],
696    now: DateTime<Utc>,
697) {
698    let mut seen = std::collections::BTreeSet::new();
699    for event in events.iter().rev() {
700        let decision = match &event.kind {
701            EventKind::PlanRevisionProposed { revision, .. } if state.pending_revision.as_ref().is_some_and(|p| p.revision == *revision) => Some((format!("Plan revision {revision}"), "Approve or reject the proposed revision through the existing revision control.".into())),
702            EventKind::GrantRequested { kind, command, milestone_id } if state.pending_grant_request.as_ref().is_some_and(|p| p.kind == *kind && p.command == *command && p.milestone_id == *milestone_id) => Some((format!("Capability grant: {kind:?}"), format!("{milestone_id}: {command}. Approve or deny this exact request in the grant control."))),
703            EventKind::PermissionRequested { request } if state.permissions.get(&request.proposal.id).is_some_and(|r| r.pending(now)) => Some((format!("Permission {}", request.proposal.id), format!("Binding {}. Deadline {}. Answer this request in the permission control.", request.binding_digest, request.proposal.deadline))),
704            EventKind::QuestionOpened { question_id, text, .. } if state.pending_questions.iter().any(|q| q.question_id == *question_id) => Some((format!("Question {question_id}"), text.clone())),
705            EventKind::MilestoneBlocked { milestone_id, reason, block_context }
706                if state.mission.milestones.iter().any(|m| m.id == *milestone_id && m.status == crate::types::MilestoneStatus::Blocked) =>
707                Some((format!("Blocked milestone {milestone_id}"), format!("{reason}\nRecorded cause: {}. Inspect this block before using the existing unblock or abandon control.", serde_json::to_string(block_context).unwrap_or_else(|_| "unavailable".into())))),
708            EventKind::GateEvaluationRequested { evaluation } => {
709                let id = evaluation.request.params.attempt_id.as_str();
710                state.gate_evaluations.get(id).filter(|r| r.closed.is_none() && r.consumed.is_none())
711                    .and_then(|r| r.resolution.as_ref()).filter(|r| r.disposition != crate::gate_evaluation::lifecycle::Disposition::Proceed)
712                    .map(|r| (format!("Gate attempt {id}"), format!("{:?}: {}. Correct the cause and retry the existing stage; this packet has no consent action.", r.disposition, r.rationale)))
713            }
714            _ => None,
715        };
716        if let Some((title, detail)) = decision {
717            if !seen.insert(title.clone()) {
718                continue;
719            }
720            packet.decisions.push(Entry {
721                seq: event.seq,
722                title,
723                detail,
724            });
725        }
726    }
727    packet.decisions.reverse();
728    if matches!(
729        state.mission.status,
730        MissionStatus::Planning | MissionStatus::Complete | MissionStatus::Blocked
731    ) {
732        let detail = match state.mission.status {
733            MissionStatus::Planning => "Review and approve the proposed plan using the existing plan control. No approved scope exists yet.",
734            MissionStatus::Complete => "Review the deliverable and current merge readiness. Merge remains a separate human action; completion alone is not merge approval.",
735            _ => "Inspect the recorded block, then use the existing retry, unblock or abandon controls. A packet cannot waive a block.",
736        };
737        packet.decisions.push(Entry {
738            seq: state.last_seq,
739            title: "Mission decision".into(),
740            detail: detail.into(),
741        });
742    }
743}
744
745/// Markdown for the CLI and mission report. Untrusted prose is literal text:
746/// it cannot introduce links to the human API or active markup in a renderer.
747pub fn render_markdown(packet: &ReviewPacket) -> String {
748    fn literal(text: &str) -> String {
749        crate::presentation::visible(&crate::scrub::scrub(text))
750            .chars()
751            .flat_map(|c| {
752                if "\\`*_{}[]<>()#+-.!|".contains(c) {
753                    vec!['\\', c]
754                } else {
755                    vec![c]
756                }
757            })
758            .collect()
759    }
760    fn name(value: &impl Serialize) -> String {
761        serde_json::to_value(value)
762            .ok()
763            .and_then(|v| v.as_str().map(str::to_string))
764            .unwrap_or_default()
765    }
766    fn entries(out: &mut String, entries: &[Entry]) {
767        for entry in entries {
768            let _ = writeln!(
769                out,
770                "\n- **{}** — event #{}\n\n  {}\n",
771                literal(&entry.title),
772                entry.seq,
773                literal(&entry.detail).replace('\n', "\n  ")
774            );
775        }
776    }
777    let mut out = format!("\n## Human review packet\n\nMission {} · observed {} · through event #{}. Refresh with `kranz review-packet` before acting.\n", literal(&packet.mission_id), packet.observed_at, packet.through_seq);
778    if let Some(plan) = &packet.approved_plan {
779        let _ = writeln!(
780            out,
781            "\n### Approved scope — event #{}\n\n{}\n\nTouch set: {}\n",
782            packet.approval_seq.unwrap_or(0),
783            literal(&plan.goal),
784            literal(&plan.touch_set.join(", "))
785        );
786        for a in &plan.validation_contract {
787            let _ = writeln!(
788                out,
789                "- {}: {} ({})",
790                literal(&a.id),
791                literal(&a.statement),
792                literal(a.command.as_deref().unwrap_or("judgment"))
793            );
794        }
795        for m in &plan.milestones {
796            for f in &m.features {
797                for c in &f.validation_criteria {
798                    let _ = writeln!(
799                        out,
800                        "- {} / {}: {}",
801                        literal(&m.title),
802                        literal(&f.title),
803                        literal(c)
804                    );
805                }
806            }
807        }
808    }
809    out.push_str("\n### Actual change\n");
810    if let Some(c) = &packet.candidate {
811        let _ = writeln!(out, "\nBase: {}\n\nHEAD: {}\n\nSource: {}\n\nCommitted diff:\n\n{}\n\nWorking tree (separate from the committed diff):\n\n{}\n\nExcluded from source evidence: {}\n", c.identity.base, c.identity.head, c.identity.inventory_digest.as_str(), literal(&c.diff_stat), literal(&c.working_tree), literal(&c.excluded_paths.join(", ")));
812        for path in &c.scope_deviations {
813            let _ = writeln!(
814                out,
815                "- **Outside the approved touch set:** {}",
816                literal(path)
817            );
818        }
819    } else if let Some(c) = &packet.committed_change {
820        let _ = writeln!(out, "\nCommitted base: {}\n\nCommitted candidate: {}\n\n{}\n\nWorking-tree source identity unavailable. This diff does not establish check freshness.\n", c.base, c.head, literal(&c.diff_stat));
821    } else {
822        out.push_str("\nCandidate unavailable.\n");
823    }
824    out.push_str("\n### Remaining human decisions\n");
825    if packet.decisions.is_empty() {
826        out.push_str("\nNo pending human decision is recorded at this observation.\n");
827    }
828    entries(&mut out, &packet.decisions);
829    out.push_str("\n### Checks and independent judgment\n\nCurrent pass means the recorded result still binds to the observed source and policy. It is not consent.\n");
830    for e in &packet.evaluations {
831        let _ = writeln!(out, "\n#### {} / {} — {}\n\n{} · {} · attempt {} · request event #{} · result event {}\n\n{}\n\n{}\n", literal(&e.gate_id), name(&e.stage), name(&e.status), name(&e.kind), name(&e.enforcement), literal(&e.attempt_id), e.requested_seq, e.result_seq.map(|s| format!("#{s}")).unwrap_or_else(|| "unavailable".into()), literal(&e.detail), literal(&e.changes_since_review));
832        for check in &e.checks {
833            let _ = writeln!(
834                out,
835                "- {}: {} — {} · receipt {}",
836                literal(&check.id),
837                literal(&check.command),
838                name(&check.status),
839                literal(check.artifact.as_deref().unwrap_or("unavailable"))
840            );
841        }
842        match &e.paths_changed_after_review {
843            Some(paths) if paths.is_empty() => {
844                out.push_str("\nNo selected source paths changed after this review.\n")
845            }
846            Some(paths) => {
847                let _ = writeln!(
848                    out,
849                    "\nPaths changed after this review: {}\n",
850                    literal(&paths.join(", "))
851                );
852            }
853            None => out.push_str(
854                "\nPath changes after this review: unavailable (no comparable source inventory).\n",
855            ),
856        }
857        for f in &e.findings {
858            let _ = writeln!(
859                out,
860                "- Finding {} ({}): {}. Resolution is not inferred. Anchors: {}",
861                literal(f.id.as_str()),
862                name(&f.severity),
863                literal(&f.summary),
864                literal(&serde_json::to_string(&f.evidence).unwrap_or_default())
865            );
866        }
867        let _ = writeln!(out, "\nRetained artifacts: {} verified, {} unavailable. Full paths and digests are in the JSON packet and evidence bundle.", e.artifacts.iter().filter(|a| a.available).count(), e.artifacts.iter().filter(|a| !a.available).count());
868        for a in e.artifacts.iter().filter(|a| !a.available) {
869            let _ = writeln!(
870                out,
871                "- {} — unavailable · {}",
872                literal(&a.path),
873                a.digest.as_str()
874            );
875        }
876    }
877    if !packet.baseline_candidates.is_empty() {
878        out.push_str("\n### Baseline and candidate observations\n");
879        for pair in &packet.baseline_candidates {
880            let summary = &pair.summary;
881            let _ = writeln!(out, "\n#### {} — recorded {} (advisory)\n\n{}\n\n{}\n\nEnvironment: {}. Checker: {}. Baseline checker overlay: {}.\n",
882                literal(&summary.assertion_id), name(&summary.status), literal(&pair.detail), literal(&summary.detail),
883                literal(&summary.spec.environment_label), literal(&summary.checker_sha256),
884                literal(summary.checker_overlay_sha256.as_deref().unwrap_or("none")));
885            for (label, observation, expected) in [
886                (
887                    "Baseline",
888                    &summary.baseline,
889                    &summary.spec.expected_baseline,
890                ),
891                (
892                    "Candidate",
893                    &summary.candidate,
894                    &summary.spec.expected_candidate,
895                ),
896            ] {
897                let _ = writeln!(
898                    out,
899                    "- {label}: expected {}. {}",
900                    literal(&serde_json::to_string(expected).unwrap_or_default()),
901                    literal(
902                        &observation
903                            .as_ref()
904                            .map(|o| {
905                                let source = o
906                                    .binding
907                                    .as_ref()
908                                    .map(|b| b.source.head.as_str())
909                                    .unwrap_or("unavailable");
910                                let receipt = o
911                                    .receipt
912                                    .as_ref()
913                                    .map(|r| {
914                                        format!(
915                                            "{} checks, {}{}{}",
916                                            r.checks_run,
917                                            name(&r.outcome),
918                                            r.failure_id
919                                                .as_ref()
920                                                .map(|id| format!(" ({id})"))
921                                                .unwrap_or_default(),
922                                            r.diagnostic
923                                                .as_ref()
924                                                .map(|d| format!("; diagnostic: {d}"))
925                                                .unwrap_or_default()
926                                        )
927                                    })
928                                    .unwrap_or_else(|| "receipt unavailable".into());
929                                format!(
930                                    "Commit {source}; exit {}; {receipt}.",
931                                    o.exit_code
932                                        .map(|c| c.to_string())
933                                        .unwrap_or_else(|| "unavailable".into())
934                                )
935                            })
936                            .unwrap_or_else(|| "Observation unavailable.".into())
937                    )
938                );
939            }
940            let _ = writeln!(
941                out,
942                "\nRetained evidence: {} · event #{} · {}.\n",
943                literal(&pair.reference),
944                pair.seq,
945                if pair.available {
946                    "digest verified"
947                } else {
948                    "unavailable"
949                }
950            );
951        }
952    }
953    out.push_str("\n### Recorded checks, findings and explicit exceptions\n");
954    entries(&mut out, &packet.history);
955    out.push_str("\n### Unknowns and limits\n");
956    for unknown in &packet.unknowns {
957        let _ = writeln!(out, "\n- {}", literal(unknown));
958    }
959    out
960}