Skip to main content

spec_driven_docs/plan/
apply.rs

1//! Execute exactly one stored plan, or refuse because the world moved.
2//!
3//! An apply that recomputed its intent from the tree could act on
4//! something the operator never saw. So it reads the plan that was
5//! reviewed, re-observes the target under the exclusive lock, recomputes
6//! the fingerprint, and compares. A difference is a refusal that names
7//! what moved, never a silent re-plan.
8//!
9//! The execution is the transaction the skill installer already proved:
10//! stage beside each destination, journal before the first replacement,
11//! replace one file at a time, write the record last. A run that does not
12//! finish leaves a journal the next invocation resolves before it plans
13//! anything new.
14
15use std::collections::BTreeMap;
16
17use camino::{Utf8Path, Utf8PathBuf};
18
19use crate::domain::ownership::Sha256;
20use crate::error::AppError;
21use crate::plan::Plan;
22use crate::plan::operation::Operation;
23use crate::plan::readiness::{Readiness, Requirement};
24use crate::plan::store::{
25    Disposition, OperationOutcome, PostconditionOutcome, RESULT_SCHEMA, Result as ApplyResult,
26    Store,
27};
28use crate::transaction::journal::{self, Entry, Journal};
29use crate::transaction::stage::Stage;
30
31/// What an apply is given.
32pub struct Request<'a> {
33    /// Where the plan lives.
34    pub store: &'a Store,
35    /// The repository the plan is for.
36    pub target: &'a Utf8Path,
37    /// The plan that was approved.
38    pub stored: &'a Plan,
39    /// The same plan, computed again from the same inputs, just now.
40    pub recomputed: &'a Plan,
41    /// The release the plan froze, for the verification postcondition.
42    pub bundle: &'a dyn crate::release::ReleaseBundle,
43    /// The clock, as an input.
44    pub now: String,
45}
46
47/// Every semantic input that moved between the plan and the apply.
48///
49/// Collected in one pass rather than reported one at a time, so an
50/// operator learns the whole difference from one refusal.
51#[must_use]
52pub fn moved(stored: &Plan, recomputed: &Plan) -> Vec<String> {
53    let mut moved = Vec::new();
54    if stored.classification != recomputed.classification {
55        moved.push(format!(
56            "the target is now {} and the plan described {}",
57            recomputed.classification, stored.classification
58        ));
59    }
60    if stored.desired_state.release_sha256 != recomputed.desired_state.release_sha256 {
61        moved.push("the release the plan resolved is no longer the one it resolved".to_string());
62    }
63    let record = |plan: &Plan| {
64        plan.observed_state
65            .installation
66            .as_ref()
67            .map(|installation| installation.record_sha256.to_string())
68    };
69    if record(stored) != record(recomputed) {
70        moved.push("the instance record changed".to_string());
71    }
72    let declaration = |plan: &Plan| {
73        plan.observed_state
74            .installation
75            .as_ref()
76            .and_then(|installation| installation.declaration_sha256.clone())
77    };
78    if declaration(stored) != declaration(recomputed) {
79        moved.push("the project's declaration changed".to_string());
80    }
81    for operation in &stored.operations {
82        // The record restates the others and carries the moment of
83        // installation, so a difference in it alone is not the world
84        // moving. The operations it summarizes are checked below.
85        if matches!(operation, Operation::WriteRecord { .. }) {
86            continue;
87        }
88        let found = recomputed
89            .operations
90            .iter()
91            .find(|other| other.path() == operation.path());
92        match found {
93            Some(other) if other == operation => {}
94            Some(_) => moved.push(format!(
95                "{} no longer needs what the plan described",
96                operation.path()
97            )),
98            None => moved.push(format!(
99                "{} is no longer part of the plan",
100                operation.path()
101            )),
102        }
103    }
104    for operation in &recomputed.operations {
105        if matches!(operation, Operation::WriteRecord { .. }) {
106            continue;
107        }
108        if !stored
109            .operations
110            .iter()
111            .any(|other| other.path() == operation.path())
112        {
113            moved.push(format!("{} is newly part of the plan", operation.path()));
114        }
115    }
116    if selected_answers(stored) != selected_answers(recomputed) {
117        moved.push("a selected decision changed".to_string());
118    }
119    moved
120}
121
122/// What the operator answered, by decision.
123fn selected_answers(plan: &Plan) -> BTreeMap<&str, &str> {
124    plan.decisions
125        .iter()
126        .filter_map(|decision| {
127            decision
128                .selected
129                .as_deref()
130                .map(|answer| (decision.id.as_str(), answer))
131        })
132        .collect()
133}
134
135/// Why an apply refused, from the closed set.
136fn refuse(reason: &str) -> AppError {
137    AppError::Refused(reason.to_string())
138}
139
140/// Execute one stored plan.
141///
142/// # Errors
143///
144/// [`AppError::Refused`] when the plan is not ready, when a semantic input
145/// moved, or when an operation could not be applied and the target was put
146/// back. [`AppError::Unrecovered`] when a run could not be put back.
147pub fn apply(request: &Request<'_>) -> std::result::Result<ApplyResult, AppError> {
148    let Request {
149        store,
150        target,
151        stored,
152        recomputed,
153        bundle,
154        now,
155    } = request;
156    let directory = store.directory(&stored.identity.plan_id);
157
158    // Recover before anything else. A run that did not finish is resolved
159    // deterministically before another plan is even considered.
160    journal::recover(&directory.journal)?;
161
162    // The fingerprint is the identity an approval bound to, so it decides.
163    // `moved` runs only to explain a difference the digest already proved,
164    // and it never decides on its own: a projection field it does not
165    // restate, the target's own path among them, would otherwise let a
166    // plan approved for one repository execute against another.
167    let mut differences = Vec::new();
168    if stored.input_fingerprint != recomputed.input_fingerprint {
169        differences = moved(stored, recomputed);
170        if differences.is_empty() {
171            differences.push(format!(
172                "the plan's inputs no longer hash to {}",
173                stored.identity.plan_id
174            ));
175        }
176    }
177    if !differences.is_empty() {
178        let result = terminal(
179            stored,
180            now,
181            Disposition::Invalidated,
182            &format!(
183                "the plan no longer describes the target: {}",
184                differences.join("; ")
185            ),
186        );
187        store.record(stored, &result)?;
188        return Err(refuse(&result.reason));
189    }
190
191    match stored.readiness {
192        Readiness::Ready => {}
193        Readiness::NeedsDecision => {
194            let waiting: Vec<&str> = stored
195                .decisions
196                .iter()
197                .filter(|decision| decision.selected.is_none())
198                .map(|decision| decision.id.as_str())
199                .collect();
200            return Err(refuse(&format!(
201                "the plan waits on a decision: {}; answer it with --set and plan again",
202                waiting.join(", ")
203            )));
204        }
205        Readiness::Blocked => {
206            let blocked: Vec<&str> = stored
207                .preconditions
208                .iter()
209                .filter(|precondition| {
210                    precondition.requirement == Requirement::Required
211                        && !precondition.evaluation.is_satisfied()
212                })
213                .map(|precondition| precondition.id.as_str())
214                .collect();
215            return Err(refuse(&format!(
216                "the plan is blocked by {}",
217                blocked.join(", ")
218            )));
219        }
220    }
221
222    if stored.operations.is_empty() {
223        // A plan with nothing to write still proves what it claims. A
224        // target that already holds every byte can still fail its own
225        // verification, and reporting success without looking would put
226        // that claim in the result unchecked.
227        let postconditions = prove(target, stored, *bundle);
228        let failed: Vec<&PostconditionOutcome> =
229            postconditions.iter().filter(|held| !held.held).collect();
230        let (disposition, reason) = failed.first().map_or_else(
231            || {
232                (
233                    Disposition::Succeeded,
234                    "the target already holds what the plan describes".to_string(),
235                )
236            },
237            |first| {
238                (
239                    Disposition::Retryable,
240                    format!(
241                        "apply aborted: the postcondition {} did not hold: {}",
242                        first.id,
243                        first.detail.clone().unwrap_or_default()
244                    ),
245                )
246            },
247        );
248        let result = ApplyResult {
249            postconditions,
250            ..terminal(stored, now, disposition, &reason)
251        };
252        store.record(stored, &result)?;
253        if disposition == Disposition::Succeeded {
254            return Ok(result);
255        }
256        return Err(refuse(&result.reason));
257    }
258
259    execute(store, target, stored, *bundle, now)
260}
261
262/// Stage, journal, replace, and prove.
263fn execute(
264    store: &Store,
265    target: &Utf8Path,
266    plan: &Plan,
267    bundle: &dyn crate::release::ReleaseBundle,
268    now: &str,
269) -> std::result::Result<ApplyResult, AppError> {
270    let directory = store.directory(&plan.identity.plan_id);
271    let (entries, planned) = stage_every_operation(store, target, plan)?;
272
273    let mut journal = Journal::begin(&directory.journal, &directory.blobs, entries)?;
274    let mut outcomes = Vec::new();
275    let mut affected = Vec::new();
276    let mut notes: Vec<String> = Vec::new();
277    for ((destination, bytes), operation) in planned.iter().zip(ordered(plan)) {
278        // Every fallible step after the journal opened routes through
279        // this one result. A `?` here would return with the journal
280        // outstanding and the operations before it still applied, which
281        // is the state the journal exists to prevent.
282        let done = contained(target, operation.path().as_path())
283            .and_then(|()| {
284                bytes.as_ref().map_or_else(
285                    || remove(target, destination),
286                    |bytes| {
287                        Stage::write(destination, bytes)
288                            .and_then(|scratch| Stage::replace(&scratch, destination))
289                            .map(|()| None)
290                    },
291                )
292            })
293            .and_then(|note| journal.mark_done(destination).map(|()| note));
294        let note = match done {
295            Ok(note) => note,
296            Err(cause) => {
297                let reason = format!("{} could not be written: {cause}", operation.path());
298                return Err(undo(store, plan, &journal, now, &reason, None));
299            }
300        };
301        if let Some(note) = note {
302            notes.push(note);
303        }
304        outcomes.push(OperationOutcome {
305            kind: operation.kind().to_string(),
306            path: operation.path().as_str().to_string(),
307            applied: true,
308            refusal: None,
309        });
310        affected.push(operation.path().as_str().to_string());
311    }
312
313    let postconditions = prove(target, plan, bundle);
314    if let Some(first) = postconditions.iter().find(|held| !held.held) {
315        let reason = format!(
316            "apply aborted: the postcondition {} did not hold: {}",
317            first.id,
318            first.detail.clone().unwrap_or_default()
319        );
320        return Err(undo(
321            store,
322            plan,
323            &journal,
324            now,
325            &reason,
326            Some(postconditions),
327        ));
328    }
329
330    // The journal is the last thing to go. Removing it is what ends the
331    // run; a failure to remove it leaves a record the next invocation
332    // rolls back, so it is a refusal. A failure to sync after the removal
333    // is not: the target already holds what the plan described, and
334    // undoing a landing that worked because a directory sync failed would
335    // trade a durability note for real lost work.
336    if let Err(cause) = journal.finish()
337        && directory.journal.exists()
338    {
339        let reason = format!("the journal could not be closed: {cause}");
340        return Err(undo(store, plan, &journal, now, &reason, None));
341    }
342
343    let reason = if notes.is_empty() {
344        "every operation landed".to_string()
345    } else {
346        format!("every operation landed; {}", notes.join("; "))
347    };
348    let result = ApplyResult {
349        operations: outcomes,
350        postconditions,
351        affected,
352        ..terminal(plan, now, Disposition::Succeeded, &reason)
353    };
354    // The target holds what the plan described and the journal is gone, so
355    // the run succeeded whether or not its record can be written. A store
356    // that refuses is reported and does not undo a landing that worked.
357    if let Err(cause) = store.record(plan, &result) {
358        return Err(AppError::Unrecovered(format!(
359            "the landing succeeded and its result could not be recorded: {cause}"
360        )));
361    }
362    Ok(result)
363}
364
365/// One destination and the bytes to put there, or nothing where it goes.
366type Staged = (Utf8PathBuf, Option<Vec<u8>>);
367
368/// Back up every destination and read every byte the plan will write.
369///
370/// Everything is in hand before the journal exists, so a failure here has
371/// nothing to roll back.
372fn stage_every_operation(
373    store: &Store,
374    target: &Utf8Path,
375    plan: &Plan,
376) -> std::result::Result<(Vec<Entry>, Vec<Staged>), AppError> {
377    let directory = store.directory(&plan.identity.plan_id);
378    let stage = Stage::new(&directory.blobs)?;
379    let mut entries = Vec::new();
380    let mut planned: Vec<Staged> = Vec::new();
381    for operation in ordered(plan) {
382        // A validated target-relative path is not containment. A directory
383        // along the way can be a symlink out of the repository, and a
384        // rename through one writes wherever it points. The check runs
385        // before anything is read or staged, so a refusal leaves the whole
386        // target untouched.
387        contained(target, operation.path().as_path())?;
388        let destination = target.join(operation.path().as_path());
389        let before = stage.back_up(&destination)?;
390        match operation.after() {
391            Some(after) => {
392                let bytes = store.blob(&plan.identity.plan_id, after)?;
393                entries.push(Entry::write(destination.clone(), before, after.clone()));
394                planned.push((destination, Some(bytes)));
395            }
396            None => {
397                if let Some(before) = before {
398                    entries.push(Entry::remove(destination.clone(), before));
399                    planned.push((destination, None));
400                }
401            }
402        }
403    }
404    Ok((entries, planned))
405}
406
407/// Put the target back, record what the attempt achieved, and say so.
408///
409/// One handler for every failure after the journal opened. Recording is
410/// best effort: the run already failed, and a store that cannot be written
411/// does not change what the target holds. What the caller must learn is
412/// whether the rollback took.
413fn undo(
414    store: &Store,
415    plan: &Plan,
416    journal: &Journal,
417    now: &str,
418    reason: &str,
419    postconditions: Option<Vec<PostconditionOutcome>>,
420) -> AppError {
421    let restored = journal.roll_back();
422    let disposition = if restored.is_ok() {
423        Disposition::Retryable
424    } else {
425        Disposition::RecoveryRequired
426    };
427    let result = ApplyResult {
428        postconditions: postconditions.unwrap_or_default(),
429        ..terminal(plan, now, disposition, reason)
430    };
431    let _ = store.record(plan, &result);
432    restored.err().map_or_else(
433        || refuse(&format!("{reason}; the target was put back")),
434        |failure| {
435            AppError::Unrecovered(format!(
436                "{reason}; the target could not be put back: {failure}"
437            ))
438        },
439    )
440}
441
442/// Take one destination away, treating an absent one as already gone.
443///
444/// A removal that empties the directory it lived in takes that directory
445/// too, where the directory is one the canon owns. A skill is a directory
446/// holding one file, and an empty directory carrying a retired skill's
447/// name is one some agents still list. The sweep runs here, inside the
448/// lock and the journal, because a directory is not a file and no
449/// operation names one.
450fn remove(
451    target: &Utf8Path,
452    destination: &Utf8Path,
453) -> std::result::Result<Option<String>, AppError> {
454    match std::fs::remove_file(destination) {
455        Ok(()) => {
456            crate::transaction::sync_parent(destination).map_err(AppError::Io)?;
457            Ok(sweep_emptied_parent(target, destination))
458        }
459        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
460        Err(source) => Err(AppError::Io(source)),
461    }
462}
463
464/// Remove the directory a removal emptied, never a root the canon owns.
465fn sweep_emptied_parent(target: &Utf8Path, destination: &Utf8Path) -> Option<String> {
466    let parent = destination.parent()?;
467    let relative = parent.strip_prefix(target).ok()?;
468    let owned = crate::domain::paths::PRUNABLE_ROOTS
469        .iter()
470        .any(|root| relative.as_str().starts_with(root.trim_end_matches('/')));
471    if !owned
472        || crate::domain::paths::PRUNABLE_ROOTS
473            .iter()
474            .any(|root| relative.as_str() == root.trim_end_matches('/'))
475    {
476        return None;
477    }
478    if std::fs::read_dir(parent).is_ok_and(|mut entries| entries.next().is_none()) {
479        // Reported rather than swallowed: a directory that stays is one
480        // an agent's picker may still list, and the operator has to know
481        // to remove it. It is not a reason to fail a landing that worked.
482        if let Err(cause) = std::fs::remove_dir(parent) {
483            return Some(format!(
484                "{relative} is empty and could not be removed: {cause}; remove it by hand"
485            ));
486        }
487    }
488    None
489}
490
491/// Refuse a destination that leaves the target.
492///
493/// The same guard the landing verbs run, applied to every operation of
494/// every kind: write, splice, and removal alike.
495///
496/// # Errors
497///
498/// [`AppError::Refused`] naming the destination and what was wrong.
499fn contained(target: &Utf8Path, relative: &Utf8Path) -> std::result::Result<(), AppError> {
500    crate::adapters::fs::check_destination(target, relative).map_err(|refusal| {
501        AppError::Refused(match refusal {
502            crate::adapters::fs::DestinationRefusal::SymlinkEscape => {
503                format!("destination escapes the target through a symlink: {relative}")
504            }
505            crate::adapters::fs::DestinationRefusal::FileBlocksDirectory(blocked) => {
506                format!("a file blocks a directory the plan needs: {blocked}")
507            }
508            crate::adapters::fs::DestinationRefusal::NotARegularFile => {
509                format!("destination exists and is not a regular file: {relative}")
510            }
511        })
512    })
513}
514
515/// The order the apply writes in.
516///
517/// The record is last, because it is the claim that the rest landed. A run
518/// the process did not finish is rolled back whole, this operation with
519/// it, so a target never carries a record for files it does not hold.
520fn ordered(plan: &Plan) -> Vec<&Operation> {
521    let mut ordered: Vec<&Operation> = plan
522        .operations
523        .iter()
524        .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
525        .collect();
526    ordered.extend(
527        plan.operations
528            .iter()
529            .filter(|operation| matches!(operation, Operation::WriteRecord { .. })),
530    );
531    ordered
532}
533
534/// What the apply proves once every operation has landed.
535fn prove(
536    target: &Utf8Path,
537    plan: &Plan,
538    bundle: &dyn crate::release::ReleaseBundle,
539) -> Vec<PostconditionOutcome> {
540    plan.postconditions
541        .iter()
542        .map(|postcondition| match postcondition.id.as_str() {
543            "record-matches-the-tree" => {
544                let wrong: Vec<String> = plan
545                    .operations
546                    .iter()
547                    .filter_map(|operation| {
548                        let destination = target.join(operation.path().as_path());
549                        let found = std::fs::read(&destination)
550                            .ok()
551                            .map(|bytes| Sha256::of(&bytes));
552                        (found.as_ref() != operation.after()).then(|| operation.path().to_string())
553                    })
554                    .collect();
555                PostconditionOutcome {
556                    id: postcondition.id.clone(),
557                    held: wrong.is_empty(),
558                    detail: (!wrong.is_empty()).then(|| {
559                        format!(
560                            "these destinations do not hold the plan's digest: {}",
561                            wrong.join(", ")
562                        )
563                    }),
564                }
565            }
566            "verification-passes" => {
567                let report = crate::services::verifier::verify(target, bundle);
568                let detail = match &report {
569                    Ok(report) if report.failures == 0 => None,
570                    Ok(report) => Some(format!(
571                        "sdd verify reports {} failure(s): {}",
572                        report.failures,
573                        report.lines.join("; ")
574                    )),
575                    Err(source) => Some(format!("sdd verify could not run: {source}")),
576                };
577                PostconditionOutcome {
578                    id: postcondition.id.clone(),
579                    held: detail.is_none(),
580                    detail,
581                }
582            }
583            // A postcondition nobody implemented is not a postcondition
584            // that held. Reporting it as proved would put a claim in the
585            // result that nothing behind it ever checked.
586            other => PostconditionOutcome {
587                id: other.to_string(),
588                held: false,
589                detail: Some(format!("{other} has no check behind it in this engine")),
590            },
591        })
592        .collect()
593}
594
595/// One result, with everything but the outcome lists filled in.
596fn terminal(plan: &Plan, now: &str, disposition: Disposition, reason: &str) -> ApplyResult {
597    ApplyResult {
598        schema: RESULT_SCHEMA.to_string(),
599        plan_id: plan.identity.plan_id.clone(),
600        fingerprint: plan.input_fingerprint.clone(),
601        result_id: format!(
602            "{}-{}",
603            now.replace([':', '.'], "-"),
604            disposition_slug(disposition)
605        ),
606        disposition,
607        finished_at: now.to_string(),
608        operations: Vec::new(),
609        postconditions: Vec::new(),
610        recovery_required: disposition == Disposition::RecoveryRequired,
611        affected: Vec::new(),
612        reason: reason.to_string(),
613    }
614}
615
616const fn disposition_slug(disposition: Disposition) -> &'static str {
617    match disposition {
618        Disposition::Succeeded => "succeeded",
619        Disposition::Invalidated => "invalidated",
620        Disposition::Retryable => "retryable",
621        Disposition::RecoveryRequired => "recovery-required",
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    #![allow(
628        clippy::unwrap_used,
629        reason = "a test panics as its failure signal, not as control flow"
630    )]
631
632    use super::*;
633    use crate::plan::operation::{Class, TargetPath};
634
635    fn write(path: &str, after: &[u8]) -> Operation {
636        Operation::WriteFile {
637            path: TargetPath::new(path).unwrap(),
638            class: Class::Managed,
639            before: None,
640            after: Sha256::of(after),
641        }
642    }
643
644    fn record(path: &str, after: &[u8]) -> Operation {
645        Operation::WriteRecord {
646            path: TargetPath::new(path).unwrap(),
647            before: None,
648            after: Sha256::of(after),
649        }
650    }
651
652    fn plan_with(operations: Vec<Operation>) -> Plan {
653        let mut plan = crate::plan::planner::plan(&crate::plan::planner::Inputs {
654            observation: &crate::plan::observe::Observation {
655                repository: crate::plan::observe::Repository {
656                    root: Utf8PathBuf::from("/nowhere"),
657                    version_controlled: true,
658                    empty: true,
659                },
660                installation: None,
661                invalid: None,
662                host: crate::plan::observe::Host {
663                    offline: true,
664                    cache_root: None,
665                },
666                corpus: crate::plan::observe::Corpus::default(),
667            },
668            declaration: &crate::domain::profile::DECLARATION,
669            candidate: &BTreeMap::new(),
670            baseline: None,
671            selector: "embedded".to_string(),
672            release: "0.0.0".to_string(),
673            release_sha256: Sha256::of(b"release"),
674            provenance: "native".to_string(),
675            registry_checksum: None,
676            yanked: false,
677            compatibility: None,
678            interval: None,
679            briefing: None,
680            proposed: None,
681            selections: &crate::plan::decision::Selections::new(),
682            budget: &[],
683            reserve: &[],
684            declared: None,
685            declarations_settled: false,
686            now: "2026-09-12T00:00:00Z".to_string(),
687        });
688        plan.operations = operations;
689        plan
690    }
691
692    #[test]
693    fn the_record_is_written_last() {
694        let plan = plan_with(vec![
695            record(".spec-driven-docs/manifest.json", b"record"),
696            write("a.md", b"a"),
697            write("b.md", b"b"),
698        ]);
699        let order: Vec<&str> = ordered(&plan)
700            .iter()
701            .map(|operation| operation.path().as_str())
702            .collect();
703        assert_eq!(order, ["a.md", "b.md", ".spec-driven-docs/manifest.json"]);
704    }
705
706    #[test]
707    fn nothing_moved_reports_no_difference() {
708        let plan = plan_with(vec![write("a.md", b"a")]);
709        assert!(moved(&plan, &plan).is_empty());
710    }
711
712    #[test]
713    fn a_changed_operation_is_named_by_its_destination() {
714        let one = plan_with(vec![write("a.md", b"a")]);
715        let two = plan_with(vec![write("a.md", b"different")]);
716        let differences = moved(&one, &two);
717        assert_eq!(differences.len(), 1);
718        assert!(differences[0].contains("a.md"), "{differences:?}");
719    }
720
721    #[test]
722    fn an_added_or_dropped_operation_is_named() {
723        let one = plan_with(vec![write("a.md", b"a")]);
724        let two = plan_with(vec![write("a.md", b"a"), write("b.md", b"b")]);
725        assert!(moved(&one, &two).iter().any(|held| held.contains("newly")));
726        assert!(
727            moved(&two, &one)
728                .iter()
729                .any(|held| held.contains("no longer part"))
730        );
731    }
732}