Skip to main content

majordomus_cli/
policy.rs

1//! The canonical policy, typed: `.ai/repo/policy.yaml` and the profiles beside it. The
2//! policy is the one canonical input of the provider projections; the shell tool reads the
3//! same file with the same subset reader, so the two agree byte for byte on what a
4//! projection is and which hash it carries.
5
6use std::path::{Path, PathBuf};
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::error::{Error, Result};
13use crate::metadata::yaml;
14use crate::repository::Repository;
15
16/// The manifest section that holds the policy file.
17pub const POLICY_SECTION: &str = "policy";
18/// The manifest section that holds the profiles directory.
19pub const PROFILES_SECTION: &str = "profiles";
20
21/// How a projection owns its target.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum ProjectionMode {
25    /// The whole file is generated; its first line is the stamp.
26    #[default]
27    File,
28    /// Only the text between `majordomus:begin` and `majordomus:end` markers is generated.
29    Region,
30}
31
32/// One declared provider projection: `projections[]` in the policy.
33#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Projection {
36    /// The provider whose template renders the target: `agents`, `claude-code`, ...
37    pub provider: String,
38    /// Repository-relative target path, e.g. `AGENTS.md`.
39    pub target: String,
40    /// Whether every worker loads this target without asking; bounded by the budget.
41    #[serde(default)]
42    pub always_loaded: bool,
43    /// Whole file or a marked region.
44    #[serde(default)]
45    pub mode: ProjectionMode,
46}
47
48/// `context:` of the policy, the part the projections need.
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
50pub struct ContextPolicy {
51    /// Hard cap, in lines, on an `always_loaded` projection.
52    #[serde(default)]
53    pub always_loaded_budget_lines: Option<u64>,
54}
55
56/// `profiles:` of the policy, the part the projections need.
57#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
58pub struct ProfilesPolicy {
59    /// The profile a worker starts with.
60    #[serde(default)]
61    pub default: Option<String>,
62    /// The checkpoint interval the default profile sets.
63    #[serde(default)]
64    pub checkpoint_interval_default: Option<String>,
65}
66
67/// `session.freshness:` of the policy: how old a continuation record may be before it stops
68/// being current.
69///
70/// These are the only numbers that decide it, and they are declared once. Divergence
71/// (`exact`/`advanced`/`diverged`/`different_context`) answers a different question — where
72/// the record's commit sits relative to HEAD — and answers it identically on the day a
73/// record is written and a month later. Keeping a default here would be a second source of
74/// truth for the same thresholds, so absence is carried as absence and reported as
75/// `unknown`, naming the missing key.
76///
77/// Both keys are independently optional, and the pair is what
78/// [`Thresholds::judge`](crate::capability::builtin::continuity::Thresholds::judge) needs:
79/// with one of them missing it has no band to place a record in and answers `unknown`
80/// rather than inventing the other half.
81///
82/// ```
83/// use majordomus_cli::policy::FreshnessPolicy;
84/// use serde_json::json;
85///
86/// // the declared form: the two numbers that draw fresh | aging | stale
87/// let declared: FreshnessPolicy =
88///     serde_json::from_value(json!({"fresh_minutes": 30, "stale_minutes": 240})).unwrap();
89/// assert_eq!(declared.fresh_minutes, Some(30));
90/// assert_eq!(declared.stale_minutes, Some(240));
91///
92/// // a policy that predates the key: absent, not defaulted. Nothing in this file
93/// // supplies a number the repository never declared.
94/// let silent: FreshnessPolicy = serde_json::from_value(json!({})).unwrap();
95/// assert_eq!(silent, FreshnessPolicy::default());
96/// assert!(silent.fresh_minutes.is_none() && silent.stale_minutes.is_none());
97/// ```
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
99pub struct FreshnessPolicy {
100    /// Below this age a record is `fresh`.
101    #[serde(default)]
102    pub fresh_minutes: Option<i64>,
103    /// At or beyond this age a record is `stale`, and is never presented as current.
104    #[serde(default)]
105    pub stale_minutes: Option<i64>,
106}
107
108/// `knowledge:` of the policy: the bounds on the review queue of derived knowledge records.
109///
110/// A candidate is a record the deriver wrote at an episode boundary and nobody has judged
111/// (ADR 0091). The queue is measured, never emptied by a machine, and both numbers are
112/// declared once in the policy and read from it by the shell validator and by
113/// `knowledge_base.candidates` alike. Absence is carried as absence: a repository whose
114/// policy predates the keys is reported as declaring no cap, never judged against one
115/// nobody wrote down.
116///
117/// ```
118/// use majordomus_cli::policy::KnowledgePolicy;
119/// let declared: KnowledgePolicy =
120///     serde_json::from_str(r#"{"candidates_max_files": 40}"#).expect("a knowledge block");
121/// assert_eq!(declared.candidates_max_files, Some(40));
122/// assert_eq!(declared.candidate_max_age_minutes, None, "absent is absent, not a default");
123/// assert_eq!(KnowledgePolicy::default().candidates_max_files, None);
124/// ```
125#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
126pub struct KnowledgePolicy {
127    /// More records with status `candidate` than this awaiting review is a finding.
128    #[serde(default)]
129    pub candidates_max_files: Option<usize>,
130    /// A candidate waiting longer than this since it entered the queue is a finding.
131    #[serde(default)]
132    pub candidate_max_age_minutes: Option<i64>,
133}
134
135/// The policy, typed to what the projections consume. Every other key is carried through
136/// unread: the policy schema under `share/schemas/majordomus/policy/policy.v1.schema.json` owns the full shape.
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
138pub struct Policy {
139    /// `version:`.
140    #[serde(default)]
141    pub version: Option<u64>,
142    /// `context:`.
143    #[serde(default)]
144    pub context: ContextPolicy,
145    /// `profiles:`.
146    #[serde(default)]
147    pub profiles: ProfilesPolicy,
148    /// `session:`.
149    #[serde(default)]
150    pub session: SessionPolicy,
151    /// `knowledge:`.
152    #[serde(default)]
153    pub knowledge: KnowledgePolicy,
154    /// `projections:`.
155    #[serde(default)]
156    pub projections: Vec<Projection>,
157    /// `server:` — the timings a lease contest between two processes is judged by. Absent
158    /// keeps the constants `lease.rs` declares, so a policy that predates the block, or one
159    /// that could not be read, does not silently change how a server is taken over.
160    #[serde(default)]
161    pub server: ServerPolicy,
162    /// `commit:` — what this repository holds a commit message to. Absent is the default,
163    /// which is what this repository's own history already satisfies.
164    #[serde(default)]
165    pub commit: crate::commit::CommitPolicy,
166    /// `intent:` — whether work must be bound to what it serves before it starts, and the
167    /// exemption classes a worker may give instead. Absent is `off`: a repository whose
168    /// policy predates the block starts work exactly as it did (ADR 0111).
169    #[serde(default)]
170    pub intent: IntentPolicy,
171    /// `release:` — who raises the version and by at least how much (ADR 0134). Absent is
172    /// `allocation: off` with no floor: a repository whose policy predates the block raises
173    /// its version exactly as it did.
174    #[serde(default)]
175    pub release: crate::release::landing::ReleasePolicy,
176}
177
178/// How strictly a start is held to its binding.
179///
180/// ```
181/// use majordomus_cli::policy::BindingMode;
182/// // a policy that says nothing asks for nothing
183/// assert_eq!(BindingMode::default(), BindingMode::Off);
184/// let m: BindingMode = serde_json::from_str("\"required\"").unwrap();
185/// assert_eq!(m, BindingMode::Required);
186/// assert_eq!(m.as_str(), "required");
187/// ```
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
189#[serde(rename_all = "snake_case")]
190pub enum BindingMode {
191    /// Nothing is asked unless the worker names an issue, an intent or an exemption.
192    #[default]
193    Off,
194    /// Every start asks; a refused binding is reported and the work starts.
195    Advisory,
196    /// Every start asks; a refused or unreadable binding does not start.
197    Required,
198}
199
200impl BindingMode {
201    /// The word the policy file and every surface use.
202    ///
203    /// ```
204    /// use majordomus_cli::policy::BindingMode;
205    /// assert_eq!(BindingMode::Advisory.as_str(), "advisory");
206    /// ```
207    pub fn as_str(self) -> &'static str {
208        match self {
209            BindingMode::Off => "off",
210            BindingMode::Advisory => "advisory",
211            BindingMode::Required => "required",
212        }
213    }
214}
215
216/// One exemption class: a named reason work may proceed under no intent.
217///
218/// ```
219/// use majordomus_cli::policy::ExemptionClass;
220/// let c: ExemptionClass = serde_json::from_str(
221///     r#"{"id": "emergency", "description": "Restoring a broken trunk"}"#,
222/// )
223/// .unwrap();
224/// assert_eq!(c.id, "emergency");
225/// ```
226#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
227pub struct ExemptionClass {
228    /// The word a worker gives: `--exempt <id>`.
229    pub id: String,
230    /// What the class is for, in one line.
231    #[serde(default)]
232    pub description: String,
233}
234
235/// `intent:` — the binding a start is held to (ADR 0111).
236///
237/// ```
238/// use majordomus_cli::policy::{BindingMode, IntentPolicy};
239/// // absent: off, and no exemption class exists to give
240/// let absent = IntentPolicy::default();
241/// assert_eq!(absent.binding, BindingMode::Off);
242/// assert!(absent.exemption("maintenance").is_none());
243/// let p: IntentPolicy = serde_json::from_str(
244///     r#"{"binding": "required", "exemptions": [{"id": "maintenance"}]}"#,
245/// )
246/// .unwrap();
247/// assert!(p.exemption("maintenance").is_some());
248/// assert!(p.exemption("because-i-say-so").is_none());
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
251pub struct IntentPolicy {
252    /// `intent.binding:` — `off`, `advisory` or `required`.
253    #[serde(default)]
254    pub binding: BindingMode,
255    /// `intent.exemptions:` — the classes a worker may give instead of naming work.
256    #[serde(default)]
257    pub exemptions: Vec<ExemptionClass>,
258}
259
260impl IntentPolicy {
261    /// The declared class with this id, when the policy declares one.
262    ///
263    /// ```
264    /// use majordomus_cli::policy::IntentPolicy;
265    /// assert!(IntentPolicy::default().exemption("x").is_none());
266    /// ```
267    pub fn exemption(&self, id: &str) -> Option<&ExemptionClass> {
268        self.exemptions.iter().find(|c| c.id == id)
269    }
270}
271
272/// `session:` — what the episode boundary does beyond drawing itself.
273///
274/// Only the half two entry paths both read is typed here. The rest of the block is carried
275/// through unread, as every other key is: the policy schema owns the full shape.
276///
277/// ```
278/// use majordomus_cli::policy::SessionPolicy;
279/// // A policy that says nothing about it still converges. The default is what this
280/// // repository's policy and the skeleton a new one is written from both declare, and a
281/// // policy that could not be read must not silently change behaviour.
282/// assert!(SessionPolicy::default().ensure_server_on_start);
283/// assert!(SessionPolicy::default().knowledge_on_end);
284/// assert!(SessionPolicy::default().knowledge_on_compact);
285/// // and a repository that has turned one off is read as having turned it off
286/// let off: SessionPolicy =
287///     serde_json::from_str(r#"{"ensure_server_on_start": false, "knowledge_on_end": false}"#)
288///         .expect("a session block");
289/// assert!(!off.ensure_server_on_start);
290/// assert!(!off.knowledge_on_end);
291/// assert!(off.knowledge_on_compact, "the two switches are independent");
292/// ```
293#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
294pub struct SessionPolicy {
295    /// `session.freshness:` — how old a continuation record may be before it stops being
296    /// current. A separate question from divergence, which is about git topology and says
297    /// nothing about age.
298    #[serde(default)]
299    pub freshness: FreshnessPolicy,
300    /// Whether entering the repository converges on a ready shared server.
301    ///
302    /// One switch, both entry paths: the provider's start event (ADR 0035) and the file a
303    /// shell evaluates on entry (ADR 0043). It defaults to `true` because that is what
304    /// this repository's policy and the skeleton a new one is written from both declare,
305    /// and because a policy that could not be read must not silently change behaviour —
306    /// an unreadable policy is `doctor`'s finding, not a reason to stop converging.
307    #[serde(default = "yes")]
308    pub ensure_server_on_start: bool,
309    /// Whether closing an episode derives the knowledge it produced into candidate records
310    /// under the tracked knowledge section (ADR 0091). Read here so that
311    /// `knowledge_base.status` judges a silent writer only where the writer is switched on,
312    /// and defaults to `true` for the reason `ensure_server_on_start` does: the policy and
313    /// the skeleton both declare it, and an unreadable policy must not change behaviour.
314    #[serde(default = "yes")]
315    pub knowledge_on_end: bool,
316    /// Whether a provider's compaction event derives the same records. Independent of
317    /// `checkpoint_on_compact`: the two record different things.
318    #[serde(default = "yes")]
319    pub knowledge_on_compact: bool,
320    /// `session.brief_interval_minutes:` — how often a session reports on itself without
321    /// being asked (ADR 0136). Absent is the default `brief.session` carries; the provider
322    /// mod reads the interval from that answer rather than from this file.
323    #[serde(default)]
324    pub brief_interval_minutes: Option<u64>,
325    /// `session.edits_require_claim:` — whether a provider's tool may write a file of the
326    /// checkout only while a claim covers it: the active task's scope, or a live mesh claim
327    /// of the writing session (I2735, [`crate::guard`]). Off unless a repository turns it
328    /// on, because it refuses edits, and a repository that never asked for that must not
329    /// find its workers stopped by an upgrade.
330    #[serde(default)]
331    pub edits_require_claim: bool,
332    /// `session.stranded_after:` — how long an open episode may be silent before its client
333    /// is taken to be gone (`90s`, `30m`, `12h`). `majordomus recover` reads it to judge an
334    /// episode stranded, and `worktree.uncommitted` reads the same value twice over: an
335    /// episode silent this long holds no work, and uncommitted work untouched this long
336    /// that nothing holds is abandoned. Absent is absent: no reader carries a default of
337    /// its own, because a second copy of the number is the drift the policy exists to stop.
338    ///
339    /// ```
340    /// use majordomus_cli::policy::SessionPolicy;
341    /// let declared: SessionPolicy =
342    ///     serde_json::from_str(r#"{"stranded_after": "12h"}"#).expect("a session block");
343    /// assert_eq!(declared.stranded_after.as_deref(), Some("12h"));
344    /// assert_eq!(SessionPolicy::default().stranded_after, None, "absent is not a default");
345    /// // a bare number is seconds, as the shell reader takes it, and never a parse failure
346    /// // that would take the rest of the policy down with it
347    /// let bare: SessionPolicy =
348    ///     serde_json::from_str(r#"{"stranded_after": 3600}"#).expect("a session block");
349    /// assert_eq!(bare.stranded_after.as_deref(), Some("3600"));
350    /// ```
351    #[serde(default, deserialize_with = "scalar_text")]
352    pub stranded_after: Option<String>,
353}
354
355/// A scalar read as the text it was written as: the subset reader hands `12h` over as a
356/// string and `3600` as a number, and both are the same kind of value to a duration.
357fn scalar_text<'de, D: serde::Deserializer<'de>>(
358    d: D,
359) -> std::result::Result<Option<String>, D::Error> {
360    Ok(match serde_json::Value::deserialize(d)? {
361        serde_json::Value::String(s) => Some(s),
362        serde_json::Value::Number(n) => Some(n.to_string()),
363        _ => None,
364    })
365}
366
367impl Default for SessionPolicy {
368    fn default() -> Self {
369        SessionPolicy {
370            freshness: FreshnessPolicy::default(),
371            ensure_server_on_start: true,
372            knowledge_on_end: true,
373            knowledge_on_compact: true,
374            brief_interval_minutes: None,
375            edits_require_claim: false,
376            stranded_after: None,
377        }
378    }
379}
380
381fn yes() -> bool {
382    true
383}
384
385/// The policy as loaded: the typed value, where it came from, and the hash the stamps
386/// carry.
387#[derive(Debug, Clone)]
388pub struct LoadedPolicy {
389    /// The typed policy.
390    pub policy: Policy,
391    /// Repository-relative path of the policy file.
392    pub path: String,
393    /// SHA-256 (hex) of the [`normal_form`] of the policy file followed by that of every
394    /// profile file, in name order, concatenated: exactly what the shell tool's
395    /// `mj_policy_cat` hashes. A comment or a blank line does not move it (I1991).
396    pub sha256: String,
397}
398
399impl LoadedPolicy {
400    /// Read the policy and profiles of a repository.
401    pub fn load(repository: &Repository) -> Result<Self> {
402        let rel =
403            repository
404                .section_path(POLICY_SECTION)
405                .ok_or_else(|| Error::InvalidManifest {
406                    path: repository.root().join(crate::repository::MANIFEST),
407                    reason: format!("the manifest names no `{POLICY_SECTION}` section"),
408                })?;
409        let path = repository.root().join(&rel);
410        let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
411        let policy: Policy = yaml::parse_into(&text).map_err(|reason| Error::InvalidPolicy {
412            path: path.clone(),
413            reason,
414        })?;
415        // The one point a repository's policy is read, and therefore the one place the lease
416        // timings can be declared for this process. `declare_timings` is a OnceLock set, so
417        // reading the policy twice does not move a judgement already in flight.
418        crate::lease::declare_timings(&policy.server);
419        let mut hasher = Sha256::new();
420        hasher.update(normal_form(&text).as_bytes());
421        for profile in profile_files(repository)? {
422            let bytes = std::fs::read(&profile).map_err(|e| Error::io(&profile, e))?;
423            hasher.update(normal_form(&String::from_utf8_lossy(&bytes)).as_bytes());
424        }
425        Ok(LoadedPolicy {
426            policy,
427            path: rel,
428            sha256: format!("{:x}", hasher.finalize()),
429        })
430    }
431}
432
433/// The normal form of a file in the layer's YAML subset, which the policy hash covers in
434/// place of its bytes: trailing spaces, tabs and carriage returns dropped; blank lines and
435/// whole-line comments dropped; a trailing comment (a `#` after a space or tab) dropped
436/// from a line that holds no quote, since inside a quoted scalar ` #` may be text.
437/// Indentation, order and every value are kept, and every line ends in a newline.
438///
439/// It is the shell tool's `mj_yaml_normal` (lib/common.sh), line for line: the two must
440/// agree, or each renderer would call the other's bootstraps stale. The subset refuses
441/// block scalars, so no `#` line is ever content.
442///
443/// ```
444/// use majordomus_cli::policy::normal_form;
445///
446/// let edited = "# a new comment\nversion: 1   # the schema\n\ncontext:\n  name: \"a #b\"\n";
447/// assert_eq!(normal_form(edited), "version: 1\ncontext:\n  name: \"a #b\"\n");
448/// assert_eq!(normal_form(edited), normal_form("version: 1\ncontext:\n  name: \"a #b\"\n"));
449/// assert_ne!(normal_form("a: 1\n"), normal_form("a: 2\n"));
450/// ```
451pub fn normal_form(text: &str) -> String {
452    let mut out = String::with_capacity(text.len());
453    for raw in text.split('\n') {
454        let mut line = raw.trim_end_matches([' ', '\t', '\r']);
455        let body = line.trim_start_matches([' ', '\t']);
456        if body.is_empty() || body.starts_with('#') {
457            continue;
458        }
459        if !line.contains(['\'', '"']) {
460            let b = line.as_bytes();
461            if let Some(i) =
462                (1..b.len()).find(|&i| b[i] == b'#' && (b[i - 1] == b' ' || b[i - 1] == b'\t'))
463            {
464                line = line[..i].trim_end_matches([' ', '\t']);
465            }
466        }
467        out.push_str(line);
468        out.push('\n');
469    }
470    out
471}
472
473/// The profile files, `<profiles>/*.yaml`, sorted by name. An absent directory is no
474/// profile, not an error.
475pub fn profile_files(repository: &Repository) -> Result<Vec<PathBuf>> {
476    let Some(rel) = repository.section_path(PROFILES_SECTION) else {
477        return Ok(Vec::new());
478    };
479    let dir = repository.root().join(rel);
480    let entries = match std::fs::read_dir(&dir) {
481        Ok(e) => e,
482        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
483        Err(e) => return Err(Error::io(&dir, e)),
484    };
485    let mut files: Vec<PathBuf> = entries
486        .filter_map(|e| e.ok().map(|e| e.path()))
487        .filter(|p| p.extension().is_some_and(|x| x == "yaml") && p.is_file())
488        .collect();
489    files.sort();
490    Ok(files)
491}
492
493/// The repository-relative directory where a repository may override a provider template.
494pub fn repository_providers_dir(repository: &Repository) -> PathBuf {
495    repository
496        .ai_dir()
497        .join(&repository.manifest().repo.path)
498        .join("providers")
499}
500
501/// Hex SHA-256 of a text.
502pub fn sha256_hex(text: &str) -> String {
503    let mut h = Sha256::new();
504    h.update(text.as_bytes());
505    format!("{:x}", h.finalize())
506}
507
508/// Hex SHA-256 of arbitrary bytes: the same digest [`sha256_hex`] takes, for content that
509/// is not necessarily text. A file the repository tracks may hold anything, and decoding it
510/// to hash it would be a decision about its encoding that nothing here is entitled to make.
511///
512/// ```
513/// use majordomus_cli::policy::{sha256_bytes_hex, sha256_hex};
514/// assert_eq!(sha256_bytes_hex(b"majordomus"), sha256_hex("majordomus"));
515/// assert_ne!(sha256_bytes_hex(&[0xff, 0xfe]), sha256_bytes_hex(&[0xfe, 0xff]));
516/// ```
517pub fn sha256_bytes_hex(bytes: &[u8]) -> String {
518    let mut h = Sha256::new();
519    h.update(bytes);
520    format!("{:x}", h.finalize())
521}
522
523/// A path is inside `root` after lexical normalisation: no absolute path, no `..`
524/// component, nothing empty.
525pub fn is_safe_relative(path: &str) -> bool {
526    let p = Path::new(path);
527    !path.is_empty()
528        && !p.is_absolute()
529        && p.components().all(|c| {
530            matches!(
531                c,
532                std::path::Component::Normal(_) | std::path::Component::CurDir
533            )
534        })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    /// `session.stranded_after` is read as the text it was written as: a duration as a
542    /// string, a bare number as its digits, anything else as absent, and a value that is
543    /// not even well-formed as the parse failure it is.
544    #[test]
545    fn a_stranded_after_is_text_a_number_or_absent() {
546        let read = |json: &str| serde_json::from_str::<SessionPolicy>(json);
547        let text = read(r#"{"stranded_after": "12h"}"#).unwrap();
548        assert_eq!(text.stranded_after.as_deref(), Some("12h"));
549        let number = read(r#"{"stranded_after": 3600}"#).unwrap();
550        assert_eq!(number.stranded_after.as_deref(), Some("3600"));
551        let other = read(r#"{"stranded_after": true}"#).unwrap();
552        assert_eq!(other.stranded_after, None);
553        assert!(read(r#"{"stranded_after": tru}"#).is_err());
554    }
555
556    #[test]
557    fn a_comment_or_a_blank_line_is_not_part_of_the_normal_form() {
558        let plain = "version: 1\ncontext:\n  budget: 150\n  list: [a, b]\n";
559        let commented = "# head\nversion: 1 # v\r\n\ncontext:   \n  # inside\n  budget: 150\t# cap\n  list: [a, b] #l\n";
560        assert_eq!(normal_form(commented), plain);
561        // a quoted line keeps what follows its quote, and a `#` with no space before it is text
562        assert_eq!(normal_form("a: 'x' # c\n"), "a: 'x' # c\n");
563        assert_eq!(normal_form("a: b#c\n"), "a: b#c\n");
564        // the last line needs no newline, and indentation is significant
565        assert_eq!(normal_form("a:\n  b: 1"), "a:\n  b: 1\n");
566        assert_ne!(normal_form("a:\n  b: 1\n"), normal_form("a:\nb: 1\n"));
567    }
568
569    #[test]
570    fn the_intent_block_is_read_from_the_policy_file_and_absent_means_off() {
571        let absent: Policy = yaml::parse_into("version: 1\n").expect("a policy");
572        assert_eq!(absent.intent.binding, BindingMode::Off);
573        assert!(absent.intent.exemptions.is_empty());
574        let p: Policy = yaml::parse_into(
575            "version: 1\nintent:\n  binding: required\n  exemptions:\n    - id: emergency\n      description: Restoring a broken trunk\n    - id: maintenance\n",
576        )
577        .expect("a policy with an intent block");
578        assert_eq!(p.intent.binding, BindingMode::Required);
579        let class = p.intent.exemption("emergency").expect("the declared class");
580        assert_eq!(class.description, "Restoring a broken trunk");
581        assert_eq!(p.intent.exemption("maintenance").unwrap().description, "");
582        assert!(p.intent.exemption("whim").is_none());
583        // each mode is the word the file spells it with
584        for (mode, word) in [
585            (BindingMode::Off, "off"),
586            (BindingMode::Advisory, "advisory"),
587            (BindingMode::Required, "required"),
588        ] {
589            assert_eq!(mode.as_str(), word);
590            let read: BindingMode = serde_json::from_value(serde_json::json!(word)).unwrap();
591            assert_eq!(read, mode);
592        }
593    }
594
595    #[test]
596    fn projection_defaults_are_file_mode_and_not_always_loaded() {
597        let p: Policy = yaml::parse_into(
598            "version: 1\nprojections:\n  - provider: agents\n    target: AGENTS.md\n",
599        )
600        .unwrap();
601        assert_eq!(p.projections.len(), 1);
602        assert_eq!(p.projections[0].mode, ProjectionMode::File);
603        assert!(!p.projections[0].always_loaded);
604        assert_eq!(p.profiles.default, None);
605    }
606
607    #[test]
608    fn unknown_projection_keys_are_refused() {
609        let r: std::result::Result<Policy, String> = yaml::parse_into(
610            "projections:\n  - provider: agents\n    target: AGENTS.md\n    colour: red\n",
611        );
612        assert!(r.unwrap_err().contains("colour"));
613    }
614
615    #[test]
616    fn safe_relative_paths() {
617        assert!(is_safe_relative("AGENTS.md"));
618        assert!(is_safe_relative("docs/x/y.md"));
619        assert!(is_safe_relative("./AGENTS.md"));
620        assert!(!is_safe_relative(""));
621        assert!(!is_safe_relative("/etc/passwd"));
622        assert!(!is_safe_relative("../x"));
623        assert!(!is_safe_relative("a/../../x"));
624    }
625}
626
627/// `server:` — what a lease contest is judged by.
628///
629/// Every field is optional and absent means "keep the compiled default", which is why there
630/// is no reader-side default *value* here: the defaults live in one place, `lease::Timings`,
631/// and this block only says which of them this repository overrides.
632///
633/// ```
634/// use majordomus_cli::policy::ServerPolicy;
635/// // a policy that predates the block: absent, not defaulted to zero
636/// assert_eq!(ServerPolicy::default().probe_timeout_seconds, None);
637/// let p: ServerPolicy =
638///     serde_json::from_str(r#"{"probe_timeout_seconds": 5}"#).expect("a server block");
639/// assert_eq!(p.probe_timeout_seconds, Some(5));
640/// ```
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
642pub struct ServerPolicy {
643    /// `server.probe_timeout_seconds:` — how long a probe waits for the current owner before
644    /// that silence counts as evidence that it is gone.
645    #[serde(default)]
646    pub probe_timeout_seconds: Option<u64>,
647    /// `server.bind_grace_seconds:` — how long a lease naming no URL yet is left alone,
648    /// because a server writes its lease before it can serve.
649    #[serde(default)]
650    pub bind_grace_seconds: Option<u64>,
651    /// `server.join_timeout_seconds:` — how long a process waits to join or create the lease
652    /// file before refusing rather than waiting forever.
653    #[serde(default)]
654    pub join_timeout_seconds: Option<u64>,
655    /// `server.busy_grace_seconds:` — how long a live owner that does not answer is waited
656    /// on, and asked again, before its lease is taken over.
657    #[serde(default)]
658    pub busy_grace_seconds: Option<u64>,
659    /// `server.read_deadline_seconds:` — how long the shared server lets a connection go
660    /// without a request read in full on it before it closes the connection.
661    #[serde(default)]
662    pub read_deadline_seconds: Option<u64>,
663    /// `server.idle_seconds:` — how long a server started for a checkout no client owns
664    /// (`serve ensure`, a shell entering the checkout) outlives its last peer before it stops
665    /// by itself. `--idle` on the command line overrides it.
666    #[serde(default)]
667    pub idle_seconds: Option<u64>,
668}