Skip to main content

mail4agent_api/
lib.rs

1//! Wire types for the mail4agent mailbox: addresses, messages, requests and
2//! named refusals.
3//!
4//! This crate is the wire contract only -- serde and nothing else. It knows
5//! about participants, addresses and messages; it knows nothing about tasks,
6//! schedulers, runs, grants, workspaces, nodes, or any particular system that
7//! happens to run agents (see `mail4agent/CLAUDE.md`).
8//!
9//! Ported from `gate4agent-harness-protocol`'s mail surface
10//! (`docs/gate4agent/research/mailbox-code-inventory-2026-09-16.md` section
11//! 1.1): the discipline is kept -- bounded, validated on both encode and
12//! decode, versioned by convention, legacy-tolerant on read -- while every
13//! name that leaked a harness/task-kernel concept (`HarnessRecordRef`,
14//! `HarnessMailAddressV1::Session`/`Task`, `task_id`) is replaced by a
15//! general shape a standalone mailbox can own outright.
16//!
17//! **The rule that defines this service**: a sender is never a field the
18//! caller fills in. See [`SendRequest`].
19
20use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize};
23
24/// Prefix every [`MessageId`] carries. Mirrors `HarnessMailMessageId`'s own
25/// `hmail_` prefix, renamed so a value can never be mistaken for a harness
26/// message id from the crate this was ported out of.
27pub const MESSAGE_ID_PREFIX: &str = "m4a_";
28
29/// Length, in lower-hex characters, of a [`MessageId`]'s body after its
30/// prefix. Same width as the source's `opaque_id!` ids.
31pub const MESSAGE_ID_HEX_LEN: usize = 24;
32
33/// Bound shared by every selector-shaped id ([`ParticipantId`], [`RoomId`],
34/// [`MessageRef::kind`]): ASCII, 1..=128 bytes. Ported from
35/// `HARNESS_SELECTOR_MAX_BYTES` / `validate_selector`.
36pub const SELECTOR_MAX_BYTES: usize = 128;
37
38/// Maximum size of [`Message::subject`] / [`SendRequest::subject`], in bytes
39/// (not characters).
40pub const SUBJECT_MAX_BYTES: usize = 512;
41
42/// Maximum size of [`Message::body`] / [`SendRequest::body`], in bytes (not
43/// characters).
44pub const BODY_MAX_BYTES: usize = 65_536;
45
46/// Maximum number of entries in [`Message::refs`] / [`SendRequest::refs`].
47/// Ported verbatim from `HARNESS_MAIL_REFS_MAX`: a message names a handful
48/// of dereferenceable results, never a manifest.
49pub const REFS_MAX: usize = 8;
50
51/// Maximum size of [`MessageRef::locator`], in bytes.
52pub const REF_LOCATOR_MAX_BYTES: usize = 512;
53
54/// Maximum length of [`MessageRef::digest`], in lower-hex characters, when
55/// present. Unlike [`MESSAGE_ID_HEX_LEN`] this is a ceiling, not an exact
56/// width: the mailbox never interprets a digest, so it does not know (and
57/// must not assume) which hash algorithm produced it.
58pub const REF_DIGEST_MAX_CHARS: usize = 128;
59
60/// Maximum value accepted for [`InboxRequest::limit`].
61pub const INBOX_LIMIT_MAX: u16 = 256;
62
63/// Value [`InboxRequest::limit`] defaults to when a caller's JSON omits it.
64pub const INBOX_LIMIT_DEFAULT: u16 = 50;
65
66/// Ceiling on [`InboxRequest::wait_secs`]. **Clamped, not refused**, when a
67/// caller asks for longer -- unlike [`INBOX_LIMIT_MAX`], which
68/// [`InboxRequest::validate`] refuses outright above. The daemon holds an
69/// HTTP (or MCP) connection open for the whole wait, so this door must
70/// answer within a bounded time the same way
71/// `mirage2operator/crates/operator-box/src/ops/mcp.rs`'s own
72/// `GET /ops/jobs/{id}?wait_secs=N` caps its one long-poll shape, for the
73/// same reason: nothing about this door streams, so nothing about it may
74/// hold a connection open indefinitely either.
75pub const INBOX_WAIT_SECS_MAX: u16 = 60;
76
77fn default_inbox_limit() -> u16 {
78    INBOX_LIMIT_DEFAULT
79}
80
81/// A named refusal. Every variant names its inputs so a caller learns what
82/// was refused and why from the refusal alone -- never a bare `Internal`
83/// (the crate contract's own discipline; see `mail4agent/CLAUDE.md`).
84///
85/// `UnknownParticipant`/`UnknownRoom` carry the offending id even though the
86/// task's own sketch of this enum omitted their fields: an "unknown X"
87/// refusal that does not say which X is exactly the unnamed-refusal failure
88/// mode the crate contract calls out by name.
89#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
91pub enum MailError {
92    /// No participant is registered under this id.
93    UnknownParticipant { participant: ParticipantId },
94    /// No room is registered under this id.
95    UnknownRoom { room: RoomId },
96    /// No session is registered under this id -- it has never been named in
97    /// an [`Address::Session`] that reached [`Address::Session`]'s
98    /// registering call, `MailboxEngine::ensure_session`.
99    UnknownSession { session: SessionId },
100    /// `session` is registered, but under a different account than the one
101    /// presented alongside it in an [`Address::Session`]. Refused rather
102    /// than silently resolved either way, because either party being wrong
103    /// about which account owns a session is exactly the confusion the
104    /// account/session split exists to prevent.
105    SessionAccountMismatch { session: SessionId, expected: ParticipantId, presented: ParticipantId },
106    /// No message is stored under this id.
107    UnknownMessage { message_id: MessageId },
108    /// The message exists, but was not sent to the caller (not their direct
109    /// address, and not a room they belong to).
110    NotAddressedToYou { message_id: MessageId },
111    /// The caller's credential does not carry the capability the operation
112    /// needs. `need` names the missing capability, e.g. `"mail:send"`.
113    PermissionDenied { need: String },
114    /// A field failed structural validation. `reason` says how.
115    Malformed { field: String, reason: String },
116    /// A field exceeded its bound. Carries both the bound and what was sent
117    /// so the caller can act without a second round trip.
118    TooLarge { field: String, limit: usize, actual: usize },
119    /// The storage layer could not complete `operation` (a filesystem
120    /// error, a lock, a corrupt row -- never a domain refusal). Names only
121    /// the operation, never the underlying cause: that cause is logged
122    /// server-side for the operator, so a caller learns *what* failed
123    /// without a path or a driver's error text leaving the process.
124    StoreUnavailable { operation: String },
125}
126
127impl fmt::Display for MailError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::UnknownParticipant { participant } => {
131                write!(f, "unknown participant \"{participant}\"")
132            }
133            Self::UnknownRoom { room } => write!(f, "unknown room \"{room}\""),
134            Self::UnknownSession { session } => write!(f, "unknown session \"{session}\""),
135            Self::SessionAccountMismatch { session, expected, presented } => write!(
136                f,
137                "session \"{session}\" belongs to account \"{expected}\", not \"{presented}\""
138            ),
139            Self::UnknownMessage { message_id } => write!(f, "unknown message \"{message_id}\""),
140            Self::NotAddressedToYou { message_id } => {
141                write!(f, "message \"{message_id}\" is not addressed to you")
142            }
143            Self::PermissionDenied { need } => write!(f, "permission denied: need \"{need}\""),
144            Self::Malformed { field, reason } => {
145                write!(f, "field \"{field}\" is malformed: {reason}")
146            }
147            Self::TooLarge { field, limit, actual } => {
148                write!(f, "field \"{field}\" is too large: limit {limit}, actual {actual}")
149            }
150            Self::StoreUnavailable { operation } => {
151                write!(f, "the store could not complete \"{operation}\"")
152            }
153        }
154    }
155}
156
157impl std::error::Error for MailError {}
158
159/// `min_hex_len`/`max_hex_len` let one function serve both a fixed-width id
160/// ([`MessageId`], `min == max`) and a variable-width one ([`SessionId`],
161/// which has no fixed width because it is derived by the *caller* from a
162/// process identity, never invented by this crate).
163fn validate_opaque_id(
164    label: &'static str,
165    value: &str,
166    prefix: &str,
167    min_hex_len: usize,
168    max_hex_len: usize,
169) -> Result<(), MailError> {
170    let Some(hex) = value.strip_prefix(prefix) else {
171        return Err(MailError::Malformed {
172            field: label.to_string(),
173            reason: format!("must start with \"{prefix}\""),
174        });
175    };
176    if hex.len() < min_hex_len
177        || hex.len() > max_hex_len
178        || !hex.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
179    {
180        let width = if min_hex_len == max_hex_len {
181            min_hex_len.to_string()
182        } else {
183            format!("{min_hex_len}..={max_hex_len}")
184        };
185        return Err(MailError::Malformed {
186            field: label.to_string(),
187            reason: format!("body after \"{prefix}\" must be {width} lowercase hex characters"),
188        });
189    }
190    Ok(())
191}
192
193fn validate_selector(label: &'static str, value: &str) -> Result<(), MailError> {
194    if value.is_empty() {
195        return Err(MailError::Malformed {
196            field: label.to_string(),
197            reason: "must not be empty".to_string(),
198        });
199    }
200    if value.len() > SELECTOR_MAX_BYTES {
201        return Err(MailError::TooLarge {
202            field: label.to_string(),
203            limit: SELECTOR_MAX_BYTES,
204            actual: value.len(),
205        });
206    }
207    if !value.is_ascii()
208        || !value
209            .bytes()
210            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@' | b'+'))
211    {
212        return Err(MailError::Malformed {
213            field: label.to_string(),
214            reason: "must be ASCII from the set [A-Za-z0-9._@+-]".to_string(),
215        });
216    }
217    Ok(())
218}
219
220/// Bound shared by every free-text field that is otherwise unstructured:
221/// [`Message::subject`]/[`SendRequest::subject`], a participant's or
222/// directory entry's `label`, and every free-text field a [`SessionCard`]
223/// carries (an executable path, a provider session id, a model name, a
224/// working directory, a role, a one-line description of what a session is
225/// working on). One rule, one place, so the bound and the "no control
226/// characters" rule cannot drift between the fields that share it.
227fn validate_bounded_text(field: &'static str, value: &str) -> Result<(), MailError> {
228    if value.len() > SUBJECT_MAX_BYTES {
229        return Err(MailError::TooLarge {
230            field: field.to_string(),
231            limit: SUBJECT_MAX_BYTES,
232            actual: value.len(),
233        });
234    }
235    if value.chars().any(char::is_control) {
236        return Err(MailError::Malformed {
237            field: field.to_string(),
238            reason: "must not contain control characters".to_string(),
239        });
240    }
241    Ok(())
242}
243
244fn validate_subject(value: &str) -> Result<(), MailError> {
245    validate_bounded_text("subject", value)
246}
247
248fn validate_body(value: &str) -> Result<(), MailError> {
249    if value.len() > BODY_MAX_BYTES {
250        return Err(MailError::TooLarge {
251            field: "body".to_string(),
252            limit: BODY_MAX_BYTES,
253            actual: value.len(),
254        });
255    }
256    if value.chars().any(|character| character.is_control() && !matches!(character, '\n' | '\t')) {
257        return Err(MailError::Malformed {
258            field: "body".to_string(),
259            reason: "must not contain control characters other than newline and tab".to_string(),
260        });
261    }
262    Ok(())
263}
264
265/// `correlation` has no bound in the source (`task_id` there was a
266/// fixed-width opaque id, not free text) but this crate's own discipline is
267/// "bounded, validated" throughout, so it reuses the subject bound rather
268/// than going unbounded.
269fn validate_correlation(value: &str) -> Result<(), MailError> {
270    if value.len() > SUBJECT_MAX_BYTES {
271        return Err(MailError::TooLarge {
272            field: "correlation".to_string(),
273            limit: SUBJECT_MAX_BYTES,
274            actual: value.len(),
275        });
276    }
277    if value.is_empty() || value.chars().any(char::is_control) {
278        return Err(MailError::Malformed {
279            field: "correlation".to_string(),
280            reason: "must be non-empty and free of control characters".to_string(),
281        });
282    }
283    Ok(())
284}
285
286fn validate_refs(refs: &[MessageRef]) -> Result<(), MailError> {
287    if refs.len() > REFS_MAX {
288        return Err(MailError::TooLarge {
289            field: "refs".to_string(),
290            limit: REFS_MAX,
291            actual: refs.len(),
292        });
293    }
294    for reference in refs {
295        reference.validate()?;
296    }
297    Ok(())
298}
299
300/// Defines an opaque, prefixed, fixed-width hex id: `Display`, `FromStr`,
301/// and serde as a plain string, rejecting a bad prefix or a bad body on
302/// both `new()` and decode. Ported from `gate4agent-harness-protocol`'s
303/// `opaque_id!` macro (`gate4agent-harness-protocol/src/lib.rs:50-97`).
304macro_rules! opaque_id {
305    ($name:ident, $prefix:expr, $label:literal, $min_hex:expr, $max_hex:expr, $doc:expr) => {
306        #[doc = $doc]
307        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
308        #[serde(transparent)]
309        pub struct $name(String);
310
311        impl $name {
312            pub const PREFIX: &'static str = $prefix;
313
314            pub fn new(value: impl Into<String>) -> Result<Self, MailError> {
315                let value = value.into();
316                validate_opaque_id($label, &value, Self::PREFIX, $min_hex, $max_hex)?;
317                Ok(Self(value))
318            }
319
320            pub fn validate(&self) -> Result<(), MailError> {
321                validate_opaque_id($label, &self.0, Self::PREFIX, $min_hex, $max_hex)
322            }
323
324            pub fn as_str(&self) -> &str {
325                &self.0
326            }
327        }
328
329        impl fmt::Debug for $name {
330            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331                formatter.debug_tuple(stringify!($name)).field(&self.0).finish()
332            }
333        }
334
335        impl fmt::Display for $name {
336            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
337                formatter.write_str(&self.0)
338            }
339        }
340
341        impl std::str::FromStr for $name {
342            type Err = MailError;
343
344            fn from_str(value: &str) -> Result<Self, Self::Err> {
345                Self::new(value)
346            }
347        }
348
349        impl<'de> Deserialize<'de> for $name {
350            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351            where
352                D: Deserializer<'de>,
353            {
354                let value = String::deserialize(deserializer)?;
355                Self::new(value).map_err(serde::de::Error::custom)
356            }
357        }
358    };
359}
360
361/// Defines a bounded selector id: ASCII, 1..=128 bytes, charset
362/// `[A-Za-z0-9._@+-]`. Ported from `HarnessSelectorV1`'s validation
363/// (`validate_selector`, `gate4agent-harness-protocol/src/lib.rs:2910-2917`).
364/// Unlike the source type, each caller of this macro gets `Display` and
365/// `FromStr` too: the source's `HarnessSelectorV1` had neither, but this
366/// crate's ids are used as map keys and address components and are worth
367/// being able to print and parse directly.
368macro_rules! selector_id {
369    ($name:ident, $label:literal, $doc:expr) => {
370        #[doc = $doc]
371        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
372        #[serde(transparent)]
373        pub struct $name(String);
374
375        impl $name {
376            pub fn new(value: impl Into<String>) -> Result<Self, MailError> {
377                let value = value.into();
378                validate_selector($label, &value)?;
379                Ok(Self(value))
380            }
381
382            pub fn validate(&self) -> Result<(), MailError> {
383                validate_selector($label, &self.0)
384            }
385
386            pub fn as_str(&self) -> &str {
387                &self.0
388            }
389        }
390
391        impl fmt::Debug for $name {
392            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
393                formatter.debug_tuple(stringify!($name)).field(&self.0).finish()
394            }
395        }
396
397        impl fmt::Display for $name {
398            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399                formatter.write_str(&self.0)
400            }
401        }
402
403        impl std::str::FromStr for $name {
404            type Err = MailError;
405
406            fn from_str(value: &str) -> Result<Self, Self::Err> {
407                Self::new(value)
408            }
409        }
410
411        impl<'de> Deserialize<'de> for $name {
412            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
413            where
414                D: Deserializer<'de>,
415            {
416                let value = String::deserialize(deserializer)?;
417                Self::new(value).map_err(serde::de::Error::custom)
418            }
419        }
420    };
421}
422
423opaque_id!(
424    MessageId,
425    MESSAGE_ID_PREFIX,
426    "message id",
427    MESSAGE_ID_HEX_LEN,
428    MESSAGE_ID_HEX_LEN,
429    "Opaque, prefixed, fixed-width hex id for a stored [`Message`]. Ported \
430     from `HarnessMailMessageId` (prefix `hmail_` there, `m4a_` here so a \
431     value can never be mistaken for a harness message id from the crate \
432     this was ported out of)."
433);
434
435/// Prefix every [`SessionId`] carries, chosen so a printed address like
436/// `claude/s-7f3a...` reads unambiguously as "an account, then one of its
437/// sessions" -- see [`Address`]'s `Display`/`FromStr`.
438pub const SESSION_ID_PREFIX: &str = "s-";
439
440/// Minimum length, in lower-hex characters, of a [`SessionId`]'s body after
441/// its prefix -- a floor against an accidentally-empty id, not an exact
442/// width (see [`SESSION_ID_HEX_MAX_CHARS`] for why there is no exact
443/// width).
444pub const SESSION_ID_HEX_MIN_CHARS: usize = 8;
445
446/// Maximum length, in lower-hex characters, of a [`SessionId`]'s body.
447/// Unlike [`MessageId`], a session id is derived by the *caller* from a
448/// process identity (`mail4agent-attest::PeerProcess`'s `(pid,
449/// started_at_unix_ms)` pair, typically hashed) and handed to this crate
450/// already formed, so it has no width this crate gets to fix -- this bound
451/// is generous enough for a SHA-256 hex digest (64 characters), a
452/// reasonable way to derive one.
453pub const SESSION_ID_HEX_MAX_CHARS: usize = 64;
454
455opaque_id!(
456    SessionId,
457    SESSION_ID_PREFIX,
458    "session id",
459    SESSION_ID_HEX_MIN_CHARS,
460    SESSION_ID_HEX_MAX_CHARS,
461    "Opaque id for one live session under a [`ParticipantId`] account. \
462     Derived by the caller from a process identity and handed to this \
463     crate already formed -- `MailboxEngine::ensure_session` registers one, \
464     it never invents one."
465);
466
467selector_id!(
468    ParticipantId,
469    "participant id",
470    "Addresses one participant directly. Distinct from [`RoomId`] on \
471     purpose: a room id must never be accepted where a participant id is \
472     meant, and a shared alias would let one slip into the other's slot."
473);
474
475selector_id!(
476    RoomId,
477    "room id",
478    "Addresses a named group of participants the mailbox itself tracks. \
479     Distinct from [`ParticipantId`] on purpose (see there)."
480);
481
482/// Where a message goes, or who it is from: one account directly, one of
483/// that account's live sessions, or a room the mailbox tracks membership
484/// for. Serde-tagged on `kind` (`"direct"` / `"session"` / `"room"`).
485///
486/// Replaces the source's `HarnessMailAddressV1::Session`/`Task`: a room is
487/// a named group of participants the mailbox itself tracks, with no
488/// relationship to any task system -- unlike `Task`, which addressed every
489/// grant able to read a given `task_id` in a foreign task kernel this crate
490/// must never learn about. [`Self::Session`] is this crate's own addition
491/// (`mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
492/// §5e): a session *is* a participant, not a new concept beside one, so it
493/// is a third shape of the same address type rather than a parallel id.
494#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
495#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
496pub enum Address {
497    Direct { participant: ParticipantId },
498    Session { participant: ParticipantId, session: SessionId },
499    Room { room: RoomId },
500}
501
502impl Address {
503    pub fn validate(&self) -> Result<(), MailError> {
504        match self {
505            Self::Direct { participant } => participant.validate(),
506            Self::Session { participant, session } => {
507                participant.validate()?;
508                session.validate()
509            }
510            Self::Room { room } => room.validate(),
511        }
512    }
513
514    /// The account this address ultimately names: itself for [`Self::Direct`],
515    /// the owning account for [`Self::Session`], `None` for [`Self::Room`]
516    /// (a room has no owning account).
517    pub fn account(&self) -> Option<&ParticipantId> {
518        match self {
519            Self::Direct { participant } | Self::Session { participant, .. } => Some(participant),
520            Self::Room { .. } => None,
521        }
522    }
523}
524
525/// Shared by [`Message::validate`] (`from`) and [`Ack::validate`]
526/// (`reader`) and [`SendResponse::validate`] (`from`): an address that
527/// identifies *someone*, never somewhere mail merely goes. A room fails
528/// this even though [`Address::validate`] alone would accept it -- nobody
529/// sends mail "from" a room or acknowledges one "as" a room, so the field
530/// itself, not just its components, must not be one.
531fn validate_participant_address(field: &'static str, address: &Address) -> Result<(), MailError> {
532    address.validate()?;
533    if address.account().is_none() {
534        return Err(MailError::Malformed {
535            field: field.to_string(),
536            reason: "must be a participant address (direct or session), not a room".to_string(),
537        });
538    }
539    Ok(())
540}
541
542impl fmt::Display for Address {
543    /// The shape a human or a tool argument writes -- `claude` for the
544    /// account, `claude/s-7f3a...` for one of its sessions, `#room-1` for a
545    /// room -- not the wire shape. The wire shape stays the tagged JSON
546    /// object [`Serialize`]/[`Deserialize`] above produce; this is a second,
547    /// display-only encoding, chosen to match
548    /// `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
549    /// §5e's own example.
550    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551        match self {
552            Self::Direct { participant } => write!(f, "{participant}"),
553            Self::Session { participant, session } => write!(f, "{participant}/{session}"),
554            Self::Room { room } => write!(f, "#{room}"),
555        }
556    }
557}
558
559impl std::str::FromStr for Address {
560    type Err = MailError;
561
562    /// Parses the same shape [`Display`] writes: a leading `#` names a
563    /// room; one `/` splits an account from one of its sessions; anything
564    /// else is the account address directly. A room id and a participant id
565    /// share the same charset (see their own `new` methods), so the
566    /// leading `#` is what disambiguates a room from an account whose name
567    /// happens to look the same -- neither charset permits `#` or `/`, so
568    /// there is nothing for either component to accidentally supply.
569    fn from_str(value: &str) -> Result<Self, Self::Err> {
570        if let Some(room) = value.strip_prefix('#') {
571            return Ok(Self::Room { room: RoomId::new(room)? });
572        }
573        match value.split_once('/') {
574            Some((participant, session)) => {
575                Ok(Self::Session { participant: ParticipantId::new(participant)?, session: SessionId::new(session)? })
576            }
577            None => Ok(Self::Direct { participant: ParticipantId::new(value)? }),
578        }
579    }
580}
581
582/// Names a reference a message carries: a `kind` (a selector), a `locator`
583/// (an opaque pointer, meaningful only to the calling application), and an
584/// optional `digest` (a lower-hex content hash of whatever the locator
585/// names).
586///
587/// Replaces the source's four structured `HarnessMailRefV1` variants
588/// (`Run`/`ContextPack`/`Result`/`WorkspacePath`), each of which named a
589/// harness-specific entity this crate must never learn about.
590///
591/// **The mailbox stores a ref and returns it verbatim; it never resolves
592/// one.** Resolving a reference is the calling application's job, because
593/// only that application knows what its own references mean.
594#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
595#[serde(deny_unknown_fields)]
596pub struct MessageRef {
597    pub kind: String,
598    pub locator: String,
599    pub digest: Option<String>,
600}
601
602impl MessageRef {
603    pub fn validate(&self) -> Result<(), MailError> {
604        validate_selector("ref kind", &self.kind)?;
605        if self.locator.len() > REF_LOCATOR_MAX_BYTES {
606            return Err(MailError::TooLarge {
607                field: "ref locator".to_string(),
608                limit: REF_LOCATOR_MAX_BYTES,
609                actual: self.locator.len(),
610            });
611        }
612        if self.locator.is_empty() || self.locator.chars().any(char::is_control) {
613            return Err(MailError::Malformed {
614                field: "ref locator".to_string(),
615                reason: "must be non-empty and free of control characters".to_string(),
616            });
617        }
618        if let Some(digest) = &self.digest {
619            if digest.len() > REF_DIGEST_MAX_CHARS {
620                return Err(MailError::TooLarge {
621                    field: "ref digest".to_string(),
622                    limit: REF_DIGEST_MAX_CHARS,
623                    actual: digest.len(),
624                });
625            }
626            if digest.is_empty()
627                || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
628            {
629                return Err(MailError::Malformed {
630                    field: "ref digest".to_string(),
631                    reason: "must be non-empty lowercase hex".to_string(),
632                });
633            }
634        }
635        Ok(())
636    }
637}
638
639/// A stored message. `refs` defaults on decode
640/// (`harness_mail_message_old_shape_without_refs_deserializes`'s own
641/// forward-compatibility property, kept here) so a message written before a
642/// future field addition still deserializes with an empty ref list rather
643/// than failing decode.
644#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct Message {
647    pub message_id: MessageId,
648    /// The session's own address if a session sent this, the account's
649    /// address if an account did -- never a room (see
650    /// [`validate_participant_address`]).
651    pub from: Address,
652    pub to: Address,
653    pub subject: String,
654    pub body: String,
655    pub reply_to: Option<MessageId>,
656    /// Replaces the source's `task_id`. Correlation only: the mailbox never
657    /// reads it to decide anything. It exists so a caller can group
658    /// messages by whatever grouping concept it owns -- a task, a run, a
659    /// conversation -- without the mailbox needing to know what that concept
660    /// is.
661    pub correlation: Option<String>,
662    #[serde(default)]
663    pub refs: Vec<MessageRef>,
664    pub created_at_unix_ms: u64,
665}
666
667impl Message {
668    pub fn validate(&self) -> Result<(), MailError> {
669        self.message_id.validate()?;
670        validate_participant_address("from", &self.from)?;
671        self.to.validate()?;
672        validate_subject(&self.subject)?;
673        validate_body(&self.body)?;
674        if let Some(reply_to) = &self.reply_to {
675            reply_to.validate()?;
676            if reply_to == &self.message_id {
677                return Err(MailError::Malformed {
678                    field: "reply_to".to_string(),
679                    reason: "must not reference its own message_id".to_string(),
680                });
681            }
682        }
683        if let Some(correlation) = &self.correlation {
684            validate_correlation(correlation)?;
685        }
686        validate_refs(&self.refs)?;
687        if self.created_at_unix_ms == 0 {
688            return Err(MailError::Malformed {
689                field: "created_at_unix_ms".to_string(),
690                reason: "must not be zero".to_string(),
691            });
692        }
693        Ok(())
694    }
695}
696
697/// Per-reader acknowledgement. Dedup key is `(message_id, reader)`, never
698/// the message alone -- a room-addressed message has one ack per reader,
699/// not one total. Ported from `HarnessMailAckV1`.
700#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
701#[serde(deny_unknown_fields)]
702pub struct Ack {
703    pub message_id: MessageId,
704    /// The exact address that acknowledged: a session's own address if a
705    /// session acked, the account's if an account did -- so two sessions of
706    /// the same account track their own read state independently, the way
707    /// Matrix scopes read markers to a `(user_id, device_id)` pair rather
708    /// than to the account alone.
709    pub reader: Address,
710    pub acked_at_unix_ms: u64,
711}
712
713impl Ack {
714    pub fn validate(&self) -> Result<(), MailError> {
715        self.message_id.validate()?;
716        validate_participant_address("reader", &self.reader)?;
717        if self.acked_at_unix_ms == 0 {
718            return Err(MailError::Malformed {
719                field: "acked_at_unix_ms".to_string(),
720                reason: "must not be zero".to_string(),
721            });
722        }
723        Ok(())
724    }
725}
726
727/// A value corroborated from a source that is real but not proof -- read
728/// out of a process's own command line, for instance, rather than attested
729/// by the kernel. Mirrors `mail4agent-attest::Declared`, which this crate
730/// cannot depend on directly: `mail4agent/CLAUDE.md` keeps this crate's
731/// dependency list empty of everything that is not serialisation, and that
732/// crate links Windows process APIs to do its job. Getting the inner value
733/// means calling [`Declared::into_inner`] or [`Declared::inner_ref`], never a
734/// plain field read, so a caller cannot treat a corroborated fact with the
735/// same weight as an attested one by accident. See [`SessionCard`] for
736/// where the split this type exists to preserve actually matters.
737#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
738#[serde(transparent)]
739pub struct Declared<T>(T);
740
741impl<T> Declared<T> {
742    pub fn new(value: T) -> Self {
743        Self(value)
744    }
745
746    /// Consumes the wrapper and returns the declared value.
747    pub fn into_inner(self) -> T {
748        self.0
749    }
750
751    /// Borrows the declared value without consuming the wrapper. Named
752    /// `inner_ref` rather than `as_ref` so it cannot be confused for
753    /// `std::convert::AsRef::as_ref` (this type deliberately does not
754    /// implement that trait).
755    pub fn inner_ref(&self) -> &T {
756        &self.0
757    }
758}
759
760impl<T: fmt::Display> fmt::Display for Declared<T> {
761    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762        fmt::Display::fmt(&self.0, f)
763    }
764}
765
766/// Proved by the kernel at the moment the connection carrying this
767/// session's request was accepted -- never rewritable by the process it
768/// describes. Mirrors the three kernel-sourced fields of
769/// `mail4agent-attest::PeerProcess` (`pid`, `started_at_unix_ms`, `exe`);
770/// this crate cannot depend on that one directly (see [`Declared`]), so
771/// this is the wire shape of the same three facts.
772#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct SessionAttested {
775    pub pid: u32,
776    pub started_at_unix_ms: u64,
777    pub exe: Option<String>,
778}
779
780impl SessionAttested {
781    pub fn validate(&self) -> Result<(), MailError> {
782        if let Some(exe) = &self.exe {
783            validate_bounded_text("attested exe", exe)?;
784        }
785        Ok(())
786    }
787}
788
789/// Read out of the process's own command line (or a CLI hook) -- real in
790/// the sense that *some* process held this in memory at read time, and
791/// never proof of what that process actually is or was launched with. Each
792/// field is [`Declared`] for that reason; see its doc comment before
793/// treating any of these with the same weight as [`SessionAttested`].
794#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct SessionCorroborated {
797    pub provider_session_id: Option<Declared<String>>,
798    pub model: Option<Declared<String>>,
799    pub cwd: Option<Declared<String>>,
800}
801
802impl SessionCorroborated {
803    pub fn validate(&self) -> Result<(), MailError> {
804        if let Some(value) = &self.provider_session_id {
805            validate_bounded_text("corroborated provider_session_id", value.inner_ref())?;
806        }
807        if let Some(value) = &self.model {
808            validate_bounded_text("corroborated model", value.inner_ref())?;
809        }
810        if let Some(value) = &self.cwd {
811            validate_bounded_text("corroborated cwd", value.inner_ref())?;
812        }
813        Ok(())
814    }
815}
816
817/// Said by the session about itself -- the weakest tier, and the only one a
818/// session can write at all: see `MailboxEngine::set_declared`, the sole
819/// way this group is ever set.
820#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
821#[serde(deny_unknown_fields)]
822pub struct SessionDeclared {
823    pub working_on: Option<String>,
824    pub role: Option<String>,
825    /// Which session spawned this one, said by this session about itself --
826    /// not verified against the registry, the same way the rest of this
827    /// group is not.
828    pub parent: Option<SessionId>,
829}
830
831impl SessionDeclared {
832    pub fn validate(&self) -> Result<(), MailError> {
833        if let Some(value) = &self.working_on {
834            validate_bounded_text("declared working_on", value)?;
835        }
836        if let Some(value) = &self.role {
837            validate_bounded_text("declared role", value)?;
838        }
839        if let Some(parent) = &self.parent {
840            parent.validate()?;
841        }
842        Ok(())
843    }
844}
845
846/// What the mailbox knows about one session, split by how sure it can be:
847/// [`SessionAttested`] from the kernel, [`SessionCorroborated`] from the
848/// process's own command line, [`SessionDeclared`] said by the session
849/// about itself. Kept as three distinct nested structs -- never flattened
850/// into one -- so the provenance of every field is visible in the type and
851/// survives into the JSON exactly as it should be trusted. See
852/// `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
853/// §5e.
854#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
855#[serde(deny_unknown_fields)]
856pub struct SessionCard {
857    pub attested: SessionAttested,
858    pub corroborated: SessionCorroborated,
859    pub declared: SessionDeclared,
860}
861
862impl SessionCard {
863    pub fn validate(&self) -> Result<(), MailError> {
864        self.attested.validate()?;
865        self.corroborated.validate()?;
866        self.declared.validate()
867    }
868}
869
870/// One session under an account, as the mailbox's directory reports it.
871/// `live` is filled by the mailbox from a liveness check it is *given*, not
872/// one it performs itself -- `mail4agent-core` learns nothing about
873/// processes or Windows; see `MailboxEngine::directory`.
874#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
875#[serde(deny_unknown_fields)]
876pub struct SessionEntry {
877    pub id: SessionId,
878    pub card: SessionCard,
879    pub last_seen_unix_ms: u64,
880    pub live: bool,
881}
882
883impl SessionEntry {
884    pub fn validate(&self) -> Result<(), MailError> {
885        self.id.validate()?;
886        self.card.validate()
887    }
888}
889
890/// A registered participant. `label` is display metadata set when the
891/// participant registers; it is never accepted on a send -- a sender's
892/// identity comes from its credential, not from a caller-supplied field
893/// (see [`SendRequest`]).
894#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct Participant {
897    pub id: ParticipantId,
898    pub label: Option<String>,
899}
900
901impl Participant {
902    pub fn validate(&self) -> Result<(), MailError> {
903        self.id.validate()?;
904        if let Some(label) = &self.label {
905            validate_label(label)?;
906        }
907        Ok(())
908    }
909}
910
911/// Shared by [`Participant::validate`] and [`DirectoryEntry::validate`]:
912/// both carry the exact same `label` shape (an optional display string,
913/// bounded like a subject, no control characters), and a directory entry
914/// is nothing more than a participant's id and label with the rest of
915/// [`Participant`] stripped away -- see [`DirectoryEntry`]'s own doc
916/// comment for why.
917fn validate_label(value: &str) -> Result<(), MailError> {
918    validate_bounded_text("label", value)
919}
920
921/// One **account** in the mailbox's directory, with its live sessions
922/// nested under it -- the XMPP/Matrix shape (an account, then its
923/// individually addressable sessions), not a flat list of CLI brands. See
924/// `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
925/// §5e. **Never carries a secret digest or a permission bit** -- a
926/// directory answers "who exists", not "what may they do" or anything that
927/// would help forge them, and a type that structurally has no such field
928/// cannot leak one even by accident (mirrors
929/// `mail4agent_core::store::ParticipantSummary`, the store-side type this
930/// is assembled from). `sessions` defaults to empty on decode so a payload
931/// written before this field existed still deserializes.
932#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
933#[serde(deny_unknown_fields)]
934pub struct DirectoryEntry {
935    pub id: ParticipantId,
936    pub label: Option<String>,
937    #[serde(default)]
938    pub sessions: Vec<SessionEntry>,
939}
940
941impl DirectoryEntry {
942    pub fn validate(&self) -> Result<(), MailError> {
943        self.id.validate()?;
944        if let Some(label) = &self.label {
945            validate_label(label)?;
946        }
947        for session in &self.sessions {
948            session.validate()?;
949        }
950        Ok(())
951    }
952}
953
954/// One room in the mailbox's directory: its id, and whether the caller
955/// who asked for the directory currently belongs to it. `member` is
956/// relative to that one caller -- two different callers reading the
957/// directory at the same moment see the same [`RoomEntry::id`] with
958/// whatever [`RoomEntry::member`] value is true for each of them.
959#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
960#[serde(deny_unknown_fields)]
961pub struct RoomEntry {
962    pub id: RoomId,
963    pub member: bool,
964}
965
966impl RoomEntry {
967    pub fn validate(&self) -> Result<(), MailError> {
968        self.id.validate()
969    }
970}
971
972/// Answers a directory request: every participant the mailbox has
973/// registered and every room it tracks, from the point of view of
974/// whoever asked (see [`RoomEntry::member`]). The whole mailbox's
975/// population in one call, deliberately unpaginated -- this is a small,
976/// local directory, not a social graph (`mail4agent/CLAUDE.md`).
977#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
978#[serde(deny_unknown_fields)]
979pub struct Directory {
980    pub participants: Vec<DirectoryEntry>,
981    pub rooms: Vec<RoomEntry>,
982}
983
984impl Directory {
985    pub fn validate(&self) -> Result<(), MailError> {
986        for participant in &self.participants {
987            participant.validate()?;
988        }
989        for room in &self.rooms {
990            room.validate()?;
991        }
992        Ok(())
993    }
994}
995
996/// Requests a send. **Has no `from` field and must never grow one.** The
997/// sender is whoever the presented credential authenticated as; the
998/// mailbox derives `from` from the verified identity, never from a field
999/// the caller filled in.
1000#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1001#[serde(deny_unknown_fields)]
1002pub struct SendRequest {
1003    pub to: Address,
1004    pub subject: String,
1005    pub body: String,
1006    pub reply_to: Option<MessageId>,
1007    pub correlation: Option<String>,
1008    #[serde(default)]
1009    pub refs: Vec<MessageRef>,
1010    /// Scopes a retry: a repeat [`SendRequest`] from the same participant
1011    /// carrying the same key returns the original [`SendResponse`] and
1012    /// creates nothing (`mail4agent-core`'s `MailboxEngine::send`).
1013    /// Optional and defaulted on decode so a payload written before this
1014    /// field existed still deserializes.
1015    ///
1016    /// **Without a key, a repeat send is a second message, and that is
1017    /// correct** -- sending the same text twice on purpose should produce
1018    /// two messages. This field opts a caller into dedup; it is never
1019    /// inferred from content.
1020    #[serde(default)]
1021    pub idempotency_key: Option<String>,
1022}
1023
1024impl SendRequest {
1025    pub fn validate(&self) -> Result<(), MailError> {
1026        self.to.validate()?;
1027        validate_subject(&self.subject)?;
1028        validate_body(&self.body)?;
1029        if let Some(reply_to) = &self.reply_to {
1030            reply_to.validate()?;
1031        }
1032        if let Some(correlation) = &self.correlation {
1033            validate_correlation(correlation)?;
1034        }
1035        validate_refs(&self.refs)?;
1036        if let Some(idempotency_key) = &self.idempotency_key {
1037            validate_selector("idempotency_key", idempotency_key)?;
1038        }
1039        Ok(())
1040    }
1041}
1042
1043/// Requests a page of the caller's inbox. `limit` defaults to
1044/// [`INBOX_LIMIT_DEFAULT`] when a caller's JSON omits it, and is bounded by
1045/// [`INBOX_LIMIT_MAX`].
1046#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1047#[serde(deny_unknown_fields)]
1048pub struct InboxRequest {
1049    pub since_unix_ms: Option<u64>,
1050    #[serde(default = "default_inbox_limit")]
1051    pub limit: u16,
1052    /// Long-polls when a read would otherwise answer an empty page: waits
1053    /// up to this many seconds for mail to arrive for the caller (or a room
1054    /// the caller currently belongs to) before answering, rather than
1055    /// returning empty at once. Clamped to [`INBOX_WAIT_SECS_MAX`] --
1056    /// **not refused** -- when a caller asks for longer; see that
1057    /// constant's own doc comment for why. Answers an empty page on
1058    /// expiry, never an error: waiting and finding nothing is not a
1059    /// refusal. `None` (the default when a caller's JSON omits this field)
1060    /// keeps today's behaviour exactly: an empty inbox answers empty at
1061    /// once, with no wait at all.
1062    #[serde(default)]
1063    pub wait_secs: Option<u16>,
1064}
1065
1066impl InboxRequest {
1067    pub fn validate(&self) -> Result<(), MailError> {
1068        if self.limit == 0 {
1069            return Err(MailError::Malformed {
1070                field: "limit".to_string(),
1071                reason: "must be at least 1".to_string(),
1072            });
1073        }
1074        if self.limit > INBOX_LIMIT_MAX {
1075            return Err(MailError::TooLarge {
1076                field: "limit".to_string(),
1077                limit: usize::from(INBOX_LIMIT_MAX),
1078                actual: usize::from(self.limit),
1079            });
1080        }
1081        Ok(())
1082    }
1083}
1084
1085/// Requests an acknowledgement be recorded for `message_id`, on behalf of
1086/// whoever the presented credential authenticated as (same discipline as
1087/// [`SendRequest`]: no reader field to fill in).
1088#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1089#[serde(deny_unknown_fields)]
1090pub struct AckRequest {
1091    pub message_id: MessageId,
1092}
1093
1094impl AckRequest {
1095    pub fn validate(&self) -> Result<(), MailError> {
1096        self.message_id.validate()
1097    }
1098}
1099
1100/// Requests one message by id.
1101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1102#[serde(deny_unknown_fields)]
1103pub struct MessageGetRequest {
1104    pub message_id: MessageId,
1105}
1106
1107impl MessageGetRequest {
1108    pub fn validate(&self) -> Result<(), MailError> {
1109        self.message_id.validate()
1110    }
1111}
1112
1113/// Requests the unread count for `target` -- an account or one of its
1114/// sessions.
1115#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1116#[serde(deny_unknown_fields)]
1117pub struct UnreadCountRequest {
1118    pub target: Address,
1119}
1120
1121impl UnreadCountRequest {
1122    pub fn validate(&self) -> Result<(), MailError> {
1123        validate_participant_address("target", &self.target)
1124    }
1125}
1126
1127/// Answers a [`SendRequest`]. Returns the caller its own address so a
1128/// participant that just wrote immediately knows where it can be answered.
1129#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct SendResponse {
1132    pub message_id: MessageId,
1133    pub from: Address,
1134}
1135
1136impl SendResponse {
1137    pub fn validate(&self) -> Result<(), MailError> {
1138        self.message_id.validate()?;
1139        validate_participant_address("from", &self.from)
1140    }
1141}
1142
1143/// A page of a caller's inbox.
1144#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1145#[serde(deny_unknown_fields)]
1146pub struct InboxPage {
1147    pub messages: Vec<Message>,
1148    pub unread: u32,
1149}
1150
1151impl InboxPage {
1152    pub fn validate(&self) -> Result<(), MailError> {
1153        for message in &self.messages {
1154            message.validate()?;
1155        }
1156        Ok(())
1157    }
1158}
1159
1160/// Answers an [`AckRequest`]. Carries the recorded [`Ack`] back so the
1161/// caller has the exact reader identity and timestamp the mailbox stamped,
1162/// mirroring how [`SendResponse`] hands the caller its own address.
1163#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1164#[serde(deny_unknown_fields)]
1165pub struct AckResponse {
1166    pub ack: Ack,
1167}
1168
1169impl AckResponse {
1170    pub fn validate(&self) -> Result<(), MailError> {
1171        self.ack.validate()
1172    }
1173}
1174
1175/// Answers an [`UnreadCountRequest`].
1176#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1177#[serde(deny_unknown_fields)]
1178pub struct UnreadCount {
1179    pub target: Address,
1180    pub unread: u32,
1181}
1182
1183impl UnreadCount {
1184    pub fn validate(&self) -> Result<(), MailError> {
1185        validate_participant_address("target", &self.target)
1186    }
1187}
1188
1189/// The body a registered delivery listener receives at its own URL when
1190/// mail arrives for the account that registered it, or for any of that
1191/// account's sessions. **Carries only ids, never `subject` or `body`.**
1192///
1193/// A notification is a doorbell, not a copy of the letter: it crosses a
1194/// boundary to a URL this mailbox does not control, chosen by whoever
1195/// registered it (`POST /admin/listener`, operator-only). The recipient can
1196/// already fetch the message itself, with its own credential, through the
1197/// ordinary mail surface once it knows `message_id` -- carrying `subject`
1198/// or `body` here would hand the message's actual content to a process
1199/// this mailbox has no way to vouch for, for no reason: nothing about
1200/// *acting on* "mail arrived" needs the mail's content, only the fact that
1201/// it arrived and where to go fetch it.
1202#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1203#[serde(deny_unknown_fields)]
1204pub struct DeliveryNotification {
1205    /// The account whose listener this is -- the one `POST /admin/listener`
1206    /// named, not necessarily the account `to` addresses (a room message
1207    /// notifies every member's own listener with the same `to`).
1208    pub account: ParticipantId,
1209    /// The address the mail was actually sent to -- a direct account, one
1210    /// specific session, or a room; never simplified down to just
1211    /// `account`, so the listener can tell a room message from a message
1212    /// aimed at one exact session.
1213    pub to: Address,
1214    pub message_id: MessageId,
1215    pub from: Address,
1216}
1217
1218impl DeliveryNotification {
1219    pub fn validate(&self) -> Result<(), MailError> {
1220        self.account.validate()?;
1221        self.to.validate()?;
1222        self.message_id.validate()?;
1223        self.from.validate()
1224    }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230
1231    const VALID_MESSAGE_ID: &str = "m4a_0123456789abcdef01234567";
1232
1233    fn participant(value: &str) -> ParticipantId {
1234        ParticipantId::new(value).expect("test participant id is valid")
1235    }
1236
1237    fn message_id() -> MessageId {
1238        MessageId::new(VALID_MESSAGE_ID).expect("test message id is valid")
1239    }
1240
1241    fn session_id(value: &str) -> SessionId {
1242        SessionId::new(value).expect("test session id is valid")
1243    }
1244
1245    fn sample_message() -> Message {
1246        Message {
1247            message_id: message_id(),
1248            from: Address::Direct { participant: participant("alice") },
1249            to: Address::Direct { participant: participant("bob") },
1250            subject: "hi".to_string(),
1251            body: "hi".to_string(),
1252            reply_to: None,
1253            correlation: None,
1254            refs: Vec::new(),
1255            created_at_unix_ms: 1,
1256        }
1257    }
1258
1259    #[test]
1260    fn message_id_round_trips_through_display_from_str_and_serde() {
1261        let id: MessageId = VALID_MESSAGE_ID.parse().expect("valid id parses");
1262        assert_eq!(id.to_string(), VALID_MESSAGE_ID);
1263        assert_eq!(id.as_str(), VALID_MESSAGE_ID);
1264
1265        let json = serde_json::to_string(&id).expect("id serializes");
1266        assert_eq!(json, format!("\"{VALID_MESSAGE_ID}\""));
1267        let decoded: MessageId = serde_json::from_str(&json).expect("id deserializes");
1268        assert_eq!(decoded, id);
1269    }
1270
1271    #[test]
1272    fn message_id_rejects_wrong_prefix() {
1273        let err = MessageId::new("wrong_0123456789abcdef01234567").expect_err("wrong prefix must be rejected");
1274        assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1275    }
1276
1277    #[test]
1278    fn message_id_rejects_short_body() {
1279        let err = MessageId::new("m4a_0123456789abcdef").expect_err("short body must be rejected");
1280        assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1281    }
1282
1283    #[test]
1284    fn message_id_rejects_non_hex_body() {
1285        let err = MessageId::new("m4a_0123456789abcdef0123456g").expect_err("non-hex body must be rejected");
1286        assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1287    }
1288
1289    #[test]
1290    fn participant_id_rejects_empty() {
1291        let err = ParticipantId::new("").expect_err("empty selector must be rejected");
1292        assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1293    }
1294
1295    #[test]
1296    fn participant_id_rejects_129_bytes() {
1297        let value = "a".repeat(129);
1298        let err = ParticipantId::new(value).expect_err("129-byte selector must be rejected");
1299        assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1300            if field == "participant id" && limit == SELECTOR_MAX_BYTES && actual == 129));
1301    }
1302
1303    #[test]
1304    fn participant_id_accepts_exactly_128_bytes() {
1305        let value = "a".repeat(SELECTOR_MAX_BYTES);
1306        ParticipantId::new(value).expect("128-byte selector is accepted");
1307    }
1308
1309    #[test]
1310    fn participant_id_rejects_non_ascii() {
1311        let err = ParticipantId::new("héllo").expect_err("non-ASCII selector must be rejected");
1312        assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1313    }
1314
1315    #[test]
1316    fn participant_id_rejects_disallowed_characters() {
1317        for value in ["a/b", "a b"] {
1318            let err = ParticipantId::new(value).expect_err("disallowed character must be rejected");
1319            assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1320        }
1321    }
1322
1323    #[test]
1324    fn room_id_and_participant_id_are_distinct_types() {
1325        // This is a compile-time property: `RoomId` and `ParticipantId` are
1326        // separate newtypes, so a room id can never be passed where a
1327        // participant id is expected. Exercised here by constructing both
1328        // from the same valid selector text and confirming they still
1329        // compare unequal in kind (different types entirely -- this test
1330        // documents the intent even though the type system already enforces
1331        // it at every call site).
1332        let room = RoomId::new("shared-name").expect("valid room id");
1333        let participant = ParticipantId::new("shared-name").expect("valid participant id");
1334        assert_eq!(room.as_str(), participant.as_str());
1335    }
1336
1337    #[test]
1338    fn subject_is_accepted_at_exactly_the_byte_limit() {
1339        let subject = "a".repeat(SUBJECT_MAX_BYTES);
1340        validate_subject(&subject).expect("exactly at the limit is accepted");
1341    }
1342
1343    #[test]
1344    fn subject_is_rejected_one_byte_over_the_limit() {
1345        let subject = "a".repeat(SUBJECT_MAX_BYTES + 1);
1346        let err = validate_subject(&subject).expect_err("one byte over the limit must be rejected");
1347        assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1348            if field == "subject" && limit == SUBJECT_MAX_BYTES && actual == SUBJECT_MAX_BYTES + 1));
1349    }
1350
1351    #[test]
1352    fn subject_byte_limit_counts_bytes_not_chars_for_multi_byte_utf8() {
1353        // U+1F600 is 4 bytes in UTF-8 but a single `char`.
1354        let subject: String = "\u{1F600}".repeat(200);
1355        assert!(subject.chars().count() < SUBJECT_MAX_BYTES, "under the byte bound counted as chars");
1356        assert!(subject.len() > SUBJECT_MAX_BYTES, "over the byte bound counted as bytes");
1357        let err = validate_subject(&subject).expect_err("byte length must govern, not char count");
1358        assert!(matches!(err, MailError::TooLarge { field, .. } if field == "subject"));
1359    }
1360
1361    #[test]
1362    fn body_is_accepted_at_exactly_the_byte_limit() {
1363        let body = "a".repeat(BODY_MAX_BYTES);
1364        validate_body(&body).expect("exactly at the limit is accepted");
1365    }
1366
1367    #[test]
1368    fn body_is_rejected_one_byte_over_the_limit() {
1369        let body = "a".repeat(BODY_MAX_BYTES + 1);
1370        let err = validate_body(&body).expect_err("one byte over the limit must be rejected");
1371        assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1372            if field == "body" && limit == BODY_MAX_BYTES && actual == BODY_MAX_BYTES + 1));
1373    }
1374
1375    #[test]
1376    fn body_byte_limit_counts_bytes_not_chars_for_multi_byte_utf8() {
1377        let body: String = "\u{1F600}".repeat(20_000);
1378        assert!(body.chars().count() < BODY_MAX_BYTES, "under the byte bound counted as chars");
1379        assert!(body.len() > BODY_MAX_BYTES, "over the byte bound counted as bytes");
1380        let err = validate_body(&body).expect_err("byte length must govern, not char count");
1381        assert!(matches!(err, MailError::TooLarge { field, .. } if field == "body"));
1382    }
1383
1384    #[test]
1385    fn message_rejects_reply_to_referencing_its_own_message_id() {
1386        let mut message = sample_message();
1387        message.reply_to = Some(message.message_id.clone());
1388        let err = message.validate().expect_err("self reply_to must be rejected");
1389        assert!(matches!(err, MailError::Malformed { field, .. } if field == "reply_to"));
1390    }
1391
1392    #[test]
1393    fn message_rejects_more_refs_than_the_bound() {
1394        let mut message = sample_message();
1395        message.refs = (0..=REFS_MAX)
1396            .map(|index| MessageRef {
1397                kind: "note".to_string(),
1398                locator: format!("loc-{index}"),
1399                digest: None,
1400            })
1401            .collect();
1402        let err = message.validate().expect_err("refs over the bound must be rejected");
1403        assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1404            if field == "refs" && limit == REFS_MAX && actual == REFS_MAX + 1));
1405    }
1406
1407    #[test]
1408    fn message_accepts_exactly_refs_max_refs() {
1409        let mut message = sample_message();
1410        message.refs = (0..REFS_MAX)
1411            .map(|index| MessageRef {
1412                kind: "note".to_string(),
1413                locator: format!("loc-{index}"),
1414                digest: None,
1415            })
1416            .collect();
1417        message.validate().expect("exactly the bound is accepted");
1418    }
1419
1420    #[test]
1421    fn message_json_without_refs_key_still_deserializes() {
1422        let json = format!(
1423            r#"{{
1424                "message_id": "{VALID_MESSAGE_ID}",
1425                "from": {{"kind": "direct", "participant": "alice"}},
1426                "to": {{"kind": "direct", "participant": "bob"}},
1427                "subject": "hi",
1428                "body": "hi",
1429                "reply_to": null,
1430                "correlation": null,
1431                "created_at_unix_ms": 1
1432            }}"#
1433        );
1434        let message: Message = serde_json::from_str(&json).expect("old shape without refs must still deserialize");
1435        assert!(message.refs.is_empty());
1436        message.validate().expect("decoded message is otherwise valid");
1437    }
1438
1439    #[test]
1440    fn address_serialises_to_the_tagged_kind_form_and_round_trips() {
1441        let direct = Address::Direct { participant: participant("alice") };
1442        let json = serde_json::to_value(&direct).expect("direct address serializes");
1443        assert_eq!(json, serde_json::json!({"kind": "direct", "participant": "alice"}));
1444        let decoded: Address = serde_json::from_value(json).expect("direct address deserializes");
1445        assert_eq!(decoded, direct);
1446
1447        let session = Address::Session { participant: participant("claude"), session: session_id("s-7f3a0000") };
1448        let json = serde_json::to_value(&session).expect("session address serializes");
1449        assert_eq!(
1450            json,
1451            serde_json::json!({"kind": "session", "participant": "claude", "session": "s-7f3a0000"})
1452        );
1453        let decoded: Address = serde_json::from_value(json).expect("session address deserializes");
1454        assert_eq!(decoded, session);
1455
1456        let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1457        let json = serde_json::to_value(&room).expect("room address serializes");
1458        assert_eq!(json, serde_json::json!({"kind": "room", "room": "room-1"}));
1459        let decoded: Address = serde_json::from_value(json).expect("room address deserializes");
1460        assert_eq!(decoded, room);
1461    }
1462
1463    #[test]
1464    fn address_display_and_from_str_use_the_familiar_shape() {
1465        let direct = Address::Direct { participant: participant("claude") };
1466        assert_eq!(direct.to_string(), "claude");
1467        assert_eq!("claude".parse::<Address>().expect("direct address parses"), direct);
1468
1469        let session = Address::Session { participant: participant("claude"), session: session_id("s-7f3a0000") };
1470        assert_eq!(session.to_string(), "claude/s-7f3a0000");
1471        assert_eq!("claude/s-7f3a0000".parse::<Address>().expect("session address parses"), session);
1472
1473        let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1474        assert_eq!(room.to_string(), "#room-1");
1475        assert_eq!("#room-1".parse::<Address>().expect("room address parses"), room);
1476    }
1477
1478    #[test]
1479    fn address_account_names_the_owning_account_and_none_for_a_room() {
1480        let alice = participant("alice");
1481        assert_eq!(Address::Direct { participant: alice.clone() }.account(), Some(&alice));
1482        assert_eq!(
1483            Address::Session { participant: alice.clone(), session: session_id("s-7f3a0000") }.account(),
1484            Some(&alice)
1485        );
1486        assert_eq!(Address::Room { room: RoomId::new("room-1").expect("valid room id") }.account(), None);
1487    }
1488
1489    #[test]
1490    fn message_and_ack_and_send_response_refuse_a_room_as_the_participant_address() {
1491        let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1492
1493        let mut message = sample_message();
1494        message.from = room.clone();
1495        let err = message.validate().expect_err("a room must not be accepted as `from`");
1496        assert!(matches!(err, MailError::Malformed { field, .. } if field == "from"));
1497
1498        let ack = Ack { message_id: message_id(), reader: room.clone(), acked_at_unix_ms: 1 };
1499        let err = ack.validate().expect_err("a room must not be accepted as `reader`");
1500        assert!(matches!(err, MailError::Malformed { field, .. } if field == "reader"));
1501
1502        let response = SendResponse { message_id: message_id(), from: room };
1503        let err = response.validate().expect_err("a room must not be accepted as `from`");
1504        assert!(matches!(err, MailError::Malformed { field, .. } if field == "from"));
1505    }
1506
1507    #[test]
1508    fn session_id_round_trips_and_rejects_a_short_body() {
1509        let id: SessionId = "s-7f3a0000".parse().expect("valid session id parses");
1510        assert_eq!(id.to_string(), "s-7f3a0000");
1511
1512        let err = SessionId::new("s-abc").expect_err("a body shorter than the minimum must be rejected");
1513        assert!(matches!(err, MailError::Malformed { field, .. } if field == "session id"));
1514
1515        let err = SessionId::new("wrong-7f3a0000").expect_err("a wrong prefix must be rejected");
1516        assert!(matches!(err, MailError::Malformed { field, .. } if field == "session id"));
1517    }
1518
1519    #[test]
1520    fn session_card_validates_every_group_and_rejects_a_control_character_anywhere() {
1521        let mut card = SessionCard {
1522            attested: SessionAttested { pid: 4242, started_at_unix_ms: 1, exe: Some("claude.exe".to_string()) },
1523            corroborated: SessionCorroborated {
1524                provider_session_id: Some(Declared::new("prov-1".to_string())),
1525                model: Some(Declared::new("opus".to_string())),
1526                cwd: None,
1527            },
1528            declared: SessionDeclared { working_on: Some("parity work".to_string()), role: None, parent: None },
1529        };
1530        card.validate().expect("a well-formed card validates");
1531
1532        card.corroborated.model = Some(Declared::new("bad\u{0007}model".to_string()));
1533        let err = card.validate().expect_err("a control character in a corroborated field must be rejected");
1534        assert!(matches!(err, MailError::Malformed { field, .. } if field == "corroborated model"));
1535    }
1536
1537    #[test]
1538    fn directory_entry_nests_its_sessions_and_defaults_to_none_on_decode() {
1539        let entry = DirectoryEntry {
1540            id: participant("claude"),
1541            label: None,
1542            sessions: vec![SessionEntry {
1543                id: session_id("s-7f3a0000"),
1544                card: SessionCard {
1545                    attested: SessionAttested { pid: 1, started_at_unix_ms: 1, exe: None },
1546                    corroborated: SessionCorroborated { provider_session_id: None, model: None, cwd: None },
1547                    declared: SessionDeclared::default(),
1548                },
1549                last_seen_unix_ms: 1,
1550                live: true,
1551            }],
1552        };
1553        entry.validate().expect("a well-formed entry with a session validates");
1554
1555        let json = serde_json::json!({"id": "claude", "label": null});
1556        let decoded: DirectoryEntry = serde_json::from_value(json).expect("an entry without sessions still decodes");
1557        assert!(decoded.sessions.is_empty());
1558    }
1559
1560    #[test]
1561    fn inbox_request_defaults_limit_when_json_omits_it() {
1562        let request: InboxRequest = serde_json::from_str(r#"{"since_unix_ms": null}"#)
1563            .expect("inbox request without limit still deserializes");
1564        assert_eq!(request.limit, INBOX_LIMIT_DEFAULT);
1565        request.validate().expect("default limit is valid");
1566    }
1567
1568    #[test]
1569    fn inbox_request_rejects_limit_over_the_max() {
1570        let request = InboxRequest { since_unix_ms: None, limit: INBOX_LIMIT_MAX + 1, wait_secs: None };
1571        let err = request.validate().expect_err("limit over the max must be rejected");
1572        assert!(matches!(err, MailError::TooLarge { field, .. } if field == "limit"));
1573    }
1574
1575    #[test]
1576    fn inbox_request_rejects_zero_limit() {
1577        let request = InboxRequest { since_unix_ms: None, limit: 0, wait_secs: None };
1578        let err = request.validate().expect_err("zero limit must be rejected");
1579        assert!(matches!(err, MailError::Malformed { field, .. } if field == "limit"));
1580    }
1581
1582    #[test]
1583    fn inbox_request_defaults_wait_secs_to_none_when_json_omits_it() {
1584        let request: InboxRequest = serde_json::from_str(r#"{"since_unix_ms": null}"#)
1585            .expect("inbox request without wait_secs still deserializes");
1586        assert_eq!(request.wait_secs, None);
1587    }
1588
1589    #[test]
1590    fn inbox_request_accepts_wait_secs_over_the_cap_without_refusing() {
1591        // `wait_secs` is clamped by the daemon, never refused here --
1592        // `validate` has no opinion on it at all, unlike `limit`.
1593        let request =
1594            InboxRequest { since_unix_ms: None, limit: INBOX_LIMIT_DEFAULT, wait_secs: Some(INBOX_WAIT_SECS_MAX + 1) };
1595        request.validate().expect("wait_secs over the cap is not a validation failure");
1596    }
1597
1598    #[test]
1599    fn delivery_notification_round_trips_and_never_carries_a_subject_or_body_field() {
1600        let notification = DeliveryNotification {
1601            account: participant("alice"),
1602            to: Address::Direct { participant: participant("alice") },
1603            message_id: message_id(),
1604            from: Address::Direct { participant: participant("bob") },
1605        };
1606        notification.validate().expect("a well-formed notification validates");
1607
1608        let json = serde_json::to_value(&notification).expect("notification serializes");
1609        assert!(json.get("subject").is_none());
1610        assert!(json.get("body").is_none());
1611        let decoded: DeliveryNotification =
1612            serde_json::from_value(json).expect("notification round-trips through serde");
1613        assert_eq!(decoded, notification);
1614    }
1615
1616    #[test]
1617    fn message_ref_rejects_more_than_the_digest_bound() {
1618        let reference = MessageRef {
1619            kind: "note".to_string(),
1620            locator: "loc".to_string(),
1621            digest: Some("a".repeat(REF_DIGEST_MAX_CHARS + 1)),
1622        };
1623        let err = reference.validate().expect_err("digest over the bound must be rejected");
1624        assert!(matches!(err, MailError::TooLarge { field, .. } if field == "ref digest"));
1625    }
1626
1627    #[test]
1628    fn a_send_request_carrying_only_its_required_fields_decodes() {
1629        // serde defaults an absent Option field to None, so the optional
1630        // fields need no attribute to be omittable. This test exists because
1631        // that is easy to doubt, and doubting it invites a second set of
1632        // argument structs that can drift from these.
1633        let json = r#"{"to":{"kind":"direct","participant":"bob"},"subject":"s","body":"b"}"#;
1634        let req: SendRequest = serde_json::from_str(json).expect("minimal send request decodes");
1635        assert!(req.reply_to.is_none());
1636        assert!(req.correlation.is_none());
1637        assert!(req.idempotency_key.is_none());
1638        assert!(req.refs.is_empty());
1639        req.validate().expect("and it validates");
1640    }
1641
1642    #[test]
1643    fn an_inbox_request_carrying_nothing_decodes_with_the_default_limit() {
1644        let req: InboxRequest = serde_json::from_str("{}").expect("empty inbox request decodes");
1645        assert!(req.since_unix_ms.is_none());
1646        assert_eq!(req.limit, INBOX_LIMIT_DEFAULT);
1647    }
1648
1649    #[test]
1650    fn send_request_old_shape_without_idempotency_key_still_deserializes_and_validates() {
1651        let json = serde_json::json!({
1652            "to": {"kind": "direct", "participant": "bob"},
1653            "subject": "hi",
1654            "body": "hi",
1655            "reply_to": null,
1656            "correlation": null
1657        });
1658        let request: SendRequest = serde_json::from_value(json)
1659            .expect("old shape without idempotency_key must still deserialize");
1660        assert_eq!(request.idempotency_key, None);
1661        request.validate().expect("decoded request is otherwise valid");
1662    }
1663
1664    #[test]
1665    fn directory_entry_serialises_with_id_label_and_sessions() {
1666        let entry = DirectoryEntry { id: participant("alice"), label: Some("Alice".to_string()), sessions: Vec::new() };
1667        let json = serde_json::to_value(&entry).expect("directory entry serializes");
1668        assert_eq!(json, serde_json::json!({"id": "alice", "label": "Alice", "sessions": []}));
1669        let decoded: DirectoryEntry = serde_json::from_value(json).expect("directory entry deserializes");
1670        assert_eq!(decoded, entry);
1671    }
1672
1673    #[test]
1674    fn directory_entry_rejects_a_control_character_label() {
1675        let entry =
1676            DirectoryEntry { id: participant("alice"), label: Some("bad\u{0007}label".to_string()), sessions: Vec::new() };
1677        let err = entry.validate().expect_err("control character in label must be rejected");
1678        assert!(matches!(err, MailError::Malformed { field, .. } if field == "label"));
1679    }
1680
1681    #[test]
1682    fn room_entry_round_trips_its_member_flag() {
1683        let entry = RoomEntry { id: RoomId::new("room-1").expect("valid room id"), member: true };
1684        let json = serde_json::to_value(&entry).expect("room entry serializes");
1685        assert_eq!(json, serde_json::json!({"id": "room-1", "member": true}));
1686        let decoded: RoomEntry = serde_json::from_value(json).expect("room entry deserializes");
1687        assert_eq!(decoded, entry);
1688    }
1689
1690    #[test]
1691    fn directory_validates_every_entry_it_carries() {
1692        let directory = Directory {
1693            participants: vec![DirectoryEntry { id: participant("alice"), label: None, sessions: Vec::new() }],
1694            rooms: vec![RoomEntry { id: RoomId::new("room-1").expect("valid room id"), member: false }],
1695        };
1696        directory.validate().expect("a directory of otherwise-valid entries validates");
1697    }
1698
1699    #[test]
1700    fn message_ref_rejects_non_hex_digest() {
1701        let reference = MessageRef {
1702            kind: "note".to_string(),
1703            locator: "loc".to_string(),
1704            digest: Some("not-hex".to_string()),
1705        };
1706        let err = reference.validate().expect_err("non-hex digest must be rejected");
1707        assert!(matches!(err, MailError::Malformed { field, .. } if field == "ref digest"));
1708    }
1709}