Skip to main content

spec_driven_docs/plan/
guidance.rs

1//! What a release asks of an instance that takes it.
2//!
3//! A changelog is written for a reader deciding whether to upgrade. A plan
4//! is read by somebody who already decided. The two answer different
5//! questions, so the bundle owes the second one as data a plan can filter
6//! rather than prose an operator reads whole.
7//!
8//! One file per release that needs one, never per release. The index is
9//! the complete coverage ledger from the capability floor onward, so a
10//! missing prose file cannot strand an interval and a missing entry is a
11//! release-time failure rather than a silent gap.
12
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::domain::version::CanonVersion;
17use crate::plan::decision::{AnswerSchema, Choice, Decision, Selections};
18use crate::plan::readiness::{Evaluation, Precondition, Requirement};
19
20/// Where the ledger sits inside a bundle.
21pub const INDEX_PATH: &str = "guidance/index.toml";
22
23/// The ledger schema this engine reads.
24pub const INDEX_SCHEMA: &str = "sdd.guidance-index/1";
25
26/// The step-file schema this engine reads.
27pub const SCHEMA: &str = "sdd.guidance/1";
28
29/// The word an index entry uses when a release asks nothing.
30pub const NONE: &str = "none";
31
32/// Guidance this engine cannot read.
33#[derive(Debug, Error, PartialEq, Eq)]
34pub enum GuidanceError {
35    /// The bytes are not the shape they claim.
36    #[error("{path} does not parse: {reason}")]
37    Malformed {
38        /// Which file.
39        path: String,
40        /// What the parser found.
41        reason: String,
42    },
43
44    /// The file is written in a schema this engine does not read.
45    #[error("{path} declares schema {found}, and this engine reads {expected}")]
46    UnknownSchema {
47        /// Which file.
48        path: String,
49        /// What it declares.
50        found: String,
51        /// What this engine reads.
52        expected: String,
53    },
54
55    /// The file contradicts itself.
56    #[error("{path} is inconsistent: {reason}")]
57    Inconsistent {
58        /// Which file.
59        path: String,
60        /// What is wrong.
61        reason: String,
62    },
63}
64
65/// What one release asks, in kind.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum StepKind {
69    /// An adopted seed the projection now lands.
70    SeedAdded,
71    /// A rule ID that no longer resolves.
72    RuleRetired,
73    /// A managed file whose bytes changed.
74    ManagedChanged,
75    /// A key the project can now set.
76    DeclarationKeyAdded,
77    /// A gate whose judged set grew.
78    GateWidened,
79}
80
81/// Who takes one step.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum Actor {
85    /// The plan's own operations already carry it.
86    Plan,
87    /// A person takes it.
88    Operator,
89}
90
91/// The destination vocabulary a step filters against.
92///
93/// Closed, because a step naming a destination nothing recognizes cannot
94/// be filtered and would reach every target.
95pub const DESTINATIONS: &[&str] = &[
96    "specs",
97    "decisions",
98    "reference",
99    "guides",
100    "declaration",
101    "debt",
102    "markdownlint",
103    "hooks-config",
104    "agents-digest",
105    "plan-zone",
106];
107
108/// One thing a release asks.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct Step {
112    /// A slug that stays put for the life of the release.
113    pub id: String,
114    /// What kind of change it is.
115    pub kind: StepKind,
116    /// Whether a target that ignores it fails a gate or loses a route.
117    pub breaking: bool,
118    /// Which destinations it applies to.
119    pub destinations: Vec<String>,
120    /// Who takes it.
121    pub actor: Actor,
122    /// The prose body, relative to the release's own directory.
123    pub text: String,
124}
125
126impl Step {
127    /// The decision identifier this step carries.
128    ///
129    /// Derived from the release and the step's own slug, never from
130    /// display text, so a reworded body moves no plan's identity.
131    #[must_use]
132    pub fn decision_id(&self, release: &CanonVersion) -> String {
133        format!("guidance:{release}:{}", self.id)
134    }
135}
136
137/// What one release asks, whole.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Guidance {
141    /// Always [`SCHEMA`] once parsed.
142    pub schema: String,
143    /// The release this describes.
144    pub release: CanonVersion,
145    /// Every step, in the order the release wrote them.
146    pub steps: Vec<Step>,
147}
148
149impl Guidance {
150    /// Read one release's steps.
151    ///
152    /// # Errors
153    ///
154    /// [`GuidanceError`] for bytes that do not parse, a schema this engine
155    /// does not read, a duplicate step identifier, an empty or unknown
156    /// destination, or a body the bundle does not carry.
157    pub fn parse(path: &str, bytes: &[u8], bodies: &[String]) -> Result<Self, GuidanceError> {
158        let malformed = |reason: String| GuidanceError::Malformed {
159            path: path.to_string(),
160            reason,
161        };
162        let text = std::str::from_utf8(bytes).map_err(|source| malformed(source.to_string()))?;
163        let held: Self = toml::from_str(text).map_err(|source| malformed(source.to_string()))?;
164        if held.schema != SCHEMA {
165            return Err(GuidanceError::UnknownSchema {
166                path: path.to_string(),
167                found: held.schema,
168                expected: SCHEMA.to_string(),
169            });
170        }
171        let inconsistent = |reason: String| GuidanceError::Inconsistent {
172            path: path.to_string(),
173            reason,
174        };
175        let mut seen: Vec<&str> = Vec::new();
176        for step in &held.steps {
177            if seen.contains(&step.id.as_str()) {
178                return Err(inconsistent(format!("{} appears twice", step.id)));
179            }
180            seen.push(&step.id);
181            if step.destinations.is_empty() {
182                return Err(inconsistent(format!("{} names no destination", step.id)));
183            }
184            for destination in &step.destinations {
185                if !DESTINATIONS.contains(&destination.as_str()) {
186                    return Err(inconsistent(format!(
187                        "{} names the destination {destination}, which is not one this engine filters against",
188                        step.id
189                    )));
190                }
191            }
192            let body = format!("guidance/{}/{}", held.release, step.text);
193            if !bodies.contains(&body) {
194                return Err(inconsistent(format!(
195                    "{} names the body {body}, which the bundle does not carry",
196                    step.id
197                )));
198            }
199        }
200        Ok(held)
201    }
202
203    /// Every step that reaches one target's destinations.
204    #[must_use]
205    pub fn filtered(&self, held: &[String]) -> Vec<&Step> {
206        self.steps
207            .iter()
208            .filter(|step| {
209                step.destinations
210                    .iter()
211                    .any(|destination| held.contains(destination))
212            })
213            .collect()
214    }
215}
216
217/// One release's coverage.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct IndexEntry {
221    /// Which release.
222    pub version: CanonVersion,
223    /// The file that carries its steps, or [`NONE`].
224    pub guidance: String,
225}
226
227/// The complete coverage ledger.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct Index {
231    /// Always [`INDEX_SCHEMA`] once parsed.
232    pub schema: String,
233    /// The lowest release this ledger covers.
234    pub capability_floor: CanonVersion,
235    /// One entry per covered release.
236    pub releases: Vec<IndexEntry>,
237}
238
239impl Index {
240    /// Read the ledger.
241    ///
242    /// # Errors
243    ///
244    /// [`GuidanceError`] for bytes that do not parse, a schema this engine
245    /// does not read, or a release listed twice.
246    pub fn parse(bytes: &[u8]) -> Result<Self, GuidanceError> {
247        let malformed = |reason: String| GuidanceError::Malformed {
248            path: INDEX_PATH.to_string(),
249            reason,
250        };
251        let text = std::str::from_utf8(bytes).map_err(|source| malformed(source.to_string()))?;
252        let held: Self = toml::from_str(text).map_err(|source| malformed(source.to_string()))?;
253        if held.schema != INDEX_SCHEMA {
254            return Err(GuidanceError::UnknownSchema {
255                path: INDEX_PATH.to_string(),
256                found: held.schema,
257                expected: INDEX_SCHEMA.to_string(),
258            });
259        }
260        let mut seen: Vec<CanonVersion> = Vec::new();
261        for entry in &held.releases {
262            if seen.contains(&entry.version) {
263                return Err(GuidanceError::Inconsistent {
264                    path: INDEX_PATH.to_string(),
265                    reason: format!("{} appears twice", entry.version),
266                });
267            }
268            seen.push(entry.version);
269        }
270        Ok(held)
271    }
272
273    /// What the ledger says about one release.
274    #[must_use]
275    pub fn entry(&self, version: CanonVersion) -> Option<&IndexEntry> {
276        self.releases.iter().find(|entry| entry.version == version)
277    }
278
279    /// Every release in the half-open interval, in order.
280    #[must_use]
281    pub fn interval(&self, from: Option<CanonVersion>, to: CanonVersion) -> Vec<&IndexEntry> {
282        let mut found: Vec<&IndexEntry> = self
283            .releases
284            .iter()
285            .filter(|entry| from.is_none_or(|held| entry.version > held) && entry.version <= to)
286            .collect();
287        found.sort_by_key(|entry| entry.version);
288        found
289    }
290}
291
292/// How much of an interval the ledger covers.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum Coverage {
296    /// Every release in the interval has an entry.
297    Complete,
298    /// The recorded release predates the floor.
299    Partial,
300}
301
302/// What the ledger says about one interval.
303#[must_use]
304pub fn coverage(index: &Index, from: Option<CanonVersion>) -> Coverage {
305    match from {
306        Some(recorded) if recorded < index.capability_floor => Coverage::Partial,
307        _ => Coverage::Complete,
308    }
309}
310
311/// The preconditions and decisions one interval's guidance carries.
312#[derive(Debug, Clone, Default)]
313pub struct Briefing {
314    /// Everything that must hold first.
315    pub preconditions: Vec<Precondition>,
316    /// Everything the operator accepts knowingly.
317    pub decisions: Vec<Decision>,
318    /// Every step that reaches this target, as `<release>:<id>`.
319    pub applicable: Vec<String>,
320    /// How many steps the target's destinations excluded.
321    pub excluded: usize,
322}
323
324/// Read one interval's guidance against one target.
325///
326/// A breaking step is a decision the operator accepts with the step's own
327/// body in front of them. An additive step is reported and blocks nothing.
328/// Partial coverage is a decision rather than a gap, because the operator
329/// can still say they know what is missing.
330#[must_use]
331pub fn brief(
332    index: &Index,
333    files: &[(CanonVersion, Guidance)],
334    from: Option<CanonVersion>,
335    destinations: &[String],
336    selections: &Selections,
337) -> Briefing {
338    let mut briefing = Briefing::default();
339    for (release, guidance) in files {
340        let applicable = guidance.filtered(destinations);
341        briefing.excluded += guidance.steps.len() - applicable.len();
342        for step in applicable {
343            let id = step.decision_id(release);
344            briefing.applicable.push(id.clone());
345            if !step.breaking {
346                continue;
347            }
348            let selected = selections.get(&id).cloned();
349            briefing.decisions.push(Decision {
350                question: format!("{release} asks: {}", step.id.replace('-', " ")),
351                schema: AnswerSchema::Choice {
352                    choices: vec![
353                        Choice {
354                            id: "accepted".to_string(),
355                            consequence: format!(
356                                "read guidance/{release}/{} and take the step",
357                                step.text
358                            ),
359                        },
360                        Choice {
361                            id: "not-applicable".to_string(),
362                            consequence: "this target does not carry what the step is about"
363                                .to_string(),
364                        },
365                    ],
366                },
367                depends_on: Vec::new(),
368                selected,
369                id,
370            });
371        }
372    }
373    if coverage(index, from) == Coverage::Partial {
374        let id = "guidance-coverage".to_string();
375        let selected = selections.get(&id).cloned();
376        briefing.preconditions.push(Precondition {
377            id: "guidance-is-covered".to_string(),
378            statement: "every release in the interval carries its guidance".to_string(),
379            requirement: Requirement::DecisionRequired,
380            evaluation: selected.as_ref().map_or_else(
381                || Evaluation::NotObserved {
382                    reason: format!(
383                        "the target records a release below {}, which this engine does not brief",
384                        index.capability_floor
385                    ),
386                },
387                |_| Evaluation::Satisfied,
388            ),
389            resolved_by: Some(id.clone()),
390            evidence_refs: vec!["release".to_string()],
391        });
392        briefing.decisions.push(Decision {
393            question: format!(
394                "guidance starts at {}; proceed without what came before?",
395                index.capability_floor
396            ),
397            schema: AnswerSchema::Choice {
398                choices: vec![
399                    Choice {
400                        id: "accepted".to_string(),
401                        consequence: "the plan proceeds with the guidance it has".to_string(),
402                    },
403                    Choice {
404                        id: "refuse".to_string(),
405                        consequence: "nothing lands; read the changelog first".to_string(),
406                    },
407                ],
408            },
409            depends_on: Vec::new(),
410            selected,
411            id,
412        });
413    }
414    for decision in &briefing.decisions {
415        if decision.id == "guidance-coverage" {
416            continue;
417        }
418        briefing.preconditions.push(Precondition {
419            id: format!("step:{}", decision.id),
420            statement: decision.question.clone(),
421            requirement: Requirement::DecisionRequired,
422            evaluation: decision.selected.as_ref().map_or_else(
423                || Evaluation::Unsatisfied {
424                    reason: "the operator has not accepted this step".to_string(),
425                },
426                |_| Evaluation::Satisfied,
427            ),
428            resolved_by: Some(decision.id.clone()),
429            evidence_refs: vec!["release".to_string()],
430        });
431    }
432    briefing
433}
434
435#[cfg(test)]
436mod tests {
437    #![allow(
438        clippy::unwrap_used,
439        reason = "a test panics as its failure signal, not as control flow"
440    )]
441
442    use super::*;
443
444    fn version(value: &str) -> CanonVersion {
445        value.parse().unwrap()
446    }
447
448    fn bodies() -> Vec<String> {
449        vec!["guidance/0.7.0/one.md".to_string()]
450    }
451
452    const ONE: &str = r#"
453schema = "sdd.guidance/1"
454release = "0.7.0"
455
456[[steps]]
457id = "one"
458kind = "rule-retired"
459breaking = true
460destinations = ["specs"]
461actor = "operator"
462text = "one.md"
463"#;
464
465    #[test]
466    fn a_step_declares_every_part_and_parses() {
467        let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
468        assert_eq!(held.release, version("0.7.0"));
469        assert_eq!(held.steps[0].kind, StepKind::RuleRetired);
470        assert_eq!(held.steps[0].actor, Actor::Operator);
471        assert!(held.steps[0].breaking);
472    }
473
474    #[test]
475    fn a_decision_id_derives_from_the_release_and_the_step_id() {
476        let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
477        assert_eq!(
478            held.steps[0].decision_id(&version("0.7.0")),
479            "guidance:0.7.0:one"
480        );
481        // A body rewritten under the same name keeps the identifier: the
482        // decision is the step, not the prose that explains it.
483        let reworded = ONE.replace("kind = \"rule-retired\"", "kind = \"gate-widened\"");
484        let again = Guidance::parse("guidance/0.7.0.toml", reworded.as_bytes(), &bodies()).unwrap();
485        assert_eq!(
486            again.steps[0].decision_id(&version("0.7.0")),
487            held.steps[0].decision_id(&version("0.7.0"))
488        );
489    }
490
491    #[test]
492    fn guidance_refuses_what_it_cannot_filter_or_resolve() {
493        let cases = [
494            (
495                ONE.replace("kind = \"rule-retired\"", "kind = \"invented\""),
496                "parse",
497            ),
498            (
499                ONE.replace("destinations = [\"specs\"]", "destinations = []"),
500                "destination",
501            ),
502            (
503                ONE.replace("destinations = [\"specs\"]", "destinations = [\"nowhere\"]"),
504                "destination",
505            ),
506            (
507                ONE.replace("text = \"one.md\"", "text = \"absent.md\""),
508                "body",
509            ),
510            (format!("{ONE}extra = 1\n"), "parse"),
511            (format!("{ONE}{ONE}"), "parse"),
512        ];
513        for (text, _) in cases {
514            assert!(
515                Guidance::parse("guidance/0.7.0.toml", text.as_bytes(), &bodies()).is_err(),
516                "{text}"
517            );
518        }
519    }
520
521    #[test]
522    fn a_duplicate_step_identifier_refuses() {
523        let doubled = format!(
524            "{ONE}\n[[steps]]\nid = \"one\"\nkind = \"seed-added\"\nbreaking = false\ndestinations = [\"specs\"]\nactor = \"plan\"\ntext = \"one.md\"\n"
525        );
526        let error =
527            Guidance::parse("guidance/0.7.0.toml", doubled.as_bytes(), &bodies()).unwrap_err();
528        assert!(error.to_string().contains("twice"), "{error}");
529    }
530
531    #[test]
532    fn a_step_is_filtered_against_the_targets_destinations() {
533        let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
534        assert_eq!(held.filtered(&["specs".to_string()]).len(), 1);
535        assert_eq!(held.filtered(&["debt".to_string()]).len(), 0);
536    }
537
538    fn index() -> Index {
539        Index::parse(
540            br#"
541schema = "sdd.guidance-index/1"
542capability_floor = "0.6.6"
543
544[[releases]]
545version = "0.6.6"
546guidance = "none"
547
548[[releases]]
549version = "0.7.0"
550guidance = "0.7.0.toml"
551
552[[releases]]
553version = "0.7.1"
554guidance = "none"
555"#,
556        )
557        .unwrap()
558    }
559
560    #[test]
561    fn the_interval_is_derived_from_the_ledger_alone() {
562        let held = index();
563        let found: Vec<String> = held
564            .interval(Some(version("0.6.6")), version("0.7.1"))
565            .iter()
566            .map(|entry| entry.version.to_string())
567            .collect();
568        assert_eq!(found, ["0.7.0", "0.7.1"]);
569        assert_eq!(held.interval(None, version("0.6.6")).len(), 1);
570        assert_eq!(held.entry(version("0.7.0")).unwrap().guidance, "0.7.0.toml");
571        assert_eq!(held.entry(version("0.7.1")).unwrap().guidance, NONE);
572        assert!(held.entry(version("9.9.9")).is_none());
573    }
574
575    #[test]
576    fn a_release_below_the_floor_is_partial_coverage() {
577        let held = index();
578        assert_eq!(coverage(&held, Some(version("0.6.5"))), Coverage::Partial);
579        assert_eq!(coverage(&held, Some(version("0.6.6"))), Coverage::Complete);
580        assert_eq!(coverage(&held, None), Coverage::Complete);
581    }
582
583    #[test]
584    fn an_additive_upgrade_needs_no_decision_and_a_breaking_one_does() {
585        let additive = ONE.replace("breaking = true", "breaking = false");
586        let held = Guidance::parse("guidance/0.7.0.toml", additive.as_bytes(), &bodies()).unwrap();
587        let briefing = brief(
588            &index(),
589            &[(version("0.7.0"), held)],
590            Some(version("0.6.6")),
591            &["specs".to_string()],
592            &Selections::new(),
593        );
594        assert!(briefing.decisions.is_empty());
595        assert_eq!(briefing.applicable, ["guidance:0.7.0:one"]);
596
597        let breaking = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
598        let briefing = brief(
599            &index(),
600            &[(version("0.7.0"), breaking.clone())],
601            Some(version("0.6.6")),
602            &["specs".to_string()],
603            &Selections::new(),
604        );
605        assert_eq!(briefing.decisions.len(), 1);
606        assert_eq!(briefing.preconditions.len(), 1);
607        assert_eq!(
608            briefing.preconditions[0].verdict(),
609            crate::plan::readiness::Readiness::NeedsDecision
610        );
611
612        let mut selections = Selections::new();
613        selections.insert("guidance:0.7.0:one".to_string(), "accepted".to_string());
614        let briefing = brief(
615            &index(),
616            &[(version("0.7.0"), breaking)],
617            Some(version("0.6.6")),
618            &["specs".to_string()],
619            &selections,
620        );
621        assert_eq!(
622            briefing.preconditions[0].verdict(),
623            crate::plan::readiness::Readiness::Ready
624        );
625    }
626
627    #[test]
628    fn a_filtered_out_step_is_counted_rather_than_hidden() {
629        let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
630        let briefing = brief(
631            &index(),
632            &[(version("0.7.0"), held)],
633            Some(version("0.6.6")),
634            &["debt".to_string()],
635            &Selections::new(),
636        );
637        assert_eq!(briefing.excluded, 1);
638        assert!(briefing.applicable.is_empty());
639        assert!(briefing.decisions.is_empty());
640    }
641
642    #[test]
643    fn partial_coverage_is_a_decision_a_selection_resolves() {
644        let briefing = brief(
645            &index(),
646            &[],
647            Some(version("0.6.5")),
648            &["specs".to_string()],
649            &Selections::new(),
650        );
651        assert_eq!(briefing.preconditions.len(), 1);
652        assert_eq!(
653            briefing.preconditions[0].verdict(),
654            crate::plan::readiness::Readiness::NeedsDecision
655        );
656        let mut selections = Selections::new();
657        selections.insert("guidance-coverage".to_string(), "accepted".to_string());
658        let briefing = brief(
659            &index(),
660            &[],
661            Some(version("0.6.5")),
662            &["specs".to_string()],
663            &selections,
664        );
665        assert_eq!(
666            briefing.preconditions[0].verdict(),
667            crate::plan::readiness::Readiness::Ready
668        );
669    }
670}