1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
// SPDX-License-Identifier: GPL-3.0-only
//! Pre-design research staleness leaf (SL-229 D2/D5) — the mint/check engine
//! behind `doctrine slice research <id>`. A research artefact is baselined
//! against the slice's *intent* docs (scope / design / plan); when those drift
//! past the baseline the artefact may be stale and want a refresh round.
//!
//! ADR-001 leaf tier: the staleness core is the pure `ContentSet::diff` from the
//! `contentset` leaf; this module owns only the fixed baseline path set, the
//! `baseline.toml` serde shape, and two thin impure shells (`mint` writes it,
//! `check` reads-and-diffs it). No command-tier imports; fs lives at the edges,
//! and the date is passed in (date/uid pattern) so the logic stays clock-free.
//!
//! Absence is a defined state (inherited from `contentset::compute`): a baseline
//! path missing on disk is *omitted* from the set, not an error. Minting
//! pre-design records only the scope doc; `design.md`/`plan.*` appearing later
//! read as `added` drift — the lifecycle advance is itself the staleness signal.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::contentset::{self, ContentSet, SetDrift};
/// Slice-relative dir holding the research artefact + its baseline (STD-001).
/// Gitignored in place, riding the pre-existing SL-055 convention (D1).
pub(crate) const RESEARCH_DIR: &str = "research";
/// The staleness baseline file inside [`RESEARCH_DIR`] (STD-001).
pub(crate) const BASELINE_FILE: &str = "baseline.toml";
/// The `baseline.toml` document (D2): the durable id it belongs to, the mint
/// date, and the `[hashes]` `ContentSet` over the fixed path set — the
/// comparison key. `serde` round-trips all three; `hashes` is rebuilt from the
/// live path set on every mint/restamp so it cannot silently drift.
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
struct Baseline {
slice: String,
date: String,
#[serde(default)]
hashes: BTreeMap<String, String>,
}
/// The research dir for a slice: `.doctrine/slice/NNN/research`.
pub(crate) fn research_dir(root: &Path, id: u32) -> PathBuf {
root.join(crate::kinds::SLICE_DIR)
.join(format!("{id:03}"))
.join(RESEARCH_DIR)
}
/// The fixed v1 baseline path set (design A2): the slice's intent docs, as
/// repo-relative keys (what [`contentset::compute`] joins against `root`).
/// `plan.*` is included because criterion edits are frequently TOML-only and
/// `/phase-plan` is exactly the consumer that cares. Phase sheets and source
/// files are deliberately outside the domain — research goes stale against
/// intent, not implementation.
fn baseline_paths(id: u32) -> Vec<String> {
let name = format!("{id:03}");
let dir = format!("{}/{name}", crate::kinds::SLICE_DIR);
vec![
format!("{dir}/slice-{name}.md"),
format!("{dir}/design.md"),
format!("{dir}/plan.md"),
format!("{dir}/plan.toml"),
]
}
/// Mint (or restamp) the baseline: create the research dir and write
/// `baseline.toml` recording the live hashes of the fixed path set. Idempotent
/// and overwriting — the sole writer for both the absent-dir mint and
/// `--restamp` re-baseline (D2). Returns the baseline path.
pub(crate) fn mint(root: &Path, id: u32, date: &str) -> anyhow::Result<PathBuf> {
let dir = research_dir(root, id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let current = contentset::compute(root, &baseline_paths(id))
.context("hash the slice's baseline path set")?;
let baseline = Baseline {
slice: format!("SL-{id:03}"),
date: date.to_owned(),
hashes: current.hashes().clone(),
};
let body = toml::to_string(&baseline).context("serialize research baseline")?;
let path = dir.join(BASELINE_FILE);
crate::fsutil::write_atomic(&path, body.as_bytes())?;
Ok(path)
}
/// Diff the recorded baseline against the live path set — the drift advisory's
/// data (per-path changed / added / removed). Pure `ContentSet::diff` over the
/// impure `compute`; the caller decides fresh-vs-drift via `SetDrift::is_empty`.
pub(crate) fn check(root: &Path, id: u32) -> anyhow::Result<SetDrift> {
let path = research_dir(root, id).join(BASELINE_FILE);
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read research baseline {}", path.display()))?;
let baseline: Baseline = toml::from_str(&text)
.with_context(|| format!("parse research baseline {}", path.display()))?;
let recorded = ContentSet::from_hashes(baseline.hashes);
let live = contentset::compute(root, &baseline_paths(id))
.context("hash the slice's baseline path set")?;
Ok(recorded.diff(&live))
}
#[cfg(test)]
mod tests {
use super::*;
/// Lay down a slice dir with the given intent docs written, returning the
/// tempdir (kept alive by the caller). Files absent from `docs` stay absent.
fn slice_tree(id: u32, docs: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let slice = dir
.path()
.join(crate::kinds::SLICE_DIR)
.join(format!("{id:03}"));
std::fs::create_dir_all(&slice).unwrap();
for (name, body) in docs {
std::fs::write(slice.join(name), body.as_bytes()).unwrap();
}
dir
}
#[test]
fn mint_writes_baseline_that_round_trips_current() {
let id = 229;
let name = format!("{id:03}");
let tree = slice_tree(
id,
&[
(&format!("slice-{name}.md"), "scope"),
("design.md", "design"),
("plan.md", "plan prose"),
("plan.toml", "plan = 1"),
],
);
let path = mint(tree.path(), id, "2026-07-25").unwrap();
assert_eq!(path.file_name().unwrap(), BASELINE_FILE);
// Fresh immediately after mint: no drift against the live set.
let drift = check(tree.path(), id).unwrap();
assert!(
drift.is_empty(),
"fresh baseline should not drift: {drift:?}"
);
// The serialized fields are present and typed.
let baseline: Baseline = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(baseline.slice, "SL-229");
assert_eq!(baseline.date, "2026-07-25");
assert_eq!(baseline.hashes.len(), 4);
}
#[test]
fn absent_intent_doc_at_mint_reads_as_added_when_it_appears() {
// Pre-design mint: only the scope doc exists; design.md/plan.* absent.
let id = 42;
let name = format!("{id:03}");
let tree = slice_tree(id, &[(&format!("slice-{name}.md"), "scope")]);
mint(tree.path(), id, "2026-07-25").unwrap();
assert!(check(tree.path(), id).unwrap().is_empty());
// design.md appears later — the lifecycle advance shows as `added` drift.
let slice = tree
.path()
.join(crate::kinds::SLICE_DIR)
.join(format!("{id:03}"));
std::fs::write(slice.join("design.md"), b"now designed").unwrap();
let drift = check(tree.path(), id).unwrap();
assert!(!drift.is_empty());
let added: Vec<&str> = drift.added.iter().map(String::as_str).collect();
assert!(
added.iter().any(|p| p.ends_with("design.md")),
"design.md should be added drift: {drift:?}"
);
}
#[test]
fn changed_and_removed_intent_docs_drift() {
let id = 7;
let name = format!("{id:03}");
let tree = slice_tree(
id,
&[
(&format!("slice-{name}.md"), "scope v1"),
("design.md", "design v1"),
],
);
mint(tree.path(), id, "2026-07-25").unwrap();
let slice = tree
.path()
.join(crate::kinds::SLICE_DIR)
.join(format!("{id:03}"));
// scope edited (changed); design deleted (removed).
std::fs::write(slice.join(format!("slice-{name}.md")), b"scope v2").unwrap();
std::fs::remove_file(slice.join("design.md")).unwrap();
let drift = check(tree.path(), id).unwrap();
assert!(
drift
.changed
.iter()
.any(|p| p.ends_with(&format!("slice-{name}.md")))
);
assert!(drift.removed.iter().any(|p| p.ends_with("design.md")));
assert!(drift.added.is_empty());
}
#[test]
fn restamp_overwrites_the_baseline_to_current() {
let id = 7;
let tree = slice_tree(id, &[("design.md", "v1")]);
mint(tree.path(), id, "2026-07-25").unwrap();
let slice = tree
.path()
.join(crate::kinds::SLICE_DIR)
.join(format!("{id:03}"));
std::fs::write(slice.join("design.md"), b"v2").unwrap();
assert!(
!check(tree.path(), id).unwrap().is_empty(),
"v2 drifts pre-restamp"
);
// Restamp re-baselines against the live set; drift clears.
mint(tree.path(), id, "2026-07-26").unwrap();
assert!(
check(tree.path(), id).unwrap().is_empty(),
"restamp clears drift"
);
}
}