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 plan zone would otherwise share an id
75    /// with one that did not.
76    pub declared: Option<&'a str>,
77    /// Whether the caller already settled the three 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::PLAN_ZONE.to_string(),
434                question: "where does the planning tool write its entry documents?".to_string(),
435                schema: AnswerSchema::ChoiceOrValue {
436                    choices: vec![
437                        choice("env", "wherever the plan-zone variable points"),
438                        choice("none", "the project keeps no plan zone"),
439                    ],
440                    prefixes: vec!["project:".to_string(), "untracked:".to_string()],
441                },
442                depends_on: depends.clone(),
443                selected: selected(decision::id::PLAN_ZONE),
444            });
445            decisions.push(Decision {
446                id: decision::id::DOCS_SCRATCH.to_string(),
447                question: "where does material that is not a statement yet stage?".to_string(),
448                schema: AnswerSchema::ChoiceOrValue {
449                    choices: vec![choice("none", "the project stages nothing")],
450                    // A recorded scratch is a path. The variable overrides
451                    // it at read time, so there is no `env` to record.
452                    prefixes: vec!["project:".to_string(), "external:".to_string()],
453                },
454                depends_on: depends.clone(),
455                selected: selected(decision::id::DOCS_SCRATCH),
456            });
457            decisions.push(Decision {
458                id: decision::id::WRITING_STYLE.to_string(),
459                question: "which writing source does the project select?".to_string(),
460                schema: AnswerSchema::ChoiceOrValue {
461                    choices: vec![
462                        choice("builtin", "this convention's own chapter, served offline"),
463                        choice("none", "no route and no conversion obligation"),
464                    ],
465                    prefixes: vec!["project:".to_string()],
466                },
467                depends_on: depends,
468                selected: selected(decision::id::WRITING_STYLE),
469            });
470        }
471
472        if classification == Classification::Migration {
473            decisions.extend(migration_decisions(inputs, structural, findings));
474        }
475    }
476
477    if inputs.yanked {
478        decisions.push(Decision {
479            id: decision::id::ACCEPT_YANKED.to_string(),
480            question: format!(
481                "the registry marks {} yanked; land it anyway?",
482                inputs.release
483            ),
484            schema: AnswerSchema::Choice {
485                choices: vec![
486                    choice("accept", "the release lands, yanked and named as such"),
487                    choice("refuse", "nothing lands; name another release"),
488                ],
489            },
490            depends_on: Vec::new(),
491            selected: selected(decision::id::ACCEPT_YANKED),
492        });
493    }
494    decisions
495}
496
497/// What a migration asks before it moves anything.
498fn migration_decisions(
499    inputs: &Inputs<'_>,
500    structural: usize,
501    findings: &[Finding],
502) -> Vec<Decision> {
503    let selected = |id: &str| inputs.selections.get(id).cloned();
504    let mut decisions = Vec::new();
505    let mut choices = vec![choice(
506        "sweep",
507        "every durable fact moves into its owner, and the old convention retires",
508    )];
509    // Incremental is offered only where nothing structural would
510    // make two conventions coexist.
511    if structural == 0 {
512        choices.push(choice(
513            "incremental",
514            "each document converts the next time somebody edits it",
515        ));
516    }
517    decisions.push(Decision {
518        id: decision::id::MIGRATION_SCOPE.to_string(),
519        question: "how much of the corpus moves?".to_string(),
520        schema: AnswerSchema::Choice { choices },
521        depends_on: vec![decision::id::PROFILE.to_string()],
522        selected: selected(decision::id::MIGRATION_SCOPE),
523    });
524    if findings
525        .iter()
526        .any(|found| found.kind == FindingKind::Budget)
527    {
528        decisions.push(Decision {
529            id: decision::id::DEBT_BASELINE.to_string(),
530            question: "are the inherited violations recorded as debt?".to_string(),
531            schema: AnswerSchema::Choice {
532                choices: vec![
533                    choice(
534                        "record",
535                        "each inherited violation becomes a ceiling that only comes down",
536                    ),
537                    choice(
538                        "skip",
539                        "nothing is recorded, and each violation fails its gate",
540                    ),
541                ],
542            },
543            depends_on: vec![decision::id::MIGRATION_SCOPE.to_string()],
544            selected: selected(decision::id::DEBT_BASELINE),
545        });
546    }
547    decisions
548}
549
550/// Every write the plan derives for itself, where the caller gave none.
551///
552/// Until the profile is chosen, every destination is unknown, so the
553/// planner offers no operation rather than one against a default nobody
554/// selected.
555fn derived_operations(
556    inputs: &Inputs<'_>,
557    profile: Option<ProfileId>,
558    classification: Classification,
559    decisions: &[Decision],
560) -> Vec<Operation> {
561    if profile.is_none() {
562        return Vec::new();
563    }
564    // A first landing is a whole projection: the managed and adopted files,
565    // the two marked regions, and the record. The caller derives that from
566    // one computation and hands it over. Deriving a partial one here would
567    // offer an operator a landing that leaves a target the verifier cannot
568    // read, so where the caller gave none there is none.
569    if matches!(
570        classification,
571        Classification::Setup | Classification::Migration
572    ) {
573        return Vec::new();
574    }
575    operations_of(inputs, profile, classification, decisions)
576}
577
578/// Every write the plan will make.
579fn operations_of(
580    inputs: &Inputs<'_>,
581    profile: Option<ProfileId>,
582    classification: Classification,
583    decisions: &[Decision],
584) -> Vec<Operation> {
585    let mut operations = Vec::new();
586    if matches!(
587        classification,
588        Classification::Invalid | Classification::Current
589    ) {
590        return operations;
591    }
592    let Some(profile) = profile else {
593        return operations;
594    };
595    let Some(docs_root) = inputs.declaration.docs_root(profile) else {
596        return operations;
597    };
598    let held = crate::plan::observe::held_by_path(inputs.observation.installation.as_ref());
599    let recorded: BTreeMap<&str, &RecordedFile> = inputs
600        .observation
601        .installation
602        .iter()
603        .flat_map(|installation| installation.adopted.iter())
604        .map(|file| (file.path.as_str(), file))
605        .collect();
606
607    for projection in &inputs.declaration.managed {
608        let Some(after) = inputs.candidate.get(&projection.source) else {
609            continue;
610        };
611        let Ok(path) = TargetPath::new(&projection.destination) else {
612            continue;
613        };
614        let before = held.get(path.as_str()).cloned();
615        if before.as_ref() == Some(after) {
616            continue;
617        }
618        operations.push(Operation::WriteFile {
619            path,
620            class: Class::Managed,
621            before,
622            after: after.clone(),
623        });
624    }
625
626    for projection in &inputs.declaration.adopted {
627        let Some(seed) = inputs.candidate.get(&projection.source) else {
628            continue;
629        };
630        let destination = resolve_destination(&projection.destination, docs_root);
631        let Ok(path) = TargetPath::new(destination.as_str()) else {
632            continue;
633        };
634        match held.get(path.as_str()) {
635            // An adopted file the target holds is the project's. Only the
636            // baseline it is read against moves.
637            Some(current) => {
638                let baseline_before = recorded
639                    .get(path.as_str())
640                    .and_then(|file| file.baseline.clone())
641                    .or_else(|| {
642                        inputs
643                            .baseline
644                            .and_then(|held| held.get(&projection.source).cloned())
645                    });
646                let Some(baseline_before) = baseline_before else {
647                    continue;
648                };
649                if &baseline_before == seed {
650                    continue;
651                }
652                operations.push(Operation::KeepFile {
653                    path,
654                    held: current.clone(),
655                    baseline_before,
656                    baseline_after: seed.clone(),
657                });
658            }
659            None => operations.push(Operation::WriteFile {
660                path,
661                class: Class::Adopted,
662                before: None,
663                after: seed.clone(),
664            }),
665        }
666    }
667
668    // The two operator-invoked writes into adopted state appear only when
669    // their decision is selected, never because a version moved.
670    let selected = |id: &str| {
671        decisions
672            .iter()
673            .find(|decision| decision.id == id)
674            .and_then(|decision| decision.selected.as_deref())
675    };
676    // The debt write waits for the budget findings that give it content:
677    // an operation whose bytes nothing carries is an operation an apply
678    // could not execute, and the plan does not offer one.
679    let _ = selected(decision::id::DEBT_BASELINE);
680    operations
681}
682
683/// Everything that must hold before the apply.
684fn preconditions_of(
685    inputs: &Inputs<'_>,
686    classification: Classification,
687    operations: &[Operation],
688    decisions: &[Decision],
689    structural: usize,
690) -> Vec<Precondition> {
691    let mut preconditions: Vec<Precondition> = Vec::new();
692    macro_rules! require {
693        ($id:expr, $statement:expr, $requirement:expr, $evaluation:expr) => {
694            preconditions.push(Precondition {
695                id: ($id).to_string(),
696                statement: $statement,
697                requirement: $requirement,
698                evaluation: $evaluation,
699                resolved_by: None,
700                evidence_refs: Vec::new(),
701            });
702        };
703    }
704
705    if let Some(reason) = inputs.observation.invalid.as_ref() {
706        require!(
707            "record-is-readable",
708            "the instance record parses".to_string(),
709            Requirement::Required,
710            Evaluation::Unsatisfied {
711                reason: reason.clone(),
712            }
713        );
714    }
715
716    let waiting = decision_preconditions(decisions);
717
718    let edited = edited_managed_files(inputs);
719    if !edited.is_empty() {
720        require!(
721            "managed-files-are-unedited",
722            "every managed file still holds what the record says".to_string(),
723            Requirement::Required,
724            Evaluation::Unsatisfied {
725                reason: edited.join("; "),
726            }
727        );
728    }
729
730    if inputs.baseline.is_none() && inputs.observation.installation.is_some() {
731        require!(
732            "baseline-is-readable",
733            "the recorded release's bundle can be read for baselines".to_string(),
734            Requirement::Advisory,
735            Evaluation::NotObserved {
736                reason:
737                    "the recorded release's bundle was not read, so an adopted baseline cannot move"
738                        .to_string(),
739            }
740        );
741    }
742
743    // An incremental migration over a structural finding would start a
744    // second convention beside the first, which is the harm the sweep
745    // exists to stop.
746    let scope = decisions
747        .iter()
748        .find(|decision| decision.id == decision::id::MIGRATION_SCOPE)
749        .and_then(|decision| decision.selected.as_deref());
750    if classification == Classification::Migration && scope == Some("incremental") && structural > 0
751    {
752        require!(
753            "incremental-scope-has-no-structural-finding",
754            "an incremental migration leaves no structural finding behind".to_string(),
755            Requirement::Required,
756            Evaluation::Unsatisfied {
757                reason: format!(
758                    "{structural} structural finding(s) would make two conventions coexist"
759                ),
760            }
761        );
762    }
763
764    preconditions.extend(waiting);
765
766    if let Err(clash) = no_duplicate_destination(operations) {
767        require!(
768            "no-destination-is-written-twice",
769            "each destination is written by at most one operation".to_string(),
770            Requirement::Required,
771            Evaluation::Unsatisfied {
772                reason: clash.to_string(),
773            }
774        );
775    }
776    preconditions
777}
778
779/// One measurement that is over its budget, as a finding.
780///
781/// A count over its cap and a condition that does not hold are both debt
782/// the corpus arrived with. The second carries no number, so it reports as
783/// one against zero: the dimension is the fact, and what the ceiling
784/// records is that the exception exists.
785fn budget_finding(measured: &crate::domain::debt::Measurement) -> Option<Finding> {
786    let path = TargetPath::new(&measured.path).ok()?;
787    let (statement, found, cap) = match measured.value {
788        crate::domain::debt::Measured::Count { value, budget } if value > budget => (
789            format!(
790                "{} is {value} {} against a budget of {budget}",
791                measured.path, measured.dimension
792            ),
793            value as u64,
794            budget as u64,
795        ),
796        crate::domain::debt::Measured::Flag(true) => (
797            format!("{} carries {}", measured.path, measured.dimension),
798            1,
799            0,
800        ),
801        _ => return None,
802    };
803    Some(Finding {
804        kind: FindingKind::Budget,
805        path,
806        rule: measured.gate.to_string(),
807        statement,
808        measurement: Some(crate::plan::finding::Measurement {
809            dimension: measured.dimension.to_string(),
810            found,
811            cap,
812        }),
813    })
814}
815
816/// Every managed file the target no longer holds as the record says.
817///
818/// Collected in one pass rather than refused one at a time, so an operator
819/// sees the whole conflict set in one run.
820fn edited_managed_files(inputs: &Inputs<'_>) -> Vec<String> {
821    let mut edited = Vec::new();
822    let Some(installation) = inputs.observation.installation.as_ref() else {
823        return edited;
824    };
825    for file in &installation.managed {
826        match file.held.as_ref() {
827            Some(found) if found == &file.recorded => {}
828            Some(_) => edited.push(format!("{} was edited", file.path)),
829            None => edited.push(format!("{} is gone", file.path)),
830        }
831    }
832    // A marked region is managed too. The next landing re-splices it, so
833    // an edit inside the markers would be lost. Every byte outside them is
834    // the project's own and is not compared.
835    for block in &installation.blocks {
836        match block.held.as_ref() {
837            Some(found) if found == &block.recorded => {}
838            Some(_) => edited.push(format!("the managed block in {} was edited", block.path)),
839            None => edited.push(format!("the managed block in {} is gone", block.path)),
840        }
841    }
842    edited
843}
844
845/// One precondition per decision the operator has not answered.
846fn decision_preconditions(decisions: &[Decision]) -> Vec<Precondition> {
847    decisions
848        .iter()
849        .map(|decision| Precondition {
850            id: format!("decision:{}", decision.id),
851            statement: decision.question.clone(),
852            requirement: Requirement::DecisionRequired,
853            evaluation: decision.selected.as_ref().map_or_else(
854                || Evaluation::Unsatisfied {
855                    reason: "the operator has not answered it".to_string(),
856                },
857                |_| Evaluation::Satisfied,
858            ),
859            resolved_by: Some(decision.id.clone()),
860            evidence_refs: Vec::new(),
861        })
862        .collect()
863}
864
865/// What the apply proves once it has finished.
866fn postconditions_of(classification: Classification) -> Vec<Postcondition> {
867    if classification == Classification::Invalid {
868        return Vec::new();
869    }
870    vec![
871        Postcondition {
872            id: "record-matches-the-tree".to_string(),
873            statement: "every operation's destination holds the digest the plan named".to_string(),
874        },
875        Postcondition {
876            id: "verification-passes".to_string(),
877            statement: "sdd verify reports OK against the target".to_string(),
878        },
879    ]
880}
881
882/// Exactly the parts that decide what the apply would do.
883///
884/// Timestamps, presentation text, and advisory evidence are out: a plan
885/// recomputed a second later must carry the same identity, or an approval
886/// could never survive the moment it was given.
887fn inputs_projection(
888    inputs: &Inputs<'_>,
889    classification: Classification,
890    operations: &[Operation],
891    preconditions: &[Precondition],
892    decisions: &[Decision],
893) -> Value {
894    let operations = Value::List(
895        operations
896            .iter()
897            // The record carries the moment of installation, which is
898            // exactly what the fingerprint must exclude, so its digest
899            // cannot stand for it. What it uniquely says is projected
900            // below instead: dropping the whole operation would let two
901            // plans that record different declarations share one id.
902            .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
903            .map(|operation| {
904                Value::map([
905                    ("kind", Value::text(operation.kind())),
906                    ("path", Value::text(operation.path().as_str())),
907                    (
908                        "before",
909                        Value::maybe(operation.before().map(std::string::ToString::to_string)),
910                    ),
911                    (
912                        "after",
913                        Value::maybe(operation.after().map(std::string::ToString::to_string)),
914                    ),
915                ])
916            })
917            .collect(),
918    );
919    // Only a precondition that can change readiness or operations belongs
920    // here. An advisory one cannot, by definition.
921    let gates = Value::List(
922        preconditions
923            .iter()
924            .filter(|precondition| precondition.requirement != Requirement::Advisory)
925            .map(|precondition| {
926                Value::map([
927                    ("id", Value::text(precondition.id.as_str())),
928                    (
929                        "state",
930                        Value::text(match precondition.evaluation {
931                            Evaluation::Satisfied => "satisfied",
932                            Evaluation::NotObserved { .. } => "not-observed",
933                            Evaluation::Unsatisfied { .. } => "unsatisfied",
934                        }),
935                    ),
936                ])
937            })
938            .collect(),
939    );
940    let selected = Value::Map(
941        decisions
942            .iter()
943            .filter_map(|decision| {
944                decision
945                    .selected
946                    .as_ref()
947                    .map(|answer| (decision.id.clone(), Value::text(answer.as_str())))
948            })
949            .collect(),
950    );
951    Value::map([
952        ("schema", Value::text(PLAN_SCHEMA)),
953        ("classification", Value::text(classification.as_str())),
954        ("release", Value::text(inputs.release.as_str())),
955        (
956            "release_sha256",
957            Value::text(inputs.release_sha256.as_str()),
958        ),
959        (
960            "target",
961            Value::text(inputs.observation.repository.root.as_str()),
962        ),
963        (
964            "record",
965            Value::maybe(
966                inputs
967                    .observation
968                    .installation
969                    .as_ref()
970                    .map(|installation| installation.record_sha256.to_string()),
971            ),
972        ),
973        (
974            "declaration",
975            Value::maybe(
976                inputs
977                    .observation
978                    .installation
979                    .as_ref()
980                    .and_then(|installation| installation.declaration_sha256.as_ref())
981                    .map(std::string::ToString::to_string),
982            ),
983        ),
984        ("operations", operations),
985        ("preconditions", gates),
986        ("decisions", selected),
987        ("declared", declared_projection(inputs)),
988    ])
989}
990
991/// What the record will say that no other operation carries.
992///
993/// The record's own digest cannot go in the fingerprint: it carries the
994/// moment of installation, and a plan recomputed a second later would
995/// stop matching itself. So the fields that decide what the record says
996/// are projected on their own, and the timestamp alone stays out.
997fn declared_projection(inputs: &Inputs<'_>) -> Value {
998    Value::map([
999        (
1000            "profile",
1001            Value::maybe(
1002                inputs
1003                    .selections
1004                    .get(crate::plan::decision::id::PROFILE)
1005                    .cloned(),
1006            ),
1007        ),
1008        (
1009            "reserved",
1010            Value::List(
1011                inputs
1012                    .reserve
1013                    .iter()
1014                    .map(|path| Value::text(path.as_str()))
1015                    .collect(),
1016            ),
1017        ),
1018        (
1019            "record",
1020            Value::maybe(inputs.declared.map(std::string::ToString::to_string)),
1021        ),
1022    ])
1023}
1024
1025/// Whether a plan may be applied, for a caller that has only the verdict.
1026#[must_use]
1027pub const fn is_ready(verdict: Readiness) -> bool {
1028    matches!(verdict, Readiness::Ready)
1029}