spec_driven_docs/domain/policy.rs
1//! The sentinel rules: one per feature a project can declare against its
2//! own adopted specifications.
3//!
4//! A feature is available the moment the binary carries it. Its normative
5//! text arrives as a new adopted specification, seeded on the next upgrade,
6//! and each such specification carries one rule that authorizes the
7//! declaration. That rule ID is the sentinel. Whether an instance's
8//! specifications authorize what its configuration declares is read by
9//! presence of the sentinel and never by matching prose, because a rule ID
10//! survives rewording and a sentence does not.
11//!
12//! A sentinel never joins a delivered gate's `cites` list. That gate check is
13//! always-run, so a cited ID an instance's specifications lack would fail
14//! every commit in the window between upgrading the binary and running
15//! `sdd upgrade`. The canon suite holds that boundary.
16
17use std::sync::LazyLock;
18
19use crate::domain::projection::SentinelDeclaration;
20use crate::domain::rule_id::RuleId;
21
22/// One sentinel: the rule, and the adopted specification that owns it.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Sentinel {
25 /// The rule an instance's specifications must define.
26 pub rule: RuleId,
27 /// The payload specification that carries it.
28 pub source: String,
29 /// Where an instance holds that specification, with `{docs_root}`
30 /// templated.
31 pub destination: String,
32 /// The declaration it authorizes, for the note that names it.
33 pub declares: String,
34}
35
36/// Every sentinel this binary's own release declares.
37///
38/// A declared rule this engine does not know is dropped rather than
39/// refused: a release may authorize a feature a later engine renamed, and
40/// a sentinel nothing can look up is a note nobody can act on.
41pub static SENTINELS: LazyLock<Vec<Sentinel>> =
42 LazyLock::new(|| from_declaration(&crate::domain::profile::DECLARATION.sentinels));
43
44/// Read a declaration's sentinels, keeping the ones this engine knows.
45#[must_use]
46pub fn from_declaration(declared: &[SentinelDeclaration]) -> Vec<Sentinel> {
47 declared
48 .iter()
49 .filter_map(|entry| {
50 let rule = RuleId::ALL
51 .iter()
52 .copied()
53 .find(|known| known.as_str() == entry.rule)?;
54 Some(Sentinel {
55 rule,
56 source: entry.source.clone(),
57 destination: entry.destination.clone(),
58 declares: entry.declares.clone(),
59 })
60 })
61 .collect()
62}
63
64/// The sentinel a rule is, if it is one.
65#[must_use]
66pub fn sentinel(rule: RuleId) -> Option<&'static Sentinel> {
67 SENTINELS.iter().find(|held| held.rule == rule)
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn every_sentinel_is_owned_by_an_embedded_adopted_specification() {
76 for s in SENTINELS.iter() {
77 assert!(
78 crate::embedded::asset(&s.source).is_some(),
79 "{} is not embedded",
80 s.source
81 );
82 let adopted = crate::domain::profile::ProfileId::KnowledgeBase
83 .profile()
84 .adopted
85 .iter()
86 .any(|p| p.source == s.source && p.destination == s.destination);
87 assert!(adopted, "{} is not an adopted projection", s.source);
88 let text = crate::embedded::asset(&s.source)
89 .and_then(|bytes| std::str::from_utf8(bytes).ok())
90 .unwrap_or_default();
91 assert!(
92 crate::embedded::rule_ids_in(text).any(|id| id == s.rule.as_str()),
93 "{} does not define {}",
94 s.source,
95 s.rule
96 );
97 }
98 }
99
100 #[test]
101 fn no_delivered_gate_cites_a_sentinel() {
102 for row in crate::gates::GATES {
103 for rule in row.cites {
104 assert!(
105 sentinel(*rule).is_none(),
106 "{} cites the sentinel {rule}; an instance upgraded after the binary would fail every commit",
107 row.id
108 );
109 }
110 }
111 }
112}