Skip to main content

spec_driven_docs/services/
policy.rs

1//! Which declarations an instance's own specifications do not yet
2//! authorize.
3//!
4//! A project overruling a specification it owns is exercising ownership, so
5//! nothing here fails. This reads the declarations, reads the sentinel each
6//! one needs, and reports the ones whose sentinel the local specifications
7//! lack. `sdd verify` prints each as a note, and `sdd policy reconcile`
8//! offers the correction.
9//!
10//! The correction is conservative. It seeds the owning specification where
11//! the instance lacks it, and otherwise appends the sentinel's rule block,
12//! taken from the embedded seed, to the end of the local file's
13//! Requirements section. It never rewrites a sentence the project may have
14//! edited: a rule block is self-contained, and the unique-id gate catches a
15//! collision. A file not in that shape gets a checklist and no write.
16
17use std::collections::BTreeSet;
18
19use camino::Utf8Path;
20
21use camino::Utf8PathBuf;
22
23use crate::adapters::fs::write_atomic;
24use crate::domain::debt::Debt;
25use crate::domain::ownership::Sha256;
26use crate::domain::policy::{SENTINELS, Sentinel};
27use crate::domain::profile::{DocsRoot, resolve_destination};
28use crate::domain::rule_id::RuleId;
29use crate::error::AppError;
30
31/// One declaration its specification does not authorize.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Reconciliation {
34    /// The sentinel the specifications lack.
35    pub sentinel: &'static Sentinel,
36}
37
38impl Reconciliation {
39    /// The note `sdd verify` prints for it.
40    #[must_use]
41    pub fn note(&self, docs_root: DocsRoot) -> String {
42        format!(
43            "note: {} and no local specification defines `{}`; {} owns it; run 'sdd policy reconcile'",
44            self.sentinel.declares,
45            self.sentinel.rule,
46            crate::domain::profile::resolve_destination(self.sentinel.destination, docs_root)
47        )
48    }
49}
50
51/// Every rule ID the instance's own specifications define.
52///
53/// # Errors
54///
55/// I/O errors when a specification cannot be read.
56pub fn local_rule_ids(
57    target: &Utf8Path,
58    docs_root: DocsRoot,
59) -> Result<BTreeSet<String>, AppError> {
60    let specs = target.join(docs_root.as_str()).join("specs");
61    let mut ids = BTreeSet::new();
62    let Ok(entries) = specs.read_dir_utf8() else {
63        return Ok(ids);
64    };
65    for entry in entries.filter_map(Result::ok) {
66        let path = entry.path();
67        #[allow(
68            clippy::case_sensitive_file_extension_comparisons,
69            reason = "the corpus convention is lowercase"
70        )]
71        if !path.as_str().ends_with(".md") {
72            continue;
73        }
74        let text = std::fs::read_to_string(path)?;
75        ids.extend(crate::embedded::rule_ids_in(&text));
76    }
77    Ok(ids)
78}
79
80/// Whether the declaration a sentinel authorizes is active at the target.
81///
82/// A debt file that does not parse is not active: the verifier reports it
83/// as a failure of its own, and a note beside that failure would name a
84/// second problem where there is one.
85fn active(target: &Utf8Path, sentinel: &Sentinel) -> bool {
86    match sentinel.rule {
87        RuleId::RecordedDimensionOnlyShrinks => {
88            Debt::read(target).is_ok_and(|debt| !debt.is_empty())
89        }
90        RuleId::ProjectSelectsOneSource => {
91            crate::domain::instance_config::InstanceConfig::read(target).is_ok_and(|declaration| {
92                declaration.writing_style.source
93                    != crate::domain::instance_config::WritingSource::Builtin
94            })
95        }
96        _ => false,
97    }
98}
99
100/// Every active declaration whose sentinel the local specifications lack.
101///
102/// # Errors
103///
104/// I/O errors when a specification cannot be read.
105pub fn needed(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Reconciliation>, AppError> {
106    let defined = local_rule_ids(target, docs_root)?;
107    Ok(SENTINELS
108        .iter()
109        .filter(|sentinel| active(target, sentinel))
110        .filter(|sentinel| !defined.contains(sentinel.rule.as_str()))
111        .map(|sentinel| Reconciliation { sentinel })
112        .collect())
113}
114
115/// What one reconciliation would do.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Action {
118    /// The owning specification is absent: write the seed.
119    Seed {
120        /// Where it lands.
121        destination: Utf8PathBuf,
122        /// The seed's bytes.
123        bytes: Vec<u8>,
124    },
125    /// The owning specification is present and recognized: append the rule
126    /// block to its Requirements section.
127    Append {
128        /// The file.
129        destination: Utf8PathBuf,
130        /// The rule block, as the seed states it.
131        block: String,
132        /// The whole file after the append.
133        rewritten: String,
134    },
135    /// The owning specification is present and not in a recognized shape:
136    /// print the block and write nothing.
137    Checklist {
138        /// The file.
139        destination: Utf8PathBuf,
140        /// The rule block to add by hand.
141        block: String,
142    },
143}
144
145/// One reconciliation and what it would do.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Plan {
148    /// The declaration and its sentinel.
149    pub reconciliation: Reconciliation,
150    /// The action.
151    pub action: Action,
152}
153
154/// The sentinel's rule block, from the heading to the line before the next
155/// heading, as the embedded seed states it.
156#[must_use]
157pub fn rule_block(seed: &str, rule: RuleId) -> Option<String> {
158    let heading = format!("### `{rule}`");
159    let mut lines = seed.lines().skip_while(|line| !line.starts_with(&heading));
160    let first = lines.next()?;
161    let mut block = format!("{first}\n");
162    for line in lines {
163        if line.starts_with("### ") || line.starts_with("## ") {
164            break;
165        }
166        block.push_str(line);
167        block.push('\n');
168    }
169    Some(format!("{}\n", block.trim_end_matches('\n')))
170}
171
172/// The file with the block appended to the end of its Requirements section,
173/// or `None` where the file has no such section to append to.
174#[must_use]
175pub fn append_to_requirements(text: &str, block: &str) -> Option<String> {
176    let lines: Vec<&str> = text.lines().collect();
177    let start = lines.iter().position(|line| *line == "## Requirements")?;
178    let end = lines[start + 1..]
179        .iter()
180        .position(|line| line.starts_with("## "))
181        .map_or(lines.len(), |offset| start + 1 + offset);
182    let mut out = String::new();
183    for line in &lines[..end] {
184        out.push_str(line);
185        out.push('\n');
186    }
187    let trimmed = out.trim_end_matches('\n').to_string();
188    out = format!("{trimmed}\n\n{block}");
189    if end < lines.len() {
190        out.push('\n');
191        for line in &lines[end..] {
192            out.push_str(line);
193            out.push('\n');
194        }
195    }
196    Some(out)
197}
198
199/// Every reconciliation the target needs, with what each would do.
200///
201/// # Errors
202///
203/// I/O errors when a specification cannot be read.
204pub fn plan(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Plan>, AppError> {
205    let mut plans = Vec::new();
206    for reconciliation in needed(target, docs_root)? {
207        let sentinel = reconciliation.sentinel;
208        let seed = crate::embedded::asset(sentinel.source)
209            .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
210        let seed_text = std::str::from_utf8(seed).map_err(anyhow::Error::from)?;
211        let block = rule_block(seed_text, sentinel.rule).ok_or_else(|| {
212            anyhow::anyhow!("{} does not define {}", sentinel.source, sentinel.rule)
213        })?;
214        let destination = resolve_destination(sentinel.destination, docs_root);
215        let full = target.join(&destination);
216        let action = if full.is_file() {
217            let text = std::fs::read_to_string(&full)?;
218            match append_to_requirements(&text, &block) {
219                Some(rewritten)
220                    if crate::embedded::rule_ids_in(&rewritten)
221                        .any(|id| id == sentinel.rule.as_str()) =>
222                {
223                    Action::Append {
224                        destination,
225                        block,
226                        rewritten,
227                    }
228                }
229                _ => Action::Checklist { destination, block },
230            }
231        } else {
232            Action::Seed {
233                destination,
234                bytes: seed.to_vec(),
235            }
236        };
237        plans.push(Plan {
238            reconciliation,
239            action,
240        });
241    }
242    Ok(plans)
243}
244
245/// The manifest's JSON with one adopted record updated, or added where the
246/// file was absent.
247///
248/// A write that left the record behind would report the instance as drifted
249/// the moment it was made correct.
250fn with_adopted_record(
251    document: &mut serde_json::Value,
252    source: &str,
253    destination: &Utf8Path,
254    bytes: &[u8],
255    baseline: &[u8],
256) -> Result<(), AppError> {
257    let digest = Sha256::of(bytes).to_string();
258    let Some(entries) = document
259        .get_mut("adopted_files")
260        .and_then(serde_json::Value::as_array_mut)
261    else {
262        return Err(AppError::ManifestInvalid(
263            "adopted_files is not an array".to_string(),
264        ));
265    };
266    let recorded = entries.iter_mut().find(|entry| {
267        entry.get("destination").and_then(serde_json::Value::as_str) == Some(destination.as_str())
268    });
269    match recorded {
270        Some(entry) => entry["sha256"] = serde_json::Value::String(digest),
271        None => entries.push(serde_json::json!({
272            "source": source,
273            "destination": destination.as_str(),
274            "sha256": digest,
275            "baseline_sha256": Sha256::of(baseline).to_string(),
276        })),
277    }
278    Ok(())
279}
280
281type Write = (Utf8PathBuf, Vec<u8>, &'static Sentinel);
282
283/// The writes a set of plans amounts to, every destination checked to stay
284/// inside the target, or the refusal that stops the whole apply.
285fn preflight(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Write>, AppError> {
286    let mut writes: Vec<Write> = Vec::new();
287    for plan in plans {
288        let sentinel = plan.reconciliation.sentinel;
289        match &plan.action {
290            Action::Seed { destination, bytes } => {
291                writes.push((destination.clone(), bytes.clone(), sentinel));
292            }
293            Action::Append {
294                destination,
295                rewritten,
296                ..
297            } => writes.push((
298                destination.clone(),
299                rewritten.clone().into_bytes(),
300                sentinel,
301            )),
302            Action::Checklist { destination, .. } => {
303                return Err(AppError::Refused(format!(
304                    "{destination} is not in a shape this command rewrites; add the rule by hand"
305                )));
306            }
307        }
308    }
309    for (destination, _, _) in &writes {
310        crate::adapters::fs::check_destination(target, destination)
311            .map_err(|refusal| AppError::Refused(format!("{destination}: {refusal}")))?;
312    }
313    let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
314    crate::adapters::fs::check_destination(target, manifest_relative)
315        .map_err(|refusal| AppError::Refused(format!("{manifest_relative}: {refusal}")))?;
316    Ok(writes)
317}
318
319/// Put every backed-up path back, and name the ones that could not be.
320///
321/// A path whose bytes already equal its backup is left alone, so a file
322/// the failure never reached is not rewritten through the same failing
323/// primitive.
324fn restore(target: &Utf8Path, backups: &[(Utf8PathBuf, Option<Vec<u8>>)]) -> Vec<Utf8PathBuf> {
325    let mut unrestored = Vec::new();
326    for (destination, previous) in backups {
327        let full = target.join(destination);
328        // Only an absent file is evidence of absence. Any other read failure
329        // says nothing about the file, so the restoration is attempted and
330        // its own result decides.
331        let current = match std::fs::read(&full) {
332            Ok(bytes) => Some(Some(bytes)),
333            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(None),
334            Err(_) => None,
335        };
336        if current.as_ref() == Some(previous) {
337            continue;
338        }
339        // A removal that fails for any reason other than the file already
340        // being gone is a restoration that did not happen.
341        let put_back = previous.as_ref().map_or_else(
342            || match std::fs::remove_file(&full) {
343                Ok(()) => true,
344                Err(error) => error.kind() == std::io::ErrorKind::NotFound,
345            },
346            |bytes| write_atomic(&full, bytes).is_ok(),
347        );
348        if !put_back {
349            unrestored.push(destination.clone());
350        }
351    }
352    unrestored
353}
354
355/// Carry out every plan as one transaction, and report the files written.
356///
357/// Every destination and the manifest are checked to stay inside the
358/// target before a byte lands, every existing file is backed up, each
359/// specification is written atomically and re-read to confirm it defines
360/// its sentinel, and the manifest is written last with every record moved
361/// at once. Any failure restores every path this call touched, so the
362/// operator never holds an adopted file the record does not describe, or
363/// one plan applied and another not.
364///
365/// # Errors
366///
367/// [`AppError::Refused`] for a checklist plan, a destination that leaves
368/// the target, or a rewrite that does not define its sentinel, and
369/// manifest and I/O errors when the tree cannot be read or written. The
370/// target is restored before any of these returns.
371pub fn apply_all(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Utf8PathBuf>, AppError> {
372    let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
373    let writes = preflight(target, plans)?;
374    let manifest_text = std::fs::read_to_string(target.join(manifest_relative))?;
375    let mut document: serde_json::Value = serde_json::from_str(&manifest_text)
376        .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
377
378    let mut backups: Vec<(Utf8PathBuf, Option<Vec<u8>>)> = Vec::new();
379    let mut attempt = |backups: &mut Vec<(Utf8PathBuf, Option<Vec<u8>>)>| -> Result<(), AppError> {
380        for (destination, bytes, sentinel) in &writes {
381            let full = target.join(destination);
382            let previous = if full.is_file() {
383                Some(std::fs::read(&full)?)
384            } else {
385                None
386            };
387            backups.push((destination.clone(), previous));
388            write_atomic(&full, bytes)?;
389            let written = std::fs::read_to_string(&full)?;
390            if !crate::embedded::rule_ids_in(&written).any(|id| id == sentinel.rule.as_str()) {
391                return Err(AppError::Refused(format!(
392                    "{destination} did not define `{}` after the rewrite",
393                    sentinel.rule
394                )));
395            }
396            let seed = crate::embedded::asset(sentinel.source)
397                .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
398            with_adopted_record(&mut document, sentinel.source, destination, bytes, seed)?;
399        }
400        backups.push((
401            manifest_relative.to_path_buf(),
402            Some(manifest_text.clone().into_bytes()),
403        ));
404        let rendered = serde_json::to_string_pretty(&document)
405            .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
406        write_atomic(
407            &target.join(manifest_relative),
408            format!("{rendered}\n").as_bytes(),
409        )?;
410        Ok(())
411    };
412    if let Err(error) = attempt(&mut backups) {
413        let unrestored = restore(target, &backups);
414        let cause = match error {
415            AppError::Refused(reason) => reason,
416            other => format!("reconciliation aborted: {other}"),
417        };
418        if unrestored.is_empty() {
419            return Err(AppError::Refused(format!(
420                "{cause}; every file is restored"
421            )));
422        }
423        let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
424        return Err(AppError::Refused(format!(
425            "{cause}; restoration is incomplete, verify by hand: {}",
426            paths.join(" ")
427        )));
428    }
429    Ok(writes
430        .into_iter()
431        .map(|(destination, _, _)| destination)
432        .collect())
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    const SPEC: &str = "# Sample\n\n## Purpose\n\nOurs.\n\n## Requirements\n\n### `sample:first` — First\n\nThe author MUST keep it.\n\n#### Scenario: One\n\n- GIVEN x\n- WHEN y\n- THEN z\n\nVerify: `true`\n\n## Unenforced\n\n| Rule | Reviewer confirms |\n| --- | --- |\n";
440
441    const BLOCK: &str = "### `sample:second` — Second\n\nThe author MUST add it.\n\n#### Scenario: Two\n\n- GIVEN a\n- WHEN b\n- THEN c\n\nVerify: `true`\n";
442
443    #[test]
444    fn the_rule_block_runs_from_its_heading_to_the_next() {
445        let seed = format!("{SPEC}\n{BLOCK}");
446        let block = rule_block(&seed, RuleId::RecordedDimensionOnlyShrinks);
447        assert!(block.is_none(), "a rule the seed lacks is not found");
448        let debt =
449            std::str::from_utf8(crate::embedded::asset("_docs/specs/SPEC-budget-debt.md").unwrap())
450                .unwrap();
451        let block = rule_block(debt, RuleId::RecordedDimensionOnlyShrinks).unwrap();
452        assert!(block.starts_with("### `budget-debt:a-recorded-dimension-only-shrinks`"));
453        assert!(block.contains("Verify:"));
454        assert!(!block.contains("debt-is-created-by-an-explicit-act"));
455        assert!(block.ends_with('\n') && !block.ends_with("\n\n"));
456    }
457
458    #[test]
459    fn the_block_is_appended_before_the_next_section_and_everything_else_survives() {
460        let out = append_to_requirements(SPEC, BLOCK).unwrap();
461        let ids: Vec<String> = crate::embedded::rule_ids_in(&out).collect();
462        assert_eq!(
463            ids,
464            vec!["sample:first".to_string(), "sample:second".to_string()]
465        );
466        assert!(out.contains("Verify: `true`\n\n### `sample:second`"));
467        assert!(out.contains("Verify: `true`\n\n## Unenforced\n"));
468        assert!(out.ends_with("| --- | --- |\n"));
469        assert!(out.starts_with("# Sample\n\n## Purpose\n\nOurs.\n"));
470    }
471
472    #[test]
473    fn a_file_whose_requirements_close_it_takes_the_block_at_the_end() {
474        let spec = SPEC.split("## Unenforced").next().unwrap();
475        let out = append_to_requirements(spec, BLOCK).unwrap();
476        assert!(out.ends_with(&format!("\n\n{BLOCK}")));
477    }
478
479    #[test]
480    fn a_file_without_a_requirements_section_is_not_rewritten() {
481        assert!(append_to_requirements("# Ours\n\nProse only.\n", BLOCK).is_none());
482    }
483}