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