Skip to main content

spec_driven_docs/plan/
session.rs

1//! One planning session, from an observed target to a stored plan.
2//!
3//! Both fronts reach the engine through this module: `sdd reconcile` and
4//! the compatibility verbs alike. It exists so there is one place that
5//! turns a target and a set of answers into a plan, and one place that
6//! stores and executes one. A second path here would be the drift the
7//! plan model exists to remove.
8
9use std::collections::BTreeMap;
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{PlanZone, parse_docs_scratch};
14use crate::domain::ownership::Sha256;
15use crate::domain::paths::UserEnv;
16use crate::domain::profile::ProfileId;
17use crate::error::AppError;
18use crate::plan::apply::{Request, apply as execute};
19use crate::plan::observe::observe;
20use crate::plan::planner::{Inputs, plan as compute};
21use crate::plan::store::{Result as ApplyResult, Store};
22use crate::plan::{Plan, compatibility, decision, guidance};
23use crate::release::crates_io::CratesIoResolver;
24use crate::release::embedded::EmbeddedReleaseBundle;
25use crate::release::{Provenance, ReleaseBundle, ReleaseResolver, Role, Selector};
26use crate::services::installer::{InitOptions, compute_target_state};
27use crate::transaction::lock::Lock;
28
29/// How long a verb waits for the store before it refuses.
30///
31/// The store's critical section is a prune and a directory write, so a
32/// second writer queues behind it rather than failing. A wait this long
33/// running out means a holder died, which is worth reporting.
34pub(crate) const STORE_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
35
36/// What the caller asked for.
37fn selector(value: &str) -> Result<Selector, AppError> {
38    match value {
39        "embedded" => Ok(Selector::Embedded),
40        "latest" => Ok(Selector::Latest),
41        version => version.parse().map(Selector::Exact).map_err(|_| {
42            AppError::Usage(format!(
43                "--to takes embedded, latest, or a semantic version; {version} is none of those"
44            ))
45        }),
46    }
47}
48
49/// Where this tool keeps state that outlives a command.
50pub(crate) fn state_root() -> Result<Utf8PathBuf, AppError> {
51    Ok(UserEnv::from_process()
52        .state_root()
53        .ok_or_else(|| AppError::Usage("no state root resolves".to_string()))?
54        .path)
55}
56
57/// The lock one target takes, keyed by where it is.
58///
59/// A digest rather than the path itself: a lock file named after a
60/// repository would put a person's directory layout in the state root, and
61/// two targets whose paths differ only in case would collide.
62pub(crate) fn target_lock(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
63    let key = Sha256::of(target.as_str().as_bytes());
64    Ok(state_root()?.join("locks").join(format!("{key}.lock")))
65}
66
67/// One release, read through the seam.
68pub(crate) struct Release {
69    pub(crate) bundle: Box<dyn ReleaseBundle>,
70    pub(crate) version: String,
71    pub(crate) provenance: String,
72    pub(crate) checksum: Option<Sha256>,
73    pub(crate) yanked: bool,
74}
75
76pub(crate) fn read_release(to: &str, offline: bool) -> Result<Release, AppError> {
77    match selector(to)? {
78        Selector::Embedded => Ok(Release {
79            bundle: Box::new(EmbeddedReleaseBundle::new()),
80            version: crate::domain::version::CanonVersion::current().to_string(),
81            provenance: "native".to_string(),
82            checksum: None,
83            yanked: false,
84        }),
85        chosen => {
86            let cache = UserEnv::from_process()
87                .user_paths()
88                .ok_or_else(|| AppError::Usage("no cache root resolves".to_string()))?
89                .bundle_cache
90                .path;
91            let resolved = CratesIoResolver::new(&cache)
92                .offline(offline)
93                .resolve(&chosen)?;
94            let manifest = resolved.bundle.manifest()?;
95            let provenance = match manifest.provenance {
96                Provenance::Native => "native",
97                Provenance::LegacyAdapted => "legacy-adapted",
98            };
99            Ok(Release {
100                bundle: resolved.bundle,
101                version: resolved.version.to_string(),
102                provenance: provenance.to_string(),
103                checksum: resolved.registry_checksum,
104                yanked: resolved.yanked,
105            })
106        }
107    }
108}
109
110/// One release a caller already holds, borrowed.
111///
112/// A front resolves its bundle once and hands the same object over, so
113/// the release its preview described is the release that lands. An owned
114/// [`Release`] lends one of these; a caller holding only a bundle builds
115/// one directly.
116#[derive(Clone)]
117pub(crate) struct ReleaseRef<'a> {
118    /// The bytes, through the seam.
119    pub bundle: &'a dyn ReleaseBundle,
120    /// The exact release this is.
121    pub version: String,
122    /// Where its facts came from.
123    pub provenance: &'static str,
124    /// The registry checksum, where one was read.
125    pub checksum: Option<Sha256>,
126    /// Whether the registry marks it withdrawn.
127    pub yanked: bool,
128}
129
130impl<'a> ReleaseRef<'a> {
131    /// Describe a bundle a caller already holds.
132    ///
133    /// # Errors
134    ///
135    /// Whatever the bundle's manifest refuses.
136    pub(crate) fn of(bundle: &'a dyn ReleaseBundle) -> Result<Self, AppError> {
137        let manifest = bundle.manifest()?;
138        Ok(Self {
139            bundle,
140            version: manifest.version.to_string(),
141            provenance: match manifest.provenance {
142                Provenance::Native => "native",
143                Provenance::LegacyAdapted => "legacy-adapted",
144            },
145            checksum: None,
146            yanked: false,
147        })
148    }
149}
150
151impl Release {
152    /// Lend this release to the planner.
153    pub(crate) fn borrow(&self) -> ReleaseRef<'_> {
154        ReleaseRef {
155            bundle: self.bundle.as_ref(),
156            version: self.version.clone(),
157            provenance: if self.provenance == "native" {
158                "native"
159            } else {
160                "legacy-adapted"
161            },
162            checksum: self.checksum.clone(),
163            yanked: self.yanked,
164        }
165    }
166}
167
168/// What every release in the interval asks of this target.
169///
170/// The vocabulary a step filters against is the destination set a landed
171/// instance has, so a step about something the target did not install is
172/// excluded rather than shown.
173fn read_briefing(
174    bundle: &dyn ReleaseBundle,
175    payload_schema: u32,
176    recorded: Option<crate::domain::version::CanonVersion>,
177    destination: crate::domain::version::CanonVersion,
178    selections: &decision::Selections,
179) -> Result<Option<guidance::Briefing>, AppError> {
180    // The same rule the compatibility declaration takes. A release that
181    // owes a ledger and carries none would otherwise plan ready with no
182    // breaking step raised, which is the one outcome the ledger exists to
183    // prevent. Only a release from before the declaration may be silent.
184    let bytes = match bundle.artifact(guidance::INDEX_PATH) {
185        Ok(bytes) => bytes,
186        Err(_) if payload_schema == 0 => return Ok(None),
187        Err(source) => {
188            return Err(AppError::Refused(format!(
189                "this release declares payload schema {payload_schema} and its {} could not be read: {source}",
190                guidance::INDEX_PATH
191            )));
192        }
193    };
194    let index =
195        guidance::Index::parse(&bytes).map_err(|error| AppError::Refused(error.to_string()))?;
196    let bodies: Vec<String> = bundle
197        .manifest()?
198        .artifacts
199        .iter()
200        .map(|artifact| artifact.path.clone())
201        .collect();
202    let mut files = Vec::new();
203    for entry in index.interval(recorded, destination) {
204        if entry.guidance == guidance::NONE {
205            continue;
206        }
207        let path = format!("guidance/{}", entry.guidance);
208        let bytes = bundle.artifact(&path)?;
209        let held = guidance::Guidance::parse(&path, &bytes, &bodies)
210            .map_err(|error| AppError::Refused(error.to_string()))?;
211        files.push((entry.version, held));
212    }
213    // Every destination a landed instance has. An unlanded target has
214    // none, so nothing filters through and the briefing is empty.
215    let held: Vec<String> = if recorded.is_some() {
216        guidance::DESTINATIONS
217            .iter()
218            .map(|destination| (*destination).to_string())
219            .collect()
220    } else {
221        Vec::new()
222    };
223    Ok(Some(guidance::brief(
224        &index, &files, recorded, &held, selections,
225    )))
226}
227
228/// Read the declarations one landing would record, from the answers given.
229fn landing_options(
230    target: &Utf8Path,
231    profile: ProfileId,
232    selections: &decision::Selections,
233    reserve: &[String],
234) -> Result<InitOptions, AppError> {
235    let plan_zone = selections
236        .get(decision::id::PLAN_ZONE)
237        .map(|answer| PlanZone::parse(answer.strip_prefix("project:").unwrap_or(answer)))
238        .transpose()
239        .map_err(|error| AppError::Usage(format!("--set plan-zone: {error}")))?;
240    let docs_scratch = selections
241        .get(decision::id::DOCS_SCRATCH)
242        .map(|answer| {
243            let bare = answer
244                .strip_prefix("project:")
245                .or_else(|| answer.strip_prefix("external:"))
246                .unwrap_or(answer);
247            parse_docs_scratch(bare)
248        })
249        .transpose()
250        .map_err(|error| AppError::Usage(format!("--set docs-scratch: {error}")))?;
251    let writing_style = selections
252        .get(decision::id::WRITING_STYLE)
253        .map(|answer| crate::domain::instance_config::WritingStyle::parse_flag(answer))
254        .transpose()
255        .map_err(|error| AppError::Usage(format!("--set writing-style: {error}")))?;
256    Ok(InitOptions {
257        target: target.to_owned(),
258        profile,
259        apply: false,
260        dry_run: true,
261        plan_zone,
262        docs_scratch,
263        reserve: reserve.to_vec(),
264        writing_style,
265    })
266}
267
268/// What the record holds now, which is what a removal can take back.
269fn recorded_managed(
270    observation: &crate::plan::observe::Observation,
271) -> Vec<(String, crate::domain::ownership::Sha256)> {
272    observation
273        .installation
274        .as_ref()
275        .map(|installed| {
276            installed
277                .managed
278                .iter()
279                .map(|file| (file.path.as_str().to_string(), file.recorded.clone()))
280                .collect()
281        })
282        .unwrap_or_default()
283}
284
285/// What the destination needs of this engine.
286///
287/// A schema-one bundle without the declaration is invalid: an absence
288/// meaning "no requirement" cannot be told from an absence meaning
289/// somebody forgot.
290///
291/// # Errors
292///
293/// [`AppError::Refused`] when the declaration does not parse, or when a
294/// bundle that owes one carries none.
295fn read_compatibility(
296    release: &ReleaseRef<'_>,
297    payload_schema: u32,
298) -> Result<Option<compatibility::Compatibility>, AppError> {
299    match release.bundle.artifact(compatibility::DECLARATION_PATH) {
300        Ok(bytes) => compatibility::Compatibility::parse(&bytes)
301            .map(Some)
302            .map_err(|error| AppError::Refused(error.to_string())),
303        Err(_) if payload_schema == 0 => Ok(None),
304        Err(_) => Err(AppError::Refused(format!(
305            "release {} declares payload schema {payload_schema} and carries no {}",
306            release.version,
307            compatibility::DECLARATION_PATH
308        ))),
309    }
310}
311
312/// What the interval between the record and the destination is.
313///
314/// # Errors
315///
316/// [`AppError::Refused`] when the release is not a version triple.
317type Interval = (
318    Option<crate::domain::version::CanonVersion>,
319    crate::domain::version::CanonVersion,
320    Option<compatibility::Interval>,
321);
322
323fn interval_of(
324    observation: &crate::plan::observe::Observation,
325    release: &ReleaseRef<'_>,
326    compatibility: Option<&compatibility::Compatibility>,
327) -> Result<Interval, AppError> {
328    let recorded = observation
329        .installation
330        .as_ref()
331        .map(|installed| installed.canon_version);
332    let destination: crate::domain::version::CanonVersion = release
333        .version
334        .parse()
335        .map_err(|_| AppError::Refused(format!("{} is not a released triple", release.version)))?;
336    let interval = compatibility.map(|_| compatibility::Interval {
337        engine: crate::domain::version::CanonVersion::current(),
338        recorded,
339        destination,
340    });
341    Ok((recorded, destination, interval))
342}
343
344/// Every payload artifact this release carries, by path.
345fn payload_digests(manifest: &crate::release::ReleaseManifest) -> BTreeMap<String, Sha256> {
346    manifest
347        .artifacts
348        .iter()
349        .filter(|artifact| artifact.role == Role::Payload)
350        .map(|artifact| (artifact.path.clone(), artifact.sha256.clone()))
351        .collect()
352}
353
354/// What a front's own flags will put in the record, in one line.
355///
356/// Nothing else in the plan says it: the record's digest cannot go in the
357/// fingerprint, and no other operation carries a declared location.
358fn declared_summary_of(options: &InitOptions) -> String {
359    format!(
360        "plan-zone={:?};docs-scratch={:?};writing-style={:?}",
361        options.plan_zone, options.docs_scratch, options.writing_style
362    )
363}
364
365/// Everything the landing derivation reads.
366struct Derivation<'a> {
367    target: &'a Utf8Path,
368    profile: ProfileId,
369    declaration: &'a crate::domain::projection::Declaration,
370    observation: &'a crate::plan::observe::Observation,
371    selections: &'a decision::Selections,
372    reserve: &'a [String],
373    declared: Option<&'a InitOptions>,
374    budget: &'a [crate::domain::debt::Measurement],
375}
376
377/// Every write one landing implies, and the bytes each one lands.
378///
379/// The installer owns what a release lands, so this takes its answer and
380/// says what kind of write each destination is, then adds the one write
381/// an operator has to ask for.
382///
383/// # Errors
384///
385/// Whatever the installer or the derivation refuses.
386fn derive_landing(
387    from: &Derivation<'_>,
388    release: &ReleaseRef<'_>,
389) -> Result<crate::plan::derive::Derived, AppError> {
390    let options = match from.declared {
391        Some(held) => held.clone(),
392        None => landing_options(from.target, from.profile, from.selections, from.reserve)?,
393    };
394    let state = compute_target_state(from.target, &options, release.bundle)?;
395    let mut derived = crate::plan::derive::operations_for(
396        from.target,
397        &state.files,
398        from.declaration,
399        from.profile,
400        &recorded_managed(from.observation),
401    )?;
402    // The inherited violations become a ceiling only where the operator
403    // asked for that. A version moving never records one.
404    if from
405        .selections
406        .get(decision::id::DEBT_BASELINE)
407        .map(String::as_str)
408        == Some("record")
409        && let Some((operation, bytes)) =
410            crate::plan::derive::debt_operation(from.target, from.budget)?
411    {
412        derived.1.insert(Sha256::of(&bytes), bytes);
413        derived.0.push(operation);
414    }
415    Ok(derived)
416}
417
418/// Compute one plan against one target.
419///
420/// # Errors
421///
422/// Whatever the observation, the bundle, or the declarations refuse.
423pub(crate) fn compute_plan(
424    target: &Utf8Path,
425    to: &str,
426    offline: bool,
427    selections: &decision::Selections,
428    reserve: &[String],
429    declared: Option<&InitOptions>,
430    release: &ReleaseRef<'_>,
431) -> Result<(Plan, BTreeMap<Sha256, Vec<u8>>), AppError> {
432    let _ = offline;
433    let observation = observe(target)?;
434    let declaration = release.bundle.declaration()?;
435    let manifest = release.bundle.manifest()?;
436    let candidate = payload_digests(&manifest);
437    // The baseline comes from the recorded release. Where that is the
438    // destination, the candidate is the baseline; where it is not, this
439    // engine does not fetch it, and the plan says so rather than guessing.
440    let recorded_is_destination = observation
441        .installation
442        .as_ref()
443        .is_some_and(|installed| installed.canon_version.to_string() == release.version);
444    let baseline = recorded_is_destination.then(|| candidate.clone());
445
446    // The installer owns what a landing writes, so the plan takes its
447    // answer once every declaration the landing records is settled.
448    let profile = observation
449        .installation
450        .as_ref()
451        .map(|installed| installed.profile)
452        .or_else(
453            || match selections.get(decision::id::PROFILE).map(String::as_str) {
454                Some("codebase") => Some(ProfileId::Codebase),
455                Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
456                _ => None,
457            },
458        );
459    // A front that handed over its declarations has answered every
460    // question the planner would otherwise ask for them.
461    let answered = declared.is_some()
462        || observation.installation.is_some()
463        || [
464            decision::id::PLAN_ZONE,
465            decision::id::DOCS_SCRATCH,
466            decision::id::WRITING_STYLE,
467        ]
468        .iter()
469        .all(|id| selections.contains_key(*id));
470    // A profile change is a named migration, not something a landing verb
471    // does because a flag said so. The record decides for an installed
472    // target, and a caller asking for another one is refused rather than
473    // quietly landing the other profile's files under this one's record.
474    if let (Some(held), Some(asked)) = (
475        observation
476            .installation
477            .as_ref()
478            .map(|installed| installed.profile),
479        declared.map(|options| options.profile),
480    ) && held != asked
481    {
482        return Err(AppError::Refused(format!(
483            "this instance records the {held} profile and the request names {asked}; a profile change is its own migration, not a landing"
484        )));
485    }
486
487    // The same measurements `sdd debt` records, taken once and read twice.
488    // The gates resolve the documentation root the same way here as they
489    // do at commit time, so a finding and a later gate failure agree.
490    let budget = crate::services::budget::measure_all(target).unwrap_or_default();
491
492    let landing = match profile {
493        Some(profile) if answered && observation.invalid.is_none() => Some(derive_landing(
494            &Derivation {
495                target,
496                profile,
497                declaration: &declaration,
498                observation: &observation,
499                selections,
500                reserve,
501                declared,
502                budget: &budget,
503            },
504            release,
505        )?),
506        _ => None,
507    };
508    let (proposed, blobs) = landing.map_or_else(
509        || (None, BTreeMap::new()),
510        |(operations, bytes)| (Some(operations), bytes),
511    );
512
513    let compatibility = read_compatibility(release, declaration.payload_schema)?;
514    let (recorded, destination, interval) =
515        interval_of(&observation, release, compatibility.as_ref())?;
516    let declared_summary = declared.map(declared_summary_of);
517    let briefing = read_briefing(
518        release.bundle,
519        declaration.payload_schema,
520        recorded,
521        destination,
522        selections,
523    )?;
524
525    let computed = compute(&Inputs {
526        observation: &observation,
527        declaration: &declaration,
528        candidate: &candidate,
529        baseline: baseline.as_ref(),
530        selector: to.to_string(),
531        release: release.version.clone(),
532        release_sha256: manifest.payload_sha256,
533        provenance: release.provenance.to_string(),
534        registry_checksum: release.checksum.clone(),
535        yanked: release.yanked,
536        compatibility: compatibility.as_ref(),
537        interval: interval.as_ref(),
538        briefing: briefing.as_ref(),
539        proposed: proposed.as_deref(),
540        selections,
541        budget: &budget,
542        reserve,
543        declared: declared_summary.as_deref(),
544        declarations_settled: declared.is_some(),
545        now: jiff::Timestamp::now().to_string(),
546    });
547    Ok((computed, blobs))
548}
549
550/// Every byte one plan will write, by digest.
551pub(crate) fn blobs_for(plan: &Plan, bundle: &dyn ReleaseBundle) -> BTreeMap<Sha256, Vec<u8>> {
552    let mut blobs = BTreeMap::new();
553    for operation in &plan.operations {
554        let Some(after) = operation.after() else {
555            continue;
556        };
557        if blobs.contains_key(after) {
558            continue;
559        }
560        // A digest the bundle does not carry came from the target state,
561        // which already handed its bytes over.
562        if let Ok(bytes) = bundle.blob(after) {
563            blobs.insert(after.clone(), bytes);
564        }
565    }
566    blobs
567}
568
569/// What one landing asked for.
570pub(crate) struct Landing<'a> {
571    /// The repository, already canonical.
572    pub target: &'a Utf8Path,
573    /// The release that lands, resolved once by the caller.
574    ///
575    /// The object, not a selector to resolve again. A caller that reads a
576    /// fixture or an older release must land that release's bytes, and a
577    /// second resolution here would land whatever this binary carries
578    /// while the caller's preview described something else.
579    pub release: ReleaseRef<'a>,
580    /// Whether the network is forbidden.
581    pub offline: bool,
582    /// Every decision the operator answered, validated against the plan.
583    ///
584    /// Only these. A value the front carries internally, such as the
585    /// profile a record already names, is not an answer anybody typed and
586    /// is not a decision the plan offers, so validating it would refuse a
587    /// correct request.
588    pub selections: decision::Selections,
589    /// Answers the front supplies for itself, which nobody typed.
590    pub carried: decision::Selections,
591    /// Paths no delivered gate judges, from the caller's flags.
592    pub reserve: Vec<String>,
593    /// The declarations a front already holds, where it holds them.
594    ///
595    /// A front carries recorded values that have no flag spelling to
596    /// round-trip through, so it hands them over as they are rather than
597    /// rendering them into answers the planner would parse back.
598    pub declared: Option<InitOptions>,
599}
600
601impl Landing<'_> {
602    /// What the plan records as the caller's request.
603    ///
604    /// The embedded release has no version to look up, so it keeps the
605    /// word rather than a triple: an apply that read the triple back
606    /// would go to the registry for a release this binary already holds.
607    fn selector(&self) -> String {
608        if self.release.provenance == "native"
609            && self.release.version == crate::domain::version::CanonVersion::current().to_string()
610        {
611            "embedded".to_string()
612        } else {
613            self.release.version.clone()
614        }
615    }
616}
617
618/// Take one plan's own lock, after an opportunistic prune.
619///
620/// The prune walks the whole store, so it takes the store lock and gives
621/// up rather than waiting: it is housekeeping, and a landing that skipped
622/// it loses nothing but disk.
623///
624/// # Errors
625///
626/// [`AppError::Busy`] when another writer holds this fingerprint.
627fn plan_lock(store: &Store, fingerprint: &str) -> Result<Lock, AppError> {
628    if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
629        let _ = store.prune(jiff::Timestamp::now(), Some(fingerprint));
630    }
631    Lock::exclusive_waiting(&store.plan_lock_path(fingerprint)?, "landing", STORE_WAIT)
632}
633
634/// Say what a refusal to write under the state root actually means.
635///
636/// A landing is a journalled transaction, so it needs somewhere to keep
637/// its lock, its plan, and the bytes it will write. A state root this user
638/// cannot write is therefore a refusal, and the message names the root and
639/// the variable that moves it rather than reporting a bare errno.
640fn unwritable_state(cause: AppError) -> AppError {
641    let AppError::Io(ref source) = cause else {
642        return cause;
643    };
644    if source.kind() != std::io::ErrorKind::PermissionDenied {
645        return cause;
646    }
647    let root = state_root().map_or_else(|_| "the state root".to_string(), |path| path.to_string());
648    AppError::Refused(format!(
649        "{root} cannot be written: {source}; every landing keeps its lock, its plan, and its journal there, so set {} to a directory this user owns",
650        crate::domain::paths::XDG_STATE_HOME_VAR
651    ))
652}
653
654/// Compute the plan one landing would run, and write nothing.
655///
656/// What a preview owes its reader is the plan, not a list of paths. A
657/// preview that could not show a blocked precondition or a decision the
658/// interval raises would tell an operator the run is ready when it is not.
659///
660/// # Errors
661///
662/// [`AppError::Busy`] when a writer holds the target, and whatever the
663/// planner refuses.
664pub(crate) fn preview(request: &Landing<'_>) -> Result<Plan, AppError> {
665    let _lock =
666        Lock::shared(&target_lock(request.target)?, "landing preview").map_err(unwritable_state)?;
667    let mut answers = request.carried.clone();
668    answers.extend(request.selections.clone());
669    let (plan, _) = compute_plan(
670        request.target,
671        &request.selector(),
672        request.offline,
673        &answers,
674        &request.reserve,
675        request.declared.as_ref(),
676        &request.release,
677    )?;
678    decision::validate(&plan.decisions, &request.selections)
679        .map_err(|error| AppError::Usage(error.to_string()))?;
680    Ok(plan)
681}
682
683/// What a preview says about a plan beyond the destinations it names.
684///
685/// One line per thing that would stop the run or ask a question. A ready
686/// plan adds nothing, because the destination list already said it all.
687#[must_use]
688pub(crate) fn preview_lines(plan: &Plan) -> Vec<String> {
689    let mut lines = Vec::new();
690    for precondition in &plan.preconditions {
691        if precondition.requirement == crate::plan::readiness::Requirement::Required
692            && !precondition.evaluation.is_satisfied()
693        {
694            lines.push(format!(
695                "BLOCKED {}: {}",
696                precondition.id, precondition.statement
697            ));
698        }
699    }
700    for decision in &plan.decisions {
701        if decision.selected.is_none() {
702            lines.push(format!("DECISION {}: {}", decision.id, decision.question));
703        }
704    }
705    lines
706}
707
708/// Plan one landing and execute it.
709///
710/// The one path from a target to a write. Every front reaches the engine
711/// here, so an operation is never derived twice and never applied outside
712/// the journal that can take it back. A caller that wants a preview asks
713/// for a plan and does not call this.
714///
715/// # Errors
716///
717/// [`AppError::Busy`] when another writer holds the target, and whatever
718/// the planner, the store, or the executor refuses.
719pub(crate) fn land(request: &Landing<'_>) -> Result<ApplyResult, AppError> {
720    let _lock =
721        Lock::exclusive(&target_lock(request.target)?, "landing").map_err(unwritable_state)?;
722    let release = &request.release;
723    let mut answers = request.carried.clone();
724    answers.extend(request.selections.clone());
725    let (plan, blobs) = compute_plan(
726        request.target,
727        &request.selector(),
728        request.offline,
729        &answers,
730        &request.reserve,
731        request.declared.as_ref(),
732        release,
733    )?;
734    // An answer the plan does not offer is not authorization. The front
735    // verbs take `--set` too, so the check belongs here rather than in one
736    // caller: a malformed answer that reached a precondition would turn
737    // typing into consent.
738    decision::validate(&plan.decisions, &request.selections)
739        .map_err(|error| AppError::Usage(error.to_string()))?;
740
741    let store = Store::new(&state_root()?);
742    // A landing is a journalled transaction, so it needs somewhere to keep
743    // the journal and the bytes it will write. A state root this user
744    // cannot write is therefore a refusal, and the message names the root
745    // and the variable that moves it rather than reporting a bare errno.
746    store.create().map_err(unwritable_state)?;
747    let _store_lock = plan_lock(&store, &plan.identity.plan_id)?;
748    if !store.holds(&plan.identity.plan_id) {
749        let mut blobs = blobs;
750        for (digest, bytes) in blobs_for(&plan, release.bundle) {
751            blobs.entry(digest).or_insert(bytes);
752        }
753        store.put(&plan, &blobs)?;
754    }
755    execute(&Request {
756        store: &store,
757        target: request.target,
758        stored: &plan,
759        // The plan was computed a moment ago under this same exclusive
760        // lock, so nothing could move between the two. The apply still
761        // compares the two fingerprints rather than assuming that.
762        recomputed: &plan,
763        bundle: release.bundle,
764        now: jiff::Timestamp::now().to_string(),
765    })
766}