Skip to main content

mars_schema/
upgrade.rs

1//! Pure legacy Mars definition and manifest upgrades to schema v1.
2
3use serde_yaml::{Mapping, Value};
4use toml_edit::{DocumentMut, Item};
5
6use crate::claude;
7use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
8use crate::frontmatter::{Frontmatter, FrontmatterError};
9use crate::harness_overrides::{Moved, move_keys_under};
10use crate::item::DefinitionKind;
11use crate::{
12    agent::{REMOVED_AGENT_FIELDS, REMOVED_AGENT_VALUES},
13    skill,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct UpgradeResult {
18    pub text: String,
19    /// What the upgrade changed, one note per key it dropped or moved.
20    pub diagnostics: Vec<SchemaDiagnostic>,
21    /// A changed definition whose text was re-rendered rather than edited minimally: the whole
22    /// frontmatter, or a `harness-overrides` block that could not be grown in place.
23    pub fully_rewritten: bool,
24    /// Authored key paths whose values or presence changed.
25    pub edited_paths: Vec<Vec<PathSegment>>,
26}
27
28impl UpgradeResult {
29    pub(crate) fn changed_from(&self, input: &str) -> bool {
30        self.text != input
31    }
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum UpgradeError {
36    #[error(transparent)]
37    Frontmatter(#[from] FrontmatterError),
38    #[error("failed to parse mars.toml: {0}")]
39    Manifest(#[from] toml_edit::TomlError),
40    #[error("mars.toml at a package root must contain [package]")]
41    MissingPackage,
42}
43
44pub fn upgrade_definition(kind: DefinitionKind, text: &str) -> Result<UpgradeResult, UpgradeError> {
45    rewrite(text, |frontmatter, diagnostics| match kind {
46        DefinitionKind::Agent => upgrade_agent(frontmatter, diagnostics),
47        DefinitionKind::Skill => upgrade_skill(frontmatter, diagnostics),
48    })
49}
50
51/// Apply only what schema 1 removes with a mechanical replacement: the Agent keys that moved
52/// under `harness-overrides`.
53///
54/// `mars migrate` runs this on a schema 1 file it is about to write back, so a file that
55/// carries a key the reader rejects is repaired instead of failing the run. Every reading
56/// caller leaves it off, because an authored schema 1 file is read exactly as written.
57pub fn relocate_removed_fields(
58    kind: DefinitionKind,
59    text: &str,
60) -> Result<UpgradeResult, UpgradeError> {
61    rewrite(text, |frontmatter, notes| {
62        rename_consumer_namespaces(frontmatter, notes);
63        if kind == DefinitionKind::Agent {
64            move_agent_fields(frontmatter, notes);
65        }
66    })
67}
68
69/// `text` with `apply`'s changes to its frontmatter, edited as minimally as the source shape
70/// allows.
71fn rewrite(
72    text: &str,
73    apply: impl FnOnce(&mut Frontmatter, &mut Vec<SchemaDiagnostic>),
74) -> Result<UpgradeResult, UpgradeError> {
75    let original = Frontmatter::parse(text)?;
76    let mut frontmatter = original.clone();
77    let mut diagnostics = Vec::new();
78    apply(&mut frontmatter, &mut diagnostics);
79    if frontmatter.mapping() == original.mapping() {
80        return Ok(UpgradeResult {
81            text: text.to_string(),
82            diagnostics,
83            fully_rewritten: false,
84            edited_paths: Vec::new(),
85        });
86    }
87
88    let edited_paths = changed_mapping_paths(original.mapping(), frontmatter.mapping());
89
90    // The fallback re-renders the frontmatter only. The body is authored prompt text that may be
91    // byte-sensitive (embedded examples, mixed line endings), so it is copied from the input
92    // rather than from the render, which ends every line in LF.
93    let fallback = {
94        let mut rewritten = with_line_ending_of(text, frontmatter.render_frontmatter());
95        rewritten.push_str(original.body());
96        rewritten
97    };
98    let minimally_edited = edit_top_level_blocks(text, original.mapping(), frontmatter.mapping())
99        .and_then(|edited| {
100            Frontmatter::parse(&edited.text)
101                .ok()
102                .filter(|parsed| parsed.mapping() == frontmatter.mapping())
103                .map(|_| edited)
104        });
105    Ok(UpgradeResult {
106        fully_rewritten: minimally_edited
107            .as_ref()
108            .is_none_or(|edited| edited.re_rendered),
109        text: minimally_edited.map_or(fallback, |edited| edited.text),
110        diagnostics,
111        edited_paths,
112    })
113}
114
115/// Convert a `mars.toml` to schema v1.
116///
117/// `prepare` and `mars migrate` run this on a pre-v1 package. It never touches `requires-mars`:
118/// the requirement gates whether a package may be read at all, so only `mars migrate`,
119/// which a human runs on the package they own, adds one (see [`ensure_requires_mars_floor`]).
120pub fn upgrade_manifest(text: &str) -> Result<UpgradeResult, UpgradeError> {
121    let mut document = text.parse::<DocumentMut>()?;
122    let package = document
123        .get_mut("package")
124        .and_then(Item::as_table_mut)
125        .ok_or(UpgradeError::MissingPackage)?;
126    if package.get("schema").and_then(Item::as_integer) == Some(1) {
127        return Ok(UpgradeResult {
128            text: text.to_string(),
129            diagnostics: Vec::new(),
130            fully_rewritten: false,
131            edited_paths: Vec::new(),
132        });
133    }
134    let mut diagnostics = Vec::new();
135    let mut edited_paths = vec![package_path("schema")];
136    package.insert("schema", toml_edit::value(1));
137    if package.remove("visibility").is_some() {
138        diagnostics.push(removed(&["package", "visibility"], "visibility"));
139        edited_paths.push(package_path("visibility"));
140    }
141    Ok(UpgradeResult {
142        text: with_line_ending_of(text, document.to_string()),
143        diagnostics,
144        fully_rewritten: false,
145        edited_paths,
146    })
147}
148
149/// Adding the `requires-mars` floor to a manifest.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct FloorOutcome {
152    /// The manifest with the key added, or the input when it was not.
153    pub text: String,
154    /// `mars check`'s advice, when the requirement exists and admits a Mars that reads the
155    /// package as pre-v1, or when the manifest has a shape migrate cannot edit minimally.
156    pub warning: Option<String>,
157}
158
159/// Add `requires-mars = ">=0.20.0"` to a manifest that has no requirement.
160///
161/// `mars migrate` runs this on the manifest it is about to write, so a schema v1 package needs
162/// no hand edit. An existing requirement is never rewritten: when it admits a Mars older than
163/// the first release that reads schema 1, [`FloorOutcome::warning`] is `mars check`'s advice and
164/// the value keeps its bytes and comment. A manifest migrate cannot edit minimally (an inline
165/// `[package]` table) comes back unchanged with the same advice, never an error, so an
166/// already-v1 package migrates exactly as it did before this key existed.
167pub fn ensure_requires_mars_floor(text: &str) -> Result<FloorOutcome, UpgradeError> {
168    let mut document = text.parse::<DocumentMut>()?;
169    let package = document
170        .get_mut("package")
171        .ok_or(UpgradeError::MissingPackage)?;
172    let requirement = package
173        .as_table_like()
174        .and_then(|table| table.get("requires-mars"))
175        .and_then(Item::as_str)
176        .map(str::to_owned);
177    if requirement.is_none()
178        && let Some(table) = package.as_table_mut()
179    {
180        table.insert(
181            "requires-mars",
182            toml_edit::value(format!(">={}", crate::package::SCHEMA_1_MARS_FLOOR)),
183        );
184        return Ok(FloorOutcome {
185            text: with_line_ending_of(text, document.to_string()),
186            warning: None,
187        });
188    }
189    Ok(FloorOutcome {
190        text: text.to_string(),
191        warning: crate::package::schema_1_requires_mars_warning(requirement.as_deref()),
192    })
193}
194
195fn package_path(key: &str) -> Vec<PathSegment> {
196    vec![
197        PathSegment::Key("package".to_string()),
198        PathSegment::Key(key.to_string()),
199    ]
200}
201
202/// Whether the file's first line ends in `\r\n`.
203fn is_crlf(text: &str) -> bool {
204    text.find('\n')
205        .is_some_and(|end| text.as_bytes()[..end].ends_with(b"\r"))
206}
207
208/// `written` uses `\n` where the upgrade added lines; a CRLF file stays CRLF.
209fn with_line_ending_of(original: &str, written: String) -> String {
210    if !is_crlf(original) {
211        return written;
212    }
213    let mut out = String::with_capacity(written.len() + written.len() / 16);
214    let mut previous = '\0';
215    for character in written.chars() {
216        if character == '\n' && previous != '\r' {
217            out.push('\r');
218        }
219        out.push(character);
220        previous = character;
221    }
222    out
223}
224
225fn changed_mapping_paths(before: &Mapping, after: &Mapping) -> Vec<Vec<PathSegment>> {
226    let mut keys = before
227        .keys()
228        .chain(after.keys())
229        .filter_map(Value::as_str)
230        .collect::<Vec<_>>();
231    keys.sort_unstable();
232    keys.dedup();
233    keys.into_iter()
234        .filter(|key| {
235            let key = Value::String((*key).to_string());
236            before.get(&key) != after.get(&key)
237        })
238        .map(|key| vec![PathSegment::Key(key.to_string())])
239        .collect()
240}
241
242#[derive(Debug)]
243struct SourceBlock<'a> {
244    key: &'a str,
245    text: &'a str,
246    key_end: usize,
247    start: usize,
248}
249
250/// A frontmatter edited in place, and whether a block had to be re-rendered from its parsed
251/// value, which drops the comments and flow style written inside it.
252struct Edited {
253    text: String,
254    re_rendered: bool,
255}
256
257/// Rewrite only changed top-level blocks. Complex top-level YAML deliberately returns `None` so
258/// the caller has one explicit, verified full-serialization fallback.
259fn edit_top_level_blocks(text: &str, before: &Mapping, after: &Mapping) -> Option<Edited> {
260    let (prefix, yaml, suffix) = frontmatter_source(text)?;
261    let blocks = source_blocks(yaml)?;
262    let before_keys = before
263        .keys()
264        .map(Value::as_str)
265        .collect::<Option<Vec<_>>>()?;
266    if blocks.iter().map(|block| block.key).collect::<Vec<_>>() != before_keys {
267        return None;
268    }
269
270    let render = |key: &str, value: &Value| with_line_ending_of(text, render_block(key, value));
271    let mut output = String::with_capacity(text.len());
272    let mut re_rendered = false;
273    output.push_str(prefix);
274    let first_key_offset = blocks.first().map_or(yaml.len(), |block| block.start);
275    output.push_str(&yaml[..first_key_offset]);
276
277    let mut emitted = Vec::new();
278    for block in blocks {
279        let before_value = before.get(Value::String(block.key.to_string()))?;
280        if let Some(rename) = renamed_key(block.key, before, after) {
281            output.push_str(rename);
282            output.push_str(&block.text[block.key_end..]);
283            emitted.push(rename);
284        } else if let Some(after_value) = after.get(Value::String(block.key.to_string())) {
285            if before_value == after_value {
286                output.push_str(block.text);
287            } else if let Some(edited) = grown_in_place(&block, before_value, after_value)
288                .or_else(|| renamed_in_place(&block, before_value, after_value))
289            {
290                output.push_str(&edited);
291            } else {
292                // Re-rendering a block loses what the author wrote inside it, which only a
293                // `harness-overrides` block can avoid by growing in place, so a file that fell back
294                // here is reported.
295                re_rendered |= block.key == "harness-overrides";
296                output.push_str(&render(block.key, after_value));
297            }
298            emitted.push(block.key);
299        }
300    }
301
302    for (key, value) in after {
303        let key = key.as_str()?;
304        if emitted.contains(&key) {
305            continue;
306        }
307        if !output.ends_with('\n') {
308            output.push_str(&with_line_ending_of(text, "\n".to_owned()));
309        }
310        output.push_str(&render(key, value));
311    }
312    output.push_str(suffix);
313    Some(Edited {
314        text: output,
315        re_rendered,
316    })
317}
318
319/// `block` with the entries `after` adds, appended as text, so the comments and flow style the
320/// author wrote in the entries already there survive. `None` when the block cannot take text that
321/// simply: it is written in flow style, or an entry it has changed.
322fn grown_in_place(block: &SourceBlock<'_>, before: &Value, after: &Value) -> Option<String> {
323    let (Value::Mapping(before), Value::Mapping(after)) = (before, after) else {
324        return None;
325    };
326    if before
327        .iter()
328        .any(|(key, value)| after.get(key) != Some(value))
329    {
330        return None;
331    }
332    let added = after
333        .iter()
334        .filter(|(key, _)| before.get(key).is_none())
335        .collect::<Vec<_>>();
336    let indent = (!added.is_empty()).then(|| child_indent(block)).flatten()?;
337    // The block's text runs up to the next top-level key, so it ends with whatever blank lines and
338    // comments were written above that key; the new entries go before them.
339    let (body, tail) = split_trailing_run(block.text);
340    let mut grown = body.to_string();
341    if !grown.ends_with('\n') {
342        grown.push('\n');
343    }
344    for (key, value) in added {
345        let rendered = render_block(key.as_str()?, value);
346        // The block's own indentation is the author's; serde's two spaces a level become theirs.
347        let indented = rendered
348            .lines()
349            .map(|line| {
350                let depth = line.len() - line.trim_start().len();
351                format!("{}{}", indent.repeat(depth / 2 + 1), line.trim_start())
352            })
353            .collect::<Vec<_>>()
354            .join("\n");
355        grown.push_str(&indented);
356        grown.push('\n');
357    }
358    grown.push_str(tail);
359    Some(with_line_ending_of(block.text, grown))
360}
361
362/// `block` with its host namespace keys renamed where they are written, so the comments, flow
363/// style and quoting around them survive. `None` when `after` is more than the renames of
364/// `before`, or a renamed key is not written as a plain key at the block's own indentation.
365fn renamed_in_place(block: &SourceBlock<'_>, before: &Value, after: &Value) -> Option<String> {
366    let (Value::Mapping(before), Value::Mapping(after)) = (before, after) else {
367        return None;
368    };
369    if before.len() != after.len() {
370        return None;
371    }
372    let mut renames = Vec::new();
373    for ((old, old_value), (new, new_value)) in before.iter().zip(after) {
374        if old_value != new_value {
375            return None;
376        }
377        if old != new {
378            let pair = (old.as_str()?, new.as_str()?);
379            if !crate::harness::RENAMED_CONSUMER_NAMESPACES.contains(&pair) {
380                return None;
381            }
382            renames.push(pair);
383        }
384    }
385    let indent = child_indent(block)?;
386    let mut done = 0;
387    let mut edited = String::with_capacity(block.text.len());
388    for line in block.text.split_inclusive('\n') {
389        let renamed = line.strip_prefix(indent).and_then(|rest| {
390            renames.iter().find_map(|&(old, new)| {
391                let after_key = rest.strip_prefix(old)?;
392                after_key
393                    .trim_start_matches(' ')
394                    .starts_with(':')
395                    .then(|| format!("{indent}{new}{after_key}"))
396            })
397        });
398        match renamed {
399            Some(line) => {
400                done += 1;
401                edited.push_str(&line);
402            }
403            None => edited.push_str(line),
404        }
405    }
406    (done == renames.len()).then_some(edited)
407}
408
409/// `text` split where an appended entry belongs: before the trailing run of blank lines and
410/// comments at the document's left edge, which belong to the key below the block.
411fn split_trailing_run(text: &str) -> (&str, &str) {
412    let mut split = text.len();
413    for line in text.split_inclusive('\n').rev() {
414        let content = line.trim_end_matches(['\r', '\n']);
415        if !content.trim().is_empty() && !content.starts_with('#') {
416            break;
417        }
418        split -= line.len();
419    }
420    text.split_at(split)
421}
422
423/// The indentation of `block`'s own children in its source text, and `None` when it has none to
424/// line up with: a flow mapping, or a value that is not a mapping at all.
425fn child_indent<'a>(block: &'a SourceBlock<'a>) -> Option<&'a str> {
426    let first = block.text.split_inclusive('\n').next()?;
427    let rest = first[block.key_end + 1..].trim_end_matches(['\r', '\n']);
428    if !(rest.trim().is_empty() || rest.trim_start().starts_with('#')) {
429        return None;
430    }
431    for line in block.text.split_inclusive('\n').skip(1) {
432        let content = line.trim_end_matches(['\r', '\n']);
433        if content.trim().is_empty() || content.trim_start().starts_with('#') {
434            continue;
435        }
436        let indent = content.len() - content.trim_start().len();
437        return (indent > 0).then(|| &content[..indent]);
438    }
439    None
440}
441
442fn renamed_key<'a>(old: &str, before: &Mapping, after: &'a Mapping) -> Option<&'a str> {
443    let new = match old {
444        "model_invocable" => "model-invocable",
445        "user_invocable" => "user-invocable",
446        "disallowed_tools" => "disallowed-tools",
447        _ => return None,
448    };
449    if before.contains_key(Value::String(new.to_string())) {
450        return None;
451    }
452    let old_value = before.get(Value::String(old.to_string()))?;
453    (after.get(Value::String(new.to_string())) == Some(old_value)).then_some(new)
454}
455
456fn render_block(key: &str, value: &Value) -> String {
457    let mut mapping = Mapping::new();
458    mapping.insert(Value::String(key.to_string()), value.clone());
459    let yaml = serde_yaml::to_string(&mapping).expect("serializing an upgraded YAML block");
460    yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string()
461}
462
463fn frontmatter_source(text: &str) -> Option<(&str, &str, &str)> {
464    let first_end = text.find('\n').map_or(text.len(), |offset| offset + 1);
465    if text[..first_end]
466        .strip_prefix('\u{feff}')
467        .unwrap_or(&text[..first_end])
468        .trim_end()
469        != "---"
470    {
471        return None;
472    }
473    let mut offset = first_end;
474    for line in text[first_end..].split_inclusive('\n') {
475        if line.trim_end() == "---" {
476            return Some((
477                &text[..first_end],
478                &text[first_end..offset],
479                &text[offset..],
480            ));
481        }
482        offset += line.len();
483    }
484    None
485}
486
487fn source_blocks(yaml: &str) -> Option<Vec<SourceBlock<'_>>> {
488    let mut starts = Vec::new();
489    let mut offset = 0;
490    for line in yaml.split_inclusive('\n') {
491        let content = line.trim_end_matches(['\r', '\n']);
492        if !content.is_empty()
493            && !content.starts_with(char::is_whitespace)
494            && !content.starts_with('#')
495        {
496            let colon = content.find(':')?;
497            let key = &content[..colon];
498            if key.is_empty()
499                || !key
500                    .bytes()
501                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
502            {
503                return None;
504            }
505            starts.push((offset, key, colon));
506        }
507        offset += line.len();
508    }
509    let mut blocks = Vec::with_capacity(starts.len());
510    for (index, (start, key, key_end)) in starts.iter().copied().enumerate() {
511        let end = starts
512            .get(index + 1)
513            .map_or(yaml.len(), |(start, _, _)| *start);
514        blocks.push(SourceBlock {
515            key,
516            text: &yaml[start..end],
517            key_end,
518            start,
519        });
520    }
521    Some(blocks)
522}
523
524/// The rules for what only pre-v1 Mars wrote, key by key: the invocability renames, the
525/// discarded Agent fields, the removed values, a scalar `permission` and the removed model-policy
526/// override keys. No foreign dialect defines any of them, so every translation applies them
527/// before its own rules (`lift_frontmatter`), and the legacy upgrade is the same rules plus its
528/// own. A tree is never classified as pre-v1 Mars by finding one: its other keys are read in the
529/// dialect they belong to.
530pub(crate) fn lift_mars_only_keys(
531    fm: &mut Frontmatter,
532    kind: DefinitionKind,
533    notes: &mut Vec<SchemaDiagnostic>,
534) -> bool {
535    let before = fm.mapping().clone();
536    rename_field(fm, "model_invocable", "model-invocable");
537    rename_field(fm, "user_invocable", "user-invocable");
538    if kind == DefinitionKind::Agent {
539        merge_host_set_spellings(fm, "network_allow", "network-allow");
540        merge_host_set_spellings(fm, "network_deny", "network-deny");
541        for field in REMOVED_AGENT_FIELDS.iter().filter(|field| field.discarded) {
542            if fm.remove(field.key).is_some() {
543                notes.push(removed(&[field.key], field.key));
544            }
545        }
546        for removed in REMOVED_AGENT_VALUES {
547            replace_scalar(fm, removed.key, removed.value, removed.replacement);
548        }
549        move_agent_fields(fm, notes);
550        strip_model_policy_overrides(fm, notes);
551    }
552    fm.mapping() != &before
553}
554
555fn upgrade_agent(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
556    // Before `move_agent_fields`, so a top-level key lands in the block it names, not beside a
557    // block still written under the old name.
558    rename_consumer_namespaces(fm, diagnostics);
559    upgrade_tools(fm);
560    lift_mars_only_keys(fm, DefinitionKind::Agent, diagnostics);
561    // A pre-v1 file passed unknown keys to every harness; Claude's own keep reaching Claude.
562    claude::translate::move_to_claude_overrides(fm, DefinitionKind::Agent, diagnostics);
563    set_aside_rejected_keys(fm, DefinitionKind::Agent, diagnostics);
564}
565
566fn upgrade_skill(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
567    rename_consumer_namespaces(fm, diagnostics);
568    lift_mars_only_keys(fm, DefinitionKind::Skill, diagnostics);
569
570    if let Some(skill_type) = fm.get("type").cloned() {
571        let metadata = fm
572            .get("metadata")
573            .cloned()
574            .unwrap_or_else(|| Value::Mapping(Mapping::new()));
575        if let Value::Mapping(mut metadata) = metadata {
576            let key = Value::String("type".to_string());
577            if !metadata.contains_key(&key) {
578                metadata.insert(key, skill_type);
579                fm.insert("metadata", Value::Mapping(metadata));
580                fm.remove("type");
581            }
582        }
583    }
584
585    if let Some(when) = fm
586        .get("when_to_use")
587        .and_then(Value::as_str)
588        .map(str::to_owned)
589    {
590        let description = match fm.get("description") {
591            Some(Value::String(description)) => Some(format!("{description}\n\n{when}")),
592            None => Some(when),
593            Some(_) => None,
594        };
595        if let Some(description) = description {
596            fm.insert("description", Value::String(description));
597            fm.remove("when_to_use");
598        }
599    }
600
601    for key in skill::tool_keys() {
602        if fm.remove(key).is_some() {
603            diagnostics.push(SchemaDiagnostic::new(
604                "skill.tools-dropped",
605                Severity::Warning,
606                vec![PathSegment::Key(key.to_string())],
607                format!("removed skill key `{key}`; skill tool rules have no v1 meaning"),
608            ));
609        }
610    }
611
612    claude::translate::move_to_claude_overrides(fm, DefinitionKind::Skill, diagnostics);
613    set_aside_rejected_keys(fm, DefinitionKind::Skill, diagnostics);
614}
615
616/// What the rules above could not make schema v1 is dropped, with a warning naming it, so the
617/// upgrade never hands the strict reader a value it rejects. It is not kept under
618/// `harness-overrides.meridian-flow`: Meridian Flow defines one key, `permission`, and `lift_mars_only_keys` writes
619/// it there when it is `read` or `edit`. Anything else in that namespace would not be Meridian Flow's.
620fn set_aside_rejected_keys(
621    fm: &mut Frontmatter,
622    kind: DefinitionKind,
623    diagnostics: &mut Vec<SchemaDiagnostic>,
624) {
625    crate::set_aside::set_aside_rejected_keys(
626        fm,
627        kind,
628        crate::set_aside::Destination::Nowhere,
629        diagnostics,
630    );
631}
632
633fn upgrade_tools(fm: &mut Frontmatter) {
634    rename_field(fm, "disallowed_tools", "disallowed-tools");
635    if let Some(Value::Sequence(entries)) = fm.get("tools").cloned()
636        && entries.iter().all(|entry| entry.as_str().is_some())
637    {
638        let policy = entries
639            .into_iter()
640            .map(|entry| (entry, Value::String("allow".to_string())))
641            .collect();
642        fm.insert("tools", Value::Mapping(policy));
643    }
644
645    let Some(Value::Sequence(disallowed)) = fm.get("disallowed-tools").cloned() else {
646        return;
647    };
648    if !disallowed.iter().all(|entry| entry.as_str().is_some()) {
649        return;
650    }
651    let policy = fm
652        .get("tools")
653        .cloned()
654        .unwrap_or_else(|| Value::Mapping(Mapping::new()));
655    let Value::Mapping(mut policy) = policy else {
656        return;
657    };
658    for entry in disallowed {
659        policy.insert(entry, Value::String("deny".to_string()));
660    }
661    fm.insert("tools", Value::Mapping(policy));
662    fm.remove("disallowed-tools");
663}
664
665/// The Agent keys schema 1 replaced with a `harness-overrides` key: the consumer namespace that
666/// owns it, and the name it takes there.
667///
668/// One conflict rule covers all three: a block already written wins, and its top-level twin is
669/// dropped with a note naming it. `permission` moves only for the values Flow defines; see
670/// [`moves_under_namespace`].
671const MOVED_AGENT_FIELDS: &[(&str, &str, &str)] = &[
672    ("meridian-flow", "permission", "permission"),
673    (
674        "meridian-cli",
675        "resident-rearm-budget",
676        "resident-rearm-budget",
677    ),
678    ("meridian-cli", "meridian-capabilities", "capabilities"),
679];
680
681/// Whether the top-level `key` moves under its consumer namespace. Meridian Flow defines
682/// `read` and `edit` only, so any other `permission` value stays where it is for the reader to report.
683fn moves_under_namespace(fm: &Frontmatter, key: &str) -> bool {
684    key != "permission"
685        || fm
686            .get(key)
687            .and_then(Value::as_str)
688            .is_some_and(|permission| matches!(permission, "read" | "edit"))
689}
690
691/// Move the Agent keys schema 1 replaced with a `harness-overrides` key. An existing block wins,
692/// and the top-level twin it displaces is dropped with a note: the reader would otherwise report
693/// the key `mars migrate` left behind.
694fn move_agent_fields(fm: &mut Frontmatter, notes: &mut Vec<SchemaDiagnostic>) {
695    let mut displaced = Vec::new();
696    for &namespace in crate::harness::RESERVED_CONSUMER_NAMESPACES {
697        let keys = MOVED_AGENT_FIELDS
698            .iter()
699            .filter_map(|(owner, from, to)| {
700                (*owner == namespace && moves_under_namespace(fm, from)).then_some((*from, *to))
701            })
702            .collect::<Vec<_>>();
703        if let Moved::Done {
704            displaced: dropped, ..
705        } = move_keys_under(fm, namespace, &keys)
706        {
707            displaced.extend(dropped.into_iter().map(|(from, to)| (namespace, from, to)));
708        }
709    }
710    for (namespace, from, to) in displaced {
711        notes.push(dropped_twin(
712            &agent_label(fm),
713            vec![PathSegment::Key(from.to_string())],
714            from,
715            namespace,
716            to,
717        ));
718    }
719}
720
721/// Rename `harness-overrides.flow` and `.meridian` to the full host names, in place. When the
722/// block under the new name exists too, the two merge key by key: a key only the old block sets
723/// moves over, and a key both set keeps the new block's value, the old one dropped with a note.
724fn rename_consumer_namespaces(fm: &mut Frontmatter, notes: &mut Vec<SchemaDiagnostic>) {
725    let agent = agent_label(fm);
726    let Some(Value::Mapping(overrides)) = fm.get_mut("harness-overrides") else {
727        return;
728    };
729    for &(old, new) in crate::harness::RENAMED_CONSUMER_NAMESPACES {
730        let old_key = Value::String(old.to_string());
731        let new_key = Value::String(new.to_string());
732        let Some(old_block) = overrides.get(&old_key).cloned() else {
733            continue;
734        };
735        let block_path = vec![
736            PathSegment::Key("harness-overrides".to_string()),
737            PathSegment::Key(old.to_string()),
738        ];
739        let Some(new_block) = overrides.get_mut(&new_key) else {
740            // Rebuilt so the renamed block keeps its place among its siblings.
741            *overrides = std::mem::take(overrides)
742                .into_iter()
743                .map(|(key, value)| {
744                    if key == old_key {
745                        (new_key.clone(), value)
746                    } else {
747                        (key, value)
748                    }
749                })
750                .collect();
751            continue;
752        };
753        match (new_block, old_block) {
754            (Value::Mapping(new_block), Value::Mapping(old_block)) => {
755                let twin = Twin {
756                    agent: &agent,
757                    old,
758                    new,
759                };
760                merge_old_block(new_block, old_block, &twin, &[], notes);
761            }
762            _ => notes.push(SchemaDiagnostic::new(
763                "field.removed",
764                Severity::Warning,
765                block_path,
766                format!(
767                    "dropped `harness-overrides.{old}`; `harness-overrides.{new}` already sets it"
768                ),
769            )),
770        }
771        overrides.remove(&old_key);
772    }
773}
774
775fn agent_label(fm: &Frontmatter) -> String {
776    fm.get("name").and_then(Value::as_str).map_or_else(
777        || "this agent".to_string(),
778        |name| format!("agent `{name}`"),
779    )
780}
781
782/// The two namespace blocks being merged, for naming a dropped key in its note.
783struct Twin<'a> {
784    agent: &'a str,
785    old: &'a str,
786    new: &'a str,
787}
788
789/// Fold `old` into `new` key by key. A key only `old` sets moves over (a non-string key too:
790/// the reader reports it), mappings on both sides merge recursively, and any other clash keeps
791/// the `new` value and notes the dropped one.
792fn merge_old_block(
793    new: &mut Mapping,
794    old: Mapping,
795    twin: &Twin,
796    rel: &[String],
797    notes: &mut Vec<SchemaDiagnostic>,
798) {
799    for (key, value) in old {
800        let name = key
801            .as_str()
802            .map_or_else(|| format!("{key:?}"), str::to_owned);
803        let mut key_rel = rel.to_vec();
804        key_rel.push(name);
805        match new.get_mut(&key) {
806            None => {
807                new.insert(key, value);
808            }
809            Some(existing) if *existing == value => {}
810            Some(Value::Mapping(existing)) if value.is_mapping() => {
811                let Value::Mapping(value) = value else {
812                    unreachable!()
813                };
814                merge_old_block(existing, value, twin, &key_rel, notes);
815            }
816            Some(_) => {
817                let dotted = key_rel.join(".");
818                let mut path = vec![
819                    PathSegment::Key("harness-overrides".to_string()),
820                    PathSegment::Key(twin.old.to_string()),
821                ];
822                path.extend(key_rel.into_iter().map(PathSegment::Key));
823                let dropped = format!("harness-overrides.{}.{dotted}", twin.old);
824                notes.push(dropped_twin(twin.agent, path, &dropped, twin.new, &dotted));
825            }
826        }
827    }
828}
829
830/// The note for a value `dropped` (named as written) because the `namespace` block already sets
831/// `key` (dotted, relative to the block). A dropped `permission` or `capabilities` entry carried
832/// a restriction, which the note names as lost (`field.restriction-lost`, as the set-aside
833/// reports the same loss).
834fn dropped_twin(
835    agent: &str,
836    path: Vec<PathSegment>,
837    dropped: &str,
838    namespace: &str,
839    key: &str,
840) -> SchemaDiagnostic {
841    if key == "permission" || key == "capabilities" || key.starts_with("capabilities.") {
842        return SchemaDiagnostic::new(
843            crate::set_aside::RESTRICTION_LOST,
844            Severity::Warning,
845            path,
846            format!(
847                "{agent} lost the restriction `{key}`: it was dropped, because \
848                 `harness-overrides.{namespace}.{key}` already sets a value, and an override \
849                 written wins"
850            ),
851        );
852    }
853    SchemaDiagnostic::new(
854        "field.removed",
855        Severity::Warning,
856        path,
857        format!("dropped `{dropped}`; `harness-overrides.{namespace}.{key}` already sets it"),
858    )
859}
860
861fn strip_model_policy_overrides(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
862    let Some(Value::Sequence(policies)) = fm.get_mut("model-policies") else {
863        return;
864    };
865    for (index, policy) in policies.iter_mut().enumerate() {
866        let Value::Mapping(policy) = policy else {
867            continue;
868        };
869        let Some(Value::Mapping(overrides)) = policy.get_mut(Value::String("override".to_string()))
870        else {
871            continue;
872        };
873        // The legacy effort spellings mean the same here as at the top level.
874        for (old, new) in [("adaptive", "medium"), ("disabled", "none")] {
875            let key = Value::String("effort".to_string());
876            if overrides.get(&key).and_then(Value::as_str) == Some(old) {
877                overrides.insert(key, Value::String(new.to_string()));
878            }
879        }
880        for key in ["autocompact", "autocompact_pct", "harness"] {
881            if overrides.remove(Value::String(key.to_string())).is_some() {
882                diagnostics.push(SchemaDiagnostic::new(
883                    "field.removed",
884                    Severity::Warning,
885                    vec![
886                        PathSegment::Key("model-policies".to_string()),
887                        PathSegment::Index(index),
888                        PathSegment::Key("override".to_string()),
889                        PathSegment::Key(key.to_string()),
890                    ],
891                    format!("removed model policy override key `{key}`"),
892                ));
893            }
894        }
895    }
896}
897
898fn rename_field(fm: &mut Frontmatter, old: &str, new: &str) {
899    let Some(value) = fm.remove(old) else {
900        return;
901    };
902    if fm.get(new).is_none() {
903        fm.insert(new, value);
904    }
905}
906
907/// A host set written under both spellings keeps every host of both: dropping either would
908/// lose a deny. A value that is not a host list or a `{replace: [...]}` map stays where it is, so
909/// the reader reports it.
910fn merge_host_set_spellings(fm: &mut Frontmatter, old: &str, new: &str) {
911    let (Some(old_value), Some(new_value)) = (fm.get(old), fm.get(new)) else {
912        return rename_field(fm, old, new);
913    };
914    let (Some((old_hosts, old_replaces)), Some((mut hosts, new_replaces))) =
915        (host_set_parts(old_value), host_set_parts(new_value))
916    else {
917        return;
918    };
919    for host in old_hosts {
920        if !hosts.contains(&host) {
921            hosts.push(host);
922        }
923    }
924    let merged = if old_replaces || new_replaces {
925        let mut replace = serde_yaml::Mapping::new();
926        replace.insert(Value::String("replace".to_string()), Value::Sequence(hosts));
927        Value::Mapping(replace)
928    } else {
929        Value::Sequence(hosts)
930    };
931    fm.insert(new, merged);
932    fm.remove(old);
933}
934
935fn host_set_parts(value: &Value) -> Option<(Vec<Value>, bool)> {
936    match value {
937        Value::Sequence(hosts) => Some((hosts.clone(), false)),
938        Value::Mapping(map) if map.len() == 1 => map
939            .get("replace")
940            .and_then(Value::as_sequence)
941            .map(|hosts| (hosts.clone(), true)),
942        _ => None,
943    }
944}
945
946fn replace_scalar(fm: &mut Frontmatter, key: &str, old: &str, new: &str) {
947    if fm.get(key).and_then(Value::as_str) == Some(old) {
948        fm.insert(key, Value::String(new.to_string()));
949    }
950}
951
952fn removed(path: &[&str], key: &str) -> SchemaDiagnostic {
953    SchemaDiagnostic::new(
954        "field.removed",
955        Severity::Warning,
956        path.iter()
957            .map(|part| PathSegment::Key((*part).to_string()))
958            .collect(),
959        format!("removed legacy key `{key}`"),
960    )
961}