Skip to main content

lean_ctx/core/
feature_list.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The feature list of a task: its locked acceptance tests (process-v1 §4,
3//! `contracts/process-v1/feature-list.schema.json`).
4//!
5//! The Spec Writer writes the list until G2. Once G2 has passed (Hive sets
6//! `spec_commit`) the list is locked: only `features[].passes` may change, and
7//! only the G6 gate writes it, from its own re-run. Any other change is a spec
8//! change and goes back to `spec`; LeanCTX refuses it.
9//!
10//! LeanCTX enforces the lock in two places:
11//! - [`crate::core::session_rights`] refuses every session write to a locked
12//!   feature list file, whatever the role (a session is never the gate);
13//! - [`apply_gate_passes`] is the one writer of `passes`, and
14//!   [`validate_transition`] refuses any other difference between two versions.
15
16use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// Schema id of a feature list.
23pub const SCHEMA: &str = "leanctx.hive.process.feature-list/v1";
24/// Largest feature list read.
25pub const MAX_FILE_BYTES: u64 = 512 * 1024;
26const MAX_FEATURES: usize = 200;
27
28/// One acceptance criterion.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Feature {
32    pub id: String,
33    pub description: String,
34    pub steps: Vec<String>,
35    pub tests: Vec<String>,
36    pub passes: bool,
37}
38
39/// A feature list as the contract defines it.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FeatureList {
43    pub schema: String,
44    pub process: String,
45    pub task: Value,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub spec_commit: Option<String>,
48    pub test_command: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub e2e_command: Option<String>,
51    pub locked_paths: Vec<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub unlocks: Option<Vec<Value>>,
54    pub features: Vec<Feature>,
55}
56
57/// Who writes a new version of the list.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Writer<'a> {
60    /// The G6 gate, writing `passes` from its own re-run.
61    Gate,
62    /// A session acting in a role (role id, e.g. `developer`).
63    Role(&'a str),
64}
65
66impl FeatureList {
67    /// Parse and check the structural rules of the schema.
68    pub fn parse(text: &str) -> Result<Self, String> {
69        let list: Self =
70            serde_json::from_str(text).map_err(|e| format!("feature list is not valid: {e}"))?;
71        list.check()?;
72        Ok(list)
73    }
74
75    /// Read and parse a feature list file (at most [`MAX_FILE_BYTES`]).
76    pub fn load(path: &Path) -> Result<Self, String> {
77        let meta = std::fs::metadata(path)
78            .map_err(|e| format!("feature list {} unreadable: {e}", path.display()))?;
79        if meta.len() > MAX_FILE_BYTES {
80            return Err(format!(
81                "feature list {} is larger than {MAX_FILE_BYTES} bytes",
82                path.display()
83            ));
84        }
85        let text = std::fs::read_to_string(path)
86            .map_err(|e| format!("feature list {} unreadable: {e}", path.display()))?;
87        Self::parse(&text)
88    }
89
90    /// True once G2 has passed: from then on only the gate writes `passes`.
91    pub fn is_locked(&self) -> bool {
92        self.spec_commit.is_some()
93    }
94
95    fn check(&self) -> Result<(), String> {
96        if self.schema != SCHEMA {
97            return Err(format!(
98                "feature list schema must be {SCHEMA} (got {})",
99                self.schema
100            ));
101        }
102        if !is_process_version(&self.process) {
103            return Err(format!(
104                "feature list process version must be 1.<minor> (got {})",
105                self.process
106            ));
107        }
108        check_task_ref(&self.task)?;
109        if let Some(sha) = &self.spec_commit
110            && !is_git_sha(sha)
111        {
112            return Err(format!(
113                "spec_commit must be a 40-character git sha (got {sha})"
114            ));
115        }
116        if self.test_command.trim().is_empty() || !len_in(&self.test_command, 1, 2000) {
117            return Err("feature list test_command must hold 1 to 2000 characters".into());
118        }
119        if let Some(e2e) = &self.e2e_command
120            && !len_in(e2e, 1, 2000)
121        {
122            return Err("feature list e2e_command must hold 1 to 2000 characters".into());
123        }
124        if self.locked_paths.is_empty() {
125            return Err("feature list names no locked_paths".into());
126        }
127        let mut unique = std::collections::BTreeSet::new();
128        for path in &self.locked_paths {
129            if !is_repo_relative(path) || !len_in(path, 1, 500) {
130                return Err(format!(
131                    "locked path must be repo-relative without '..', at most 500 characters: {path}"
132                ));
133            }
134            if !unique.insert(path.as_str()) {
135                return Err(format!("locked path {path} appears twice"));
136            }
137        }
138        for unlock in self.unlocks.iter().flatten() {
139            check_unlock(unlock)?;
140        }
141        if self.features.is_empty() || self.features.len() > MAX_FEATURES {
142            return Err(format!(
143                "feature list must hold 1 to {MAX_FEATURES} features"
144            ));
145        }
146        let mut seen = std::collections::BTreeSet::new();
147        for feature in &self.features {
148            if !is_feature_id(&feature.id) {
149                return Err(format!("feature id must match F<digits>: {}", feature.id));
150            }
151            if !seen.insert(feature.id.as_str()) {
152                return Err(format!("feature id {} appears twice", feature.id));
153            }
154            if feature.steps.is_empty() || feature.tests.is_empty() {
155                return Err(format!("feature {} needs steps and tests", feature.id));
156            }
157            if !len_in(&feature.description, 1, 2000)
158                || feature.steps.iter().any(|s| !len_in(s, 1, 1000))
159                || feature.tests.iter().any(|t| !len_in(t, 1, 500))
160            {
161                return Err(format!(
162                    "feature {}: description 1-2000, steps 1-1000, tests 1-500 characters",
163                    feature.id
164                ));
165            }
166        }
167        Ok(())
168    }
169}
170
171fn len_in(text: &str, min: usize, max: usize) -> bool {
172    (min..=max).contains(&text.chars().count())
173}
174
175/// `common.schema.json#/$defs/process_version`: `1.<minor>`.
176pub(crate) fn is_process_version(raw: &str) -> bool {
177    let Some(minor) = raw.strip_prefix("1.") else {
178        return false;
179    };
180    minor == "0"
181        || (!minor.is_empty()
182            && minor.len() <= 4
183            && !minor.starts_with('0')
184            && minor.bytes().all(|b| b.is_ascii_digit()))
185}
186
187fn is_git_sha(raw: &str) -> bool {
188    raw.len() == 40
189        && raw
190            .bytes()
191            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
192}
193
194fn matches(pattern: &str, raw: &str) -> bool {
195    regex::Regex::new(pattern).is_ok_and(|re| re.is_match(raw))
196}
197
198fn only_keys(object: &serde_json::Map<String, Value>, allowed: &[&str]) -> bool {
199    object.keys().all(|k| allowed.contains(&k.as_str()))
200}
201
202/// `common.schema.json#/$defs/task_ref`.
203fn check_task_ref(task: &Value) -> Result<(), String> {
204    let ok = task.as_object().is_some_and(|t| {
205        only_keys(t, &["vault", "number"])
206            && t.get("vault")
207                .and_then(Value::as_str)
208                .is_some_and(|v| matches(r"^#[a-z0-9_-]{1,64}/[a-z0-9-]{2,40}$", v))
209            && t.get("number")
210                .and_then(Value::as_i64)
211                .is_some_and(|n| (1..=2_147_483_647).contains(&n))
212    });
213    if ok {
214        Ok(())
215    } else {
216        Err("feature list task must be {vault: \"#workspace/vault\", number: 1..}".into())
217    }
218}
219
220const UUID: &str = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
221
222/// `common.schema.json#/$defs/actor`: an agent or a person.
223fn is_actor(actor: &Value) -> bool {
224    let Some(a) = actor.as_object() else {
225        return false;
226    };
227    let text = |key: &str| a.get(key).and_then(Value::as_str);
228    match text("kind") {
229        Some("agent") => {
230            only_keys(
231                a,
232                &[
233                    "kind",
234                    "agent_id",
235                    "worker_id",
236                    "instance_id",
237                    "role",
238                    "queen",
239                ],
240            ) && text("agent_id")
241                .is_some_and(|v| matches(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", v))
242                && text("instance_id").is_some_and(|v| matches(UUID, v))
243                && text("role").is_some_and(|r| crate::core::session_rights::ROLE_IDS.contains(&r))
244                && a.get("worker_id").is_none_or(|w| {
245                    w.as_str()
246                        .is_some_and(|v| matches(r"^wkr_[a-z2-7]{20}$", v))
247                })
248                && a.get("queen").is_none_or(Value::is_boolean)
249        }
250        Some("person") => {
251            only_keys(a, &["kind", "person_id", "session"])
252                && text("person_id").is_some_and(|v| matches(UUID, v))
253                && text("session") == Some("human")
254        }
255        _ => false,
256    }
257}
258
259/// One `unlocks[]` entry: `{by: actor, decision: dec_…, at: timestamp}`.
260fn check_unlock(unlock: &Value) -> Result<(), String> {
261    let ok = unlock.as_object().is_some_and(|u| {
262        only_keys(u, &["by", "decision", "at"])
263            && u.get("by").is_some_and(is_actor)
264            && u.get("decision")
265                .and_then(Value::as_str)
266                .is_some_and(|d| matches(r"^dec_[0-9A-HJKMNP-TV-Z]{26}$", d))
267            && u.get("at").and_then(Value::as_str).is_some_and(|t| {
268                matches(
269                    r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?Z$",
270                    t,
271                )
272            })
273    });
274    if ok {
275        Ok(())
276    } else {
277        Err("feature list unlocks[] must be {by: actor, decision: dec_…, at: UTC timestamp}".into())
278    }
279}
280
281/// A repo-relative path or glob: not absolute, no `..` component.
282pub fn is_repo_relative(path: &str) -> bool {
283    !path.is_empty()
284        && !path.starts_with('/')
285        && !path.starts_with('\\')
286        && !path.split(['/', '\\']).any(|part| part == "..")
287}
288
289fn is_feature_id(id: &str) -> bool {
290    let digits = id.strip_prefix('F').unwrap_or("");
291    (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
292}
293
294/// Decide whether `writer` may turn `before` into `after`.
295///
296/// Before G2 (`before.spec_commit` unset) the Spec Writer may change anything
297/// (Hive's G2 then judges it). After G2 the only change allowed is `passes`,
298/// and only by the gate. Every refusal names the rule `feature_list_locked`.
299pub fn validate_transition(
300    before: &FeatureList,
301    after: &FeatureList,
302    writer: Writer<'_>,
303) -> Result<(), String> {
304    if !before.is_locked() {
305        return match writer {
306            Writer::Role("spec_writer") | Writer::Gate => Ok(()),
307            Writer::Role(role) => Err(format!(
308                "feature_list_locked: the feature list is written by the Spec Writer until G2, not by role '{role}'"
309            )),
310        };
311    }
312    let mut before_rest = before.clone();
313    let mut after_rest = after.clone();
314    let passes_changed = before.features.len() == after.features.len()
315        && before
316            .features
317            .iter()
318            .zip(&after.features)
319            .any(|(a, b)| a.passes != b.passes);
320    for feature in before_rest
321        .features
322        .iter_mut()
323        .chain(after_rest.features.iter_mut())
324    {
325        feature.passes = false;
326    }
327    if before_rest != after_rest {
328        return Err(
329            "feature_list_locked: after G2 only features[].passes may change; any other change \
330             is a spec change and sends the task back to spec"
331                .into(),
332        );
333    }
334    if passes_changed && writer != Writer::Gate {
335        let role = match writer {
336            Writer::Role(role) => role,
337            Writer::Gate => "",
338        };
339        return Err(format!(
340            "feature_list_locked: passes is written only by the G6 gate from its own re-run, not by role '{role}'"
341        ));
342    }
343    Ok(())
344}
345
346/// The gate's write of `passes`: set each named feature's result and store the
347/// list atomically. Refuses a list that is not locked yet (no G2), an unknown
348/// feature id, and any result set that is not complete.
349pub fn apply_gate_passes(path: &Path, results: &BTreeMap<String, bool>) -> Result<(), String> {
350    let before = FeatureList::load(path)?;
351    if !before.is_locked() {
352        return Err(
353            "feature_list_locked: the gate writes passes only after G2 (spec_commit unset)".into(),
354        );
355    }
356    let mut after = before.clone();
357    for feature in &mut after.features {
358        let Some(&passes) = results.get(&feature.id) else {
359            return Err(format!(
360                "gate result for feature {} is missing; the gate sets every feature",
361                feature.id
362            ));
363        };
364        feature.passes = passes;
365    }
366    if results.len() != after.features.len() {
367        return Err("gate result names a feature the list does not hold".into());
368    }
369    validate_transition(&before, &after, Writer::Gate)?;
370    let mut text = serde_json::to_string_pretty(&after).map_err(|e| e.to_string())?;
371    text.push('\n');
372    let tmp = path.with_extension("json.gate-tmp");
373    std::fs::write(&tmp, text).map_err(|e| format!("gate write failed: {e}"))?;
374    std::fs::rename(&tmp, path).map_err(|e| {
375        let _ = std::fs::remove_file(&tmp);
376        format!("gate write failed: {e}")
377    })
378}