Skip to main content

mecha_core/
outbox.rs

1//! The outbox: staged outbound actions awaiting the user's review.
2//!
3//! An outbox-routed tool call is never executed by the agent loop — it is
4//! written here as a draft, and nothing leaves the machine until the user
5//! reads exactly what would be sent and releases it (`mecha outbox send`).
6//! That is "draft-only, never send" made structural: the gate lives in core,
7//! so an email or calendar tool — including a third-party MCP server's —
8//! needs no knowledge of it to be covered by it.
9//!
10//! The item keeps the drafted arguments (`args_before`) separate from the
11//! arguments the release will execute (`args`), because the difference is a
12//! measurement: a user edit before sending is a writing correction, and
13//! `mecha reflect` mines `diff(args_before, args)` into the learning store.
14//!
15//! **Staging is sink-agnostic; reviewing is not.** The outbox generalised to a
16//! new kind of outbound action — publishing a bundle to the public surface —
17//! without a line changing here, which was the design goal. Its *review*
18//! affordances did not: `show` printing arguments, `edit` opening them in
19//! `$EDITOR`, and the writing miner reading the diff all assume the staged
20//! thing is a message someone wrote. That is why an item carries an
21//! [`OutboxKind`], set at staging from `[outbox] publish_tools`, and why the
22//! miner keys on it — see [`OutboxKind::Publish`].
23//!
24//! Storage follows the learning store's rules: one pretty-printed JSON file
25//! per item so `$EDITOR` and `git diff` work on it, temp-sibling-and-rename
26//! for every rewrite so a reader never sees a half-written file, and an
27//! advisory flock for writers — taken *before reading the state acted on*,
28//! and never held across an editor invocation. Staging takes no lock at all:
29//! a fresh item is a fresh file with a unique id, and the agent loop must
30//! never block on a human's review session.
31
32use anyhow::{bail, Context, Result};
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::path::{Path, PathBuf};
36
37use crate::agent::Taint;
38use crate::session::Session;
39
40/// What kind of outbound action a staged item is, which decides how it is
41/// *reviewed* rather than how it is staged.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum OutboxKind {
45    /// Prose somebody wrote and somebody else will read: an email, a calendar
46    /// invitation, a message. The reviewable object *is* the arguments, and an
47    /// edit before release is a writing correction worth learning from.
48    #[default]
49    Message,
50    /// A publication to the public surface: a rendered bundle, an alias move,
51    /// a request-type push. Three things follow, and each is a bug if undone:
52    ///
53    /// - The reviewable object is the **rendered page**, not the arguments —
54    ///   which are a path and a visibility flag.
55    /// - `edit` is refused. Editing the content means editing the source and
56    ///   re-rendering, which stages a new item; rewriting the path is not
57    ///   editing the draft.
58    /// - The writing miner **excludes it**. Feeding `diff(args_before, args)`
59    ///   of a changed directory path to a reflector that mines voice rules
60    ///   would carry noise into every future run's cached prefix. Same mistake
61    ///   as learning from `"Blocked by a hook:"`, in a new costume.
62    Publish,
63}
64
65impl OutboxKind {
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            OutboxKind::Message => "message",
69            OutboxKind::Publish => "publish",
70        }
71    }
72}
73
74/// One staged outbound action.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct OutboxItem {
77    pub id: String,
78    /// `pending` | `sent` | `rejected`.
79    pub status: String,
80    /// The tool a release will execute, by registry name (`web__fetch`).
81    pub tool: String,
82    /// How this is reviewed. Defaulted rather than required, so items staged
83    /// before the field existed load as the kind they in fact were.
84    #[serde(default)]
85    pub kind: OutboxKind,
86    /// The arguments as the agent drafted them. Never modified — this is the
87    /// baseline the learning capture diffs against.
88    pub args_before: Value,
89    /// The arguments a release will execute. Starts equal to `args_before`;
90    /// `mecha outbox edit` rewrites it.
91    pub args: Value,
92    /// One line for `mecha outbox list`.
93    pub summary: String,
94    /// The session that drafted this, when the front-end knew it.
95    #[serde(default)]
96    pub session_id: Option<String>,
97    /// The path jail the call was drafted under.
98    ///
99    /// A staged call is a *deferred* tool call, and a tool call only means
100    /// anything relative to the workspace it was made in: `bundle` here is a
101    /// directory under the drafting run's jail. Release happens in another
102    /// process, minutes or hours later, from whatever directory the reviewer
103    /// happens to be standing in — so without this the release resolves the
104    /// argument against the wrong root. An absolute path fails loudly; a
105    /// relative one is worse, because a same-named directory beside the
106    /// reviewer would quietly publish the wrong bytes.
107    ///
108    /// Recording it also keeps the release inside the jail the *agent* was
109    /// held to, rather than the reviewer's, which is the stricter of the two
110    /// and the one the interlock reasoned about.
111    ///
112    /// Defaulted, like `kind`: items staged before the field existed load as
113    /// `None` and release exactly as they did before.
114    #[serde(default)]
115    pub workspace: Option<PathBuf>,
116    /// The conversation's taint at the moment of staging. An armed snapshot
117    /// means third-party text was in context when this draft was written —
118    /// review it as possibly an attacker's words, not the assistant's.
119    #[serde(default)]
120    pub taint: Taint,
121    pub created_at: String,
122    #[serde(default)]
123    pub resolved_at: Option<String>,
124    /// Why it was rejected, when it was.
125    #[serde(default)]
126    pub reason: Option<String>,
127    /// The last release attempt's failure, if any. A failed send stays
128    /// `pending` — the draft is still good; the delivery was not.
129    #[serde(default)]
130    pub error: Option<String>,
131}
132
133impl OutboxItem {
134    pub fn edited(&self) -> bool {
135        self.args != self.args_before
136    }
137
138    /// Whether `mecha reflect` may mine this item as a **writing** correction.
139    ///
140    /// A `writing`-domain reflection can become a consolidated rule, and a rule
141    /// rides in every future run's system prompt inside the cached prefix. That
142    /// is the longest half-life anything in this project has, so what feeds it
143    /// is filtered structurally rather than by a prompt asking the reflector to
144    /// use its judgement:
145    ///
146    /// - **Sent, and edited.** An unedited release is not a correction — it is
147    ///   *positive* evidence, which is [`WritingOutcome::SentUnchanged`] and no
148    ///   longer unread. A rejected one never went out.
149    /// - **A message.** A publish's `diff(args_before, args)` is a changed
150    ///   filesystem path or visibility flag. Mining it would teach voice rules
151    ///   from bookkeeping — the same mistake as learning from
152    ///   `"Blocked by a hook:"`, which is machine policy read as a human
153    ///   correction.
154    pub fn mineable_as_writing(&self) -> bool {
155        self.writing_outcome() == Some(WritingOutcome::SentEdited)
156    }
157
158    /// What this item says about the drafting, if it says anything.
159    ///
160    /// **The signed half of the outbox's evidence, and the cheapest signal in
161    /// the goal system** (`docs/GOAL-SYSTEM-DESIGN.md` §5.2). Every evaluative
162    /// signal mecha had was a cost or a correction: `Trigger` is four ways of
163    /// saying a person stepped in, and every `Metric` is phrased so that lower
164    /// is better. So a draft could be recorded as *wrong* and never as *right*,
165    /// and the `writing` domain learned only from what displeased.
166    ///
167    /// This needed no new recording. `args_before` has always been kept beside
168    /// `args`, so "the owner read a letter written in their name and sent it as
169    /// drafted" was already on disk and simply had no reader.
170    ///
171    /// **It is the owner's judgement, not the agent's**, which is what makes it
172    /// immune to the failure that rules out scoring your own work: nothing the
173    /// model does can produce a `SentUnchanged` except drafting something a
174    /// person then chose to send unaltered.
175    ///
176    /// `None` for anything that says nothing about drafting — a pending item
177    /// (undecided), a rejected one (never went out, and its reason is the
178    /// record), or a publish (whose diff is a path and a visibility flag, not
179    /// prose).
180    pub fn writing_outcome(&self) -> Option<WritingOutcome> {
181        if self.kind != OutboxKind::Message || self.status != "sent" {
182            return None;
183        }
184        Some(match self.edited() {
185            true => WritingOutcome::SentEdited,
186            false => WritingOutcome::SentUnchanged,
187        })
188    }
189}
190
191/// What a released draft says about how it was written.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum WritingOutcome {
194    /// The owner sent it as drafted. Positive evidence.
195    SentUnchanged,
196    /// The owner rewrote it before sending. The correction `reflect` mines.
197    SentEdited,
198}
199
200/// How the drafting has been going, counted over released items.
201///
202/// Deliberately counts and never judges — the threshold for "well enough"
203/// belongs to whoever acts on it, the same division `runlog` keeps.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub struct WritingTally {
206    pub unchanged: usize,
207    pub edited: usize,
208}
209
210impl WritingTally {
211    pub fn of<'a>(items: impl IntoIterator<Item = &'a OutboxItem>) -> WritingTally {
212        let mut tally = WritingTally::default();
213        for item in items {
214            match item.writing_outcome() {
215                Some(WritingOutcome::SentUnchanged) => tally.unchanged += 1,
216                Some(WritingOutcome::SentEdited) => tally.edited += 1,
217                None => {}
218            }
219        }
220        tally
221    }
222
223    pub fn sent(&self) -> usize {
224        self.unchanged + self.edited
225    }
226
227    /// The share of sent drafts that went out as written.
228    ///
229    /// `None` over an empty denominator, never zero. "Nothing was edited" and
230    /// "nothing has been sent" are opposite findings, and rendering the second
231    /// as 0% would report an outbox nobody has used as one whose every draft
232    /// was rewritten — the null-run bug, in the one measure here that is
233    /// supposed to say something went *well*.
234    pub fn unchanged_rate(&self) -> Option<f64> {
235        (self.sent() > 0).then(|| self.unchanged as f64 / self.sent() as f64)
236    }
237}
238
239/// What the agent loop consults per call: the store plus the routed names.
240///
241/// `session_id` is interior-mutable because the front-end learns it after the
242/// agent (and its default [`RunContext`](crate::agent::RunContext)) is built:
243/// the session is created at run start, the route at setup. Best-effort —
244/// `None` on front-ends that record no session (batch, eval).
245pub struct OutboxRoute {
246    pub store: OutboxStore,
247    routed: std::collections::BTreeSet<String>,
248    publishes: std::collections::BTreeSet<String>,
249    session_id: std::sync::Mutex<Option<String>>,
250}
251
252impl OutboxRoute {
253    pub fn new(
254        store: OutboxStore,
255        routed: impl IntoIterator<Item = String>,
256        publishes: impl IntoIterator<Item = String>,
257    ) -> Self {
258        OutboxRoute {
259            store,
260            routed: routed.into_iter().collect(),
261            publishes: publishes.into_iter().collect(),
262            session_id: std::sync::Mutex::new(None),
263        }
264    }
265
266    pub fn routes(&self, tool: &str) -> bool {
267        self.routed.contains(tool)
268    }
269
270    pub fn routed(&self) -> impl Iterator<Item = &str> {
271        self.routed.iter().map(String::as_str)
272    }
273
274    /// A tool's kind, which is config's to declare and never the tool's: the
275    /// loop must not learn what a publish is, and a third-party MCP server
276    /// cannot be trusted to say. Anything unnamed is a message, which is the
277    /// conservative default — it keeps the arguments reviewable and the item
278    /// mineable, and the cost of getting it wrong is a voice rule learned from
279    /// a path rather than a page nobody could review.
280    pub fn kind_of(&self, tool: &str) -> OutboxKind {
281        if self.publishes.contains(tool) {
282            OutboxKind::Publish
283        } else {
284            OutboxKind::Message
285        }
286    }
287
288    /// Names declared as publishes. Used at startup to warn about one that is
289    /// not routed at all — it would execute unstaged, which is the
290    /// silently-degrading-sandbox shape the routed-name warning already
291    /// catches.
292    pub fn publishes(&self) -> impl Iterator<Item = &str> {
293        self.publishes.iter().map(String::as_str)
294    }
295
296    pub fn set_session_id(&self, id: &str) {
297        if let Ok(mut slot) = self.session_id.lock() {
298            *slot = Some(id.to_string());
299        }
300    }
301
302    pub fn session_id(&self) -> Option<String> {
303        self.session_id.lock().ok().and_then(|s| s.clone())
304    }
305}
306
307pub struct OutboxStore {
308    root: PathBuf,
309}
310
311/// Holds the store's writer lock for as long as it lives.
312pub struct OutboxLock {
313    _file: std::fs::File,
314}
315
316impl OutboxStore {
317    pub fn default_root() -> Result<PathBuf> {
318        if let Ok(dir) = std::env::var("MECHA_OUTBOX_DIR") {
319            return Ok(PathBuf::from(dir));
320        }
321        Ok(crate::work::mecha_home()?.join("outbox"))
322    }
323
324    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
325        let root = root.into();
326        crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
327        Ok(OutboxStore { root })
328    }
329
330    /// Open at the default location only if it already exists — for read
331    /// paths that must not create state as a side effect.
332    pub fn open_existing_default() -> Option<Self> {
333        let root = Self::default_root().ok()?;
334        root.is_dir().then_some(OutboxStore { root })
335    }
336
337    pub fn root(&self) -> &Path {
338        &self.root
339    }
340
341    /// Stage a drafted call. No lock: the id is fresh, so there is no state
342    /// to race on, and the agent loop must never wait on a review session.
343    pub fn stage(
344        &self,
345        tool: &str,
346        kind: OutboxKind,
347        args: Value,
348        taint: Taint,
349        session_id: Option<String>,
350        workspace: Option<PathBuf>,
351    ) -> Result<OutboxItem> {
352        let item = OutboxItem {
353            id: Session::new_id(),
354            status: "pending".into(),
355            tool: tool.to_string(),
356            kind,
357            summary: summarize(tool, &args),
358            args_before: args.clone(),
359            args,
360            session_id,
361            workspace,
362            taint,
363            created_at: chrono::Utc::now().to_rfc3339(),
364            resolved_at: None,
365            reason: None,
366            error: None,
367        };
368        self.write_item(&item)?;
369        Ok(item)
370    }
371
372    /// Every item, oldest first. A file that fails to parse is skipped with
373    /// a `tracing::warn!` rather than failing the whole read — right for a
374    /// listing, which should show what it can. See [`Self::items_strict`]
375    /// for the caller that cannot accept a silent skip.
376    pub fn items(&self) -> Result<Vec<OutboxItem>> {
377        self.items_impl(false)
378    }
379
380    /// Every item, oldest first — but a single unparseable file fails the
381    /// whole read instead of being skipped.
382    ///
383    /// `items()`'s skip-and-warn is right for a listing and wrong for a
384    /// caller about to write a permanent record from what it read. **Not**
385    /// because of a half-written file mid-save — this module's own header
386    /// names the reason that cannot happen: temp-sibling-and-rename means a
387    /// reader never sees a partial write, and `items_impl` only looks at the
388    /// `.json` extension the rename lands on, so the `.json.tmp` sibling is
389    /// invisible to the walk regardless. The realistic cause is a
390    /// *persistent* one: a stray file, or an item written by a schema this
391    /// binary cannot read (the hand-rolled `Deserialize` on [`OutboxKind`]
392    /// and `Proposed` exists for exactly that skew). Either way, `items()`
393    /// would pass it through as a silently short result — indistinguishable
394    /// from an outbox that simply has fewer drafts. `mecha distill`'s
395    /// episode tagging is exactly the caller that cannot accept that: its
396    /// own `tracing::warn!` is invisible there anyway (the nightly runs
397    /// with no `MECHA_LOG`), and because the cause is persistent rather
398    /// than transient, a caller that reacts to this by only asking the
399    /// operator to retry will keep failing the same way every night; see
400    /// that caller's own handling for how it names the distinction.
401    pub fn items_strict(&self) -> Result<Vec<OutboxItem>> {
402        self.items_impl(true)
403    }
404
405    fn items_impl(&self, strict: bool) -> Result<Vec<OutboxItem>> {
406        let mut out = Vec::new();
407        for entry in std::fs::read_dir(&self.root)? {
408            let path = entry?.path();
409            if path.extension().and_then(|e| e.to_str()) != Some("json") {
410                continue;
411            }
412            match serde_json::from_str(&std::fs::read_to_string(&path)?) {
413                Ok(item) => out.push(item),
414                Err(e) if strict => {
415                    bail!("outbox item {} failed to parse: {e}", path.display())
416                }
417                Err(e) => {
418                    tracing::warn!("skipping unreadable outbox item {}: {e}", path.display())
419                }
420            }
421        }
422        out.sort_by(|a: &OutboxItem, b: &OutboxItem| a.id.cmp(&b.id));
423        Ok(out)
424    }
425
426    /// Find one item by id or unique prefix. Ambiguity is an error rather
427    /// than a guess, same as session and proposal lookup.
428    pub fn item(&self, id: &str) -> Result<OutboxItem> {
429        let all = self.items()?;
430        let matches: Vec<&OutboxItem> = all.iter().filter(|i| i.id.starts_with(id)).collect();
431        match matches.len() {
432            0 => anyhow::bail!("no outbox item matching `{id}`"),
433            1 => Ok(matches[0].clone()),
434            n => anyhow::bail!(
435                "`{id}` matches {n} outbox items: {}",
436                matches
437                    .iter()
438                    .map(|i| i.id.as_str())
439                    .collect::<Vec<_>>()
440                    .join(", ")
441            ),
442        }
443    }
444
445    /// One item by its exact store-minted id — a single file read, never a
446    /// directory scan. For the hot paths (a button press on an event loop)
447    /// that already hold the full id and must not pay `items()`'s
448    /// read-and-parse of every draft ever staged. Prefix lookup stays
449    /// [`OutboxStore::item`]'s business.
450    ///
451    /// The id is validated by shape *before* it is joined onto the store
452    /// root: ids arrive here from button payloads, and a value shaped like a
453    /// path (`../…`) must be refused, not resolved. A hostile shape is an
454    /// error; a missing item is `Ok(None)`; a torn file is an error, so a
455    /// caller that maps errors to "unreadable" keeps failing closed.
456    pub fn item_exact(&self, id: &str) -> Result<Option<OutboxItem>> {
457        anyhow::ensure!(is_item_id(id), "`{id}` is not shaped like an outbox id");
458        let path = self.root.join(format!("{id}.json"));
459        let text = match std::fs::read_to_string(&path) {
460            Ok(text) => text,
461            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
462            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
463        };
464        Ok(Some(
465            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
466        ))
467    }
468
469    /// Replace a pending item's release arguments. `args_before` is untouched
470    /// — it is the baseline the learning capture diffs against.
471    pub fn update_args(&self, id: &str, args: Value) -> Result<OutboxItem> {
472        let mut item = self.item(id)?;
473        anyhow::ensure!(
474            item.status == "pending",
475            "outbox item {} is {}, not pending",
476            item.id,
477            item.status
478        );
479        item.args = args;
480        item.summary = summarize(&item.tool, &item.args);
481        self.write_item(&item)?;
482        Ok(item)
483    }
484
485    /// Resolve a pending item as `sent` or `rejected`, in place — the file is
486    /// its own audit record, so nothing moves to an archive.
487    pub fn resolve(&self, id: &str, status: &str, reason: Option<String>) -> Result<OutboxItem> {
488        let mut item = self.item(id)?;
489        anyhow::ensure!(
490            item.status == "pending",
491            "outbox item {} is {}, not pending",
492            item.id,
493            item.status
494        );
495        item.status = status.to_string();
496        item.resolved_at = Some(chrono::Utc::now().to_rfc3339());
497        item.reason = reason;
498        item.error = None;
499        self.write_item(&item)?;
500        Ok(item)
501    }
502
503    /// Record a failed release attempt. The item stays `pending`: the draft
504    /// is still good, and the next `send` retries.
505    pub fn record_error(&self, id: &str, error: &str) -> Result<()> {
506        let mut item = self.item(id)?;
507        item.error = Some(error.to_string());
508        self.write_item(&item)
509    }
510
511    fn write_item(&self, item: &OutboxItem) -> Result<()> {
512        let path = self.root.join(format!("{}.json", item.id));
513        let tmp = path.with_extension("json.tmp");
514        std::fs::write(&tmp, serde_json::to_string_pretty(item)?)?;
515        std::fs::rename(&tmp, &path)?;
516        Ok(())
517    }
518
519    /// Writer lock for read-modify-write paths (edit, send, reject). Taken
520    /// before reading the item acted on; never held across `$EDITOR`.
521    pub fn lock(&self) -> Result<OutboxLock> {
522        use std::os::unix::io::AsRawFd;
523        let file = std::fs::OpenOptions::new()
524            .create(true)
525            .truncate(false)
526            .write(true)
527            .open(self.root.join(".lock"))?;
528        // SAFETY: flock on an fd we own, held open by the returned guard.
529        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
530            return Err(std::io::Error::last_os_error()).context("locking the outbox");
531        }
532        Ok(OutboxLock { _file: file })
533    }
534}
535
536/// The shape of a store-minted id (`Session::new_id`: a timestamp, a hyphen,
537/// a uuid fragment). Checked before an id from the outside is joined onto the
538/// store root — nothing with a separator or a dot can name a file elsewhere.
539fn is_item_id(id: &str) -> bool {
540    !id.is_empty()
541        && id.len() <= 80
542        && id
543            .chars()
544            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
545}
546
547/// A per-line diff of two argument renderings, `- `/`+ ` prefixed. Line-set
548/// based: enough to show *what* changed in a draft without a diff crate, and
549/// the full before/after always survives on the item itself. Used by both
550/// `mecha outbox show` and the reflect pass that mines edits.
551pub fn diff_args(before: &Value, after: &Value) -> String {
552    let pretty = |v: &Value| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
553    let b = pretty(before);
554    let a = pretty(after);
555    let b_lines: Vec<&str> = b.lines().collect();
556    let a_lines: Vec<&str> = a.lines().collect();
557    let mut out = String::new();
558    for line in &b_lines {
559        if !a_lines.contains(line) {
560            out.push_str(&format!("  - {line}\n"));
561        }
562    }
563    for line in &a_lines {
564        if !b_lines.contains(line) {
565            out.push_str(&format!("  + {line}\n"));
566        }
567    }
568    if out.is_empty() {
569        out.push_str("  (no textual change)\n");
570    }
571    out
572}
573
574/// One line for the list view: who and what when the arguments say, the
575/// compact JSON when they do not.
576///
577/// Keyed on well-known argument *names*, never on the tool — the store stays
578/// tool-agnostic, but a queue of mail drafts whose rows all lead with
579/// `{"body_markdown":…` made every review surface start with the least
580/// informative bytes of each item. Anything without the conventional fields
581/// falls back to what it always was.
582fn summarize(tool: &str, args: &Value) -> String {
583    let text = headline(args).unwrap_or_else(|| serde_json::to_string(args).unwrap_or_default());
584    format!("{tool} {}", clip(text, 80))
585}
586
587/// "to a@x — \"subject\"", when the arguments carry the conventional names.
588fn headline(args: &Value) -> Option<String> {
589    let map = args.as_object()?;
590    let field = |key: &str| {
591        map.get(key)
592            .and_then(|v| match v {
593                Value::String(s) => Some(s.clone()),
594                // `to` is a list on some surfaces and a string on others.
595                Value::Array(a) => Some(
596                    a.iter()
597                        .filter_map(|x| x.as_str())
598                        .collect::<Vec<_>>()
599                        .join(", "),
600                ),
601                _ => None,
602            })
603            .filter(|s| !s.trim().is_empty())
604    };
605    let to = field("to");
606    let subject = field("subject").or_else(|| field("title"));
607    match (to, subject) {
608        (Some(to), Some(subject)) => Some(format!("to {to} — \"{subject}\"")),
609        (Some(to), None) => Some(format!("to {to}")),
610        (None, Some(subject)) => Some(format!("\"{subject}\"")),
611        (None, None) => None,
612    }
613}
614
615/// Truncate on a char boundary; the text can be any UTF-8.
616fn clip(mut text: String, max: usize) -> String {
617    if text.len() > max {
618        let cut = (0..=max)
619            .rev()
620            .find(|&i| text.is_char_boundary(i))
621            .unwrap_or(0);
622        text.truncate(cut);
623        text.push('…');
624    }
625    text
626}
627
628/// A staged message, shaped the way a person reads one.
629///
630/// [`OutboxKind::Publish`]'s lesson generalises: **a message's reviewable
631/// object is the message**, not the JSON carrying it. A review surface that
632/// prints `{"body_markdown": "Dear Dirk,\n\nThank you…"}` asks the reviewer to
633/// decode escape sequences to find out what would be said in their name — and
634/// "approve without reading" is the exact failure the outbox exists to
635/// prevent, so a draft that is hard to read is a security cost rather than a
636/// cosmetic one. It is also what an editor should open: editing prose inside a
637/// JSON string literal is where a real newline becomes `\n`, a stray quote
638/// becomes a parse error, and the whole edit is refused for a reason that has
639/// nothing to do with what the person meant to say.
640///
641/// Keyed on well-known argument *names*, like [`headline`] and for the same
642/// reason: the store stays tool-agnostic, so a tool nobody anticipated is
643/// still reviewable — its fields land in `other` rather than vanishing.
644///
645/// **Nothing is dropped.** Every key of the arguments appears in exactly one
646/// of `headers`, `body` or `other`, and there is a test on that, because a
647/// field the reviewer cannot see is a field they approved without reading.
648/// That is the whole difference between reshaping a draft and summarising it.
649#[derive(Debug, Default, PartialEq, Eq)]
650pub struct DraftView {
651    /// Addressing and the other short scalars, in reading order.
652    pub headers: Vec<(String, String)>,
653    /// The prose, with its real newlines.
654    pub body: Option<String>,
655    /// Which argument the prose came from, so an edit writes it back to the
656    /// same key rather than guessing a second time.
657    pub body_field: Option<String>,
658    /// Everything else, unshaped — shown after the body, never hidden.
659    pub other: Vec<(String, String)>,
660}
661
662/// Header-ish arguments, in the order a person reads them rather than the
663/// order a map hands them back.
664/// Deliberately short. `thread_id` and `message_id` address the *provider*,
665/// not a person, and a reviewer answering "would I send this?" needs them the
666/// way a letter writer needs the postcode format — which is to say later, and
667/// not above the prose. They fall through to `other`, which every surface
668/// shows below the body.
669/// `start_time`/`end_time` sit here rather than falling through to `other`
670/// for one reason: `other` is in map order, which is alphabetical, so an
671/// event read *end before start* — nonsense on a page and worse in an ear,
672/// where a listener cannot glance back to sort it out.
673const HEADER_FIELDS: [&str; 12] = [
674    "to",
675    "cc",
676    "bcc",
677    "channel",
678    "subject",
679    "title",
680    "when",
681    "start",
682    "start_time",
683    "end",
684    "end_time",
685    "account",
686];
687
688/// Arguments that carry the prose, most specific first. Exactly one wins; the
689/// runners-up are ordinary arguments and are shown as such.
690const BODY_FIELDS: [&str; 8] = [
691    "body_markdown",
692    "body_text",
693    "body_html",
694    "body",
695    "text",
696    "markdown",
697    "message",
698    "content",
699];
700
701impl DraftView {
702    pub fn of(args: &Value) -> DraftView {
703        let mut view = DraftView::default();
704        let Some(map) = args.as_object() else {
705            // Not an object: there is nothing to shape, and showing it raw is
706            // still showing all of it.
707            view.other.push(("arguments".into(), args.to_string()));
708            return view;
709        };
710        for key in HEADER_FIELDS {
711            if let Some(value) = map.get(key) {
712                view.headers.push((key.to_string(), render(value)));
713            }
714        }
715        for key in BODY_FIELDS {
716            if let Some(text) = map.get(key).and_then(Value::as_str) {
717                view.body = Some(text.to_string());
718                view.body_field = Some(key.to_string());
719                break;
720            }
721        }
722        for (key, value) in map {
723            if HEADER_FIELDS.contains(&key.as_str())
724                || view.body_field.as_deref() == Some(key.as_str())
725            {
726                continue;
727            }
728            view.other.push((key.clone(), render(value)));
729        }
730        view
731    }
732}
733
734/// A draft as it would be **read out loud** — every argument, nothing
735/// summarised.
736///
737/// The reviewable object of a message is the message, and that rule does not
738/// change when the reviewer is listening instead of looking. What changes is
739/// that a listener cannot skim: they hear it once, in order, at speaking
740/// speed, so the only safe spoken offer is one that utters the whole thing.
741/// A paraphrase read aloud is not a smaller review, it is a different
742/// document — and the field it leaves out (one more address on the `to` line)
743/// is exactly the field an injection would add.
744///
745/// So this is [`DraftView`]'s three buckets spoken in reading order, and it
746/// inherits that type's guarantee: **every argument key appears**, with a
747/// test on it. The only thing it decides is wording.
748#[derive(Debug, Default, PartialEq, Eq)]
749pub struct SpokenDraft {
750    /// The draft, in speakable lines. Concatenate with pauses between.
751    pub lines: Vec<String>,
752}
753
754impl SpokenDraft {
755    /// How much speech this is. Characters rather than words because that is
756    /// what a TTS leg is actually handed, and the two are proportional at any
757    /// rate worth caring about.
758    pub fn chars(&self) -> usize {
759        self.lines.iter().map(|l| l.chars().count() + 1).sum()
760    }
761
762    pub fn text(&self) -> String {
763        self.lines.join(" ")
764    }
765}
766
767/// An argument name as a person hears it: `body_markdown` → `Body markdown`.
768///
769/// Deliberately mechanical rather than a lookup table of nice phrasings. A
770/// table would cover the tools thought of today and quietly mis-speak the
771/// rest, and the store is tool-agnostic on purpose — an unanticipated field
772/// must arrive sounding slightly stiff, never sounding like something else.
773fn spoken_label(key: &str) -> String {
774    let words = key.replace('_', " ");
775    let mut chars = words.chars();
776    match chars.next() {
777        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
778        None => words,
779    }
780}
781
782/// A value as it should be *heard*.
783///
784/// One case, and it is the case a calendar draft is made of:
785/// `2026-08-28T14:30:00-04:00` read aloud is a run of digits nobody can check
786/// a meeting against, and being checkable is the entire purpose of reading a
787/// draft back. So a timestamp is spoken as a date and a time.
788///
789/// **Rendered in the offset the string itself carries, never in local time.**
790/// A reviewer must hear the moment the draft actually names; translating it
791/// into some other zone would be the wrong-bytes review arriving through the
792/// one door built to prevent it. Anything that does not parse is spoken
793/// unchanged — a value this does not understand must reach the listener as
794/// itself, not as a guess.
795fn spoken_value(value: &str) -> String {
796    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
797        let on = dt.format("%A %B %-d");
798        return if dt.format("%M").to_string() == "00" {
799            format!("{on} at {}", dt.format("%-I %p"))
800        } else {
801            format!("{on} at {}", dt.format("%-I:%M %p"))
802        };
803    }
804    // A local datetime with no offset — which is what a calendar tool sends
805    // when the zone rides in a separate `timezone` argument, spoken beside
806    // it. Formatted, never *converted*: there is no offset here to convert
807    // from, and inventing one would be the harness telling the listener a
808    // different hour than the draft says.
809    for form in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M"] {
810        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(value, form) {
811            let on = dt.format("%A %B %-d");
812            return if dt.format("%M").to_string() == "00" {
813                format!("{on} at {}", dt.format("%-I %p"))
814            } else {
815                format!("{on} at {}", dt.format("%-I:%M %p"))
816            };
817        }
818    }
819    if let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
820        return date.format("%A %B %-d").to_string();
821    }
822    value.to_string()
823}
824
825impl DraftView {
826    /// This draft, spoken.
827    pub fn spoken(&self) -> SpokenDraft {
828        let mut lines = Vec::new();
829        for (key, value) in &self.headers {
830            lines.push(format!("{}: {}.", spoken_label(key), spoken_value(value)));
831        }
832        if let Some(body) = &self.body {
833            // The prose is uttered as prose, with no label in front of it:
834            // "Body markdown: Dear Dirk" is a field name a listener has to
835            // parse past. Which argument carried it is `body_field`'s to
836            // report, and no surface has ever needed to hear it.
837            lines.push(body.trim().to_string());
838        }
839        for (key, value) in &self.other {
840            lines.push(format!("{}: {}.", spoken_label(key), spoken_value(value)));
841        }
842        SpokenDraft { lines }
843    }
844}
845
846/// One argument as a person should see it: a string as itself, a list of
847/// addresses joined, anything else as its JSON. An empty value says so rather
848/// than rendering as nothing — a blank `to` is a fact about the draft, and a
849/// label with nothing after it reads as a display bug instead.
850fn render(value: &Value) -> String {
851    let text = match value {
852        Value::String(s) => s.clone(),
853        Value::Array(a) if a.iter().all(Value::is_string) => a
854            .iter()
855            .filter_map(Value::as_str)
856            .collect::<Vec<_>>()
857            .join(", "),
858        other => other.to_string(),
859    };
860    if text.trim().is_empty() {
861        "(empty)".to_string()
862    } else {
863        text
864    }
865}
866
867/// The arguments that address the **provider** rather than a person.
868///
869/// The leftovers of the classification [`DraftView`] already makes: not
870/// addressing, not the prose, and a string. What remains — `thread_id`,
871/// `message_id`, a Slack `ts` — names the *object* the call acts on, which is
872/// exactly what two calls about the same object have in common. That makes it
873/// the join key from a staged draft back to the read that produced it
874/// ([`crate::outbox_source`]), and it lives here so the one decision about
875/// which argument is which is still made once.
876///
877/// `account` and the other headers are excluded on purpose, and it is the
878/// exclusion that makes the join worth anything: `{"account": "dartmouth"}`
879/// is shared by every mail call in the session and would match all of them,
880/// which is a filter that filters nothing. Provider ids are high-entropy
881/// because they have to be.
882pub fn provider_ids(args: &Value) -> Vec<(String, String)> {
883    let Some(map) = args.as_object() else {
884        return Vec::new();
885    };
886    let body = DraftView::of(args).body_field;
887    map.iter()
888        .filter(|(key, _)| !HEADER_FIELDS.contains(&key.as_str()))
889        .filter(|(key, _)| body.as_deref() != Some(key.as_str()))
890        .filter_map(|(key, value)| {
891            let text = value.as_str()?;
892            (!text.trim().is_empty()).then(|| (key.clone(), text.to_string()))
893        })
894        .collect()
895}
896
897/// Write an edited body back into the arguments it came from.
898///
899/// The inverse of [`DraftView::body_field`], and it lives here so the one
900/// decision about which key holds the prose is made once. Returns the changed
901/// arguments, or `None` when there was no body to replace — a caller must
902/// then fall back to editing the arguments themselves rather than silently
903/// changing nothing.
904pub fn with_body(args: &Value, body: &str) -> Option<Value> {
905    let field = DraftView::of(args).body_field?;
906    let mut args = args.clone();
907    args.as_object_mut()?
908        .insert(field, Value::String(body.to_string()));
909    Some(args)
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use serde_json::json;
916
917    fn scratch(name: &str) -> PathBuf {
918        let dir =
919            std::env::temp_dir().join(format!("mecha-outbox-test-{name}-{}", std::process::id()));
920        let _ = std::fs::remove_dir_all(&dir);
921        dir
922    }
923
924    #[test]
925    fn a_summary_leads_with_who_and_what_when_the_arguments_say() {
926        // The conventional fields, in every combination they arrive in.
927        assert_eq!(
928            summarize(
929                "mail__send",
930                &json!({"to": "a@x.org", "subject": "Tuesday?", "body_markdown": "long…"})
931            ),
932            "mail__send to a@x.org — \"Tuesday?\""
933        );
934        assert_eq!(
935            summarize(
936                "mail__send",
937                &json!({"to": ["a@x.org", "b@x.org"], "body": "hi"})
938            ),
939            "mail__send to a@x.org, b@x.org"
940        );
941        assert_eq!(
942            summarize(
943                "cal__event_create",
944                &json!({"title": "Standup", "start": "…"})
945            ),
946            "cal__event_create \"Standup\""
947        );
948
949        // Without them, the compact JSON it always was — and still bounded.
950        let plain = summarize("factory__bundle_publish", &json!({"bundle": "/tmp/x"}));
951        assert!(plain.contains("bundle"), "{plain}");
952        let long = summarize("t", &json!({"to": "x".repeat(200)}));
953        assert!(long.len() < 120, "{}", long.len());
954        assert!(long.ends_with('…'), "{long}");
955
956        // An empty `to` is absence, not an addressee.
957        assert_eq!(
958            summarize("t", &json!({"to": "", "body": "x"})),
959            r#"t {"body":"x","to":""}"#
960        );
961    }
962
963    #[test]
964    fn an_item_round_trips_and_lists_in_id_order() {
965        let root = scratch("roundtrip");
966        let store = OutboxStore::open(&root).unwrap();
967
968        let a = store
969            .stage(
970                "web__fetch",
971                OutboxKind::Message,
972                json!({"url": "https://a"}),
973                Taint::default(),
974                None,
975                None,
976            )
977            .unwrap();
978        let b = store
979            .stage(
980                "email__send",
981                OutboxKind::Message,
982                json!({"to": "x@y"}),
983                Taint {
984                    private: true,
985                    untrusted: true,
986                },
987                Some("sess-1".into()),
988                None,
989            )
990            .unwrap();
991
992        let items = store.items().unwrap();
993        assert_eq!(items.len(), 2);
994        // Ids sort by creation time, so listing order is staging order.
995        assert_eq!(items[0].id, a.id.min(b.id.clone()));
996
997        let loaded = store.item(&b.id).unwrap();
998        assert_eq!(loaded.tool, "email__send");
999        assert!(loaded.taint.trifecta_armed());
1000        assert_eq!(loaded.session_id.as_deref(), Some("sess-1"));
1001        assert_eq!(loaded.args, loaded.args_before);
1002        assert!(!loaded.edited());
1003
1004        let _ = std::fs::remove_dir_all(&root);
1005    }
1006
1007    #[test]
1008    fn items_skips_a_malformed_file_but_items_strict_bails_on_it() {
1009        let root = scratch("malformed");
1010        let store = OutboxStore::open(&root).unwrap();
1011        store
1012            .stage(
1013                "t",
1014                OutboxKind::Message,
1015                json!({}),
1016                Taint::default(),
1017                None,
1018                None,
1019            )
1020            .unwrap();
1021        // A stray file, or one written by a schema this binary cannot read —
1022        // never a half-written save, which temp-sibling-and-rename rules out.
1023        std::fs::write(root.join("zzz-corrupt.json"), "{not json").unwrap();
1024
1025        let items = store.items().unwrap();
1026        assert_eq!(items.len(), 1, "the listing shows what it can");
1027
1028        let err = store.items_strict().unwrap_err();
1029        assert!(
1030            format!("{err:#}").contains("zzz-corrupt.json"),
1031            "the error names the file that failed: {err:#}"
1032        );
1033
1034        let _ = std::fs::remove_dir_all(&root);
1035    }
1036
1037    #[test]
1038    fn a_prefix_that_matches_two_items_is_an_error_not_a_guess() {
1039        let root = scratch("prefix");
1040        let store = OutboxStore::open(&root).unwrap();
1041        store
1042            .stage(
1043                "t",
1044                OutboxKind::Message,
1045                json!({}),
1046                Taint::default(),
1047                None,
1048                None,
1049            )
1050            .unwrap();
1051        store
1052            .stage(
1053                "t",
1054                OutboxKind::Message,
1055                json!({}),
1056                Taint::default(),
1057                None,
1058                None,
1059            )
1060            .unwrap();
1061
1062        // Both ids share the timestamp prefix of the second they were made in.
1063        let err = store.item("2").unwrap_err();
1064        assert!(err.to_string().contains("matches 2"), "{err}");
1065
1066        let _ = std::fs::remove_dir_all(&root);
1067    }
1068
1069    #[test]
1070    fn editing_replaces_args_and_never_touches_the_baseline() {
1071        let root = scratch("edit");
1072        let store = OutboxStore::open(&root).unwrap();
1073        let item = store
1074            .stage(
1075                "web__fetch",
1076                OutboxKind::Message,
1077                json!({"url": "https://a"}),
1078                Taint::default(),
1079                None,
1080                None,
1081            )
1082            .unwrap();
1083
1084        let edited = store
1085            .update_args(&item.id, json!({"url": "https://b"}))
1086            .unwrap();
1087        assert!(edited.edited());
1088        assert_eq!(edited.args_before, json!({"url": "https://a"}));
1089        assert_eq!(edited.args, json!({"url": "https://b"}));
1090
1091        let _ = std::fs::remove_dir_all(&root);
1092    }
1093
1094    /// The rule that protects every future run's system prompt. A publish's
1095    /// edit diff is a changed filesystem path, and a `writing` reflection
1096    /// becomes a rule in the cached prefix — the same class of mistake as
1097    /// mining `"Blocked by a hook:"` as if a human had said it. Fails on the
1098    /// old behaviour, which mined any sent-and-edited item.
1099    #[test]
1100    fn the_writing_miner_takes_edited_messages_and_never_publishes() {
1101        let root = scratch("mineable");
1102        let store = OutboxStore::open(&root).unwrap();
1103
1104        let cases = [
1105            (OutboxKind::Message, "sent", true, true),
1106            // A publish, edited and sent — the one the old filter accepted.
1107            (OutboxKind::Publish, "sent", true, false),
1108            // Unedited is not a correction; rejected never went out.
1109            (OutboxKind::Message, "sent", false, false),
1110            (OutboxKind::Message, "rejected", true, false),
1111            (OutboxKind::Message, "pending", true, false),
1112        ];
1113        for (kind, status, edited, expected) in cases {
1114            let mut item = store
1115                .stage(
1116                    "x__send",
1117                    kind,
1118                    json!({"path": "/tmp/a"}),
1119                    Taint::default(),
1120                    None,
1121                    None,
1122                )
1123                .unwrap();
1124            item.status = status.into();
1125            if edited {
1126                item.args = json!({"path": "/tmp/b"});
1127            }
1128            assert_eq!(
1129                item.mineable_as_writing(),
1130                expected,
1131                "{kind:?} / {status} / edited={edited}"
1132            );
1133        }
1134
1135        let _ = std::fs::remove_dir_all(&root);
1136    }
1137
1138    /// The signal that had no reader: the owner read a letter written in their
1139    /// name and sent it as drafted. Positive evidence, and deliberately *not*
1140    /// a correction — mining it as one would teach voice rules from approval.
1141    #[test]
1142    fn a_draft_sent_as_written_is_positive_evidence_and_never_a_correction() {
1143        let root = scratch("writing-outcome");
1144        let store = OutboxStore::open(&root).unwrap();
1145        let stage = |kind| {
1146            store
1147                .stage(
1148                    "x__send",
1149                    kind,
1150                    json!({"body": "Dear Dirk,"}),
1151                    Taint::default(),
1152                    None,
1153                    None,
1154                )
1155                .unwrap()
1156        };
1157
1158        let mut unchanged = stage(OutboxKind::Message);
1159        unchanged.status = "sent".into();
1160        assert_eq!(
1161            unchanged.writing_outcome(),
1162            Some(WritingOutcome::SentUnchanged)
1163        );
1164        assert!(
1165            !unchanged.mineable_as_writing(),
1166            "approval is not a correction"
1167        );
1168
1169        let mut edited = stage(OutboxKind::Message);
1170        edited.status = "sent".into();
1171        edited.args = json!({"body": "Dear Dr Baumgartner,"});
1172        assert_eq!(edited.writing_outcome(), Some(WritingOutcome::SentEdited));
1173        assert!(edited.mineable_as_writing());
1174
1175        // Says nothing about drafting: undecided, never went out, or not prose.
1176        let pending = stage(OutboxKind::Message);
1177        assert_eq!(pending.writing_outcome(), None);
1178
1179        let mut rejected = stage(OutboxKind::Message);
1180        rejected.status = "rejected".into();
1181        assert_eq!(rejected.writing_outcome(), None);
1182
1183        let mut published = stage(OutboxKind::Publish);
1184        published.status = "sent".into();
1185        assert_eq!(published.writing_outcome(), None);
1186
1187        let tally = WritingTally::of([&unchanged, &edited, &pending, &rejected, &published]);
1188        assert_eq!(
1189            tally,
1190            WritingTally {
1191                unchanged: 1,
1192                edited: 1
1193            }
1194        );
1195        assert_eq!(tally.unchanged_rate(), Some(0.5));
1196
1197        let _ = std::fs::remove_dir_all(&root);
1198    }
1199
1200    /// "Nothing was edited" and "nothing has been sent" are opposite findings.
1201    /// Reporting the second as 0% would describe an outbox nobody has used as
1202    /// one whose every draft was rewritten — the null-run bug, arriving in the
1203    /// one measure here whose job is to say something went well.
1204    #[test]
1205    fn a_rate_over_nothing_sent_is_absent_rather_than_zero() {
1206        assert_eq!(WritingTally::default().unchanged_rate(), None);
1207        assert_eq!(WritingTally::default().sent(), 0);
1208        assert_eq!(
1209            WritingTally {
1210                unchanged: 0,
1211                edited: 3
1212            }
1213            .unchanged_rate(),
1214            Some(0.0),
1215            "every draft rewritten is a real zero, and is not the same finding"
1216        );
1217    }
1218
1219    /// The kind is config's to declare, and anything unnamed stays a message —
1220    /// which keeps the arguments reviewable rather than silently hiding them.
1221    #[test]
1222    fn a_routes_kind_comes_from_config_and_defaults_to_message() {
1223        let root = scratch("kindof");
1224        let store = OutboxStore::open(&root).unwrap();
1225        let route = OutboxRoute::new(
1226            store,
1227            [
1228                "mail__send".to_string(),
1229                "factory__bundle_publish".to_string(),
1230            ],
1231            ["factory__bundle_publish".to_string()],
1232        );
1233        assert_eq!(
1234            route.kind_of("factory__bundle_publish"),
1235            OutboxKind::Publish
1236        );
1237        assert_eq!(route.kind_of("mail__send"), OutboxKind::Message);
1238        assert_eq!(route.kind_of("never__heard_of_it"), OutboxKind::Message);
1239
1240        let _ = std::fs::remove_dir_all(&root);
1241    }
1242
1243    /// Items written before the field existed must load as what they in fact
1244    /// were, or an upgrade would reclassify every staged email as unknown.
1245    #[test]
1246    fn an_item_recorded_before_kinds_existed_loads_as_a_message() {
1247        let item: OutboxItem = serde_json::from_value(json!({
1248            "id": "20260101-000000-abc",
1249            "status": "sent",
1250            "tool": "mail__send",
1251            "args_before": {"body": "a"},
1252            "args": {"body": "b"},
1253            "summary": "mail__send",
1254            "created_at": "2026-01-01T00:00:00Z",
1255        }))
1256        .unwrap();
1257        assert_eq!(item.kind, OutboxKind::Message);
1258        assert!(item.mineable_as_writing());
1259        // And the same for the jail it was drafted under: an older item names
1260        // none, and a release falls back to the reviewer's workspace, which is
1261        // exactly what it did before the field existed.
1262        assert_eq!(item.workspace, None);
1263    }
1264
1265    /// A staged call is a deferred tool call, and the release happens in
1266    /// another process from another directory. Without the drafting jail on
1267    /// the item, `{"bundle": "site"}` resolves against wherever the reviewer
1268    /// stands — an absolute path fails loudly, and a relative one silently
1269    /// publishes whatever `./site` happens to be there.
1270    #[test]
1271    fn a_staged_call_records_the_jail_it_was_drafted_under() {
1272        let root = scratch("workspace");
1273        let store = OutboxStore::open(&root).unwrap();
1274        let jail = PathBuf::from("/home/someone/.mecha/work/morning");
1275
1276        let item = store
1277            .stage(
1278                "factory__bundle_publish",
1279                OutboxKind::Publish,
1280                json!({"bundle": "site", "id": "brief"}),
1281                Taint::default(),
1282                None,
1283                Some(jail.clone()),
1284            )
1285            .unwrap();
1286        assert_eq!(item.workspace.as_ref(), Some(&jail));
1287
1288        // And it survives the round-trip through the file, which is the only
1289        // form the reviewing process ever sees.
1290        let loaded = store.item(&item.id).unwrap();
1291        assert_eq!(loaded.workspace.as_ref(), Some(&jail));
1292
1293        let _ = std::fs::remove_dir_all(&root);
1294    }
1295
1296    #[test]
1297    fn resolution_rewrites_in_place_and_only_pending_resolves() {
1298        let root = scratch("resolve");
1299        let store = OutboxStore::open(&root).unwrap();
1300        let item = store
1301            .stage(
1302                "t",
1303                OutboxKind::Message,
1304                json!({}),
1305                Taint::default(),
1306                None,
1307                None,
1308            )
1309            .unwrap();
1310
1311        let sent = store.resolve(&item.id, "sent", None).unwrap();
1312        assert_eq!(sent.status, "sent");
1313        assert!(sent.resolved_at.is_some());
1314        assert_eq!(
1315            store.items().unwrap().len(),
1316            1,
1317            "resolved in place, not archived"
1318        );
1319
1320        let err = store.resolve(&item.id, "rejected", None).unwrap_err();
1321        assert!(err.to_string().contains("not pending"), "{err}");
1322        let err = store.update_args(&item.id, json!({"x": 1})).unwrap_err();
1323        assert!(err.to_string().contains("not pending"), "{err}");
1324
1325        let _ = std::fs::remove_dir_all(&root);
1326    }
1327
1328    #[test]
1329    fn a_failed_release_records_the_error_and_stays_pending() {
1330        let root = scratch("error");
1331        let store = OutboxStore::open(&root).unwrap();
1332        let item = store
1333            .stage(
1334                "t",
1335                OutboxKind::Message,
1336                json!({}),
1337                Taint::default(),
1338                None,
1339                None,
1340            )
1341            .unwrap();
1342
1343        store.record_error(&item.id, "server unreachable").unwrap();
1344        let loaded = store.item(&item.id).unwrap();
1345        assert_eq!(loaded.status, "pending");
1346        assert_eq!(loaded.error.as_deref(), Some("server unreachable"));
1347
1348        // A later successful resolution clears the stale error.
1349        let sent = store.resolve(&item.id, "sent", None).unwrap();
1350        assert_eq!(sent.error, None);
1351
1352        let _ = std::fs::remove_dir_all(&root);
1353    }
1354
1355    /// The targeted read behind the hot paths: one file, found or honestly
1356    /// missing, and a value shaped like a path is refused before it can name
1357    /// a file outside the store — even one that exists and would parse.
1358    #[test]
1359    fn an_exact_lookup_reads_one_file_and_refuses_a_hostile_id() {
1360        let root = scratch("exact");
1361        let store = OutboxStore::open(&root).unwrap();
1362        let staged = store
1363            .stage(
1364                "mail__send",
1365                OutboxKind::Message,
1366                json!({"to": "a@x.org"}),
1367                Taint::default(),
1368                None,
1369                None,
1370            )
1371            .unwrap();
1372
1373        let found = store.item_exact(&staged.id).unwrap().expect("found");
1374        assert_eq!(found.id, staged.id);
1375        assert_eq!(found.tool, "mail__send");
1376
1377        // Missing is None, not an error: the caller decides what absence means.
1378        assert!(store
1379            .item_exact("20990101T000000-deadbeef")
1380            .unwrap()
1381            .is_none());
1382
1383        // A perfectly valid item file sitting *beside* the store, reachable
1384        // only by traversal — the refusal below is not vacuous.
1385        let outside = root.parent().unwrap().join("mecha-outbox-evil.json");
1386        std::fs::write(&outside, serde_json::to_string_pretty(&staged).unwrap()).unwrap();
1387        for hostile in [
1388            "../mecha-outbox-evil",
1389            "a/b",
1390            "a.b",
1391            ".",
1392            "",
1393            &"x".repeat(200),
1394        ] {
1395            assert!(
1396                store.item_exact(hostile).is_err(),
1397                "{hostile:?} must be refused, not resolved"
1398            );
1399        }
1400        let _ = std::fs::remove_file(&outside);
1401
1402        let _ = std::fs::remove_dir_all(&root);
1403    }
1404
1405    /// The invariant that separates reshaping a draft from summarising one:
1406    /// every argument reaches the reviewer somewhere. A field that falls
1407    /// between the three buckets is a field released unread.
1408    #[test]
1409    fn a_draft_view_drops_no_argument() {
1410        let args = json!({
1411            "to": ["a@x.org", "b@x.org"],
1412            "subject": "Tuesday?",
1413            "body_markdown": "Dear A,\n\nHello.\n\nLuke",
1414            "account": "dartmouth",
1415            "importance": "high",
1416            "attachments": [{"name": "f.pdf"}],
1417        });
1418        let view = DraftView::of(&args);
1419        let mut seen: Vec<String> = view
1420            .headers
1421            .iter()
1422            .map(|(k, _)| k.clone())
1423            .chain(view.body_field.clone())
1424            .chain(view.other.iter().map(|(k, _)| k.clone()))
1425            .collect();
1426        seen.sort();
1427        let mut keys: Vec<String> = args.as_object().unwrap().keys().cloned().collect();
1428        keys.sort();
1429        assert_eq!(seen, keys);
1430        assert_eq!(view.body.as_deref(), Some("Dear A,\n\nHello.\n\nLuke"));
1431        // Reading order, not map order.
1432        assert_eq!(
1433            view.headers
1434                .iter()
1435                .map(|(k, _)| k.as_str())
1436                .collect::<Vec<_>>(),
1437            ["to", "subject", "account"]
1438        );
1439        assert_eq!(view.headers[0].1, "a@x.org, b@x.org");
1440    }
1441
1442    /// The spoken form carries the same guarantee, and it is the one that
1443    /// matters most: a listener cannot skim back over the line where the
1444    /// extra recipient was. Every argument's value must be *audible* — the
1445    /// check is on values rather than keys, because the body is spoken with
1446    /// no label and a key-only check would pass on a draft that read out its
1447    /// field names and none of its content.
1448    #[test]
1449    fn a_spoken_draft_utters_every_argument() {
1450        let args = json!({
1451            "to": ["a@x.org", "b@x.org"],
1452            "subject": "Tuesday?",
1453            "body_markdown": "Dear A,\n\nHello.\n\nLuke",
1454            "account": "dartmouth",
1455            "importance": "high",
1456        });
1457        let spoken = DraftView::of(&args).spoken().text();
1458        for audible in [
1459            "a@x.org",
1460            "b@x.org",
1461            "Tuesday?",
1462            "Dear A,",
1463            "Luke",
1464            "dartmouth",
1465            "high",
1466        ] {
1467            assert!(
1468                spoken.contains(audible),
1469                "{audible} was never said: {spoken}"
1470            );
1471        }
1472        // Labels are spoken as words, and the body carries none — "Body
1473        // markdown:" is a field name a listener has to parse past.
1474        assert!(spoken.contains("Subject: Tuesday?."), "{spoken}");
1475        assert!(!spoken.contains("Body markdown"), "{spoken}");
1476        assert!(spoken.contains("Importance: high."), "{spoken}");
1477    }
1478
1479    /// A calendar draft is mostly timestamps, and a timestamp read out as
1480    /// digits is a draft nobody can check. Rendered in the offset the string
1481    /// carries — hearing a different moment than the draft names is the
1482    /// wrong-bytes review arriving through the ear.
1483    #[test]
1484    fn a_timestamp_is_spoken_as_a_time_in_its_own_offset() {
1485        let spoken = DraftView::of(&json!({
1486            "title": "Walk with Sage",
1487            "start_time": "2026-08-28T14:00:00-04:00",
1488            "end_time": "2026-08-28T14:30:00-04:00",
1489        }))
1490        .spoken()
1491        .text();
1492        assert!(spoken.contains("Friday August 28 at 2 PM"), "{spoken}");
1493        assert!(spoken.contains("Friday August 28 at 2:30 PM"), "{spoken}");
1494        assert!(!spoken.contains("T14:00"), "{spoken}");
1495        // A calendar tool that puts the zone in its own argument sends a
1496        // *naive* datetime, which is the form this missed on the first pass:
1497        // it fell through to the fallback and read out "2026-08-28T16:00:00".
1498        let naive = DraftView::of(&json!({
1499            "start_time": "2026-08-28T16:00:00",
1500            "timezone": "America/New_York",
1501        }))
1502        .spoken()
1503        .text();
1504        assert!(naive.contains("Friday August 28 at 4 PM"), "{naive}");
1505        // Start before end, whatever order the map hands them back in.
1506        let start = spoken.find("Start time").expect("start");
1507        let end = spoken.find("End time").expect("end");
1508        assert!(start < end, "an event read end-first is nonsense: {spoken}");
1509    }
1510
1511    /// A value the renderer does not understand reaches the listener as
1512    /// itself. Guessing at it would be the one thing a spoken review cannot
1513    /// afford.
1514    #[test]
1515    fn an_unparseable_value_is_spoken_unchanged() {
1516        let spoken = DraftView::of(&json!({"when": "sometime next week"}))
1517            .spoken()
1518            .text();
1519        assert_eq!(spoken, "When: sometime next week.");
1520    }
1521
1522    /// A tool nobody anticipated is still speakable, stiffly and completely.
1523    /// The alternative — a lookup table of nice phrasings — covers the tools
1524    /// thought of today and mis-speaks the rest.
1525    #[test]
1526    fn an_unanticipated_argument_is_spoken_stiffly_not_silently() {
1527        let spoken = DraftView::of(&json!({"emoji": "wave", "ts": 17}))
1528            .spoken()
1529            .text();
1530        assert_eq!(spoken, "Emoji: wave. Ts: 17.");
1531    }
1532
1533    /// A tool nobody anticipated is still reviewable: no headers, no body, and
1534    /// every argument shown.
1535    #[test]
1536    fn an_unrecognised_draft_shows_everything_as_other() {
1537        let view = DraftView::of(&json!({"emoji": "wave", "ts": 17}));
1538        assert!(view.headers.is_empty() && view.body.is_none());
1539        assert_eq!(
1540            view.other,
1541            vec![
1542                ("emoji".to_string(), "wave".to_string()),
1543                ("ts".to_string(), "17".to_string())
1544            ]
1545        );
1546    }
1547
1548    /// A blank value is shown as blank-on-purpose. The alternative is a label
1549    /// with nothing after it, which reads as a broken display rather than as
1550    /// an empty recipient list.
1551    #[test]
1552    fn an_empty_argument_says_so() {
1553        let view = DraftView::of(&json!({"to": "", "body": "hi"}));
1554        assert_eq!(
1555            view.headers,
1556            vec![("to".to_string(), "(empty)".to_string())]
1557        );
1558    }
1559
1560    /// An edit writes back to the key the body came from, and to nothing else.
1561    #[test]
1562    fn an_edited_body_returns_to_its_own_field() {
1563        let args = json!({"thread_id": "t1", "body_markdown": "old", "account": "personal"});
1564        let edited = with_body(&args, "new").unwrap();
1565        assert_eq!(edited["body_markdown"], "new");
1566        assert_eq!(edited["thread_id"], "t1");
1567        assert_eq!(edited["account"], "personal");
1568        // No prose, no body edit — the caller must fall back rather than
1569        // silently save nothing.
1570        assert!(with_body(&json!({"event_id": "e1", "response": "accept"}), "x").is_none());
1571    }
1572}