1use std::collections::BTreeMap;
14
15use camino::Utf8Path;
16use serde::Serialize;
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::{self, manifest};
22use crate::self_depend::manager::{self, Manager, PinRead};
23use crate::setup::journal::Journal;
24
25use super::{Classification, Operation, Plan, Postcondition, Readiness, fingerprint};
26
27pub const APPLY_SCHEMA: &str = "rk.reconcile-apply/1";
29
30#[derive(Debug, Clone, Serialize)]
32pub struct OperationResult {
33 pub op: &'static str,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub path: Option<String>,
38 pub status: &'static str,
40}
41
42#[derive(Debug, Clone, Serialize)]
44pub struct PostconditionResult {
45 pub check: String,
47 pub status: &'static str,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub detail: Option<String>,
53}
54
55#[derive(Debug, Serialize)]
57pub struct Applied {
58 pub schema: &'static str,
60 pub plan_id: String,
62 pub input_fingerprint: Digest,
64 pub target: String,
66 pub classification: Classification,
68 pub operations: Vec<OperationResult>,
70 pub postconditions: Vec<PostconditionResult>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub run_id: Option<String>,
75 pub next: Vec<String>,
77}
78
79impl Applied {
80 #[must_use]
82 pub fn failed(&self) -> Vec<&PostconditionResult> {
83 self.postconditions
84 .iter()
85 .filter(|result| result.status == "failed")
86 .collect()
87 }
88
89 #[must_use]
93 pub fn failure(&self) -> Option<RkError> {
94 let failed = self.failed();
95 if failed.is_empty() {
96 return None;
97 }
98 Some(RkError::check_failed(
99 Diagnostic::new(
100 Reason::PostconditionFailed,
101 format!(
102 "the writes landed and {} postcondition{} failed: {}",
103 failed.len(),
104 if failed.len() == 1 { "" } else { "s" },
105 failed
106 .iter()
107 .map(|result| {
108 format!(
109 "{} ({})",
110 result.check,
111 result.detail.as_deref().unwrap_or("no detail")
112 )
113 })
114 .collect::<Vec<_>>()
115 .join(", ")
116 ),
117 )
118 .expected("every postcondition the plan carries satisfied after the writes")
119 .action(format!(
120 "rk status --target {} names what is short",
121 self.target
122 ))
123 .target_state("written"),
124 ))
125 }
126}
127
128pub fn gate(plan: &Plan) -> Result<(), RkError> {
136 match plan.readiness {
137 Readiness::Ready => Ok(()),
138 Readiness::NeedsDecision => {
139 let ids: Vec<String> = plan
140 .preconditions
141 .iter()
142 .filter(|p| !p.evaluation.holds())
143 .filter_map(|p| p.decision.clone())
144 .collect();
145 Err(RkError::refusal(
146 Diagnostic::new(
147 Reason::PlanNotReady,
148 format!(
149 "plan {} waits on a decision, and nothing was written: {}",
150 plan.identity.plan_id,
151 ids.join(", ")
152 ),
153 )
154 .expected("every decision the plan names selected")
155 .action("rk reconcile plan --decide <id>=<answer> selects an answer and stores a fresh plan")
156 .target_state("unchanged"),
157 ))
158 }
159 Readiness::Blocked => {
160 let failed: Vec<String> = plan
161 .preconditions
162 .iter()
163 .filter(|p| p.requirement == super::Requirement::Required && !p.evaluation.holds())
164 .map(|p| {
165 let reason = match &p.evaluation {
166 super::Evaluation::Satisfied => String::new(),
167 super::Evaluation::NotObserved { reason }
168 | super::Evaluation::Unsatisfied { reason } => reason.clone(),
169 };
170 format!("{} ({reason})", p.id)
171 })
172 .collect();
173 Err(RkError::refusal(
174 Diagnostic::new(
175 Reason::PlanNotReady,
176 format!(
177 "plan {} is blocked, and nothing was written: {}",
178 plan.identity.plan_id,
179 failed.join(", ")
180 ),
181 )
182 .expected("every required precondition satisfied")
183 .action("resolve each, then rk reconcile plan again")
184 .target_state("unchanged"),
185 ))
186 }
187 }
188}
189
190pub fn revalidate(stored: &Plan, fresh: &Plan) -> Result<(), RkError> {
198 let stored_now = fingerprint::compute(stored);
201 if stored.input_fingerprint == fresh.input_fingerprint && stored_now == stored.input_fingerprint
202 {
203 return Ok(());
204 }
205 let before = fingerprint::canonical(stored);
206 let after = fingerprint::canonical(fresh);
207 let before_lines: Vec<&str> = before.lines().collect();
208 let after_lines: Vec<&str> = after.lines().collect();
209 let mut moved: Vec<String> = Vec::new();
210 if stored_now != stored.input_fingerprint {
211 moved.push("the stored plan".to_owned());
212 }
213 for line in before_lines
214 .iter()
215 .filter(|line| !after_lines.contains(line))
216 .chain(
217 after_lines
218 .iter()
219 .filter(|line| !before_lines.contains(line)),
220 )
221 {
222 let label = label(line);
223 if !moved.contains(&label) {
224 moved.push(label);
225 }
226 }
227 Err(RkError::refusal(
228 Diagnostic::new(
229 Reason::StateDrift,
230 format!(
231 "plan {} no longer matches its inputs, and nothing was written: {}",
232 stored.identity.plan_id,
233 moved.join(", ")
234 ),
235 )
236 .expected("the target, the bundle, and the decisions as the plan observed them")
237 .action("rk reconcile plan computes a fresh plan over what is there now")
238 .target_state("unchanged"),
239 ))
240}
241
242fn label(line: &str) -> String {
244 let mut parts = line.split('\t');
245 match parts.next().unwrap_or_default() {
246 "candidate" => "the candidate bundle".to_owned(),
247 "record" => "the record".to_owned(),
248 "configuration" => "the configuration".to_owned(),
249 "operation" => {
250 let kind = parts.next().unwrap_or_default();
251 let subject = parts.next().unwrap_or_default();
252 format!("operation {kind} {subject}")
253 }
254 "precondition" => format!("precondition {}", parts.next().unwrap_or_default()),
255 "decision" => format!("decision {}", parts.next().unwrap_or_default()),
256 other => other.to_owned(),
257 }
258}
259
260pub fn verify_before_digests(target: &Utf8Path, plan: &Plan) -> Result<(), RkError> {
268 let mut moved: Vec<String> = Vec::new();
269 let pin = crate::self_depend::observe(target).ok();
270 for operation in &plan.operations {
271 let (subject, held, wanted): (String, Option<String>, Option<String>) = match operation {
272 Operation::WriteFile { path, before, .. }
273 | Operation::SpliceBlock { path, before, .. } => (
274 path.clone(),
275 landing::read_recorded(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
276 before.as_ref().map(ToString::to_string),
277 ),
278 Operation::RemoveOwnedFile { path, before } => (
279 path.clone(),
280 read_optional(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
281 Some(before.to_string()),
282 ),
283 Operation::WriteRecord { before, .. } => (
284 manifest::MANIFEST_PATH.to_owned(),
285 read_optional(target, manifest::MANIFEST_PATH)?
286 .map(|bytes| Digest::of(&bytes).to_string()),
287 before.as_ref().map(ToString::to_string),
288 ),
289 Operation::UpdatePin {
290 manager, before, ..
291 } => (
292 format!("the {manager} pin"),
293 pin.as_ref().and_then(|observed| {
294 parse_manager(manager)
295 .and_then(|m| observed.entry(m))
296 .and_then(|entry| entry.version.clone())
297 }),
298 Some(before.clone()),
299 ),
300 };
301 if held != wanted {
302 moved.push(subject);
303 }
304 }
305 if moved.is_empty() {
306 return Ok(());
307 }
308 Err(RkError::refusal(
309 Diagnostic::new(
310 Reason::StateDrift,
311 format!(
312 "these destinations changed since the plan observed them, and nothing was written: {}",
313 moved.join(", ")
314 ),
315 )
316 .expected("every destination holding what the plan's before digest names")
317 .action("rk reconcile plan computes a fresh plan over what is there now")
318 .target_state("unchanged"),
319 ))
320}
321
322pub fn run(
334 target: &Utf8Path,
335 stored: &Plan,
336 blobs: &BTreeMap<Digest, Vec<u8>>,
337 fresh: &Plan,
338 mut journal: Option<Journal>,
339) -> Result<Applied, RkError> {
340 let outcome = execute(target, stored, blobs, fresh, journal.as_mut());
341 if let Some(journal) = journal.as_mut() {
342 match &outcome {
343 Ok(applied) => {
344 let failed = applied.failed();
345 if failed.is_empty() {
346 journal.finish(0, None);
347 } else {
348 journal.finish(1, Some(Reason::PostconditionFailed.as_str()));
349 }
350 }
351 Err(error) => {
352 journal.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
353 }
354 }
355 }
356 let run_id = journal.as_ref().map(|journal| journal.run_id().to_owned());
357 outcome.map(|mut applied| {
358 applied.run_id = run_id;
359 applied
360 })
361}
362
363fn execute(
364 target: &Utf8Path,
365 stored: &Plan,
366 blobs: &BTreeMap<Digest, Vec<u8>>,
367 fresh: &Plan,
368 mut journal: Option<&mut Journal>,
369) -> Result<Applied, RkError> {
370 gate(stored)?;
371 revalidate(stored, fresh)?;
372 verify_before_digests(target, stored)?;
373 if let Some(journal) = journal.as_deref_mut() {
374 journal.event_line(&format!(
375 r#"{{"event":"plan","plan_id":"{}","input_fingerprint":"{}","operations":{}}}"#,
376 stored.identity.plan_id,
377 stored.input_fingerprint,
378 stored.operations.len()
379 ));
380 }
381 let (txn, removals) = stage(target, stored, blobs)?;
382 let stop = std::env::var_os("RK_APPLY_INTERRUPT_AT").map(std::path::PathBuf::from);
384 let landed = txn
385 .commit_stopping_at(stop.as_deref())
386 .map_err(|interrupted| {
387 if let Some(journal) = journal.as_deref_mut() {
388 for path in &interrupted.landed {
389 journal.event_line(&format!(
390 r#"{{"event":"operation","path":"{}","status":"ok"}}"#,
391 path.display()
392 ));
393 }
394 journal.event_line(&format!(
395 r#"{{"event":"operation","path":"{}","status":"failed","detail":"{}"}}"#,
396 interrupted.failed.display(),
397 interrupted.error
398 ));
399 }
400 interrupted_error(&interrupted)
401 })?;
402 for path in &removals {
403 std::fs::remove_file(target.join(path))?;
404 }
405 debug_assert_eq!(landed.len(), txn_len(&stored.operations));
406 let operations: Vec<OperationResult> = stored
407 .operations
408 .iter()
409 .map(|operation| OperationResult {
410 op: operation.kind(),
411 path: match operation {
412 Operation::WriteRecord { .. } => Some(manifest::MANIFEST_PATH.to_owned()),
413 Operation::UpdatePin { manager, .. } => Some(format!("the {manager} pin")),
414 other => other.path().map(str::to_owned),
415 },
416 status: "ok",
417 })
418 .collect();
419 let postconditions = postconditions(target, stored);
420 if let Some(journal) = journal {
421 for result in &operations {
422 journal.event_line(&format!(
423 r#"{{"event":"operation","op":"{}","path":"{}","status":"{}"}}"#,
424 result.op,
425 result.path.as_deref().unwrap_or_default(),
426 result.status
427 ));
428 }
429 for result in &postconditions {
430 journal.event_line(&format!(
431 r#"{{"event":"postcondition","check":"{}","status":"{}"}}"#,
432 result.check, result.status
433 ));
434 }
435 }
436 Ok(Applied {
437 schema: APPLY_SCHEMA,
438 plan_id: stored.identity.plan_id.clone(),
439 input_fingerprint: stored.input_fingerprint.clone(),
440 target: target.to_string(),
441 classification: stored.classification,
442 operations,
443 postconditions,
444 run_id: None,
445 next: vec![
446 "commit the written files, the record included".to_owned(),
447 format!("rk status --target {target} reports the result"),
448 ],
449 })
450}
451
452fn stage(
457 target: &Utf8Path,
458 stored: &Plan,
459 blobs: &BTreeMap<Digest, Vec<u8>>,
460) -> Result<(crate::atomic::Transaction, Vec<String>), RkError> {
461 let mut txn = crate::atomic::Transaction::new();
462 let mut removals: Vec<String> = Vec::new();
463 let pin = stored
464 .operations
465 .iter()
466 .any(|operation| matches!(operation, Operation::UpdatePin { .. }))
467 .then(|| crate::self_depend::observe(target))
468 .transpose()?;
469 for operation in &stored.operations {
470 match operation {
471 Operation::WriteFile { path, after, .. } => {
472 txn.stage(target.join(path).as_std_path(), blob(blobs, after)?)?;
473 }
474 Operation::SpliceBlock { path, after, .. } => {
475 let existing = read_optional(target, path)?
476 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
477 let block = String::from_utf8_lossy(blob(blobs, after)?).into_owned();
478 let spliced = if path == landing::HOOKS_DESTINATION {
479 landing::splice_hooks_block(existing.as_deref(), &block)
480 .map_err(std::io::Error::other)?
481 } else {
482 landing::splice_agents_block(existing.as_deref(), &block)
483 };
484 txn.stage(target.join(path).as_std_path(), spliced.as_bytes())?;
485 }
486 Operation::RemoveOwnedFile { path, .. } => removals.push(path.clone()),
487 Operation::WriteRecord { after, .. } => {
488 txn.stage(
489 target.join(manifest::MANIFEST_PATH).as_std_path(),
490 blob(blobs, after)?,
491 )?;
492 }
493 Operation::UpdatePin {
494 manager,
495 before,
496 after,
497 } => {
498 let (file, text) = pin_text(pin.as_ref(), manager, before)?;
499 let PinRead::One { line, .. } =
500 manager::read_pin(parse_manager(manager).unwrap_or(Manager::Mise), &text)
501 else {
502 return Err(pin_moved(manager));
503 };
504 let rewritten = manager::rewrite_line(&text, line, before, after);
505 txn.stage(target.join(&file).as_std_path(), rewritten.as_bytes())?;
506 }
507 }
508 }
509 Ok((txn, removals))
510}
511
512fn interrupted_error(interrupted: &crate::atomic::Interrupted) -> RkError {
515 RkError::Io(std::io::Error::new(
516 interrupted.error.kind(),
517 format!(
518 "the apply stopped at {}: {}; each destination holds its previous bytes or its new ones, and these landed before it: {}",
519 interrupted.failed.display(),
520 interrupted.error,
521 if interrupted.landed.is_empty() {
522 "none".to_owned()
523 } else {
524 interrupted
525 .landed
526 .iter()
527 .map(|path| path.display().to_string())
528 .collect::<Vec<_>>()
529 .join(", ")
530 }
531 ),
532 ))
533}
534
535fn txn_len(operations: &[Operation]) -> usize {
537 operations
538 .iter()
539 .filter(|operation| !matches!(operation, Operation::RemoveOwnedFile { .. }))
540 .count()
541}
542
543#[must_use]
545pub fn postconditions(target: &Utf8Path, plan: &Plan) -> Vec<PostconditionResult> {
546 let pin = plan
547 .postconditions
548 .iter()
549 .any(|check| matches!(check, Postcondition::PinReads { .. }))
550 .then(|| crate::self_depend::observe(target).ok())
551 .flatten();
552 plan.postconditions
553 .iter()
554 .map(|check| {
555 let (name, outcome): (String, Result<(), String>) = match check {
556 Postcondition::RecordReadsBack { sha256 } => (
557 "record-reads-back".to_owned(),
558 holds(read_optional(target, manifest::MANIFEST_PATH), sha256),
559 ),
560 Postcondition::DestinationHolds { path, sha256 } => (
561 format!("destination-holds:{path}"),
562 holds(
563 landing::read_recorded(target, path).map_err(RkError::Io),
564 sha256,
565 ),
566 ),
567 Postcondition::StatusCheckClean => {
568 ("status-check-clean".to_owned(), status_check(target))
569 }
570 Postcondition::PinReads { manager, version } => (
571 format!("pin-reads:{manager}"),
572 match pin
573 .as_ref()
574 .and_then(|observed| parse_manager(manager).and_then(|m| observed.entry(m)))
575 .and_then(|entry| entry.version.clone())
576 {
577 Some(found) if &found == version => Ok(()),
578 Some(found) => Err(format!("the {manager} pin reads {found}")),
579 None => Err(format!("no {manager} pin reads")),
580 },
581 ),
582 };
583 let advisory = matches!(check, Postcondition::StatusCheckClean);
587 match outcome {
588 Ok(()) => PostconditionResult {
589 check: name,
590 status: "ok",
591 detail: None,
592 },
593 Err(detail) => PostconditionResult {
594 check: name,
595 status: if advisory { "reported" } else { "failed" },
596 detail: Some(detail),
597 },
598 }
599 })
600 .collect()
601}
602
603fn holds(read: Result<Option<Vec<u8>>, RkError>, wanted: &Digest) -> Result<(), String> {
605 match read {
606 Ok(Some(bytes)) if Digest::of(&bytes) == *wanted => Ok(()),
607 Ok(Some(bytes)) => Err(format!("holds {}", Digest::of(&bytes))),
608 Ok(None) => Err("absent".to_owned()),
609 Err(error) => Err(error.to_string()),
610 }
611}
612
613fn status_check(target: &Utf8Path) -> Result<(), String> {
616 let exe = std::env::current_exe().map_err(|error| format!("no engine path: {error}"))?;
617 let mut command = std::process::Command::new(exe);
618 for var in crate::maintenance::GIT_HOOK_VARS {
619 command.env_remove(var);
620 }
621 let out = command
622 .args(["status", "--check", "--target"])
623 .arg(target)
624 .output()
625 .map_err(|error| format!("rk status --check could not run: {error}"))?;
626 if out.status.success() {
627 return Ok(());
628 }
629 let stderr = String::from_utf8_lossy(&out.stderr);
630 Err(stderr
631 .lines()
632 .find(|line| !line.trim().is_empty())
633 .unwrap_or("rk status --check exited nonzero")
634 .trim()
635 .to_owned())
636}
637
638fn blob<'a>(blobs: &'a BTreeMap<Digest, Vec<u8>>, digest: &Digest) -> Result<&'a [u8], RkError> {
639 blobs.get(digest).map(Vec::as_slice).ok_or_else(|| {
640 RkError::Other(anyhow::anyhow!(
641 "the plan names digest {digest} and its store holds no such blob"
642 ))
643 })
644}
645
646fn read_optional(target: &Utf8Path, rel: &str) -> Result<Option<Vec<u8>>, RkError> {
647 match std::fs::read(target.join(rel)) {
648 Ok(bytes) => Ok(Some(bytes)),
649 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
650 Err(error) => Err(RkError::Io(error)),
651 }
652}
653
654fn parse_manager(name: &str) -> Option<Manager> {
655 Manager::ALL.into_iter().find(|m| m.as_str() == name)
656}
657
658fn pin_text(
661 observed: Option<&crate::self_depend::Observed>,
662 manager: &str,
663 before: &str,
664) -> Result<(String, String), RkError> {
665 let parsed = parse_manager(manager).ok_or_else(|| pin_moved(manager))?;
666 if parsed == Manager::Flake {
667 return Err(RkError::refusal(
668 Diagnostic::new(
669 Reason::PrerequisiteUnmet,
670 "the flake pin moves through the self-depend sync verb under --apply, with nix and the network; an offline apply cannot stage it",
671 )
672 .expected("a one-fact manager pin, or the sync verb for the flake pair")
673 .target_state("unchanged"),
674 ));
675 }
676 let entry = observed
677 .and_then(|observed| observed.entry(parsed))
678 .filter(|entry| entry.version.as_deref() == Some(before))
679 .ok_or_else(|| pin_moved(manager))?;
680 match (&entry.file, &entry.text) {
681 (Some(file), Some(text)) => Ok((file.clone(), text.clone())),
682 _ => Err(pin_moved(manager)),
683 }
684}
685
686fn pin_moved(manager: &str) -> RkError {
687 RkError::refusal(
688 Diagnostic::new(
689 Reason::StateDrift,
690 format!(
691 "the {manager} pin no longer reads as the plan observed it, and nothing was written"
692 ),
693 )
694 .expected("the manager file as the plan observed it")
695 .action("rk reconcile plan computes a fresh plan over what is there now")
696 .target_state("unchanged"),
697 )
698}
699
700#[cfg(test)]
701mod tests {
702 use camino::Utf8PathBuf;
703
704 use super::{PostconditionResult, postconditions};
705 use crate::digest::Digest;
706 use crate::plan::{Plan, Postcondition};
707
708 fn plan_with(checks: &[Postcondition]) -> Plan {
710 let json = serde_json::json!({
711 "schema": crate::plan::PLAN_SCHEMA,
712 "identity": {"plan_id": "0123456789abcdef", "created_at": "2026-01-01T00:00:00Z", "engine_version": "0.0.0"},
713 "classification": "setup",
714 "findings": [],
715 "desired_state": {"intent": "reconcile", "selector": "embedded", "release": {"version": "0.0.0", "venue": "embedded", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1}},
716 "observed_state": {
717 "repository": {"target": "/tmp/t", "git": false, "tags": 0, "long_lived_branches": [], "release_markers": [], "collisions": [], "verdict": "greenfield", "evidence_refs": []},
718 "installation": {"record": {"state": "absent"}, "configuration": {"present": false, "pending": []}, "destinations": [], "evidence_refs": []},
719 "host": {"engine_version": "0.0.0", "evidence_refs": []},
720 "forge": {"state": "not-observed", "reason": "not requested"}
721 },
722 "release": {"candidate": {"version": "0.0.0", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1, "artifacts": 0, "evidence_refs": []}, "verification": {"method": "embedded"}, "baseline": {"state": "not-needed"}, "compatibility": {"engine_schema": 1, "bundle_schema": 1, "readable": true}, "guidance": {"coverage": "not-shipped"}},
723 "operations": [],
724 "preconditions": [],
725 "decisions": [],
726 "postconditions": checks,
727 "evidence": [],
728 "readiness": "ready",
729 "input_fingerprint": Digest::of(b"a").to_string()
730 });
731 serde_json::from_value(json).expect("a plan deserializes")
732 }
733
734 #[test]
738 fn postconditions_run_and_a_failure_is_reported() {
739 let dir = tempfile::tempdir().expect("a scratch target exists");
740 let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
741 std::fs::write(target.join("SECURITY.md"), b"held").expect("writes");
742 let plan = plan_with(&[
743 Postcondition::DestinationHolds {
744 path: "SECURITY.md".into(),
745 sha256: Digest::of(b"held"),
746 },
747 Postcondition::DestinationHolds {
748 path: "SECURITY.md".into(),
749 sha256: Digest::of(b"promised"),
750 },
751 Postcondition::RecordReadsBack {
752 sha256: Digest::of(b"record"),
753 },
754 ]);
755 let results: Vec<PostconditionResult> = postconditions(&target, &plan);
756 assert_eq!(results.len(), 3);
757 assert_eq!(results[0].status, "ok");
758 assert_eq!(results[1].status, "failed");
759 assert_eq!(
760 results[1].detail.as_deref(),
761 Some(format!("holds {}", Digest::of(b"held")).as_str())
762 );
763 assert_eq!(results[2].status, "failed");
764 assert_eq!(results[2].detail.as_deref(), Some("absent"));
765 let applied = super::Applied {
766 schema: super::APPLY_SCHEMA,
767 plan_id: "0123456789abcdef".into(),
768 input_fingerprint: Digest::of(b"a"),
769 target: target.to_string(),
770 classification: crate::plan::Classification::Setup,
771 operations: vec![],
772 postconditions: results,
773 run_id: None,
774 next: vec![],
775 };
776 let failure = applied
777 .failure()
778 .expect("a failed postcondition is a failure");
779 assert_eq!(failure.exit_code(), 1);
780 assert_eq!(
781 failure.reason(),
782 crate::diagnostic::Reason::PostconditionFailed
783 );
784 }
785
786 #[test]
788 fn the_apply_report_schema_snapshot_holds() {
789 let applied = super::Applied {
790 schema: super::APPLY_SCHEMA,
791 plan_id: "0123456789abcdef".into(),
792 input_fingerprint: Digest::of(b"a"),
793 target: "/tmp/t".into(),
794 classification: crate::plan::Classification::Upgrade,
795 operations: vec![super::OperationResult {
796 op: "write-record",
797 path: Some(".release-kit/manifest.json".into()),
798 status: "ok",
799 }],
800 postconditions: vec![PostconditionResult {
801 check: "record-reads-back".into(),
802 status: "failed",
803 detail: Some("absent".into()),
804 }],
805 run_id: Some("run".into()),
806 next: vec!["commit the written files, the record included".into()],
807 };
808 assert_eq!(
809 serde_json::to_string(&applied).expect("serializes"),
810 format!(
811 r#"{{"schema":"rk.reconcile-apply/1","plan_id":"0123456789abcdef","input_fingerprint":"{}","target":"/tmp/t","classification":"upgrade","operations":[{{"op":"write-record","path":".release-kit/manifest.json","status":"ok"}}],"postconditions":[{{"check":"record-reads-back","status":"failed","detail":"absent"}}],"run_id":"run","next":["commit the written files, the record included"]}}"#,
812 Digest::of(b"a")
813 )
814 );
815 }
816}