Skip to main content

spec_driven_docs/plan/
planner.rs

1//! The pure function from what was observed to what will be done.
2//!
3//! Nothing here reads a disk, reaches a network, or asks a clock. Every
4//! one of those is an input, so the same inputs produce the same plan and
5//! the same fingerprint. That is what lets an approval bind to a plan and
6//! an apply prove it is still executing the plan that was approved.
7
8use std::collections::BTreeMap;
9
10use crate::domain::ownership::Sha256;
11use crate::domain::profile::{ProfileId, resolve_destination};
12use crate::domain::projection::Declaration;
13use crate::plan::classify::{Classification, Signals, classify};
14use crate::plan::decision::{self, AnswerSchema, Choice, Decision, Selections};
15use crate::plan::evidence::{Ledger, Producer};
16use crate::plan::finding::{Finding, FindingKind, StyleCandidate, detector};
17use crate::plan::fingerprint::{Value, fingerprint};
18use crate::plan::observe::{Observation, RecordedFile};
19use crate::plan::operation::{Class, Operation, TargetPath, no_duplicate_destination};
20use crate::plan::readiness::{Evaluation, Precondition, Readiness, Requirement, readiness};
21use crate::plan::{
22    Declared, DesiredState, Identity, ObservedState, PLAN_SCHEMA, Plan, Postcondition,
23    ReleaseSource,
24};
25
26/// What the planner is given besides the observation.
27#[derive(Debug, Clone)]
28pub struct Inputs<'a> {
29    /// What was observed at the target.
30    pub observation: &'a Observation,
31    /// What the destination release declares it lands.
32    pub declaration: &'a Declaration,
33    /// Each destination's bytes in the destination release, by source path.
34    pub candidate: &'a BTreeMap<String, Sha256>,
35    /// Each destination's bytes in the recorded release, where it could be
36    /// read. Absent means the baseline was not observed.
37    pub baseline: Option<&'a BTreeMap<String, Sha256>>,
38    /// What the caller asked for, before resolution.
39    pub selector: String,
40    /// The release the selector resolved to.
41    pub release: String,
42    /// The digest over that release's content.
43    pub release_sha256: Sha256,
44    /// Where the release's facts came from.
45    pub provenance: String,
46    /// The registry checksum, where a registry served it.
47    pub registry_checksum: Option<Sha256>,
48    /// Whether the registry marks the release yanked.
49    pub yanked: bool,
50    /// What the destination release needs of this engine.
51    pub compatibility: Option<&'a crate::plan::compatibility::Compatibility>,
52    /// The interval the plan crosses, for the compatibility check.
53    pub interval: Option<&'a crate::plan::compatibility::Interval>,
54    /// What the interval's releases ask of this target.
55    pub briefing: Option<&'a crate::plan::guidance::Briefing>,
56    /// What one landing would write, where the caller computed it.
57    ///
58    /// The installer owns that computation, so the planner takes its
59    /// answer rather than deriving a second one that could disagree.
60    pub proposed: Option<&'a [Operation]>,
61    /// What the operator selected.
62    pub selections: &'a Selections,
63    /// Paths no delivered gate judges, as the caller reserved them.
64    pub reserve: &'a [String],
65    /// What the budget gates measured at the target.
66    ///
67    /// The gates decide pass or fail; the planner reports a number over a
68    /// cap as debt a corpus arrived with. One measurement, so a finding
69    /// and a ceiling can never disagree about what a budget is.
70    pub budget: &'a [crate::domain::debt::Measurement],
71    /// The declarations a front carries, rendered for the fingerprint.
72    ///
73    /// A front's flags reach the record and no other operation, so a plan
74    /// that recorded a different docs scratch would otherwise share an id
75    /// with one that did not.
76    pub declared: Option<&'a str>,
77    /// Whether the caller already settled the two declarations.
78    ///
79    /// A front carries them as flags, and an omitted flag keeps whatever
80    /// is recorded. So the questions are answered before the plan is
81    /// computed, and asking them again would ask an operator to repeat
82    /// something they already said.
83    pub declarations_settled: bool,
84    /// The clock, as an input.
85    pub now: String,
86}
87
88/// Compute one plan.
89///
90/// The order is fixed: classify from what was observed, read the findings,
91/// offer the decisions, derive the operations the selected decisions allow,
92/// evaluate the preconditions, and take the readiness from the worst of
93/// them. The fingerprint is last, over exactly the parts that decide what
94/// the apply would do.
95#[must_use]
96pub fn plan(inputs: &Inputs<'_>) -> Plan {
97    let observation = inputs.observation;
98    let (ledger, refs, release_ref) = ledger_of(inputs);
99
100    let profile = chosen_profile(inputs);
101    let classification = classification_of(inputs, profile);
102    let findings = findings_of(inputs, profile, classification);
103    let style_candidates = style_candidates_of(inputs, classification);
104    let structural = findings
105        .iter()
106        .filter(|found| found.kind == FindingKind::Structural)
107        .count();
108    let mut decisions = decisions_of(inputs, classification, profile, structural, &findings);
109    if let Some(briefing) = inputs.briefing {
110        decisions.extend(briefing.decisions.iter().cloned());
111    }
112    let operations = inputs.proposed.map_or_else(
113        || derived_operations(inputs, profile, classification, &decisions),
114        <[Operation]>::to_vec,
115    );
116
117    let mut preconditions =
118        preconditions_of(inputs, classification, &operations, &decisions, structural);
119    if let (Some(held), Some(interval)) = (inputs.compatibility, inputs.interval) {
120        preconditions.extend(crate::plan::compatibility::preconditions(held, interval));
121    }
122    if let Some(briefing) = inputs.briefing {
123        preconditions.extend(briefing.preconditions.iter().cloned());
124    }
125    let verdict = readiness(&preconditions);
126
127    let desired_state = DesiredState {
128        selector: inputs.selector.clone(),
129        release: inputs.release.clone(),
130        release_sha256: inputs.release_sha256.clone(),
131        profile,
132        reserved: inputs.reserve.to_vec(),
133        declared: Declared {
134            payload_schema: inputs.declaration.payload_schema,
135            managed: inputs.declaration.managed.len(),
136            adopted: inputs.declaration.adopted.len(),
137            sentinels: inputs.declaration.sentinels.len(),
138        },
139    };
140    let observed_state = ObservedState {
141        repository: observation.repository.clone(),
142        installation: observation.installation.clone(),
143        host: observation.host.clone(),
144        corpus: observation.corpus.clone(),
145        evidence_refs: refs,
146    };
147    let release = ReleaseSource {
148        version: inputs.release.clone(),
149        provenance: inputs.provenance.clone(),
150        registry_checksum: inputs.registry_checksum.clone(),
151        yanked: inputs.yanked,
152        minimum_engine: inputs
153            .compatibility
154            .map(|held| held.minimum_engine.to_string()),
155        guidance_coverage: inputs.interval.and_then(|interval| {
156            inputs
157                .briefing
158                .map(|_| crate::plan::guidance::Coverage::Complete)
159                .filter(|_| interval.recorded.is_some())
160        }),
161        guidance_steps: inputs
162            .briefing
163            .map(|briefing| briefing.applicable.clone())
164            .unwrap_or_default(),
165        guidance_excluded: inputs.briefing.map_or(0, |briefing| briefing.excluded),
166        evidence_refs: vec![release_ref],
167    };
168    let digest = fingerprint(&inputs_projection(
169        inputs,
170        classification,
171        &operations,
172        &preconditions,
173        &decisions,
174    ));
175    Plan {
176        identity: Identity {
177            schema: PLAN_SCHEMA.to_string(),
178            plan_id: digest.to_string(),
179            created_at: inputs.now.clone(),
180            engine_version: env!("CARGO_PKG_VERSION").to_string(),
181        },
182        classification,
183        findings,
184        style_candidates,
185        desired_state,
186        observed_state,
187        release,
188        operations,
189        preconditions,
190        decisions,
191        postconditions: postconditions_of(classification),
192        evidence: ledger.items,
193        readiness: verdict,
194        input_fingerprint: digest,
195    }
196}
197
198/// Everything the plan observed, and the references its sections cite.
199fn ledger_of(inputs: &Inputs<'_>) -> (Ledger, Vec<String>, String) {
200    let mut ledger = Ledger::new();
201    let mut refs = Vec::new();
202    refs.push(ledger.record(
203        "target",
204        "the target's working tree",
205        Producer::Disk,
206        &inputs.now,
207        None,
208        "walked, skipping version control and build output",
209    ));
210    if let Some(installation) = inputs.observation.installation.as_ref() {
211        refs.push(ledger.record(
212            "record",
213            "the instance record",
214            Producer::Record,
215            &inputs.now,
216            Some(installation.record_sha256.clone()),
217            "read and parsed",
218        ));
219        if let Some(digest) = installation.declaration_sha256.as_ref() {
220            refs.push(ledger.record(
221                "declaration",
222                "the project's own declaration",
223                Producer::Declaration,
224                &inputs.now,
225                Some(digest.clone()),
226                "read from the target",
227            ));
228        }
229    }
230    let release_ref = ledger.record(
231        "release",
232        "the destination release",
233        Producer::Bundle,
234        &inputs.now,
235        Some(inputs.release_sha256.clone()),
236        "read through the release seam",
237    );
238    refs.push(ledger.record(
239        "host",
240        "the resolved user-scope paths",
241        Producer::Host,
242        &inputs.now,
243        None,
244        "read from the environment",
245    ));
246    (ledger, refs, release_ref)
247}
248
249/// The profile this plan lands, where one is settled.
250fn chosen_profile(inputs: &Inputs<'_>) -> Option<ProfileId> {
251    if let Some(installation) = inputs.observation.installation.as_ref() {
252        return Some(installation.profile);
253    }
254    match inputs
255        .selections
256        .get(decision::id::PROFILE)
257        .map(String::as_str)
258    {
259        Some("codebase") => Some(ProfileId::Codebase),
260        Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
261        _ => None,
262    }
263}
264
265fn classification_of(inputs: &Inputs<'_>, profile: Option<ProfileId>) -> Classification {
266    let observation = inputs.observation;
267    let drifted = observation
268        .installation
269        .as_ref()
270        .is_some_and(crate::plan::observe::Installation::drifted);
271    let at_destination = observation
272        .installation
273        .as_ref()
274        .is_some_and(|installed| installed.canon_version.to_string() == inputs.release);
275    let _ = profile;
276    classify(Signals {
277        invalid: observation.invalid.is_some(),
278        installed: observation.installation.is_some(),
279        at_destination,
280        drifted,
281        settled: observation.corpus.settled(),
282    })
283}
284
285/// Every finding the program can prove.
286fn findings_of(
287    inputs: &Inputs<'_>,
288    profile: Option<ProfileId>,
289    classification: Classification,
290) -> Vec<Finding> {
291    let mut findings = Vec::new();
292    if classification != Classification::Migration {
293        return findings;
294    }
295    // A number over a cap is debt the corpus arrived with, never a defect
296    // in it. It records as a ceiling and comes down as documents shrink.
297    findings.extend(inputs.budget.iter().filter_map(budget_finding));
298
299    let corpus = &inputs.observation.corpus;
300    // The foreign-root detector needs the profile, so it waits for the
301    // decision rather than judging against a default.
302    if let Some(profile) = profile
303        && let Some(docs_root) = inputs.declaration.docs_root(profile)
304    {
305        for root in &corpus.populated_doc_roots {
306            if root == docs_root.as_str() {
307                continue;
308            }
309            if let Ok(path) = TargetPath::new(root) {
310                findings.push(Finding {
311                    kind: FindingKind::Structural,
312                    path,
313                    rule: detector::FOREIGN_DOCS_ROOT.to_string(),
314                    statement: format!(
315                        "{root} holds documents and the {profile} profile keeps them under {docs_root}"
316                    ),
317                    measurement: None,
318                });
319            }
320        }
321        if corpus.settled()
322            && !corpus.has_specs_directory
323            && let Ok(path) = TargetPath::new(&format!("{docs_root}/specs"))
324        {
325            findings.push(Finding {
326                kind: FindingKind::Structural,
327                path,
328                rule: detector::NO_SPECS_DIRECTORY.to_string(),
329                statement: "the corpus is settled and no specifications directory holds its rules"
330                    .to_string(),
331                measurement: None,
332            });
333        }
334    }
335    for path in &corpus.spec_without_rule_id {
336        findings.push(Finding {
337            kind: FindingKind::Structural,
338            path: path.clone(),
339            rule: detector::SPEC_WITHOUT_RULE_ID.to_string(),
340            statement: "the document is shaped like a specification and defines no rule ID"
341                .to_string(),
342            measurement: None,
343        });
344    }
345    for path in &corpus.ordinal_named {
346        findings.push(Finding {
347            kind: FindingKind::Structural,
348            path: path.clone(),
349            rule: detector::ORDINAL_FILENAME.to_string(),
350            statement: "the document is named by its position rather than its subject".to_string(),
351            measurement: None,
352        });
353    }
354    for path in &corpus.records_outside_decisions {
355        findings.push(Finding {
356            kind: FindingKind::Structural,
357            path: path.clone(),
358            rule: detector::RECORD_OUTSIDE_DECISIONS.to_string(),
359            statement: "the decision record sits outside a decisions directory".to_string(),
360            measurement: None,
361        });
362    }
363    findings
364}
365
366/// Documents written before the instance, named and never judged.
367fn style_candidates_of(inputs: &Inputs<'_>, classification: Classification) -> Vec<StyleCandidate> {
368    if classification != Classification::Migration {
369        return Vec::new();
370    }
371    inputs
372        .observation
373        .corpus
374        .documents
375        .iter()
376        .map(|path| StyleCandidate {
377            path: path.clone(),
378            reason: "the document predates the instance, and no gate judges its prose".to_string(),
379        })
380        .collect()
381}
382
383fn choice(id: &str, consequence: &str) -> Choice {
384    Choice {
385        id: id.to_string(),
386        consequence: consequence.to_string(),
387    }
388}
389
390/// Every decision the plan is waiting on, in dependency order.
391fn decisions_of(
392    inputs: &Inputs<'_>,
393    classification: Classification,
394    profile: Option<ProfileId>,
395    structural: usize,
396    findings: &[Finding],
397) -> Vec<Decision> {
398    let mut decisions = Vec::new();
399    let selected = |id: &str| inputs.selections.get(id).cloned();
400
401    if inputs.observation.installation.is_none()
402        && matches!(
403            classification,
404            Classification::Setup | Classification::Migration
405        )
406    {
407        decisions.push(Decision {
408            id: decision::id::PROFILE.to_string(),
409            question: "which profile does this repository take?".to_string(),
410            schema: AnswerSchema::Choice {
411                choices: vec![
412                    choice("codebase", "records live under docs/"),
413                    choice("knowledge-base", "records live under _docs/"),
414                ],
415            },
416            depends_on: Vec::new(),
417            selected: selected(decision::id::PROFILE),
418        });
419    }
420
421    // Everything below is profile-relative, so it waits for the profile.
422    if profile.is_some() {
423        if inputs.observation.installation.is_none() && !inputs.declarations_settled {
424            let depends: Vec<String> = if decisions
425                .iter()
426                .any(|held| held.id == decision::id::PROFILE)
427            {
428                vec![decision::id::PROFILE.to_string()]
429            } else {
430                Vec::new()
431            };
432            decisions.push(Decision {
433                id: decision::id::DOCS_SCRATCH.to_string(),
434                question: "where does material that is not a statement yet stage?".to_string(),
435                schema: AnswerSchema::ChoiceOrValue {
436                    choices: vec![choice("none", "the project stages nothing")],
437                    // A recorded scratch is a path. The variable overrides
438                    // it at read time, so there is no `env` to record.
439                    prefixes: vec!["project:".to_string(), "external:".to_string()],
440                },
441                depends_on: depends.clone(),
442                selected: selected(decision::id::DOCS_SCRATCH),
443            });
444            decisions.push(Decision {
445                id: decision::id::WRITING_STYLE.to_string(),
446                question: "which writing source does the project select?".to_string(),
447                schema: AnswerSchema::ChoiceOrValue {
448                    choices: vec![
449                        choice("builtin", "this convention's own chapter, served offline"),
450                        choice("none", "no route and no conversion obligation"),
451                    ],
452                    prefixes: vec!["project:".to_string()],
453                },
454                depends_on: depends,
455                selected: selected(decision::id::WRITING_STYLE),
456            });
457        }
458
459        if classification == Classification::Migration {
460            decisions.extend(migration_decisions(inputs, structural, findings));
461        }
462    }
463
464    if inputs.yanked {
465        decisions.push(Decision {
466            id: decision::id::ACCEPT_YANKED.to_string(),
467            question: format!(
468                "the registry marks {} yanked; land it anyway?",
469                inputs.release
470            ),
471            schema: AnswerSchema::Choice {
472                choices: vec![
473                    choice("accept", "the release lands, yanked and named as such"),
474                    choice("refuse", "nothing lands; name another release"),
475                ],
476            },
477            depends_on: Vec::new(),
478            selected: selected(decision::id::ACCEPT_YANKED),
479        });
480    }
481    decisions
482}
483
484/// What a migration asks before it moves anything.
485fn migration_decisions(
486    inputs: &Inputs<'_>,
487    structural: usize,
488    findings: &[Finding],
489) -> Vec<Decision> {
490    let selected = |id: &str| inputs.selections.get(id).cloned();
491    let mut decisions = Vec::new();
492    let mut choices = vec![choice(
493        "sweep",
494        "every durable fact moves into its owner, and the old convention retires",
495    )];
496    // Incremental is offered only where nothing structural would
497    // make two conventions coexist.
498    if structural == 0 {
499        choices.push(choice(
500            "incremental",
501            "each document converts the next time somebody edits it",
502        ));
503    }
504    decisions.push(Decision {
505        id: decision::id::MIGRATION_SCOPE.to_string(),
506        question: "how much of the corpus moves?".to_string(),
507        schema: AnswerSchema::Choice { choices },
508        depends_on: vec![decision::id::PROFILE.to_string()],
509        selected: selected(decision::id::MIGRATION_SCOPE),
510    });
511    if findings
512        .iter()
513        .any(|found| found.kind == FindingKind::Budget)
514    {
515        decisions.push(Decision {
516            id: decision::id::DEBT_BASELINE.to_string(),
517            question: "are the inherited violations recorded as debt?".to_string(),
518            schema: AnswerSchema::Choice {
519                choices: vec![
520                    choice(
521                        "record",
522                        "each inherited violation becomes a ceiling that only comes down",
523                    ),
524                    choice(
525                        "skip",
526                        "nothing is recorded, and each violation fails its gate",
527                    ),
528                ],
529            },
530            depends_on: vec![decision::id::MIGRATION_SCOPE.to_string()],
531            selected: selected(decision::id::DEBT_BASELINE),
532        });
533    }
534    decisions
535}
536
537/// Every write the plan derives for itself, where the caller gave none.
538///
539/// Until the profile is chosen, every destination is unknown, so the
540/// planner offers no operation rather than one against a default nobody
541/// selected.
542fn derived_operations(
543    inputs: &Inputs<'_>,
544    profile: Option<ProfileId>,
545    classification: Classification,
546    decisions: &[Decision],
547) -> Vec<Operation> {
548    if profile.is_none() {
549        return Vec::new();
550    }
551    // A first landing is a whole projection: the managed and adopted files,
552    // the two marked regions, and the record. The caller derives that from
553    // one computation and hands it over. Deriving a partial one here would
554    // offer an operator a landing that leaves a target the verifier cannot
555    // read, so where the caller gave none there is none.
556    if matches!(
557        classification,
558        Classification::Setup | Classification::Migration
559    ) {
560        return Vec::new();
561    }
562    operations_of(inputs, profile, classification, decisions)
563}
564
565/// Every write the plan will make.
566fn operations_of(
567    inputs: &Inputs<'_>,
568    profile: Option<ProfileId>,
569    classification: Classification,
570    decisions: &[Decision],
571) -> Vec<Operation> {
572    let mut operations = Vec::new();
573    if matches!(
574        classification,
575        Classification::Invalid | Classification::Current
576    ) {
577        return operations;
578    }
579    let Some(profile) = profile else {
580        return operations;
581    };
582    let Some(docs_root) = inputs.declaration.docs_root(profile) else {
583        return operations;
584    };
585    let held = crate::plan::observe::held_by_path(inputs.observation.installation.as_ref());
586    let recorded: BTreeMap<&str, &RecordedFile> = inputs
587        .observation
588        .installation
589        .iter()
590        .flat_map(|installation| installation.adopted.iter())
591        .map(|file| (file.path.as_str(), file))
592        .collect();
593
594    for projection in &inputs.declaration.managed {
595        let Some(after) = inputs.candidate.get(&projection.source) else {
596            continue;
597        };
598        let Ok(path) = TargetPath::new(&projection.destination) else {
599            continue;
600        };
601        let before = held.get(path.as_str()).cloned();
602        if before.as_ref() == Some(after) {
603            continue;
604        }
605        operations.push(Operation::WriteFile {
606            path,
607            class: Class::Managed,
608            before,
609            after: after.clone(),
610        });
611    }
612
613    for projection in &inputs.declaration.adopted {
614        let Some(seed) = inputs.candidate.get(&projection.source) else {
615            continue;
616        };
617        let destination = resolve_destination(&projection.destination, docs_root);
618        let Ok(path) = TargetPath::new(destination.as_str()) else {
619            continue;
620        };
621        match held.get(path.as_str()) {
622            // An adopted file the target holds is the project's. Only the
623            // baseline it is read against moves.
624            Some(current) => {
625                let baseline_before = recorded
626                    .get(path.as_str())
627                    .and_then(|file| file.baseline.clone())
628                    .or_else(|| {
629                        inputs
630                            .baseline
631                            .and_then(|held| held.get(&projection.source).cloned())
632                    });
633                let Some(baseline_before) = baseline_before else {
634                    continue;
635                };
636                if &baseline_before == seed {
637                    continue;
638                }
639                operations.push(Operation::KeepFile {
640                    path,
641                    held: current.clone(),
642                    baseline_before,
643                    baseline_after: seed.clone(),
644                });
645            }
646            None => operations.push(Operation::WriteFile {
647                path,
648                class: Class::Adopted,
649                before: None,
650                after: seed.clone(),
651            }),
652        }
653    }
654
655    // The two operator-invoked writes into adopted state appear only when
656    // their decision is selected, never because a version moved.
657    let selected = |id: &str| {
658        decisions
659            .iter()
660            .find(|decision| decision.id == id)
661            .and_then(|decision| decision.selected.as_deref())
662    };
663    // The debt write waits for the budget findings that give it content:
664    // an operation whose bytes nothing carries is an operation an apply
665    // could not execute, and the plan does not offer one.
666    let _ = selected(decision::id::DEBT_BASELINE);
667    operations
668}
669
670/// Everything that must hold before the apply.
671fn preconditions_of(
672    inputs: &Inputs<'_>,
673    classification: Classification,
674    operations: &[Operation],
675    decisions: &[Decision],
676    structural: usize,
677) -> Vec<Precondition> {
678    let mut preconditions: Vec<Precondition> = Vec::new();
679    macro_rules! require {
680        ($id:expr, $statement:expr, $requirement:expr, $evaluation:expr) => {
681            preconditions.push(Precondition {
682                id: ($id).to_string(),
683                statement: $statement,
684                requirement: $requirement,
685                evaluation: $evaluation,
686                resolved_by: None,
687                evidence_refs: Vec::new(),
688            });
689        };
690    }
691
692    if let Some(reason) = inputs.observation.invalid.as_ref() {
693        require!(
694            "record-is-readable",
695            "the instance record parses".to_string(),
696            Requirement::Required,
697            Evaluation::Unsatisfied {
698                reason: reason.clone(),
699            }
700        );
701    }
702
703    let waiting = decision_preconditions(decisions);
704
705    let edited = edited_managed_files(inputs);
706    if !edited.is_empty() {
707        require!(
708            "managed-files-are-unedited",
709            "every managed file still holds what the record says".to_string(),
710            Requirement::Required,
711            Evaluation::Unsatisfied {
712                reason: edited.join("; "),
713            }
714        );
715    }
716
717    if inputs.baseline.is_none() && inputs.observation.installation.is_some() {
718        require!(
719            "baseline-is-readable",
720            "the recorded release's bundle can be read for baselines".to_string(),
721            Requirement::Advisory,
722            Evaluation::NotObserved {
723                reason:
724                    "the recorded release's bundle was not read, so an adopted baseline cannot move"
725                        .to_string(),
726            }
727        );
728    }
729
730    // An incremental migration over a structural finding would start a
731    // second convention beside the first, which is the harm the sweep
732    // exists to stop.
733    let scope = decisions
734        .iter()
735        .find(|decision| decision.id == decision::id::MIGRATION_SCOPE)
736        .and_then(|decision| decision.selected.as_deref());
737    if classification == Classification::Migration && scope == Some("incremental") && structural > 0
738    {
739        require!(
740            "incremental-scope-has-no-structural-finding",
741            "an incremental migration leaves no structural finding behind".to_string(),
742            Requirement::Required,
743            Evaluation::Unsatisfied {
744                reason: format!(
745                    "{structural} structural finding(s) would make two conventions coexist"
746                ),
747            }
748        );
749    }
750
751    preconditions.extend(waiting);
752
753    if let Err(clash) = no_duplicate_destination(operations) {
754        require!(
755            "no-destination-is-written-twice",
756            "each destination is written by at most one operation".to_string(),
757            Requirement::Required,
758            Evaluation::Unsatisfied {
759                reason: clash.to_string(),
760            }
761        );
762    }
763    preconditions
764}
765
766/// One measurement that is over its budget, as a finding.
767///
768/// A count over its cap and a condition that does not hold are both debt
769/// the corpus arrived with. The second carries no number, so it reports as
770/// one against zero: the dimension is the fact, and what the ceiling
771/// records is that the exception exists.
772fn budget_finding(measured: &crate::domain::debt::Measurement) -> Option<Finding> {
773    let path = TargetPath::new(&measured.path).ok()?;
774    let (statement, found, cap) = match measured.value {
775        crate::domain::debt::Measured::Count { value, budget } if value > budget => (
776            format!(
777                "{} is {value} {} against a budget of {budget}",
778                measured.path, measured.dimension
779            ),
780            value as u64,
781            budget as u64,
782        ),
783        crate::domain::debt::Measured::Flag(true) => (
784            format!("{} carries {}", measured.path, measured.dimension),
785            1,
786            0,
787        ),
788        _ => return None,
789    };
790    Some(Finding {
791        kind: FindingKind::Budget,
792        path,
793        rule: measured.gate.to_string(),
794        statement,
795        measurement: Some(crate::plan::finding::Measurement {
796            dimension: measured.dimension.to_string(),
797            found,
798            cap,
799        }),
800    })
801}
802
803/// Every managed file the target no longer holds as the record says.
804///
805/// Collected in one pass rather than refused one at a time, so an operator
806/// sees the whole conflict set in one run.
807fn edited_managed_files(inputs: &Inputs<'_>) -> Vec<String> {
808    let mut edited = Vec::new();
809    let Some(installation) = inputs.observation.installation.as_ref() else {
810        return edited;
811    };
812    for file in &installation.managed {
813        match file.held.as_ref() {
814            Some(found) if found == &file.recorded => {}
815            Some(_) => edited.push(format!("{} was edited", file.path)),
816            None => edited.push(format!("{} is gone", file.path)),
817        }
818    }
819    // A marked region is managed too. The next landing re-splices it, so
820    // an edit inside the markers would be lost. Every byte outside them is
821    // the project's own and is not compared.
822    for block in &installation.blocks {
823        match block.held.as_ref() {
824            Some(found) if found == &block.recorded => {}
825            Some(_) => edited.push(format!("the managed block in {} was edited", block.path)),
826            None => edited.push(format!("the managed block in {} is gone", block.path)),
827        }
828    }
829    edited
830}
831
832/// One precondition per decision the operator has not answered.
833fn decision_preconditions(decisions: &[Decision]) -> Vec<Precondition> {
834    decisions
835        .iter()
836        .map(|decision| Precondition {
837            id: format!("decision:{}", decision.id),
838            statement: decision.question.clone(),
839            requirement: Requirement::DecisionRequired,
840            evaluation: decision.selected.as_ref().map_or_else(
841                || Evaluation::Unsatisfied {
842                    reason: "the operator has not answered it".to_string(),
843                },
844                |_| Evaluation::Satisfied,
845            ),
846            resolved_by: Some(decision.id.clone()),
847            evidence_refs: Vec::new(),
848        })
849        .collect()
850}
851
852/// What the apply proves once it has finished.
853fn postconditions_of(classification: Classification) -> Vec<Postcondition> {
854    if classification == Classification::Invalid {
855        return Vec::new();
856    }
857    vec![
858        Postcondition {
859            id: "record-matches-the-tree".to_string(),
860            statement: "every operation's destination holds the digest the plan named".to_string(),
861        },
862        Postcondition {
863            id: "verification-passes".to_string(),
864            statement: "sdd verify reports OK against the target".to_string(),
865        },
866    ]
867}
868
869/// Exactly the parts that decide what the apply would do.
870///
871/// Timestamps, presentation text, and advisory evidence are out: a plan
872/// recomputed a second later must carry the same identity, or an approval
873/// could never survive the moment it was given.
874fn inputs_projection(
875    inputs: &Inputs<'_>,
876    classification: Classification,
877    operations: &[Operation],
878    preconditions: &[Precondition],
879    decisions: &[Decision],
880) -> Value {
881    let operations = Value::List(
882        operations
883            .iter()
884            // The record carries the moment of installation, which is
885            // exactly what the fingerprint must exclude, so its digest
886            // cannot stand for it. What it uniquely says is projected
887            // below instead: dropping the whole operation would let two
888            // plans that record different declarations share one id.
889            .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
890            .map(|operation| {
891                Value::map([
892                    ("kind", Value::text(operation.kind())),
893                    ("path", Value::text(operation.path().as_str())),
894                    (
895                        "before",
896                        Value::maybe(operation.before().map(std::string::ToString::to_string)),
897                    ),
898                    (
899                        "after",
900                        Value::maybe(operation.after().map(std::string::ToString::to_string)),
901                    ),
902                ])
903            })
904            .collect(),
905    );
906    // Only a precondition that can change readiness or operations belongs
907    // here. An advisory one cannot, by definition.
908    let gates = Value::List(
909        preconditions
910            .iter()
911            .filter(|precondition| precondition.requirement != Requirement::Advisory)
912            .map(|precondition| {
913                Value::map([
914                    ("id", Value::text(precondition.id.as_str())),
915                    (
916                        "state",
917                        Value::text(match precondition.evaluation {
918                            Evaluation::Satisfied => "satisfied",
919                            Evaluation::NotObserved { .. } => "not-observed",
920                            Evaluation::Unsatisfied { .. } => "unsatisfied",
921                        }),
922                    ),
923                ])
924            })
925            .collect(),
926    );
927    let selected = Value::Map(
928        decisions
929            .iter()
930            .filter_map(|decision| {
931                decision
932                    .selected
933                    .as_ref()
934                    .map(|answer| (decision.id.clone(), Value::text(answer.as_str())))
935            })
936            .collect(),
937    );
938    Value::map([
939        ("schema", Value::text(PLAN_SCHEMA)),
940        ("classification", Value::text(classification.as_str())),
941        ("release", Value::text(inputs.release.as_str())),
942        (
943            "release_sha256",
944            Value::text(inputs.release_sha256.as_str()),
945        ),
946        (
947            "target",
948            Value::text(inputs.observation.repository.root.as_str()),
949        ),
950        (
951            "record",
952            Value::maybe(
953                inputs
954                    .observation
955                    .installation
956                    .as_ref()
957                    .map(|installation| installation.record_sha256.to_string()),
958            ),
959        ),
960        (
961            "declaration",
962            Value::maybe(
963                inputs
964                    .observation
965                    .installation
966                    .as_ref()
967                    .and_then(|installation| installation.declaration_sha256.as_ref())
968                    .map(std::string::ToString::to_string),
969            ),
970        ),
971        ("operations", operations),
972        ("preconditions", gates),
973        ("decisions", selected),
974        ("declared", declared_projection(inputs)),
975    ])
976}
977
978/// What the record will say that no other operation carries.
979///
980/// The record's own digest cannot go in the fingerprint: it carries the
981/// moment of installation, and a plan recomputed a second later would
982/// stop matching itself. So the fields that decide what the record says
983/// are projected on their own, and the timestamp alone stays out.
984fn declared_projection(inputs: &Inputs<'_>) -> Value {
985    Value::map([
986        (
987            "profile",
988            Value::maybe(
989                inputs
990                    .selections
991                    .get(crate::plan::decision::id::PROFILE)
992                    .cloned(),
993            ),
994        ),
995        (
996            "reserved",
997            Value::List(
998                inputs
999                    .reserve
1000                    .iter()
1001                    .map(|path| Value::text(path.as_str()))
1002                    .collect(),
1003            ),
1004        ),
1005        (
1006            "record",
1007            Value::maybe(inputs.declared.map(std::string::ToString::to_string)),
1008        ),
1009    ])
1010}
1011
1012/// Whether a plan may be applied, for a caller that has only the verdict.
1013#[must_use]
1014pub const fn is_ready(verdict: Readiness) -> bool {
1015    matches!(verdict, Readiness::Ready)
1016}