1use 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 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
117pub 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 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
171pub 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 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 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 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 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 (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
590fn 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
745pub 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}