mecha_core/charter.rs
1//! The charter: what mecha is for, in the owner's own words.
2//!
3//! `docs/GOAL-SYSTEM-DESIGN.md` §11 is the design. The short form: a small,
4//! ordered list of standing priorities, written once by a person and read by
5//! every run from then on. It exists so that a goal error can eventually be
6//! signed *against* something — "did this help what mecha is for" — rather
7//! than every evaluative signal in the system being a cost or a correction,
8//! which is the gap the whole goal-system arc is closing.
9//!
10//! ## The safety argument is the skills argument, verbatim
11//!
12//! No `mecha charter learn`, no registry, nothing derived from a session, and
13//! **no way for a model to author or edit one** — see [`crate::skill`]'s
14//! module doc for the incident this is copied from (Snyk found 36.8% of
15//! published Agent Skills carrying a security flaw; Datadog's sharper finding
16//! is that a cloned repository can bring one into a trusted session even
17//! without an install step). A model that could edit its own charter could
18//! edit its way around every other guardrail.
19//!
20//! **The invariant is about the author, not about the verb**, and this file
21//! used to state it as the latter — "there is deliberately no write path here
22//! at all". That was wrong by the time it was written: the TUI's `/charter`
23//! already handed the file to `$EDITOR`, and the web settings page already
24//! POSTed a validated save. What every one of those surfaces has in common is
25//! the thing that actually matters, and it is worth stating positively so the
26//! next surface copies the right rule:
27//!
28//! > **The owner may edit the charter from anywhere. Every `[[line]]` is
29//! > typed by a person, and no model — privileged, quarantined or otherwise —
30//! > ever composes, suggests or edits one.**
31//!
32//! So a surface may create the comments-only [`TEMPLATE`] and hand over an
33//! editor; it may validate and refuse; it may not put words in the file. This
34//! module itself only ever *reads*: the write is the owner's editor or a
35//! validated save at a surface, never a derivation in here, which is what
36//! keeps "a model authored this" impossible rather than merely discouraged.
37//!
38//! **Global only, and there is no config field to point it elsewhere.** A
39//! `mecha.toml` arrives with a cloned repository, and a repo that could hand
40//! your agent standing priorities is the `[[trigger]]` rule in a worse
41//! costume. Triggers and skills both keep this guarantee by never having a
42//! configurable path in `Config`/`ConfigLayer` at all rather than by relying
43//! on callers to choose the global loader, and this module follows the same
44//! shape: [`Charter::default_path`] is the only path there is.
45//!
46//! **Loading it arms no taint.** It is the user's own words, exactly like the
47//! system prompt — the same argument `crate::skill` makes, and for the same
48//! reason this module has no dependency on `crate::agent::Taint` at all: the
49//! absence is the enforcement, not a rule someone has to remember to apply.
50//!
51//! ## Ordered, not weighted
52//!
53//! §11 is explicit that priority is the file's line order and there is no
54//! priority field: value conflict — "protect the owner" against "don't let a
55//! colleague down" — is the measured cause of goal drift, and a weighted sum
56//! can always be outvoted by enough small goods (*"this is urgent for many
57//! people"*). A lexicographic order cannot be outvoted that way, so this
58//! module preserves the TOML array's order exactly rather than sorting it —
59//! unlike [`crate::skill::SkillStore`], which sorts because its block is a
60//! menu the model chooses from and filesystem order is not an order. A
61//! charter's order *is* the content.
62//!
63//! ## Rendered directly, never lazily loaded
64//!
65//! Unlike a skill, there is no progressive disclosure and no tool call: §11
66//! says this rides in the cached prefix "like `RULES_CHAR_BUDGET`" — i.e. it
67//! is rendered straight into the system prompt every run, the same way the
68//! learned-rules block is, because a handful of standing priorities is cheap
69//! enough to always carry and too important to make conditional on the model
70//! deciding to ask for it.
71
72use anyhow::{bail, Context, Result};
73use serde::Deserialize;
74use std::collections::BTreeSet;
75use std::path::{Path, PathBuf};
76
77/// The charter's whole rendered form is meant to fit this many characters —
78/// checked by [`Charter::over_budget`], never enforced by [`Charter::load`].
79///
80/// Moves with [`crate::learning::RULES_CHAR_BUDGET`] in spirit — this rides
81/// in every run's cached prefix exactly like the learned-rules block — but is
82/// smaller: a charter is a handful of standing priorities, not a per-domain
83/// accumulation, and a lexicographic order only stays legible while it stays
84/// short enough for a person to hold in mind at once. Argued, not measured:
85/// there is no corpus yet of how many lines a charter needs before it stops
86/// being read carefully.
87pub const CHARTER_CHAR_BUDGET: usize = 2000;
88
89/// One standing priority.
90///
91/// **Denies unknown fields**, unlike [`crate::skill::Skill`]'s frontmatter —
92/// that leniency is for portability across harnesses that might author a
93/// `SKILL.md`, and nothing else authors a `charter.toml`. A stray `priority`
94/// or `rank` key is exactly the field §11 says there deliberately is none of;
95/// silently dropping it would let an owner write one, believe it did
96/// something, and never find out it didn't.
97#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct CharterLine {
100 /// What a [`crate::goal::GoalRef::Charter`] names. Unique within a
101 /// charter — see [`Charter::load`].
102 pub id: String,
103 pub text: String,
104}
105
106/// The owner's charter, in file order.
107///
108/// **Order is rank.** See the module doc — there is no field for it because
109/// a second statement of priority disagrees with the first the moment either
110/// is edited, the same rule `TASK-AGENT-DESIGN.md` R1 gives task urgency.
111#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct Charter {
113 lines: Vec<CharterLine>,
114}
115
116/// The file's own shape: `[[line]]` tables, in the order they appear.
117///
118/// **Denies unknown top-level tables too** — `[[lines]]` (plural) or any
119/// other typo'd name would otherwise vanish silently rather than parse as an
120/// error, which is worse when it sits *beside* correctly-named lines: the
121/// charter would load non-empty, `over_budget` would be false, and the
122/// owner's ranking would be silently short whatever the typo'd entries were,
123/// with nothing anywhere saying so.
124#[derive(Debug, Default, Deserialize)]
125#[serde(deny_unknown_fields)]
126struct RawCharter {
127 #[serde(default, rename = "line")]
128 line: Vec<CharterLine>,
129}
130
131impl Charter {
132 /// `~/.mecha/charter.toml` — the only path there is. See the module doc:
133 /// this is deliberately not a `Config` field, because a configurable path
134 /// is a path a project layer could set.
135 pub fn default_path() -> Result<PathBuf> {
136 Ok(crate::work::mecha_home()?.join("charter.toml"))
137 }
138
139 /// Read and validate `path`. A missing file is an **empty** charter, not
140 /// an error — a machine nobody has written one for yet must still start,
141 /// on [`crate::skill::SkillStore::load`]'s rule for a missing directory.
142 pub fn load(path: &Path) -> Result<Charter> {
143 let text = match std::fs::read_to_string(path) {
144 Ok(text) => text,
145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Charter::default()),
146 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
147 };
148 Charter::parse(&text).with_context(|| format!("parsing {}", path.display()))
149 }
150
151 /// Parse and validate charter TOML that has not been written anywhere
152 /// yet. The seam a surface that *accepts* an edit needs — the web
153 /// settings page validates a proposed charter with exactly the reader
154 /// every run will load it through, and refuses the save on an error,
155 /// so a file that reaches disk is one that will load. `load` goes
156 /// through here so the two can never diverge on what "valid" means.
157 pub fn parse(text: &str) -> Result<Charter> {
158 let raw: RawCharter = toml::from_str(text)?;
159 Charter::validate(raw.line)
160 }
161
162 /// Only the conditions that make a line *ambiguous or unusable* refuse
163 /// the whole document. Crossing [`CHARTER_CHAR_BUDGET`] is deliberately
164 /// **not** one of them — see [`Charter::over_budget`] — on the
165 /// `over_budget_domains` precedent for the learned-rules cap
166 /// (`crate::learning`): a document that costs more of the cached prefix
167 /// than argued still means exactly what it says, and dropping the whole
168 /// charter because an eleventh line pushed it over a budget would un-rank
169 /// every priority in it over a problem that is really about cost, not
170 /// validity.
171 fn validate(lines: Vec<CharterLine>) -> Result<Charter> {
172 let mut seen = BTreeSet::new();
173 for line in &lines {
174 if line.id.trim().is_empty() {
175 bail!("a charter line has an empty `id`");
176 }
177 if line.text.trim().is_empty() {
178 bail!("charter line `{}` has empty `text`", line.id);
179 }
180 // Trimmed, to match both the emptiness check two lines up and
181 // `GoalRef::from_str` (`goal.rs`), which trims an id it parses —
182 // `"x"` and `"x "` must collide here or they answer to the same
183 // `charter:x` reference without this check ever having noticed.
184 if !seen.insert(line.id.trim()) {
185 // Ambiguous rather than merely untidy: a `GoalRef::Charter(id)`
186 // naming a duplicated id would point at whichever line a
187 // lookup happened to find first, silently.
188 bail!(
189 "charter line id `{}` is used more than once — a goal reference \
190 naming it would not know which line it meant",
191 line.id
192 );
193 }
194 }
195 // Checking uniqueness on the trimmed form and then keeping the
196 // untrimmed one would make the guarantee above a check rather than a
197 // fact: `id = "x "` would still render with the trailing space and a
198 // future `GoalRef::Charter("x")` lookup — itself trimmed, per
199 // `goal.rs` — would miss a line recorded as `"x "`. Store what was
200 // checked.
201 let lines = lines
202 .into_iter()
203 .map(|l| CharterLine {
204 id: l.id.trim().to_string(),
205 ..l
206 })
207 .collect();
208 Ok(Charter { lines })
209 }
210
211 pub fn lines(&self) -> &[CharterLine] {
212 &self.lines
213 }
214
215 /// How many characters the charter actually costs when rendered into the
216 /// system prompt — [`prompt_block`]'s own length, not just the authored
217 /// `id`/`text` content. The header and the per-line `"N. `id` — "`
218 /// formatting ride in the cached prefix too, so measuring only the
219 /// authored text would under-report the true cost by a few hundred
220 /// characters of fixed overhead. What [`Charter::over_budget`] checks
221 /// against [`CHARTER_CHAR_BUDGET`], and the same number every message
222 /// that quotes "characters" beside "the prompt" means.
223 pub fn char_count(&self) -> usize {
224 prompt_block(self).map_or(0, |b| b.chars().count())
225 }
226
227 /// Costs more of the cached prefix than [`CHARTER_CHAR_BUDGET`] argues
228 /// for. Not refused by [`Charter::load`] — see its doc comment — so a
229 /// caller that cares (today: `mecha doctor`) checks this after loading.
230 pub fn over_budget(&self) -> bool {
231 self.char_count() > CHARTER_CHAR_BUDGET
232 }
233
234 pub fn is_empty(&self) -> bool {
235 self.lines.is_empty()
236 }
237}
238
239/// The comments-only template a surface may write when no charter exists
240/// yet, so the first edit never starts from an empty buffer — which is how
241/// a first charter ends up shaped wrong. **No active `[[line]]` entries**:
242/// a template that shipped priorities would be mecha authoring the charter,
243/// the one thing every surface here refuses (§11), and the test on this
244/// constant fails on any uncommented line. Lives here so the TUI's `e` and
245/// the web settings editor hand out the same bytes rather than two copies
246/// that drift. The commented example exists because the costliest authoring
247/// mistake (§11) is a "never disappoint"-shaped line, and the place to say
248/// so is inside the file being edited.
249pub const TEMPLATE: &str = "\
250# Your charter: standing priorities, in your own words, ranked highest
251# first — ORDER IS RANK. There is no priority field; when two lines
252# conflict, the higher one wins outright, and re-ranking is moving a line.
253#
254# mecha only ever reads this file. Each entry is:
255#
256# [[line]]
257# id = \"a-short-stable-slug\" # unique; goal references point at it
258# text = \"The priority itself, one or two sentences.\"
259#
260# One authoring trap, from the design doc: a line shaped like \"never
261# disappoint anyone\" produces sycophancy and withheld bad news. Point it
262# the other way — e.g.:
263#
264# [[line]]
265# id = \"tell-the-truth-early\"
266# text = \"Tell me the truth early, especially when it disappoints.\"
267";
268
269/// The block rendered straight into the system prompt. `None` when the
270/// charter is empty, so a machine with no charter authored yet sends no block
271/// at all — the same reason [`crate::skill::prompt_block`] returns `None` on
272/// an empty store.
273pub fn prompt_block(charter: &Charter) -> Option<String> {
274 if charter.is_empty() {
275 return None;
276 }
277 // No instruction to cite a line's id via `serves`, deliberately: `todo`'s
278 // schema documents only `task:<id>` there (`tool/todo.rs`), and this
279 // block is unconditional — rendered whether or not `todo` is even in the
280 // tool surface (a narrow `--tool` allowlist, Slack's own set). Asking for
281 // a citation with nowhere reliable to put it, or a tool that may not
282 // exist, is worse than not asking; wiring that up is the appraisal
283 // consumer's job (see the rung 10 note in `GOAL-SYSTEM-DESIGN.md`), not
284 // this block's.
285 let mut out = String::from(
286 "## Charter\n\n\
287 Standing priorities the owner has written for you, ranked highest first \
288 and listed in that order. They are not weighted: when two conflict, the \
289 higher one wins outright, whatever the lower one would otherwise argue \
290 for — no amount of urgency on a lower line outranks a higher one.\n\n",
291 );
292 for (i, line) in charter.lines().iter().enumerate() {
293 out.push_str(&format!("{}. `{}` — {}\n", i + 1, line.id, line.text));
294 }
295 Some(out.trim_end().to_string())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 /// The template must never author a priority: a `[[line]]` not behind a
303 /// comment would ship ranked content mecha wrote, which is the invariant
304 /// every charter surface exists to refuse.
305 #[test]
306 fn the_template_carries_no_active_lines() {
307 for l in TEMPLATE.lines() {
308 let l = l.trim();
309 assert!(
310 l.is_empty() || l.starts_with('#'),
311 "template has an uncommented line: {l:?}"
312 );
313 }
314 // And it stays honest as a TOML document: parsing it yields nothing.
315 let c = Charter::parse(TEMPLATE).unwrap();
316 assert!(c.is_empty());
317 }
318
319 fn line(id: &str, text: &str) -> CharterLine {
320 CharterLine {
321 id: id.to_string(),
322 text: text.to_string(),
323 }
324 }
325
326 /// Write `raw` to a scratch `charter.toml` and load it, unique per test
327 /// and thread so parallel tests don't collide on the path.
328 fn write_and_load(raw: &str) -> Result<Charter> {
329 let dir = std::env::temp_dir().join(format!(
330 "mecha-charter-test-{}-{:?}-{:?}",
331 std::process::id(),
332 std::thread::current().id(),
333 std::time::Instant::now()
334 ));
335 std::fs::create_dir_all(&dir).unwrap();
336 let path = dir.join("charter.toml");
337 std::fs::write(&path, raw).unwrap();
338 Charter::load(&path)
339 }
340
341 #[test]
342 fn the_standard_shape_parses_in_file_order() {
343 let raw = r#"
344[[line]]
345id = "protect-the-owner"
346text = "Protect the owner's interests above all else."
347
348[[line]]
349id = "tell-the-truth-early"
350text = "Tell the owner the truth early, especially when it disappoints."
351"#;
352 let charter = write_and_load(raw).unwrap();
353 assert_eq!(
354 charter.lines(),
355 &[
356 line(
357 "protect-the-owner",
358 "Protect the owner's interests above all else."
359 ),
360 line(
361 "tell-the-truth-early",
362 "Tell the owner the truth early, especially when it disappoints."
363 ),
364 ]
365 );
366 }
367
368 #[test]
369 fn a_missing_file_is_an_empty_charter_not_an_error() {
370 let path = std::env::temp_dir().join("mecha-charter-does-not-exist.toml");
371 let _ = std::fs::remove_file(&path);
372 let charter = Charter::load(&path).unwrap();
373 assert!(charter.is_empty());
374 }
375
376 #[test]
377 fn a_typo_d_table_name_is_a_load_error_not_a_silently_short_charter() {
378 // `[[lines]]` (plural) beside a correctly-named `[[line]]` used to
379 // vanish rather than fail — non-empty, under budget, and quietly
380 // missing whatever the typo'd entries were meant to say.
381 let raw = r#"
382[[line]]
383id = "protect-the-owner"
384text = "Protect the owner's interests above all else."
385
386[[lines]]
387id = "tell-the-truth-early"
388text = "Tell the owner the truth early, especially when it disappoints."
389"#;
390 let e = write_and_load(raw).unwrap_err().to_string();
391 assert!(e.contains("parsing"), "{e}");
392 }
393
394 #[test]
395 fn a_stray_priority_field_on_a_line_is_a_load_error() {
396 // §11: rank is file order and there is deliberately no priority
397 // field. Accepting one silently would let an owner write it and
398 // believe it did something.
399 let raw = r#"
400[[line]]
401id = "a"
402text = "one"
403priority = 1
404"#;
405 assert!(write_and_load(raw).is_err());
406 }
407
408 #[test]
409 fn a_duplicate_id_is_refused_because_a_reference_to_it_would_be_ambiguous() {
410 let e = Charter::validate(vec![line("a", "one"), line("a", "two")])
411 .unwrap_err()
412 .to_string();
413 assert!(e.contains("used more than once"), "{e}");
414 }
415
416 #[test]
417 fn ids_differing_only_by_surrounding_whitespace_still_collide() {
418 // `GoalRef::from_str` trims an id it parses (`goal.rs`), so `"x"` and
419 // `"x "` answer to the same `charter:x` reference — this check has to
420 // trim too, or two visually distinct-looking lines pass as unique
421 // and then can't be told apart by anything that resolves the id.
422 let e = Charter::validate(vec![line("a", "one"), line("a ", "two")])
423 .unwrap_err()
424 .to_string();
425 assert!(e.contains("used more than once"), "{e}");
426 }
427
428 #[test]
429 fn a_surviving_id_is_stored_trimmed_not_just_checked_trimmed() {
430 // Checking uniqueness on the trimmed form and then keeping the
431 // untrimmed one would make the guarantee a check rather than a
432 // fact — a lone `"x "` line would pass validation and then render
433 // with the trailing space, answering to `charter:x` by nothing more
434 // than luck.
435 let charter = Charter::validate(vec![line(" x ", "one")]).unwrap();
436 assert_eq!(charter.lines()[0].id, "x");
437 }
438
439 #[test]
440 fn char_count_is_the_rendered_costs_not_just_the_authored_text() {
441 // The header and the `"1. `id` — "` formatting ride in the cached
442 // prefix too — a budget checked only against authored text would
443 // under-report the true cost, and the messages that quote this
444 // number beside "in the prompt" would be naming a different number
445 // than the one that actually rides there.
446 let charter = Charter::validate(vec![line("a", "short")]).unwrap();
447 assert_eq!(
448 charter.char_count(),
449 prompt_block(&charter).unwrap().chars().count()
450 );
451 assert!(charter.char_count() > "a".len() + "short".len());
452 }
453
454 #[test]
455 fn an_empty_id_or_text_is_refused() {
456 assert!(Charter::validate(vec![line("", "text")]).is_err());
457 assert!(Charter::validate(vec![line("id", " ")]).is_err());
458 }
459
460 #[test]
461 fn a_charter_over_the_character_budget_still_loads_and_says_so() {
462 // Refusing the whole document over its eleventh line would un-rank
463 // every priority in it for a problem that is about cost, not
464 // validity — the `over_budget_domains` precedent, applied here.
465 let long = "x".repeat(CHARTER_CHAR_BUDGET + 1);
466 let charter = Charter::validate(vec![line("only-line", &long)]).unwrap();
467 assert_eq!(charter.lines().len(), 1);
468 assert!(charter.over_budget());
469 }
470
471 #[test]
472 fn a_charter_under_the_budget_is_not_over_it() {
473 let charter = Charter::validate(vec![line("a", "short")]).unwrap();
474 assert!(!charter.over_budget());
475 }
476
477 #[test]
478 fn an_empty_charter_contributes_no_block_at_all() {
479 assert_eq!(prompt_block(&Charter::default()), None);
480 }
481
482 #[test]
483 fn the_block_lists_lines_in_file_order_not_sorted() {
484 // Unlike skills, order is content: a charter authored `["b-line",
485 // "a-line"]` must render in that order, never alphabetically.
486 let charter = Charter {
487 lines: vec![
488 line("b-line", "second priority"),
489 line("a-line", "first priority"),
490 ],
491 };
492 let block = prompt_block(&charter).unwrap();
493 let b = block.find("b-line").unwrap();
494 let a = block.find("a-line").unwrap();
495 assert!(b < a, "{block}");
496 }
497
498 #[test]
499 fn the_block_explains_the_ordering_is_load_bearing() {
500 let charter = Charter {
501 lines: vec![line("only", "the only priority")],
502 };
503 let block = prompt_block(&charter).unwrap();
504 assert!(block.contains("not weighted"), "{block}");
505 }
506}