Skip to main content

spec_driven_docs/plan/
store.rs

1//! Where a computed plan waits for its approval.
2//!
3//! A plan carries every byte it will write, and some of those bytes come
4//! out of the target. So the store is owner-only, it lives under the state
5//! root rather than in the repository, and a plan is ephemeral: it is
6//! never committed, and never pasted into a forge.
7//!
8//! Identity is the fingerprint and nothing else. Identical inputs while an
9//! executable plan exists reuse it, so an operator who plans twice
10//! approves one thing. A terminal result moves the plan out and frees the
11//! fingerprint, so identical inputs after a success plan again into a
12//! fresh directory rather than meeting a stripped one.
13//!
14//! The lifecycle is fixed rather than configurable. An unapplied plan
15//! expires after seven days. A terminal result is kept for thirty. A run
16//! that needs recovery keeps everything recovery needs, whatever the
17//! calendar says.
18
19use std::collections::BTreeMap;
20
21use camino::{Utf8Path, Utf8PathBuf};
22use serde::{Deserialize, Serialize};
23
24use crate::domain::ownership::Sha256;
25use crate::error::AppError;
26use crate::plan::Plan;
27
28/// The machine schema a result declares.
29pub const RESULT_SCHEMA: &str = "sdd.result/1";
30
31/// How long an unapplied plan waits before it expires.
32pub const PLAN_TTL_DAYS: i64 = 7;
33
34/// How long a terminal result is kept.
35pub const RESULT_TTL_DAYS: i64 = 30;
36
37/// How one apply ended.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum Disposition {
41    /// Every operation landed and every postcondition held.
42    Succeeded,
43    /// The world moved, so the plan no longer describes it. Terminal.
44    Invalidated,
45    /// Nothing semantic moved, so the same plan can be applied again.
46    Retryable,
47    /// A run did not finish, and the next one must recover it first.
48    RecoveryRequired,
49}
50
51impl Disposition {
52    /// Whether this disposition ends the plan's life.
53    #[must_use]
54    pub const fn is_terminal(self) -> bool {
55        matches!(self, Self::Succeeded | Self::Invalidated)
56    }
57}
58
59/// What one operation did.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct OperationOutcome {
62    /// Which operation, by its kind.
63    pub kind: String,
64    /// Where, relative to the target.
65    pub path: String,
66    /// Whether it landed.
67    pub applied: bool,
68    /// What went wrong, where anything did.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub refusal: Option<String>,
71}
72
73/// What one postcondition found.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct PostconditionOutcome {
76    /// Which postcondition.
77    pub id: String,
78    /// Whether it held.
79    pub held: bool,
80    /// What was found instead, where it did not.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub detail: Option<String>,
83}
84
85/// What one apply did.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Result {
88    /// The machine schema of this object.
89    pub schema: String,
90    /// The plan this result is for.
91    pub plan_id: String,
92    /// The fingerprint that plan carried.
93    pub fingerprint: Sha256,
94    /// A stable identifier for this attempt.
95    pub result_id: String,
96    /// How it ended.
97    pub disposition: Disposition,
98    /// When it ended.
99    pub finished_at: String,
100    /// What each operation did.
101    pub operations: Vec<OperationOutcome>,
102    /// What each postcondition found.
103    pub postconditions: Vec<PostconditionOutcome>,
104    /// Whether a journal is outstanding.
105    pub recovery_required: bool,
106    /// Every path this attempt touched, relative to the target.
107    pub affected: Vec<String>,
108    /// Why it ended as it did, in one sentence.
109    pub reason: String,
110}
111
112/// The owner-only store under the state root.
113#[derive(Debug, Clone)]
114pub struct Store {
115    root: Utf8PathBuf,
116}
117
118/// Where one plan's own directory sits.
119#[derive(Debug, Clone)]
120pub struct PlanDirectory {
121    /// The directory itself.
122    pub root: Utf8PathBuf,
123    /// The executable plan.
124    pub plan: Utf8PathBuf,
125    /// Every byte the plan will write, by digest.
126    pub blobs: Utf8PathBuf,
127    /// The journal an unfinished apply leaves.
128    pub journal: Utf8PathBuf,
129}
130
131impl Store {
132    /// The store under one state root.
133    #[must_use]
134    pub fn new(state_root: &Utf8Path) -> Self {
135        Self {
136            root: state_root.join(crate::domain::paths::PLAN_STORE_DIR),
137        }
138    }
139
140    /// The store's own root, for a diagnostic that names it.
141    #[must_use]
142    pub fn root(&self) -> &Utf8Path {
143        &self.root
144    }
145
146    /// One attempt's directory name, as a name and never as a path.
147    ///
148    /// The attempt id is built from the caller's clock, so it is not a
149    /// digest and cannot be checked as one. It is reduced to the
150    /// characters a directory name may carry, which is what keeps a
151    /// separator out of the join.
152    fn attempt_slug(value: &str) -> String {
153        let held: String = value
154            .chars()
155            .map(|held| {
156                if held.is_ascii_alphanumeric() || held == '-' {
157                    held
158                } else {
159                    '-'
160                }
161            })
162            .collect();
163        if held.is_empty() {
164            "attempt".to_string()
165        } else {
166            held
167        }
168    }
169
170    /// Refuse an id that is not a fingerprint.
171    ///
172    /// Every public entry takes the id as a string, and the strings reach
173    /// `Path::join`, which appends whatever it is given. An id carrying a
174    /// path component would name a directory outside the store, and a
175    /// terminal result removes the directory its plan names. So the shape
176    /// is checked once, at the boundary: exactly a lowercase hex sha256.
177    ///
178    /// # Errors
179    ///
180    /// [`AppError::Refused`] naming the id.
181    pub fn checked(fingerprint: &str) -> std::result::Result<&str, AppError> {
182        fingerprint.parse::<Sha256>().map_err(|_| {
183            AppError::Refused(format!(
184                "'{fingerprint}' is not a plan id; a plan id is the 64-character fingerprint 'sdd reconcile plan' printed"
185            ))
186        })?;
187        Ok(fingerprint)
188    }
189
190    /// Where one fingerprint's executable plan lives.
191    ///
192    /// The caller has already run [`Store::checked`] on the id: every
193    /// public entry does, and this is reachable only through one of them.
194    #[must_use]
195    pub fn directory(&self, fingerprint: &str) -> PlanDirectory {
196        let root = self.root.join("plans").join(fingerprint);
197        PlanDirectory {
198            plan: root.join("plan.json"),
199            blobs: root.join("blobs"),
200            journal: root.join("apply.journal"),
201            root,
202        }
203    }
204
205    /// Where one fingerprint's terminal results live.
206    #[must_use]
207    pub fn results(&self, fingerprint: &str) -> Utf8PathBuf {
208        self.root.join("results").join(fingerprint)
209    }
210
211    /// Where the store keeps the lock that orders a whole-store walk.
212    ///
213    /// Only the prune takes it, and only for as long as the walk. A
214    /// per-plan write takes [`Store::plan_lock_path`] instead, because two
215    /// planners for different fingerprints touch nothing in common and
216    /// serializing them would make one landing wait on another's target.
217    #[must_use]
218    pub fn lock_path(&self) -> Utf8PathBuf {
219        self.root.join("store.lock")
220    }
221
222    /// Where one fingerprint's own lock lives.
223    ///
224    /// Two planners that computed the same plan race to publish it, and
225    /// this is what makes the second reuse the first rather than meet a
226    /// half-written directory.
227    ///
228    /// # Errors
229    ///
230    /// [`AppError::Refused`] for an id that is not a fingerprint.
231    pub fn plan_lock_path(&self, fingerprint: &str) -> std::result::Result<Utf8PathBuf, AppError> {
232        // Beside the plans rather than among them: the prune walks the
233        // plans directory and treats every aged entry as a directory, so a
234        // lock file sitting in it would break housekeeping.
235        Ok(self
236            .root
237            .join("locks")
238            .join(format!("{}.lock", Self::checked(fingerprint)?)))
239    }
240
241    /// Create the store, owner-only.
242    ///
243    /// A plan carries bytes out of a target, and some of those are not the
244    /// world's business. The directory mode says so on the filesystem
245    /// rather than only in a document.
246    ///
247    /// # Errors
248    ///
249    /// Any I/O error creating the directories or setting their mode.
250    pub fn create(&self) -> std::result::Result<(), AppError> {
251        for directory in [
252            self.root.clone(),
253            self.root.join("plans"),
254            self.root.join("results"),
255        ] {
256            std::fs::create_dir_all(&directory)?;
257            owner_only(&directory)?;
258        }
259        Ok(())
260    }
261
262    /// Whether an executable plan exists for this fingerprint.
263    #[must_use]
264    pub fn holds(&self, fingerprint: &str) -> bool {
265        Self::checked(fingerprint).is_ok_and(|held| self.directory(held).plan.is_file())
266    }
267
268    /// Write one plan and every byte it will land.
269    ///
270    /// # Errors
271    ///
272    /// Any I/O error creating the directory or writing the plan.
273    pub fn put(
274        &self,
275        plan: &Plan,
276        blobs: &BTreeMap<Sha256, Vec<u8>>,
277    ) -> std::result::Result<PlanDirectory, AppError> {
278        self.create()?;
279        let held = self.directory(Self::checked(&plan.identity.plan_id)?);
280        std::fs::create_dir_all(&held.blobs)?;
281        owner_only(&held.root)?;
282        owner_only(&held.blobs)?;
283        for (digest, bytes) in blobs {
284            let path = held.blobs.join(digest.to_string());
285            if !path.is_file() {
286                crate::adapters::fs::write_atomic(&path, bytes)?;
287            }
288        }
289        let text = serde_json::to_string_pretty(plan)
290            .map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
291        crate::adapters::fs::write_atomic(&held.plan, format!("{text}\n").as_bytes())?;
292        Ok(held)
293    }
294
295    /// Read one stored plan.
296    ///
297    /// # Errors
298    ///
299    /// [`AppError::Refused`] when no executable plan carries that
300    /// fingerprint, and I/O errors when it cannot be read.
301    pub fn get(&self, fingerprint: &str) -> std::result::Result<Plan, AppError> {
302        let held = self.directory(Self::checked(fingerprint)?);
303        let text = std::fs::read_to_string(&held.plan).map_err(|_| {
304            AppError::Refused(format!(
305                "no executable plan carries the id {fingerprint}; run 'sdd reconcile plan' again"
306            ))
307        })?;
308        let plan: Plan = serde_json::from_str(&text).map_err(|source| {
309            AppError::Refused(format!("{} does not parse: {source}", held.plan))
310        })?;
311        // The document decides where its own results and its own cleanup
312        // go, so a document that does not answer to the id it was fetched
313        // by is refused rather than trusted.
314        if plan.identity.plan_id != fingerprint {
315            return Err(AppError::Refused(format!(
316                "{} carries the id {} and was fetched as {fingerprint}",
317                held.plan, plan.identity.plan_id
318            )));
319        }
320        Ok(plan)
321    }
322
323    /// One byte string the plan will write.
324    ///
325    /// # Errors
326    ///
327    /// [`AppError::Refused`] when the plan's blob store does not carry it.
328    pub fn blob(
329        &self,
330        fingerprint: &str,
331        digest: &Sha256,
332    ) -> std::result::Result<Vec<u8>, AppError> {
333        let path = self
334            .directory(Self::checked(fingerprint)?)
335            .blobs
336            .join(digest.to_string());
337        let bytes = std::fs::read(&path).map_err(|source| {
338            AppError::Refused(format!(
339                "the plan {fingerprint} carries no blob {digest}: {source}"
340            ))
341        })?;
342        if &Sha256::of(&bytes) != digest {
343            return Err(AppError::Refused(format!(
344                "the blob at {path} no longer hashes to {digest}"
345            )));
346        }
347        Ok(bytes)
348    }
349
350    /// Record one attempt's outcome.
351    ///
352    /// A terminal disposition moves the redacted plan out with its result
353    /// and frees the fingerprint. Anything else leaves the executable plan
354    /// where it is, because the same plan can still be applied.
355    ///
356    /// # Errors
357    ///
358    /// Any I/O error writing the result or moving the plan.
359    pub fn record(&self, plan: &Plan, result: &Result) -> std::result::Result<(), AppError> {
360        let id = Self::checked(&plan.identity.plan_id)?;
361        let directory = self
362            .results(Self::checked(&result.fingerprint.to_string())?)
363            .join(Self::attempt_slug(&result.result_id));
364        std::fs::create_dir_all(&directory)?;
365        owner_only(&directory)?;
366        let redacted = redact(plan);
367        let text = serde_json::to_string_pretty(&redacted)
368            .map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
369        crate::adapters::fs::write_atomic(
370            &directory.join("plan.json"),
371            format!("{text}\n").as_bytes(),
372        )?;
373        let text = serde_json::to_string_pretty(result)
374            .map_err(|source| anyhow::anyhow!("the result did not serialize: {source}"))?;
375        crate::adapters::fs::write_atomic(
376            &directory.join("result.json"),
377            format!("{text}\n").as_bytes(),
378        )?;
379        if result.disposition.is_terminal() {
380            // The fingerprint is free again, so identical inputs plan into
381            // a fresh directory rather than meeting a stripped one. A
382            // directory that will not go leaves an executable plan for a
383            // run that already ended, which the caller has to hear about.
384            let held = self.directory(id).root;
385            if let Err(cause) = std::fs::remove_dir_all(&held)
386                && held.exists()
387            {
388                return Err(AppError::Refused(format!(
389                    "the result was recorded and the plan at {held} could not be removed: {cause}; remove it by hand before planning the same inputs again"
390                )));
391            }
392        }
393        Ok(())
394    }
395
396    /// The latest result for one fingerprint, where any exists.
397    #[must_use]
398    pub fn latest_result(&self, fingerprint: &str) -> Option<Result> {
399        let fingerprint = Self::checked(fingerprint).ok()?;
400        let mut found: Vec<(String, Result)> = std::fs::read_dir(self.results(fingerprint))
401            .ok()?
402            .filter_map(std::result::Result::ok)
403            .filter_map(|entry| {
404                let name = entry.file_name().to_str()?.to_string();
405                let text = std::fs::read_to_string(entry.path().join("result.json")).ok()?;
406                let held: Result = serde_json::from_str(&text).ok()?;
407                Some((name, held))
408            })
409            .collect();
410        found.sort_by(|left, right| left.0.cmp(&right.0));
411        found.pop().map(|(_, held)| held)
412    }
413
414    /// Remove what the lifecycle says is over.
415    ///
416    /// Never removes the fingerprint the caller named, and never removes a
417    /// directory that still holds a journal: a run that did not finish
418    /// keeps everything recovery needs, whatever the calendar says.
419    ///
420    /// # Errors
421    ///
422    /// Any I/O error reading the store.
423    pub fn prune(
424        &self,
425        now: jiff::Timestamp,
426        keep: Option<&str>,
427    ) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
428        let mut removed = Vec::new();
429        removed.extend(self.prune_under(
430            &self.root.join("plans"),
431            now,
432            PLAN_TTL_DAYS,
433            keep,
434            true,
435        )?);
436        removed.extend(self.prune_under(
437            &self.root.join("results"),
438            now,
439            RESULT_TTL_DAYS,
440            keep,
441            false,
442        )?);
443        Ok(removed)
444    }
445
446    fn prune_under(
447        &self,
448        root: &Utf8Path,
449        now: jiff::Timestamp,
450        days: i64,
451        keep: Option<&str>,
452        guard_journal: bool,
453    ) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
454        let Ok(entries) = std::fs::read_dir(root) else {
455            return Ok(Vec::new());
456        };
457        let mut removed = Vec::new();
458        for entry in entries.filter_map(std::result::Result::ok) {
459            let Ok(path) = Utf8PathBuf::from_path_buf(entry.path()) else {
460                continue;
461            };
462            let name = path.file_name().unwrap_or_default();
463            if Some(name) == keep {
464                continue;
465            }
466            // Only a fingerprint directory is a plan. Anything else under
467            // here is not this walk's business, and treating it as one
468            // would fail the whole prune on the first stray entry.
469            if !path.is_dir() || Self::checked(name).is_err() {
470                continue;
471            }
472            if guard_journal && path.join("apply.journal").exists() {
473                continue;
474            }
475            // A writer that took this plan's lock has not written its
476            // journal yet, and removing the plan under it would take the
477            // blobs the apply is about to read. The guard is held across
478            // the removal, not probed and dropped: a writer arriving in
479            // between would meet a directory going away underneath it.
480            let _guard = if guard_journal {
481                match self.hold_plan(name) {
482                    Some(held) => Some(held),
483                    None => continue,
484                }
485            } else {
486                None
487            };
488            if older_than(&path, now, days) {
489                std::fs::remove_dir_all(&path)?;
490                removed.push(path);
491            }
492        }
493        Ok(removed)
494    }
495
496    /// Take one plan's lock for a prune, or give up.
497    ///
498    /// Taken without waiting: this is housekeeping, and a plan somebody is
499    /// working on is one to leave alone rather than to queue behind. The
500    /// caller holds what this returns for as long as it touches the plan.
501    fn hold_plan(&self, fingerprint: &str) -> Option<crate::transaction::lock::Lock> {
502        let path = self.plan_lock_path(fingerprint).ok()?;
503        crate::transaction::lock::Lock::exclusive(&path, "plan store prune").ok()
504    }
505}
506
507/// Whether a directory has outlived its allowance.
508fn older_than(path: &Utf8Path, now: jiff::Timestamp, days: i64) -> bool {
509    let Ok(metadata) = std::fs::metadata(path) else {
510        return false;
511    };
512    let Ok(modified) = metadata.modified() else {
513        return false;
514    };
515    let Ok(elapsed) = modified.elapsed() else {
516        return false;
517    };
518    let _ = now;
519    #[expect(
520        clippy::cast_sign_loss,
521        reason = "the allowance is a positive number of days, declared as a constant here"
522    )]
523    let allowance = std::time::Duration::from_secs(days as u64 * 24 * 60 * 60);
524    elapsed > allowance
525}
526
527/// Restrict a directory to its owner.
528fn owner_only(path: &Utf8Path) -> std::result::Result<(), AppError> {
529    let mut permissions = std::fs::metadata(path)?.permissions();
530    std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
531    std::fs::set_permissions(path, permissions)?;
532    Ok(())
533}
534
535/// The plan as a result keeps it: no absolute target path.
536///
537/// A result outlives the run, and a run's target path names a person's
538/// filesystem. What a reader needs is which destinations moved, and those
539/// are relative.
540#[must_use]
541pub fn redact(plan: &Plan) -> Plan {
542    let mut held = plan.clone();
543    held.observed_state.repository.root = Utf8PathBuf::from("<target>");
544    held.observed_state.host.cache_root = None;
545    held
546}
547
548#[cfg(test)]
549mod tests {
550    #![allow(
551        clippy::unwrap_used,
552        reason = "a test panics as its failure signal, not as control flow"
553    )]
554
555    use super::*;
556
557    fn store(dir: &tempfile::TempDir) -> Store {
558        Store::new(&Utf8PathBuf::from(dir.path().to_str().unwrap()))
559    }
560
561    #[test]
562    fn the_store_is_owner_only() {
563        let dir = tempfile::tempdir().unwrap();
564        let held = store(&dir);
565        held.create().unwrap();
566        for path in [held.root().to_owned(), held.root().join("plans")] {
567            let mode = std::os::unix::fs::PermissionsExt::mode(
568                &std::fs::metadata(&path).unwrap().permissions(),
569            );
570            assert_eq!(mode & 0o777, 0o700, "{path} is not owner-only");
571        }
572    }
573
574    #[test]
575    fn a_directory_a_command_named_is_never_pruned() {
576        let dir = tempfile::tempdir().unwrap();
577        let held = store(&dir);
578        held.create().unwrap();
579        let one = held.directory("keepme");
580        std::fs::create_dir_all(&one.root).unwrap();
581        let removed = held.prune(jiff::Timestamp::now(), Some("keepme")).unwrap();
582        assert!(removed.is_empty());
583        assert!(one.root.is_dir());
584    }
585
586    #[test]
587    fn a_journal_holds_its_directory_past_ordinary_expiry() {
588        let dir = tempfile::tempdir().unwrap();
589        let held = store(&dir);
590        held.create().unwrap();
591        let one = held.directory("unfinished");
592        std::fs::create_dir_all(&one.root).unwrap();
593        std::fs::write(&one.journal, "{}").unwrap();
594        // Even with no allowance left, a run that did not finish keeps
595        // everything recovery needs.
596        let removed = held.prune(jiff::Timestamp::now(), None).unwrap();
597        assert!(removed.is_empty());
598        assert!(one.root.is_dir());
599    }
600
601    #[test]
602    fn a_blob_that_no_longer_hashes_to_its_name_refuses() {
603        let dir = tempfile::tempdir().unwrap();
604        let held = store(&dir);
605        held.create().unwrap();
606        let one = held.directory("f");
607        std::fs::create_dir_all(&one.blobs).unwrap();
608        let digest = Sha256::of(b"intended");
609        std::fs::write(one.blobs.join(digest.to_string()), b"tampered").unwrap();
610        assert!(held.blob("f", &digest).is_err());
611    }
612
613    #[test]
614    fn an_absent_plan_refuses_with_the_next_command() {
615        let dir = tempfile::tempdir().unwrap();
616        let error = store(&dir).get("nope").unwrap_err();
617        assert!(error.to_string().contains("sdd reconcile plan"), "{error}");
618    }
619}