Skip to main content

majordomus_cli/
guard.rs

1//! The edit guard (I2735): before a provider's tool writes a file of this checkout, is the
2//! path claimed?
3//!
4//! A session reaches the mesh at its start (I2734), a task's scope is claimed there by
5//! `start` (I2288), and a task completed off the mesh is refused (I2289). None of that stops
6//! a worker from editing a file nobody claimed: the claim was a convention until the edit
7//! itself asked for one. This is the asking. It answers one question — may this session
8//! write this path now — from four facts, each read without building the index, because it
9//! runs before every edit a provider makes:
10//!
11//! 1. the policy switch `session.edits_require_claim`, off unless a repository turns it on;
12//! 2. whether the repository declares its mesh enabled — a repository without a mesh has no
13//!    claims to be covered by;
14//! 3. the active task's scope, which `start` has claimed on the mesh as `task-<id>`;
15//! 4. the live claims the mesh holds for the session doing the writing.
16//!
17//! What it cannot decide it lets through and says why: an unreadable policy, no server to
18//! ask, a mesh state that does not parse. Refusing an edit because a probe failed would make a
19//! checkout nobody can serve a checkout nobody can edit, which is worse than the edit it
20//! would have stopped; I2289 still refuses the completion of such work.
21//!
22//! One edit, from the path a provider names to the verdict it reads:
23//!
24//! ```
25//! use std::path::Path;
26//! use majordomus_cli::guard::{judge_facts, relative_to, ClaimSource, EditFacts};
27//!
28//! // a provider is about to write this file of the checkout at /work/repo
29//! let root = Path::new("/work/repo");
30//! let asked = "/work/repo/docs/MESH.md";
31//! let rel = relative_to(root, asked);
32//! assert_eq!(rel.as_deref(), Some("docs/MESH.md"));
33//!
34//! // the facts gathered for it: the policy asks for a claim, the mesh is on, a task claims lib
35//! let scope = vec!["lib".to_string()];
36//! let facts = EditFacts {
37//!     required: Ok(true),
38//!     mesh_enabled: true,
39//!     task: Some(("t-1", &scope[..])),
40//!     session: Some("claude-code-s1"),
41//!     claims: ClaimSource::Claims(vec![]),
42//! };
43//! let verdict = judge_facts(asked, rel.as_deref(), facts);
44//! assert!(!verdict.allowed, "neither the task nor a claim of the session covers docs");
45//! assert!(verdict.remedy.unwrap().contains("majordomus start"));
46//! ```
47
48use std::path::{Component, Path, PathBuf};
49
50use schemars::JsonSchema;
51use serde::{Deserialize, Serialize};
52
53use crate::integration::exclusive::Mesh;
54use crate::mesh::state::{ClaimState, ClaimView};
55
56/// The answer to one edit: whether it may proceed, the one word it was decided on, the
57/// sentence a person reads and, for a refusal, what would allow it. `guard.edit` returns it
58/// as it is; `guard hook` turns it into an exit code.
59///
60/// ```
61/// use majordomus_cli::guard::{judge_facts, ClaimSource, EditFacts, EditVerdict};
62///
63/// let facts = EditFacts {
64///     required: Ok(false),
65///     mesh_enabled: true,
66///     task: None,
67///     session: None,
68///     claims: ClaimSource::Claims(vec![]),
69/// };
70/// let v: EditVerdict = judge_facts("a.md", Some("a.md"), facts);
71/// assert!(v.allowed);
72/// assert_eq!(v.ground, "off");
73/// let json = serde_json::to_value(&v).unwrap();
74/// assert!(json.get("remedy").is_none(), "an allowed edit carries no remedy");
75/// ```
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct EditVerdict {
78    /// Whether the edit may proceed.
79    pub allowed: bool,
80    /// The path asked about, repository-relative when it is inside the checkout.
81    pub path: String,
82    /// Why, as one word: `off`, `outside`, `local`, `no_mesh`, `task`, `claim`,
83    /// `undecided` or `unclaimed`. Only `unclaimed` refuses.
84    pub ground: String,
85    /// The sentence a person reads.
86    pub detail: String,
87    /// What makes the edit allowed, when it is refused.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub remedy: Option<String>,
90}
91
92impl EditVerdict {
93    fn allow(path: &str, ground: &str, detail: impl Into<String>) -> Self {
94        EditVerdict {
95            allowed: true,
96            path: path.to_string(),
97            ground: ground.to_string(),
98            detail: detail.into(),
99            remedy: None,
100        }
101    }
102}
103
104/// Where the guard reads the mesh's live claims: the running server's own state, or that
105/// server asked over HTTP, or nothing when no server answers.
106///
107/// ```
108/// use majordomus_cli::guard::{judge_facts, ClaimSource, EditFacts};
109///
110/// let facts = |claims| EditFacts {
111///     required: Ok(true),
112///     mesh_enabled: true,
113///     task: None,
114///     session: Some("claude-code-s1"),
115///     claims,
116/// };
117/// // the claims were read and none covers the path: refused
118/// assert!(!judge_facts("a", Some("a"), facts(ClaimSource::Claims(vec![]))).allowed);
119/// // nothing could be read: let through, and said
120/// let v = judge_facts("a", Some("a"), facts(ClaimSource::Unavailable("no server".into())));
121/// assert!(v.allowed);
122/// assert!(v.detail.contains("no server"));
123/// ```
124pub enum ClaimSource {
125    /// The claims, already read.
126    Claims(Vec<ClaimView>),
127    /// No server answers, or its answer could not be read: why.
128    Unavailable(String),
129}
130
131/// Whether a claim on `claim` covers a write to `path`: the same path, an ancestor of it,
132/// or the whole repository (`.`).
133///
134/// ```
135/// use majordomus_cli::guard::covers;
136///
137/// assert!(covers("lib", "lib/capture.sh"));
138/// assert!(covers("lib/capture.sh", "lib/capture.sh"));
139/// assert!(covers(".", "docs/MESH.md"), "a claim on the root covers everything");
140/// assert!(covers("lib/", "lib/a.sh"), "a trailing slash is the same directory");
141/// assert!(!covers("lib/capture.sh", "lib"), "a file does not cover its directory");
142/// assert!(!covers("li", "lib/a.sh"), "a prefix of a name is not an ancestor");
143/// assert!(!covers("", "lib/a.sh"));
144/// ```
145pub fn covers(claim: &str, path: &str) -> bool {
146    let claim = claim.trim().trim_end_matches('/');
147    let path = path.trim().trim_end_matches('/');
148    if claim == "." {
149        return !path.is_empty();
150    }
151    !claim.is_empty() && (path == claim || path.starts_with(&format!("{claim}/")))
152}
153
154/// `path` relative to `root`, when it is inside it: absolute or relative to `root`, with `.`
155/// and `..` resolved lexically, so a file that does not exist yet — the commonest thing a
156/// tool writes — is placed as surely as one that does. `None` outside the checkout.
157///
158/// ```
159/// use std::path::Path;
160/// use majordomus_cli::guard::relative_to;
161///
162/// let root = Path::new("/work/repo");
163/// assert_eq!(relative_to(root, "/work/repo/lib/a.sh").as_deref(), Some("lib/a.sh"));
164/// assert_eq!(relative_to(root, "lib/new.sh").as_deref(), Some("lib/new.sh"));
165/// assert_eq!(relative_to(root, "/work/repo/docs/../lib/a.sh").as_deref(), Some("lib/a.sh"));
166/// assert_eq!(relative_to(root, "/work/other/a.sh"), None);
167/// assert_eq!(relative_to(root, "/work/repo/../repo-wt/a.sh"), None);
168/// ```
169pub fn relative_to(root: &Path, path: &str) -> Option<String> {
170    let given = Path::new(path);
171    let joined = if given.is_absolute() {
172        given.to_path_buf()
173    } else {
174        root.join(given)
175    };
176    let normal = lexical(&joined);
177    let root = lexical(root);
178    let rel = normal.strip_prefix(&root).ok()?;
179    let rel = rel.to_string_lossy().replace('\\', "/");
180    (!rel.is_empty()).then_some(rel)
181}
182
183fn lexical(path: &Path) -> PathBuf {
184    let mut out = PathBuf::new();
185    for c in path.components() {
186        match c {
187            Component::ParentDir => {
188                out.pop();
189            }
190            Component::CurDir => {}
191            other => out.push(other.as_os_str()),
192        }
193    }
194    out
195}
196
197/// The facts one edit is judged from, gathered by the caller so that this function reads
198/// nothing and can be proved on every combination.
199///
200/// ```
201/// use majordomus_cli::guard::{judge_facts, ClaimSource, EditFacts};
202///
203/// let scope = vec!["lib".to_string()];
204/// let facts = EditFacts {
205///     required: Ok(true),
206///     mesh_enabled: true,
207///     task: Some(("t-1", &scope[..])),
208///     session: None,
209///     claims: ClaimSource::Unavailable("not asked".into()),
210/// };
211/// let v = judge_facts("lib/a.sh", Some("lib/a.sh"), facts);
212/// assert_eq!((v.allowed, v.ground.as_str()), (true, "task"), "the task's scope decides first");
213/// ```
214pub struct EditFacts<'a> {
215    /// `session.edits_require_claim`, or why the policy could not be read.
216    pub required: Result<bool, String>,
217    /// Whether the repository declares its mesh enabled.
218    pub mesh_enabled: bool,
219    /// The active task's id and scope, when one is active in this checkout.
220    pub task: Option<(&'a str, &'a [String])>,
221    /// The writing session's id on the mesh, when the caller knows it.
222    pub session: Option<&'a str>,
223    /// The mesh's live claims.
224    pub claims: ClaimSource,
225}
226
227/// Judge one edit of `rel` (repository-relative, or `None` when outside the checkout).
228///
229/// ```
230/// use majordomus_cli::guard::{judge_facts, ClaimSource, EditFacts};
231///
232/// let scope = vec!["lib".to_string()];
233/// let facts = |task, claims| EditFacts {
234///     required: Ok(true), mesh_enabled: true, task, session: Some("claude-code-s1"), claims,
235/// };
236/// // the active task's scope covers it
237/// let v = judge_facts("lib/a.sh", Some("lib/a.sh"), facts(Some(("t-1", &scope[..])), ClaimSource::Claims(vec![])));
238/// assert!(v.allowed);
239/// assert_eq!(v.ground, "task");
240/// // nothing covers it
241/// let v = judge_facts("docs/x.md", Some("docs/x.md"), facts(Some(("t-1", &scope[..])), ClaimSource::Claims(vec![])));
242/// assert!(!v.allowed);
243/// assert_eq!(v.ground, "unclaimed");
244/// assert!(v.remedy.unwrap().contains("--session claude-code-s1"));
245/// // no server to ask: let through, and said
246/// let v = judge_facts("docs/x.md", Some("docs/x.md"), facts(None, ClaimSource::Unavailable("no server".into())));
247/// assert!(v.allowed);
248/// assert_eq!(v.ground, "undecided");
249/// ```
250pub fn judge_facts(asked: &str, rel: Option<&str>, facts: EditFacts<'_>) -> EditVerdict {
251    let Some(rel) = rel else {
252        return EditVerdict::allow(asked, "outside", "the path is outside this checkout");
253    };
254    match facts.required {
255        Ok(false) => {
256            return EditVerdict::allow(
257                rel,
258                "off",
259                "session.edits_require_claim is off in this repository's policy",
260            )
261        }
262        Err(e) => {
263            return EditVerdict::allow(
264                rel,
265                "undecided",
266                format!("the policy could not be read, so nothing is enforced: {e}"),
267            )
268        }
269        Ok(true) => {}
270    }
271    if rel == ".ai/local"
272        || rel.starts_with(".ai/local/")
273        || rel == ".git"
274        || rel.starts_with(".git/")
275    {
276        return EditVerdict::allow(rel, "local", "the path is this checkout's local state");
277    }
278    if !facts.mesh_enabled {
279        return EditVerdict::allow(rel, "no_mesh", "the repository declares no enabled mesh");
280    }
281    if let Some((id, scope)) = facts.task {
282        if let Some(s) = scope.iter().find(|s| covers(s, rel)) {
283            return EditVerdict::allow(rel, "task", format!("the active task {id} claims {s}"));
284        }
285    }
286    let claims = match facts.claims {
287        ClaimSource::Claims(c) => c,
288        ClaimSource::Unavailable(why) => {
289            return EditVerdict::allow(
290                rel,
291                "undecided",
292                format!("the mesh could not be asked, so nothing is enforced: {why}"),
293            )
294        }
295    };
296    if let Some(session) = facts.session {
297        let own = format!("/{session}");
298        if let Some(c) = claims.iter().find(|c| {
299            matches!(c.state, ClaimState::Held | ClaimState::Conflicted(_))
300                && c.session.ends_with(&own)
301                && c.scope.iter().any(|s| covers(s, rel))
302        }) {
303            return EditVerdict::allow(
304                rel,
305                "claim",
306                format!("the session's claim {} covers it", c.key),
307            );
308        }
309    }
310    let by_session = facts
311        .session
312        .map(|s| format!(", or claim it for this session: majordomus mesh claim {rel} --session {s} --advisory"))
313        .unwrap_or_default();
314    EditVerdict {
315        allowed: false,
316        path: rel.to_string(),
317        ground: "unclaimed".into(),
318        detail: format!(
319            "no claim covers {rel}: neither the active task's scope{} nor a live mesh claim of this session",
320            facts.task.map(|(id, _)| format!(" ({id})")).unwrap_or_default()
321        ),
322        remedy: Some(format!(
323            "start a task that claims it: majordomus start \"<task>\" --scope {rel}{by_session}"
324        )),
325    }
326}
327
328/// Whether the repository at `root` declares its mesh enabled: a declaration under
329/// `.ai/repo/mesh/` whose top-level `enabled` is `true`. Read from the files, not the index:
330/// this runs before every edit.
331///
332/// ```
333/// use majordomus_cli::guard::mesh_declared_enabled;
334///
335/// let dir = tempfile::tempdir().unwrap();
336/// assert!(!mesh_declared_enabled(dir.path()), "no declaration");
337/// let mesh = dir.path().join(".ai/repo/mesh");
338/// std::fs::create_dir_all(&mesh).unwrap();
339/// std::fs::write(mesh.join("mesh.yaml"), "enabled: false\n").unwrap();
340/// assert!(!mesh_declared_enabled(dir.path()));
341/// std::fs::write(mesh.join("mesh.yaml"), "enabled: true\n").unwrap();
342/// assert!(mesh_declared_enabled(dir.path()));
343/// ```
344pub fn mesh_declared_enabled(root: &Path) -> bool {
345    let Ok(entries) = std::fs::read_dir(root.join(".ai/repo/mesh")) else {
346        return false;
347    };
348    entries.flatten().any(|e| {
349        let p = e.path();
350        matches!(p.extension().and_then(|x| x.to_str()), Some("yaml" | "yml"))
351            && std::fs::read_to_string(&p)
352                .ok()
353                .and_then(|t| crate::metadata::yaml::parse_mapping(&t).ok())
354                .and_then(|m| m.get("enabled").and_then(|v| v.as_bool()))
355                == Some(true)
356    })
357}
358
359/// `session.edits_require_claim` of the repository at `root`, or why its policy could not
360/// be read — which [`judge_facts`] answers by enforcing nothing.
361///
362/// ```
363/// use majordomus_cli::guard::edits_require_claim;
364///
365/// let dir = tempfile::tempdir().unwrap();
366/// assert!(edits_require_claim(dir.path()).is_err(), "no manifest, so no policy");
367/// ```
368pub fn edits_require_claim(root: &Path) -> Result<bool, String> {
369    let repository = crate::repository::Repository::open(root).map_err(|e| e.to_string())?;
370    crate::policy::LoadedPolicy::load(&repository)
371        .map(|p| p.policy.session.edits_require_claim)
372        .map_err(|e| e.to_string())
373}
374
375/// The active task of the checkout at `root`: its id and scope, while its outcome is active.
376///
377/// ```
378/// use majordomus_cli::guard::active_task;
379///
380/// let dir = tempfile::tempdir().unwrap();
381/// assert_eq!(active_task(dir.path()), None, "no task record");
382/// let state = dir.path().join(".ai/local/state");
383/// std::fs::create_dir_all(&state).unwrap();
384/// let record = "id: t-1\noutcome: active\nscope:\n  - lib\n";
385/// std::fs::write(state.join("current.yaml"), record).unwrap();
386/// assert_eq!(active_task(dir.path()), Some(("t-1".to_string(), vec!["lib".to_string()])));
387/// let finished = record.replace("active", "completed");
388/// std::fs::write(state.join("current.yaml"), finished).unwrap();
389/// assert_eq!(active_task(dir.path()), None, "a finished task claims nothing");
390/// ```
391pub fn active_task(root: &Path) -> Option<(String, Vec<String>)> {
392    let task = crate::capability::builtin::continuity::read_task(
393        &root.join(".ai/local/state/current.yaml"),
394    )?;
395    (task.outcome == "active").then_some((task.id, task.scope))
396}
397
398/// The live claims of the checkout's shared server, asked over HTTP from outside it.
399///
400/// ```
401/// use majordomus_cli::guard::{server_claims, ClaimSource};
402///
403/// let dir = tempfile::tempdir().unwrap();
404/// let ClaimSource::Unavailable(why) = server_claims(dir.path()) else {
405///     panic!("a checkout no server serves has no claims to read");
406/// };
407/// assert!(why.contains("no shared server"));
408/// ```
409pub fn server_claims(root: &Path) -> ClaimSource {
410    match crate::integration::exclusive::server_of(root) {
411        Some(server) => claims_of(&server),
412        None => ClaimSource::Unavailable("no shared server answers for this checkout".into()),
413    }
414}
415
416/// The live claims `mesh` answers with, or why they could not be read. A server that does
417/// not run the mesh answers 200 with `active: false` and its reason, and that is said as
418/// such rather than as a state that does not parse.
419pub(crate) fn claims_of(mesh: &dyn Mesh) -> ClaimSource {
420    match mesh.ask("GET", "/api/v1/mesh/state?view=full&claims=live", None) {
421        Ok((200, v)) if v["active"] == false => {
422            let why = v["reason"].as_str().unwrap_or("no reason given");
423            ClaimSource::Unavailable(format!("the server does not run the mesh: {why}"))
424        }
425        Ok((200, v)) => {
426            match serde_json::from_value::<Vec<ClaimView>>(v["state"]["claims"].clone()) {
427                Ok(c) => ClaimSource::Claims(c),
428                Err(e) => ClaimSource::Unavailable(format!("the mesh state does not parse: {e}")),
429            }
430        }
431        Ok((status, v)) => {
432            let m = v["error"]["message"]
433                .as_str()
434                .unwrap_or_default()
435                .to_string();
436            ClaimSource::Unavailable(format!("the server answered {status}: {m}"))
437        }
438        Err(e) => ClaimSource::Unavailable(e),
439    }
440}
441
442/// Judge an edit of `path` by `session` in the checkout at `root`, reading every fact.
443/// `claims` is asked only when the cheaper facts have not already decided.
444///
445/// ```
446/// use majordomus_cli::guard::judge;
447///
448/// let dir = tempfile::tempdir().unwrap();
449/// // no manifest, so no policy: nothing is enforced, and the mesh is never asked
450/// let v = judge(dir.path(), "a.md", None, || unreachable!("the claims are not needed"));
451/// assert!(v.allowed);
452/// assert_eq!((v.path.as_str(), v.ground.as_str()), ("a.md", "undecided"));
453/// ```
454pub fn judge(
455    root: &Path,
456    path: &str,
457    session: Option<&str>,
458    claims: impl FnOnce() -> ClaimSource,
459) -> EditVerdict {
460    let rel = relative_to(root, path);
461    let required = edits_require_claim(root);
462    let mesh_enabled = mesh_declared_enabled(root);
463    let task = active_task(root);
464    let needs_claims = rel.is_some()
465        && required == Ok(true)
466        && mesh_enabled
467        && !task
468            .as_ref()
469            .zip(rel.as_deref())
470            .is_some_and(|((_, scope), r)| scope.iter().any(|s| covers(s, r)));
471    let claims = if needs_claims {
472        claims()
473    } else {
474        ClaimSource::Claims(Vec::new())
475    };
476    judge_facts(
477        path,
478        rel.as_deref(),
479        EditFacts {
480            required,
481            mesh_enabled,
482            task: task
483                .as_ref()
484                .map(|(id, scope)| (id.as_str(), scope.as_slice())),
485            session,
486            claims,
487        },
488    )
489}
490
491/// The path a provider's pre-tool payload names, and the provider's own session id: the
492/// fields Claude Code sends (`tool_input.file_path`, `tool_input.notebook_path`,
493/// `session_id`). `None` when the payload names no path — a tool that writes no file.
494///
495/// ```
496/// use majordomus_cli::guard::from_payload;
497///
498/// let p = r#"{"session_id":"s1","tool_name":"Edit","tool_input":{"file_path":"/r/lib/a.sh"}}"#;
499/// assert_eq!(from_payload(p), Some(("/r/lib/a.sh".to_string(), Some("s1".to_string()))));
500/// let n = r#"{"session_id":"s1","tool_input":{"notebook_path":"/r/n.ipynb"}}"#;
501/// assert_eq!(from_payload(n).unwrap().0, "/r/n.ipynb");
502/// assert_eq!(from_payload(r#"{"tool_input":{"command":"ls"}}"#), None);
503/// assert_eq!(from_payload("not json"), None);
504/// ```
505pub fn from_payload(payload: &str) -> Option<(String, Option<String>)> {
506    let v: serde_json::Value = serde_json::from_str(payload).ok()?;
507    let input = &v["tool_input"];
508    let path = input["file_path"]
509        .as_str()
510        .or_else(|| input["notebook_path"].as_str())?
511        .to_string();
512    let session = v["session_id"].as_str().map(str::to_string);
513    Some((path, session))
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn claim(session: &str, scope: &[&str], state: ClaimState) -> ClaimView {
521        let stream = "641bdb94dae9f62268abf5ca73c11e15-94bb82f89e960c38-daeb650615a562b1";
522        serde_json::from_value(serde_json::json!({
523            "key": format!("{stream}/c-1"),
524            "stream": stream,
525            "runtime": "641bdb94dae9f62268abf5ca73c11e15-94bb82f89e960c38",
526            "session": format!("{stream}/{session}"),
527            "scope": scope,
528            "mode": "advisory",
529            "acquired_lamport": 1,
530            "acquired_seq": 1,
531            "state": state,
532        }))
533        .expect("a claim view")
534    }
535
536    fn facts<'a>(task: Option<(&'a str, &'a [String])>, claims: ClaimSource) -> EditFacts<'a> {
537        EditFacts {
538            required: Ok(true),
539            mesh_enabled: true,
540            task,
541            session: Some("claude-code-s1"),
542            claims,
543        }
544    }
545
546    #[test]
547    fn a_claim_covers_its_path_its_descendants_and_the_root_covers_all() {
548        assert!(covers(".", "docs/a.md"));
549        assert!(!covers(".", ""));
550        assert!(covers("docs/", "docs/a.md"));
551        assert!(!covers("doc", "docs/a.md"));
552    }
553
554    #[test]
555    fn a_path_is_placed_lexically_inside_or_outside_the_checkout() {
556        let root = Path::new("/work/repo");
557        assert_eq!(
558            relative_to(root, "./docs/../lib/a.sh").as_deref(),
559            Some("lib/a.sh")
560        );
561        assert_eq!(relative_to(root, "/work/other/a.sh"), None);
562        assert_eq!(
563            relative_to(root, "/work/repo"),
564            None,
565            "the root itself is no file"
566        );
567        let relative = Path::new("./repo");
568        assert_eq!(relative_to(relative, "a.sh").as_deref(), Some("a.sh"));
569    }
570
571    #[test]
572    fn a_task_that_claims_elsewhere_is_named_in_the_refusal() {
573        let scope = ["lib".to_string()];
574        let v = judge_facts(
575            "docs/a.md",
576            Some("docs/a.md"),
577            facts(Some(("t-1", &scope[..])), ClaimSource::Claims(vec![])),
578        );
579        assert!(!v.allowed);
580        assert!(v.detail.contains("(t-1)"), "{}", v.detail);
581    }
582
583    #[test]
584    fn without_a_session_only_a_task_is_offered_as_the_remedy() {
585        let f = EditFacts {
586            session: None,
587            ..facts(None, ClaimSource::Claims(vec![]))
588        };
589        let v = judge_facts("docs/a.md", Some("docs/a.md"), f);
590        assert!(!v.allowed);
591        let remedy = v.remedy.unwrap();
592        assert!(remedy.contains("majordomus start"), "{remedy}");
593        assert!(!remedy.contains("--session"), "{remedy}");
594    }
595
596    #[test]
597    fn the_sessions_live_claim_covers_the_edit() {
598        let c = vec![claim("claude-code-s1", &["docs"], ClaimState::Held)];
599        let v = judge_facts(
600            "docs/a.md",
601            Some("docs/a.md"),
602            facts(None, ClaimSource::Claims(c)),
603        );
604        assert!(v.allowed, "{v:?}");
605        assert_eq!(v.ground, "claim");
606    }
607
608    #[test]
609    fn another_sessions_claim_does_not() {
610        let c = vec![claim("claude-code-s2", &["docs"], ClaimState::Held)];
611        let v = judge_facts(
612            "docs/a.md",
613            Some("docs/a.md"),
614            facts(None, ClaimSource::Claims(c)),
615        );
616        assert!(!v.allowed);
617        assert_eq!(v.ground, "unclaimed");
618    }
619
620    #[test]
621    fn a_released_claim_does_not() {
622        let c = vec![claim("claude-code-s1", &["docs"], ClaimState::Released)];
623        let v = judge_facts(
624            "docs/a.md",
625            Some("docs/a.md"),
626            facts(None, ClaimSource::Claims(c)),
627        );
628        assert!(!v.allowed);
629    }
630
631    #[test]
632    fn off_no_mesh_local_and_outside_are_let_through() {
633        let off = EditFacts {
634            required: Ok(false),
635            ..facts(None, ClaimSource::Claims(vec![]))
636        };
637        assert_eq!(judge_facts("a", Some("a"), off).ground, "off");
638        let no_mesh = EditFacts {
639            mesh_enabled: false,
640            ..facts(None, ClaimSource::Claims(vec![]))
641        };
642        assert_eq!(judge_facts("a", Some("a"), no_mesh).ground, "no_mesh");
643        let local = judge_facts(
644            ".ai/local/state/x",
645            Some(".ai/local/state/x"),
646            facts(None, ClaimSource::Claims(vec![])),
647        );
648        assert_eq!(local.ground, "local");
649        assert_eq!(
650            judge_facts("/elsewhere", None, facts(None, ClaimSource::Claims(vec![]))).ground,
651            "outside"
652        );
653    }
654
655    #[test]
656    fn an_unreadable_policy_enforces_nothing() {
657        let f = EditFacts {
658            required: Err("no manifest".into()),
659            ..facts(None, ClaimSource::Claims(vec![]))
660        };
661        let v = judge_facts("a", Some("a"), f);
662        assert!(v.allowed);
663        assert_eq!(v.ground, "undecided");
664    }
665
666    /// A repository made of the shipped skeleton's manifest and policy, with the policy
667    /// switch and the mesh declaration as asked.
668    fn repository(required: bool, mesh: bool) -> tempfile::TempDir {
669        let skeleton = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share/skeleton");
670        let dir = tempfile::tempdir().unwrap();
671        let ai = dir.path().join(".ai");
672        std::fs::create_dir_all(ai.join("repo/mesh")).unwrap();
673        std::fs::copy(skeleton.join("ai/manifest.yaml"), ai.join("manifest.yaml")).unwrap();
674        let policy = std::fs::read_to_string(skeleton.join("policy.yaml")).unwrap();
675        let line = "edits_require_claim: false";
676        assert!(policy.contains(line), "the skeleton declares the switch");
677        let policy = policy.replace(line, &format!("edits_require_claim: {required}"));
678        std::fs::write(ai.join("repo/policy.yaml"), policy).unwrap();
679        std::fs::write(ai.join("repo/mesh/mesh.yaml"), format!("enabled: {mesh}\n")).unwrap();
680        dir
681    }
682
683    #[test]
684    fn the_policy_switch_is_read_from_the_repository() {
685        assert!(
686            !crate::policy::SessionPolicy::default().edits_require_claim,
687            "off unless a repository turns it on"
688        );
689        assert_eq!(edits_require_claim(repository(true, true).path()), Ok(true));
690        assert_eq!(
691            edits_require_claim(repository(false, true).path()),
692            Ok(false)
693        );
694        let bare = tempfile::tempdir().unwrap();
695        assert!(edits_require_claim(bare.path()).is_err());
696        let broken = repository(true, true);
697        std::fs::write(broken.path().join(".ai/repo/policy.yaml"), "session: [\n").unwrap();
698        assert!(
699            edits_require_claim(broken.path()).is_err(),
700            "a policy that does not parse"
701        );
702    }
703
704    #[test]
705    fn the_mesh_declaration_is_read_from_its_yaml_files() {
706        assert!(mesh_declared_enabled(repository(true, true).path()));
707        let bare = tempfile::tempdir().unwrap();
708        assert!(!mesh_declared_enabled(bare.path()), "no mesh directory");
709        let off = repository(true, false);
710        assert!(!mesh_declared_enabled(off.path()));
711        let mesh = off.path().join(".ai/repo/mesh");
712        std::fs::write(mesh.join("README.md"), "enabled: true\n").unwrap();
713        assert!(
714            !mesh_declared_enabled(off.path()),
715            "only a YAML file declares"
716        );
717        std::fs::write(mesh.join("other.yml"), "enabled: true\n").unwrap();
718        assert!(mesh_declared_enabled(off.path()));
719    }
720
721    #[test]
722    fn an_unclaimed_edit_of_a_repository_on_the_mesh_is_refused() {
723        let dir = repository(true, true);
724        let asked = std::cell::Cell::new(false);
725        let v = judge(dir.path(), "docs/a.md", Some("claude-code-s1"), || {
726            asked.set(true);
727            ClaimSource::Claims(vec![])
728        });
729        assert!(asked.get(), "only the claims could decide");
730        assert!(!v.allowed);
731        assert_eq!(v.ground, "unclaimed");
732    }
733
734    #[test]
735    fn the_active_tasks_scope_lets_the_edit_through_without_asking_the_mesh() {
736        let dir = repository(true, true);
737        let state = dir.path().join(".ai/local/state");
738        std::fs::create_dir_all(&state).unwrap();
739        let record = "id: t-1\noutcome: active\nscope:\n  - docs\n";
740        std::fs::write(state.join("current.yaml"), record).unwrap();
741        let v = judge(dir.path(), "docs/a.md", None, || {
742            unreachable!("the task decides")
743        });
744        assert_eq!((v.allowed, v.ground.as_str()), (true, "task"));
745    }
746
747    #[test]
748    fn a_checkout_no_server_serves_has_no_claims_to_read() {
749        let dir = repository(true, true);
750        assert!(matches!(
751            server_claims(dir.path()),
752            ClaimSource::Unavailable(_)
753        ));
754    }
755
756    /// A server that answers every question the same way.
757    struct Answering(Result<(u16, serde_json::Value), String>);
758
759    impl Mesh for Answering {
760        fn ask(
761            &self,
762            _: &str,
763            _: &str,
764            _: Option<serde_json::Value>,
765        ) -> Result<(u16, serde_json::Value), String> {
766            self.0.clone()
767        }
768    }
769
770    #[test]
771    fn the_servers_answer_is_read_as_claims_or_as_why_not() {
772        let held = claim("claude-code-s1", &["docs"], ClaimState::Held);
773        let ok = serde_json::json!({ "state": { "claims": [held] } });
774        let ClaimSource::Claims(c) = claims_of(&Answering(Ok((200, ok)))) else {
775            panic!("a well-formed answer is claims");
776        };
777        assert_eq!(c.len(), 1);
778        let inactive = serde_json::json!({ "active": false, "reason": "not declared" });
779        assert!(matches!(
780            claims_of(&Answering(Ok((200, inactive)))),
781            ClaimSource::Unavailable(w) if w == "the server does not run the mesh: not declared"
782        ));
783        let silent = serde_json::json!({ "active": false });
784        assert!(matches!(
785            claims_of(&Answering(Ok((200, silent)))),
786            ClaimSource::Unavailable(w) if w.ends_with("no reason given")
787        ));
788        let malformed = serde_json::json!({ "state": { "claims": "none" } });
789        assert!(matches!(
790            claims_of(&Answering(Ok((200, malformed)))),
791            ClaimSource::Unavailable(w) if w.contains("does not parse")
792        ));
793        let refused = serde_json::json!({ "error": { "message": "mesh off" } });
794        assert!(matches!(
795            claims_of(&Answering(Ok((503, refused)))),
796            ClaimSource::Unavailable(w) if w.contains("503: mesh off")
797        ));
798        assert!(matches!(
799            claims_of(&Answering(Err("connection refused".into()))),
800            ClaimSource::Unavailable(w) if w == "connection refused"
801        ));
802    }
803}