Skip to main content

octl_core/
schema.rs

1//! On-disk state schema types per `design.md` §1.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7/// The current state-on-disk schema version this crate writes.
8pub const STATE_SCHEMA_VERSION: u32 = 1;
9
10/// All state-schema versions this crate can read.
11pub const SUPPORTED_STATE_SCHEMAS: &[u32] = &[1];
12
13/// Crockford base32 alphabet in lowercase (excludes `i`, `l`, `o`, `u`). The
14/// charset for the bare ULID of a [`RunId`].
15const CROCKFORD_LOWER: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
16
17/// True iff every byte of `s` is a lowercase Crockford base32 character.
18fn all_crockford_lower(s: &str) -> bool {
19    s.bytes().all(|b| CROCKFORD_LOWER.contains(&b))
20}
21
22/// True iff `s` is a syntactically valid (possibly partial) prefix of a
23/// [`RunId`]: non-empty, no longer than a full ULID, every character a lowercase
24/// Crockford base32 digit, and a first character within ULID's `0..=7`
25/// timestamp bound. Used by the CLI to resolve an unambiguous run-id prefix
26/// (like `git`) — a value failing this is a malformed argument (`invalid_run_id`),
27/// not a legitimate-but-unknown prefix. The first-char bound is enforced because
28/// no valid `RunId` can begin outside `0..=7`, so an `8…`/`9…` prefix is
29/// impossible rather than merely absent — reporting it as malformed keeps the
30/// error class honest and consistent with how [`RunId::parse_str`] rejects a
31/// full-length id with the same leading digit.
32pub fn is_run_id_prefix(s: &str) -> bool {
33    !s.is_empty()
34        && s.len() <= RunId::LEN
35        && all_crockford_lower(s)
36        && matches!(s.as_bytes().first(), Some(b'0'..=b'7'))
37}
38
39/// True iff every byte of `s` is an RFC 4648 base32 character, lowercase
40/// (`a-z` and `2-7`). Distinct from Crockford in both directions: it *includes*
41/// `i`/`l`/`o`/`u` and *excludes* `0`/`1`/`8`/`9`. This is the alphabet of the
42/// 10-char deterministic-id (`x-<sha-prefix>`) form the supervisor emits — see
43/// `octl_cli::supervise::reducer::base32_lower_10`.
44fn all_rfc4648_base32_lower(s: &str) -> bool {
45    s.bytes()
46        .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
47}
48
49/// True iff `body` is a canonical [`DiscussionId`] / [`ProposalId`] body: the
50/// *syntactic* union of the two shapes real generators use.
51///
52/// 1. A 26-char lowercase Crockford base32 string — the `d-<ulid>` / `s-<ulid>`
53///    shape from [`crate::new_discussion_id`] / [`crate::new_proposal_id`].
54/// 2. A 10-char RFC 4648 base32 lowercase string (`a-z2-7`) — the
55///    deterministic-id (`x-<sha-prefix>`) shape the supervisor emits.
56///
57/// This is a length+charset check, not proof a value was actually emitted by a
58/// generator. Two deliberate looseness notes:
59/// - The 10-char arm accepts any RFC 4648 base32 string (e.g. `zzzzzzzzzz`),
60///   not only sha-derived ones — the supervisor's digest prefix is itself
61///   uniform over that alphabet, so there is no charset rule that separates
62///   "real" from "syntactically possible" output.
63/// - The 26-char arm checks the Crockford charset only; unlike [`RunId`] it does
64///   *not* enforce the first-char `0..=7` ULID-timestamp bound. Tightening
65///   [`RunId`]/[`NodeId`] is out of scope for this validator (it would also
66///   reject the looser ULID-shaped fixtures the suite still uses); the goal here
67///   is just to reject the previously-accepted-but-impossible loose forms — any
68///   body length other than 10 or 26, a 10-char body carrying `0`/`1`/`8`/`9`,
69///   or a 26-char body carrying `i`/`l`/`o`/`u`.
70fn is_canonical_disc_or_proposal_body(body: &str) -> bool {
71    (body.len() == 26 && all_crockford_lower(body))
72        || (body.len() == 10 && all_rfc4648_base32_lower(body))
73}
74
75/// Error returned when a typed identifier fails parse-time validation.
76///
77/// Every [`RunId`], [`NodeId`], [`DiscussionId`], and [`ProposalId`] is
78/// constructed only through its `parse_str` constructor (or the equivalent
79/// validating `Deserialize`), so any value that reaches a path helper has
80/// already been checked for prefix, charset, and length. This is the
81/// path-traversal guard: a raw id containing `/`, `..`, or a leading dot can
82/// never be turned into one of these newtypes, so it can never name a file
83/// outside the run directory.
84#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
85pub enum IdValidationError {
86    /// The value carried the right prefix (or needs none) but its body had the
87    /// wrong length or used characters outside the permitted charset.
88    #[error("invalid {kind} id {value:?}: expected {expected}")]
89    InvalidFormat {
90        /// Which id type rejected the value (`run`, `node`, `discussion`, `spinoff`).
91        kind: &'static str,
92        /// The offending raw value.
93        value: String,
94        /// Human-readable description of the accepted shape (e.g. `n-NNNN`).
95        expected: &'static str,
96    },
97    /// The value did not start with the id type's required prefix
98    /// (`n-`, `d-`, `s-`).
99    #[error("invalid {kind} id: wrong prefix, expected {expected}")]
100    WrongPrefix {
101        /// Which id type rejected the value.
102        kind: &'static str,
103        /// Human-readable description of the accepted shape.
104        expected: &'static str,
105    },
106}
107
108impl IdValidationError {
109    /// The id type that rejected the value (`run`, `node`, `discussion`, `spinoff`).
110    pub fn kind(&self) -> &'static str {
111        match self {
112            Self::InvalidFormat { kind, .. } | Self::WrongPrefix { kind, .. } => kind,
113        }
114    }
115
116    /// The accepted-shape hint, suitable for the `expected` field of a CLI
117    /// error envelope.
118    pub fn expected(&self) -> &'static str {
119        match self {
120            Self::InvalidFormat { expected, .. } | Self::WrongPrefix { expected, .. } => expected,
121        }
122    }
123}
124
125/// Generate the shared trait surface for a validated id newtype: `as_str`,
126/// `FromStr`, `Display`, `Debug`, `Ord` / `PartialOrd` (lexicographic over the
127/// inner string), `Serialize` (as the bare string), and a validating
128/// `Deserialize` (delegates to `parse_str`, so reading an old file with a
129/// malformed id fails loudly rather than silently widening the type). Each
130/// newtype supplies its own `parse_str` in a separate `impl` block.
131///
132/// `Ord` / `PartialOrd` are derived, so they forward to the inner `String`'s
133/// ordering — i.e. plain `&str` byte comparison. For the fixed-width ULID forms
134/// ([`RunId`], and the 26-char arm of `DiscussionId`/`ProposalId`) this
135/// preserves the natural time ordering ULIDs encode in their lexical sort.
136///
137/// CAVEAT — this ordering is lexical, *not* numeric or semantic:
138/// - [`NodeId`] is `n-` + a variable-width number, so once the counter grows a
139///   digit the byte order diverges from the numeric order: `n-10000 < n-9999`.
140///   Do not sort `NodeId`s expecting ascending node number; parse the body if
141///   you need that.
142/// - `DiscussionId`/`ProposalId` mix a 26-char and a 10-char form, so ordering
143///   across the two forms is arbitrary, not creation order.
144///
145/// The trait is provided for `BTreeMap`/`BTreeSet` keys and stable sorts, not
146/// as a claim of meaningful order for these two types.
147macro_rules! id_newtype {
148    ($(#[$m:meta])* $name:ident) => {
149        $(#[$m])*
150        #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
151        pub struct $name(String);
152
153        impl $name {
154            /// The validated id as a string slice. There is no mutable or
155            /// owned-`String` accessor by design: the inner value can never be
156            /// mutated into an unvalidated state after construction.
157            pub fn as_str(&self) -> &str {
158                &self.0
159            }
160        }
161
162        impl std::str::FromStr for $name {
163            type Err = IdValidationError;
164
165            /// Parse via the newtype's own `parse_str`; lets callers use the
166            /// `str::parse` / `FromStr` ecosystem (`s.parse::<RunId>()?`).
167            fn from_str(s: &str) -> Result<Self, Self::Err> {
168                Self::parse_str(s)
169            }
170        }
171
172        impl std::fmt::Display for $name {
173            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174                f.write_str(&self.0)
175            }
176        }
177
178        impl std::fmt::Debug for $name {
179            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180                write!(f, "{}({:?})", stringify!($name), self.0)
181            }
182        }
183
184        impl serde::Serialize for $name {
185            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
186                s.serialize_str(&self.0)
187            }
188        }
189
190        impl<'de> serde::Deserialize<'de> for $name {
191            fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
192                let s = String::deserialize(d)?;
193                Self::parse_str(&s).map_err(serde::de::Error::custom)
194            }
195        }
196    };
197}
198
199id_newtype! {
200    /// A validated run identifier: a lowercase ULID (26 Crockford base32
201    /// characters whose first character keeps the encoded timestamp within
202    /// ULID's 48-bit range). Mirrors what [`crate::new_run_id`] emits.
203    RunId
204}
205
206impl RunId {
207    /// Accepted-shape hint shared by every rejection.
208    const EXPECTED: &'static str = "26-char lowercase Crockford base32 ULID";
209    /// Canonical length of a ULID in Crockford base32. Public so CLI-side prefix
210    /// resolution can branch on "full id vs. prefix" without mirroring the
211    /// constant (which would silently drift if the id shape ever changed).
212    pub const LEN: usize = 26;
213
214    /// Parse and validate a `run_id`. Accepts only the 26-character lowercase
215    /// ULID shape; rejects wrong length, non-Crockford characters, and a first
216    /// character outside `0..=7` (which would overflow ULID's 48-bit timestamp).
217    pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
218        let reject = || IdValidationError::InvalidFormat {
219            kind: "run",
220            value: s.to_string(),
221            expected: Self::EXPECTED,
222        };
223        if s.len() != Self::LEN || !all_crockford_lower(s) {
224            return Err(reject());
225        }
226        // The first base32 char carries the top 5 bits of the 128-bit ULID;
227        // the 48-bit timestamp cannot overflow only if it is in `0..=7`.
228        if !(b'0'..=b'7').contains(&s.as_bytes()[0]) {
229            return Err(reject());
230        }
231        Ok(Self(s.to_string()))
232    }
233}
234
235id_newtype! {
236    /// A validated node identifier: `n-` followed by 4 or more ASCII digits
237    /// (e.g. `n-0001`). Mirrors what [`crate::format_node_id`] emits.
238    NodeId
239}
240
241impl NodeId {
242    /// Accepted-shape hint shared by every rejection.
243    const EXPECTED: &'static str = "n-NNNN (n- followed by 4-10 ASCII digits)";
244
245    /// Parse and validate a `node_id`. Requires the `n-` prefix followed by
246    /// 4 to 10 ASCII digits; rejects anything else (wrong prefix, too few or
247    /// too many digits, non-digit body). The 10-digit ceiling covers the full
248    /// `u32` counter range [`crate::format_node_id`] draws from while bounding
249    /// the filename length (a defense against `ENAMETOOLONG` from a forged id).
250    pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
251        let body = s.strip_prefix("n-").ok_or(IdValidationError::WrongPrefix {
252            kind: "node",
253            expected: Self::EXPECTED,
254        })?;
255        if (4..=10).contains(&body.len()) && body.bytes().all(|b| b.is_ascii_digit()) {
256            Ok(Self(s.to_string()))
257        } else {
258            Err(IdValidationError::InvalidFormat {
259                kind: "node",
260                value: s.to_string(),
261                expected: Self::EXPECTED,
262            })
263        }
264    }
265}
266
267id_newtype! {
268    /// A validated discussion identifier: `d-` followed by exactly one of the
269    /// two canonical bodies a generator emits — a 26-char lowercase Crockford
270    /// base32 ULID (`d-<ulid>`) or a 10-char RFC 4648 base32 lowercase string
271    /// (`d-<sha-prefix>`, the deterministic-id form). See
272    /// [`is_canonical_disc_or_proposal_body`](crate::schema).
273    DiscussionId
274}
275
276impl DiscussionId {
277    /// Accepted-shape hint shared by every rejection.
278    const EXPECTED: &'static str =
279        "d- followed by a 26-char lowercase Crockford ULID or a 10-char RFC 4648 base32 lowercase id (a-z2-7)";
280
281    /// Parse and validate a `discussion_id`. Requires the `d-` prefix followed
282    /// by exactly one canonical body — see [`is_canonical_disc_or_proposal_body`](crate::schema).
283    pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
284        let body = s.strip_prefix("d-").ok_or(IdValidationError::WrongPrefix {
285            kind: "discussion",
286            expected: Self::EXPECTED,
287        })?;
288        if is_canonical_disc_or_proposal_body(body) {
289            Ok(Self(s.to_string()))
290        } else {
291            Err(IdValidationError::InvalidFormat {
292                kind: "discussion",
293                value: s.to_string(),
294                expected: Self::EXPECTED,
295            })
296        }
297    }
298}
299
300id_newtype! {
301    /// A validated spin-off proposal identifier: `s-` followed by exactly one
302    /// of the two canonical bodies a generator emits — a 26-char lowercase
303    /// Crockford base32 ULID (`s-<ulid>`) or a 10-char RFC 4648 base32 lowercase
304    /// string (`s-<sha-prefix>`, the deterministic-id form). See
305    /// [`is_canonical_disc_or_proposal_body`](crate::schema).
306    ProposalId
307}
308
309impl ProposalId {
310    /// Accepted-shape hint shared by every rejection.
311    const EXPECTED: &'static str =
312        "s- followed by a 26-char lowercase Crockford ULID or a 10-char RFC 4648 base32 lowercase id (a-z2-7)";
313
314    /// Parse and validate a `proposal_id`. Requires the `s-` prefix followed
315    /// by exactly one canonical body — see [`is_canonical_disc_or_proposal_body`](crate::schema).
316    pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
317        let body = s.strip_prefix("s-").ok_or(IdValidationError::WrongPrefix {
318            kind: "spinoff",
319            expected: Self::EXPECTED,
320        })?;
321        if is_canonical_disc_or_proposal_body(body) {
322            Ok(Self(s.to_string()))
323        } else {
324            Err(IdValidationError::InvalidFormat {
325                kind: "spinoff",
326                value: s.to_string(),
327                expected: Self::EXPECTED,
328            })
329        }
330    }
331}
332
333/// The run/node kind enum (design.md §1.2).
334///
335/// All 8 kinds are active in MVP.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
337#[serde(rename_all = "kebab-case")]
338pub enum Kind {
339    /// Interactive, human-reviewed coding worktree (`/worktree-code`).
340    Code,
341    /// Autonomous fire-and-forget task that merges itself back (`/worktree-spinoff`).
342    Spinoff,
343    /// Orchestrated worker reporting to an orchestrator (`/worktree-orchestrated`).
344    Orchestrated,
345    /// Autonomous multi-source research worktree (`/worktree-research`).
346    Research,
347    /// Drives one architectural decision to an ADR (`/worktree-technical-decision`).
348    TechnicalDecision,
349    /// Authors a new Claude Code skill (`/worktree-make-skill`).
350    MakeSkill,
351    /// Parallel fan-out of many identical units (`/fan-out`).
352    FanOut,
353    /// End-to-end bug investigate-fix-review worktree (`/worktree-bugfix`).
354    Bugfix,
355    /// Top-level DAG driver run (`/orchestrate`). Coordinates `Kind::Orchestrated`
356    /// child workers. Has no worktree of its own — the orchestrator agent runs
357    /// in the user's main conversation and uses the run dir only to host the
358    /// event log, manifest, and final hierarchical report.
359    Orchestrate,
360}
361
362impl Kind {
363    /// The kebab-case wire name for this kind — the same string serde
364    /// (de)serializes via `rename_all = "kebab-case"`.
365    ///
366    /// The exhaustive `match` is deliberate: adding a `Kind` variant fails
367    /// to compile until its wire name is listed here, so [`Kind::WIRE_NAMES`]
368    /// and any caller that advertises the accepted kinds (e.g. the report
369    /// validator's `expected` hint) cannot silently drift from the enum.
370    #[must_use]
371    pub const fn wire_name(self) -> &'static str {
372        match self {
373            Kind::Code => "code",
374            Kind::Spinoff => "spinoff",
375            Kind::Orchestrated => "orchestrated",
376            Kind::Research => "research",
377            Kind::TechnicalDecision => "technical-decision",
378            Kind::MakeSkill => "make-skill",
379            Kind::FanOut => "fan-out",
380            Kind::Bugfix => "bugfix",
381            Kind::Orchestrate => "orchestrate",
382        }
383    }
384
385    /// Every kind's kebab-case wire name, in declaration order. Single
386    /// source of truth for "the set of accepted kinds" — see [`Kind::wire_name`].
387    pub const WIRE_NAMES: &'static [&'static str] = &[
388        Kind::Code.wire_name(),
389        Kind::Spinoff.wire_name(),
390        Kind::Orchestrated.wire_name(),
391        Kind::Research.wire_name(),
392        Kind::TechnicalDecision.wire_name(),
393        Kind::MakeSkill.wire_name(),
394        Kind::FanOut.wire_name(),
395        Kind::Bugfix.wire_name(),
396        Kind::Orchestrate.wire_name(),
397    ];
398
399    /// Default lifecycle for a kind (design.md §7.4). `code` is
400    /// interactive (human-driven inside tmux); every other MVP kind is
401    /// autonomous (agent runs to completion, watchdog adjudicates).
402    pub fn lifecycle(self) -> Lifecycle {
403        match self {
404            // `Code` is human-driven inside tmux. `Orchestrate` is also
405            // interactive in the sense that the orchestrator agent runs in
406            // the user's main conversation — there is no detached worker
407            // for the watchdog to adjudicate, only the children it spawns.
408            Kind::Code | Kind::Orchestrate => Lifecycle::Interactive,
409            Kind::Spinoff
410            | Kind::Orchestrated
411            | Kind::Research
412            | Kind::TechnicalDecision
413            | Kind::MakeSkill
414            | Kind::FanOut
415            | Kind::Bugfix => Lifecycle::Autonomous,
416        }
417    }
418
419    /// Whether this kind is a **top-level, single-node, autonomous worker** —
420    /// one detached agent that materializes its own worktree and self-merges,
421    /// with no children and no parent DAG driving it. These are exactly the
422    /// kinds eligible for the supervisor's bounded auto-retry on an empty-handed
423    /// `agent-died` (issue `autoretry-agent-died-worker`).
424    ///
425    /// Excludes:
426    /// - `Code` / `Orchestrate` (interactive — a human drives; never force-retry);
427    /// - `FanOut` (a multi-unit driver — its driver node has no agent of its own);
428    /// - `Orchestrated` (a DAG child — its PARENT supervisor owns its retry policy,
429    ///   so retrying it independently would desync the parent's child bookkeeping).
430    ///
431    /// The exhaustive `match` fails to compile when a new `Kind` is added, forcing
432    /// a deliberate eligibility decision rather than a silent default.
433    #[must_use]
434    pub fn is_autonomous_single_node_worker(self) -> bool {
435        match self {
436            Kind::Spinoff
437            | Kind::Research
438            | Kind::TechnicalDecision
439            | Kind::MakeSkill
440            | Kind::Bugfix => true,
441            Kind::Code | Kind::Orchestrate | Kind::FanOut | Kind::Orchestrated => false,
442        }
443    }
444}
445
446/// Lifecycle (design.md §1.2, §7.4).
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "kebab-case")]
449pub enum Lifecycle {
450    /// Agent runs to completion unattended; the watchdog adjudicates exit.
451    Autonomous,
452    /// Human-driven inside a tmux window; no watchdog-forced termination.
453    Interactive,
454}
455
456/// Run/node status (design.md §1.2).
457///
458/// `Done`, `Failed`, and `Cancelled` are **terminal**: once a run or node
459/// reaches one of them its `status` must never change again. The reducer
460/// enforces this — `apply_run_status`, `apply_node_status`, and
461/// `apply_node_report` are all no-ops once [`Status::is_terminal`] holds — so
462/// a late-arriving event (e.g. an agent success report racing a `run cancel`)
463/// cannot resurrect a settled state. Only the `status` field is frozen;
464/// other projection fields may still be mutated by non-status events.
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "kebab-case")]
467pub enum Status {
468    /// Created but not yet started.
469    Pending,
470    /// Actively executing.
471    Running,
472    /// Stalled awaiting input (e.g. an open discussion).
473    Blocked,
474    /// Completed successfully (terminal).
475    Done,
476    /// Completed with failure (terminal).
477    Failed,
478    /// Terminated before completion by an operator or parent (terminal).
479    Cancelled,
480}
481
482impl Status {
483    /// True for the terminal states `Done | Failed | Cancelled`. A run or
484    /// node in a terminal state is settled: the reducer treats any further
485    /// *status* transition as a no-op. "Settled" applies to `status` only —
486    /// non-status projection fields (e.g. `Node::children` via
487    /// `child.spawned`, or manifest counters) can still change.
488    pub fn is_terminal(self) -> bool {
489        matches!(self, Status::Done | Status::Failed | Status::Cancelled)
490    }
491}
492
493/// Discussion lifecycle status (design.md §1.5).
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "kebab-case")]
496pub enum DiscussionStatus {
497    /// Awaiting a decision.
498    Open,
499    /// A choice has been recorded; the run may proceed.
500    Resolved,
501}
502
503/// Spin-off proposal status (design.md §1.5, §7.3).
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(rename_all = "kebab-case")]
506pub enum SpinoffStatus {
507    /// Suggested by an agent, awaiting human triage.
508    Proposed,
509    /// Accepted; typically promoted to a tracked issue.
510    Approved,
511    /// Declined, with a reason recorded.
512    Rejected,
513}
514
515/// `manifest.json` (design.md §1.2).
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct Manifest {
518    /// State-schema version this file was written with.
519    pub schema_version: u32,
520    /// Watermark: the highest event `seq` whose projection fold is durably
521    /// committed. Events in `events.jsonl` with `seq > applied_seq` are
522    /// *unapplied tail* events — replayed into the projections on the next
523    /// lock acquisition before any new append (see
524    /// [`crate::events::append_and_apply_event`]). This is what makes
525    /// append-then-apply atomic across a reducer crash: the event log can run
526    /// ahead of the projections, but the gap is always healed before the next
527    /// writer observes stale state.
528    ///
529    /// `#[serde(default)]` so a legacy `manifest.json` written before this
530    /// field existed deserializes with `applied_seq = 0`. Such a manifest
531    /// self-migrates on its next write: the catch-up replay re-folds the whole
532    /// log — every event a no-op, because legacy state was already projected
533    /// synchronously under the old append-then-apply path — and advances the
534    /// watermark to `last_seq`. No separate migration pass or schema bump is
535    /// required (the field is purely additive to a derived-cache file).
536    #[serde(default)]
537    pub applied_seq: u64,
538    /// Unique run identifier (ULID). Validated on read.
539    pub run_id: RunId,
540    /// Kind of work this run performs.
541    pub kind: Kind,
542    /// Execution lifecycle (autonomous vs interactive).
543    pub lifecycle: Lifecycle,
544    /// Human-readable run title.
545    pub title: String,
546    /// Current aggregate run status.
547    pub status: Status,
548    /// When the run was created.
549    pub created_at: DateTime<Utc>,
550    /// When the manifest was last modified.
551    pub updated_at: DateTime<Utc>,
552    /// Source repository the run operates on, if any.
553    pub source_repo: Option<String>,
554    /// Branch the run was started from, if any.
555    pub source_branch: Option<String>,
556    /// Root directory under which this run's worktrees live, if any.
557    pub worktree_root: Option<String>,
558    /// tmux session orchestratectl created to host this run's headless windows
559    /// (`--headless` / `--tmux-session <name>`), if any. `None` for a foreground
560    /// run whose window lives in the user's own session — that session is never
561    /// a teardown target. When set, the supervisor kills this session once its
562    /// last orchestratectl-owned window is torn down and only the synthetic
563    /// bootstrap shell window remains, so an empty headless session is not left
564    /// behind (issue `headless-tmux-session-not-torn-down`). `#[serde(default)]`
565    /// keeps a manifest written before this field existed readable.
566    #[serde(default)]
567    pub managed_tmux_session: Option<String>,
568    /// Completion-notification command registered at `run create --notify`,
569    /// if any. When the run reaches a terminal state (`done | failed |
570    /// cancelled`) the supervisor runs this command (at-least-once, deduped on a
571    /// durable `run.notified` marker event — the healthy path fires once, a
572    /// crash between firing and recording may re-fire) with `OCTL_RUN_ID` /
573    /// `OCTL_STATUS` / `OCTL_SUMMARY` (and `OCTL_RUN_KIND` / `OCTL_RUN_TITLE`)
574    /// in its environment, BEFORE teardown removes the worktree/window. This is
575    /// how a spawning session learns of completion without polling (issue
576    /// `no-completion-notification-to-parent`). `None` for a run created without
577    /// `--notify`; `#[serde(default)]` keeps a manifest written before this
578    /// field existed readable.
579    #[serde(default)]
580    pub notify_cmd: Option<String>,
581    /// The code-harness adapter selected for this run's worker
582    /// (`claude` | `pi` | `aider` | `claude-deepseek`), resolved at `run create`
583    /// via the flag > env > config > default precedence and recorded here as
584    /// provenance. This is the *selected* harness — recorded before the worker is
585    /// spawned, so it reflects intent even if the spawn later fails. `None` for a
586    /// manifest written before this field existed
587    /// (`#[serde(default)]`) — such legacy runs predate harness selection and
588    /// were all `claude`. Surfaced on `run show` / `run list --json`.
589    #[serde(default)]
590    pub harness: Option<String>,
591    /// Number of nodes created in this run (denormalized counter).
592    pub node_count: u32,
593    /// Count of currently open discussions (denormalized counter).
594    pub open_discussions: u32,
595    /// Count of currently pending spin-off proposals (denormalized counter).
596    pub pending_spinoffs: u32,
597    /// Run that spawned this run, if it is itself a child.
598    pub parent_run_id: Option<RunId>,
599    /// Node in the parent run that spawned this run, if any.
600    pub parent_node_id: Option<NodeId>,
601}
602
603/// `(child_run_id, child_node_id)` pointer recorded in `Node::children`.
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
605pub struct ChildRef {
606    /// Run id of the spawned child. Validated on read.
607    pub run_id: RunId,
608    /// Node id within the child run. Validated on read.
609    pub node_id: NodeId,
610}
611
612/// `nodes/<node-id>.json` (design.md §1.3).
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct Node {
615    /// State-schema version this file was written with.
616    pub schema_version: u32,
617    /// Unique node identifier within its run (e.g. `n-0001`). Validated on
618    /// read; this is the projection's filename key, so it can never name a
619    /// path outside `nodes/`.
620    pub node_id: NodeId,
621    /// Run this node belongs to. Validated on read.
622    pub run_id: RunId,
623    /// Parent node within the same run, if this is a sub-node.
624    pub parent_node_id: Option<NodeId>,
625    /// Kind of work this node performs.
626    pub kind: Kind,
627    /// Current node status.
628    pub status: Status,
629    /// Task description / prompt driving the node, if recorded.
630    pub task: Option<String>,
631    /// Filesystem path of the node's git worktree, if created.
632    pub worktree_path: Option<String>,
633    /// Git branch the node works on, if any.
634    pub branch: Option<String>,
635    /// The commit SHA the node's branch/worktree was forked from at spawn
636    /// (the branch tip the moment `create.sh` materialized the worktree). It
637    /// is the fixed reference point that lets the supervisor tell "this branch
638    /// produced work that merged into source" from "this branch never diverged
639    /// from its fork point": a branch still at `base_sha` is trivially an
640    /// ancestor of its source branch but has merged nothing, so it must NOT be
641    /// reconciled to success or torn down (that would drop a live agent's
642    /// uncommitted work). Only a branch whose tip has moved past `base_sha`
643    /// *and* is now an ancestor of the run's `source_branch` is a confirmed
644    /// merge (issues `false-failed-after-merge` /
645    /// `supervisor-stuck-pending-after-self-merge`). `#[serde(default)]` keeps a
646    /// node written before this field existed readable (`None` → the
647    /// git-reconcile fallback simply does not fire for it).
648    #[serde(default)]
649    pub base_sha: Option<String>,
650    /// tmux window hosting the node's agent, if interactive. This is the
651    /// human-readable window *name* — not unique across sessions and blind to
652    /// non-default sockets. Kept for display and as the legacy liveness key;
653    /// prefer [`Node::tmux_identity`] when present.
654    pub tmux_window: Option<String>,
655    /// Fully-qualified tmux identity (`session:window_id` + socket path)
656    /// captured at spawn time. `None` for nodes registered before create.sh
657    /// emitted the qualified fields — those fall back to bare-name matching on
658    /// [`Node::tmux_window`]. New spawns always populate this when create.sh
659    /// returns it.
660    #[serde(default)]
661    pub tmux_identity: Option<TmuxIdentity>,
662    /// PID of the running agent process, if live.
663    pub agent_pid: Option<i32>,
664    /// Start time of the agent process, used to detect PID reuse.
665    pub agent_pid_start_time: Option<DateTime<Utc>>,
666    /// PID of the supervisor watching this node, if live.
667    pub supervisor_pid: Option<i32>,
668    /// Children this node has spawned.
669    #[serde(default)]
670    pub children: Vec<ChildRef>,
671    /// When the node started executing, if it has.
672    pub started_at: Option<DateTime<Utc>>,
673    /// When the node file was last modified.
674    pub updated_at: DateTime<Utc>,
675    /// The `node.report` payload that drove this node to its terminal status.
676    /// Set only by the report that actually transitions the node (Done /
677    /// Failed / Cancelled). Once the node is terminal it is frozen: a late
678    /// report against an already-settled node is dropped without overwriting
679    /// this field (see `reducer::apply_node_report`). So for a node cancelled
680    /// by `run cancel`, this holds the synthesized cancel report, not a
681    /// later-arriving agent report — that payload remains only in
682    /// `events.jsonl`.
683    pub last_report: Option<Value>,
684    /// Highest report `seq` consumed per child run id, for idempotent
685    /// report processing across supervisor restarts.
686    #[serde(default)]
687    pub last_processed_report_seq_by_child: Map<String, Value>,
688    /// Number of times the supervisor has auto-retried this node after an
689    /// empty-handed `agent-died` (issue `autoretry-agent-died-worker`). The
690    /// DURABLE, restart-safe bound on the bounded-retry loop: each `node.retry`
691    /// event increments it, and the watchdog terminalizes the run `failed` once
692    /// it reaches `RETRY_MAX_ATTEMPTS`. `#[serde(default)]` keeps a node written
693    /// before this field existed readable (`0` — never retried).
694    #[serde(default)]
695    pub retry_attempts: u32,
696}
697
698/// A fully-qualified tmux window identity recorded at spawn time.
699///
700/// `tmux_window` (the human name) is not unique across sessions, and a bare
701/// `tmux list-windows -a` cannot see windows on a non-default socket. This
702/// triple pins the exact window the agent runs in — `session:window_id` is
703/// unique per server, `window_id` (the `@NNNN` form) survives renames, and
704/// `socket` disambiguates multiple tmux servers. The watchdog matches on this
705/// when present (design.md §8.1).
706///
707/// `pane_id` (the `%NN` form) pins the agent's *specific* pane within that
708/// window, recorded at spawn. Window-owning operations (`kill-window` teardown —
709/// the supervisor owns the whole window per the cleanup invariants) key off
710/// `window_id`; only per-pane operations that must not follow the window's
711/// *active* pane — chiefly `pipe-pane` agent-log capture — use `pane_id`. It is
712/// `None` for a run spawned before create.sh emitted the field; capture then
713/// falls back to `window_id` (issue `capture-agent-pane-by-pane-id`).
714///
715/// The watchdog's liveness probe still keys off `window_id` (correct for the
716/// single-pane autonomous path). A pane-aware liveness probe — needed so a split
717/// interactive window whose agent pane dies while a user shell pane survives is
718/// still seen as dead — is a follow-up (`watchdog-pane-aware-liveness`), not this
719/// change.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct TmuxIdentity {
722    /// Server socket path (`#{socket_path}`). `None` if create.sh could not
723    /// read it; the watchdog then queries tmux on its default socket.
724    #[serde(default)]
725    pub socket: Option<String>,
726    /// Session that owns the window (`#{session_name}`).
727    pub session: String,
728    /// Stable window id in `@NNNN` form (`#{window_id}`). Survives renames and
729    /// is unique within the server.
730    pub window_id: String,
731    /// Stable pane id in `%NN` form (`#{pane_id}`), recorded at spawn — the
732    /// agent's own pane. `None` for a run whose create.sh predates the field
733    /// (back-compat: old state deserializes with `pane_id: None`). Prefer
734    /// [`TmuxIdentity::capture_target`] over reading this directly.
735    #[serde(default)]
736    pub pane_id: Option<String>,
737}
738
739impl TmuxIdentity {
740    /// The tmux target for a per-pane operation that must hit the agent's own
741    /// pane, not the window's *active* pane: the recorded `pane_id` when
742    /// present, else the `window_id` (which resolves to the active pane).
743    ///
744    /// Used by agent-log capture (`pipe-pane`). Window-level operations
745    /// (`kill-window`, liveness) must NOT use this — they key off `window_id`
746    /// directly so they act on the whole window.
747    ///
748    /// A recorded `pane_id` is preferred only when non-empty; an empty string
749    /// (a directly-deserialized/corrupt state that the reducer/spawn normalizers
750    /// never produce) is treated as absent so capture never targets `-t ""`.
751    pub fn capture_target(&self) -> &str {
752        self.pane_id
753            .as_deref()
754            .filter(|id| !id.is_empty())
755            .unwrap_or(&self.window_id)
756    }
757}
758
759/// `discussions/<discussion-id>.json` (design.md §1.5).
760#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct Discussion {
762    /// State-schema version this file was written with.
763    pub schema_version: u32,
764    /// Unique discussion identifier. Validated on read; this is the
765    /// projection's filename key, so it can never name a path outside
766    /// `discussions/`.
767    pub discussion_id: DiscussionId,
768    /// Run this discussion belongs to. Validated on read.
769    pub run_id: RunId,
770    /// Node that opened the discussion. Validated on read.
771    pub node_id: NodeId,
772    /// When the discussion was opened.
773    pub opened_at: DateTime<Utc>,
774    /// Severity tag (e.g. `critical`, `normal`) driving alerting.
775    pub severity: String,
776    /// Short summary of what needs deciding.
777    pub topic: String,
778    /// Optional longer context for the decision.
779    pub context: Option<String>,
780    /// Candidate choices offered to the resolver.
781    #[serde(default)]
782    pub options: Vec<String>,
783    /// Open vs resolved.
784    pub status: DiscussionStatus,
785    /// The chosen resolution, once resolved.
786    pub resolution: Option<String>,
787    /// Free-form note accompanying the resolution.
788    #[serde(default)]
789    pub note: Option<String>,
790    /// When the discussion was resolved, if it has been.
791    pub resolved_at: Option<DateTime<Utc>>,
792}
793
794/// `spinoffs/<proposal-id>.json` (design.md §1.5).
795#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct SpinoffProposal {
797    /// State-schema version this file was written with.
798    pub schema_version: u32,
799    /// Unique proposal identifier. Validated on read; this is the projection's
800    /// filename key, so it can never name a path outside `spinoffs/`.
801    pub proposal_id: ProposalId,
802    /// Run this proposal belongs to. Validated on read.
803    pub run_id: RunId,
804    /// Node that proposed the spin-off. Validated on read.
805    pub node_id: NodeId,
806    /// When the proposal was made.
807    pub proposed_at: DateTime<Utc>,
808    /// Suggested title for the spun-off work.
809    pub proposed_title: String,
810    /// Suggested kind for the spun-off run.
811    pub proposed_kind: Kind,
812    /// Why the agent proposed this spin-off.
813    pub rationale: Option<String>,
814    /// Proposed / approved / rejected.
815    pub status: SpinoffStatus,
816    /// Issue slug the proposal was promoted to, once approved.
817    pub accepted_as_issue_slug: Option<String>,
818    /// Reason recorded when the proposal is rejected.
819    pub rejected_reason: Option<String>,
820    /// When the proposal was approved or rejected, if it has been.
821    pub resolved_at: Option<DateTime<Utc>>,
822}
823
824/// One event-log line (design.md §1.4).
825///
826/// `run_id` / `node_id` are the typed id newtypes, so deserializing an
827/// `events.jsonl` line validates the whole envelope on read: a malformed
828/// `run_id` or `node_id` fails the `serde` parse at the read boundary (the
829/// id newtypes' validating `Deserialize`) rather than being carried as an
830/// unvalidated `String` until some later path helper. The parse failure
831/// surfaces as whatever error the reader maps a bad line to — e.g. a
832/// newline-terminated bad line is [`Error::CorruptEventLog`] from both
833/// [`read_all_events`] and [`find_prior_with_key`], which share one physical
834/// reader and torn-tail policy. The reducer still performs its own per-event
835/// checks (envelope `run_id` matches the run it is folded into; `data`-borne
836/// ids parse), but the envelope ids can no longer be the unvalidated party.
837///
838/// [`read_all_events`]: crate::events::read_all_events
839/// [`find_prior_with_key`]: crate::events
840/// [`Error::CorruptEventLog`]: crate::Error::CorruptEventLog
841#[derive(Debug, Clone, Serialize, Deserialize)]
842pub struct Event {
843    /// Wall-clock timestamp the event was appended.
844    pub ts: DateTime<Utc>,
845    /// Monotonic per-run sequence number (recovered on append).
846    pub seq: u64,
847    /// Event kind discriminator (e.g. `node.created`, `discussion.opened`).
848    pub kind: String,
849    /// Run the event belongs to. Validated on read.
850    pub run_id: RunId,
851    /// Node the event concerns, when applicable. Validated on read.
852    #[serde(skip_serializing_if = "Option::is_none", default)]
853    pub node_id: Option<NodeId>,
854    /// Caller-supplied key used to dedupe retried appends.
855    #[serde(skip_serializing_if = "Option::is_none", default)]
856    pub idempotency_key: Option<String>,
857    /// Kind-specific payload applied by the reducer.
858    #[serde(default)]
859    pub data: Value,
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    /// `Kind::wire_name` (and thus `Kind::WIRE_NAMES`) must stay identical
867    /// to what serde actually (de)serializes. If the `rename_all` routing
868    /// or a variant name ever diverges from `wire_name`, this fails — which
869    /// is what keeps the report validator's `expected` hint honest.
870    #[test]
871    fn wire_names_match_serde_round_trip() {
872        for &name in Kind::WIRE_NAMES {
873            let kind: Kind = serde_json::from_value(Value::String(name.to_string()))
874                .unwrap_or_else(|_| panic!("WIRE_NAMES entry {name:?} is not a valid Kind"));
875            assert_eq!(
876                serde_json::to_value(kind).unwrap(),
877                Value::String(name.to_string()),
878                "serde round-trip diverged from wire_name for {name:?}",
879            );
880        }
881    }
882
883    /// The bounded auto-retry eligibility gate (issue `autoretry-agent-died-worker`)
884    /// must include exactly the autonomous single-node worker kinds and exclude
885    /// interactive kinds, the fan-out driver, and the DAG child.
886    #[test]
887    fn autonomous_single_node_worker_set_is_exact() {
888        for k in [
889            Kind::Spinoff,
890            Kind::Research,
891            Kind::TechnicalDecision,
892            Kind::MakeSkill,
893            Kind::Bugfix,
894        ] {
895            assert!(
896                k.is_autonomous_single_node_worker(),
897                "{k:?} should be retry-eligible"
898            );
899            assert_eq!(k.lifecycle(), Lifecycle::Autonomous);
900        }
901        for k in [
902            Kind::Code,         // interactive — a human drives
903            Kind::Orchestrate,  // interactive driver
904            Kind::FanOut,       // multi-unit driver
905            Kind::Orchestrated, // DAG child — parent owns its retry policy
906        ] {
907            assert!(
908                !k.is_autonomous_single_node_worker(),
909                "{k:?} must NOT be retry-eligible"
910            );
911        }
912    }
913
914    /// Back-compat acceptance criterion (issue `capture-agent-pane-by-pane-id`):
915    /// a `TmuxIdentity` persisted before `pane_id` existed — with the field
916    /// entirely absent, or written as an explicit `null` — must still
917    /// deserialize, yielding `pane_id: None` and a `window_id` capture target.
918    #[test]
919    fn tmux_identity_deserializes_legacy_state_without_pane_id() {
920        // Field entirely absent (a state file written by an older binary).
921        let absent: TmuxIdentity = serde_json::from_value(serde_json::json!({
922            "socket": null,
923            "session": "octl",
924            "window_id": "@42",
925        }))
926        .expect("legacy identity without pane_id must deserialize");
927        assert_eq!(absent.pane_id, None);
928        assert_eq!(absent.capture_target(), "@42");
929
930        // Field present but explicitly null.
931        let null: TmuxIdentity = serde_json::from_value(serde_json::json!({
932            "socket": null,
933            "session": "octl",
934            "window_id": "@42",
935            "pane_id": null,
936        }))
937        .expect("identity with explicit null pane_id must deserialize");
938        assert_eq!(null.pane_id, None);
939        assert_eq!(null.capture_target(), "@42");
940    }
941
942    /// `capture_target` prefers a recorded `pane_id` (`%NN`) over the window id,
943    /// but treats an empty `pane_id` as absent (never targets `-t ""`).
944    #[test]
945    fn capture_target_prefers_nonempty_pane_id() {
946        let with_pane = TmuxIdentity {
947            socket: None,
948            session: "octl".into(),
949            window_id: "@42".into(),
950            pane_id: Some("%7".into()),
951        };
952        assert_eq!(with_pane.capture_target(), "%7");
953
954        let empty_pane = TmuxIdentity {
955            pane_id: Some(String::new()),
956            ..with_pane.clone()
957        };
958        assert_eq!(empty_pane.capture_target(), "@42");
959    }
960}
961
962#[cfg(test)]
963mod id_tests {
964    use super::*;
965
966    /// Inputs every id type must reject — the path-traversal vectors plus the
967    /// generic malformed cases called out in the issue's success criteria.
968    const TRAVERSAL_VECTORS: &[&str] = &[
969        "..",
970        "../etc",
971        "a/b",
972        "a/../b",
973        ".hidden",
974        "./x",
975        "foo/bar.json",
976        "n-0001/../../etc",
977        "",
978    ];
979
980    #[test]
981    fn run_id_accepts_generator_output_and_rejects_malformed() {
982        let id = crate::new_run_id();
983        assert!(
984            RunId::parse_str(&id).is_ok(),
985            "generator must validate: {id}"
986        );
987        for bad in [
988            "tooshort",
989            "01jxsnap0000000000000000000", // 27 chars
990            "01JXSNAP000000000000000000",  // uppercase
991            "01jxiiiiiiiiiiiiiiiiiiiiii",  // `i` not in Crockford
992            "80000000000000000000000000",  // first char exceeds ULID range
993            "n-0001",                      // wrong shape entirely
994        ] {
995            assert!(RunId::parse_str(bad).is_err(), "expected reject: {bad:?}");
996        }
997        for bad in TRAVERSAL_VECTORS {
998            assert!(
999                RunId::parse_str(bad).is_err(),
1000                "traversal not rejected: {bad:?}"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn node_id_accepts_canonical_and_rejects_malformed() {
1007        for ok in ["n-0001", "n-0010", "n-123456"] {
1008            assert!(NodeId::parse_str(ok).is_ok(), "expected accept: {ok}");
1009        }
1010        // Wrong prefix is its own error variant.
1011        assert!(matches!(
1012            NodeId::parse_str("d-0001"),
1013            Err(IdValidationError::WrongPrefix { .. })
1014        ));
1015        assert!(matches!(
1016            NodeId::parse_str("0001"),
1017            Err(IdValidationError::WrongPrefix { .. })
1018        ));
1019        for bad in [
1020            "n-1",           // too few digits
1021            "n-abcd",        // non-digit body
1022            "n-",            // empty body
1023            "n-00a1",        // mixed
1024            "n-00000000000", // 11 digits — over the 10-digit ceiling
1025        ] {
1026            assert!(
1027                matches!(
1028                    NodeId::parse_str(bad),
1029                    Err(IdValidationError::InvalidFormat { .. })
1030                ),
1031                "expected InvalidFormat: {bad:?}",
1032            );
1033        }
1034        for bad in TRAVERSAL_VECTORS {
1035            assert!(
1036                NodeId::parse_str(bad).is_err(),
1037                "traversal not rejected: {bad:?}"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn discussion_id_accepts_both_canonical_forms_and_rejects_malformed() {
1044        let gen = crate::new_discussion_id();
1045        assert!(
1046            DiscussionId::parse_str(&gen).is_ok(),
1047            "generator must validate: {gen}"
1048        );
1049        // 26-char lowercase Crockford ULID form.
1050        assert!(DiscussionId::parse_str("d-01arz3ndektsv4rrffq69g5fav").is_ok());
1051        // 10-char RFC 4648 base32 deterministic-id form (contains i/l/o/u and
1052        // 2-7), which the supervisor actually emits — must validate.
1053        assert!(DiscussionId::parse_str("d-ilou234567").is_ok());
1054        assert!(DiscussionId::parse_str("d-abcdefghij").is_ok());
1055        // A 10-char body of all-`z` is a legitimate RFC 4648 base32 string
1056        // (`z` is in `a-z2-7`), so it is accepted — same class as `ilou234567`.
1057        // The issue's success-criteria example listing `d-zzzzzzzzzz` among
1058        // "now-invalid" forms is imprecise: it IS canonical RFC 4648 base32 and
1059        // there is no charset-based rule that rejects it without also rejecting
1060        // the supervisor's real deterministic ids.
1061        assert!(DiscussionId::parse_str("d-zzzzzzzzzz").is_ok());
1062        assert!(matches!(
1063            DiscussionId::parse_str("s-0123456789"),
1064            Err(IdValidationError::WrongPrefix { .. })
1065        ));
1066        for bad in [
1067            "d-0123456789",                   // 10 chars but `0`/`1` ∉ RFC 4648 base32
1068            "d-short",                        // body < 10
1069            "d-abcdefghijk",                  // 11 chars — between the two forms
1070            "d-01arz3ndektsv4rrffq69g5fa",    // 25 chars — one short of a ULID
1071            "d-0123456789012345678901234567", // body > 26
1072            "d-01arz3ndeilov4rrffq69g5fav",   // 26 chars but carries `i`/`l`/`o` ∉ Crockford
1073            "d-ABCDEFGHIJ",                   // uppercase not allowed
1074            "d-abc_def012",                   // `_` outside both alphabets
1075            "d-",                             // empty body
1076        ] {
1077            assert!(
1078                matches!(
1079                    DiscussionId::parse_str(bad),
1080                    Err(IdValidationError::InvalidFormat { .. })
1081                ),
1082                "expected InvalidFormat: {bad:?}",
1083            );
1084        }
1085        for bad in TRAVERSAL_VECTORS {
1086            assert!(
1087                DiscussionId::parse_str(bad).is_err(),
1088                "traversal not rejected: {bad:?}"
1089            );
1090        }
1091    }
1092
1093    #[test]
1094    fn proposal_id_accepts_both_canonical_forms_and_rejects_malformed() {
1095        let gen = crate::new_proposal_id();
1096        assert!(
1097            ProposalId::parse_str(&gen).is_ok(),
1098            "generator must validate: {gen}"
1099        );
1100        // 26-char lowercase Crockford ULID form.
1101        assert!(ProposalId::parse_str("s-01arz3ndektsv4rrffq69g5fav").is_ok());
1102        // 10-char RFC 4648 base32 deterministic-id form (the supervisor's
1103        // actual output); `u` is valid RFC 4648 base32.
1104        assert!(ProposalId::parse_str("s-uuuuuuuuuu").is_ok());
1105        assert!(matches!(
1106            ProposalId::parse_str("d-0123456789"),
1107            Err(IdValidationError::WrongPrefix { .. })
1108        ));
1109        for bad in [
1110            "s-0123456789",                 // 10 chars but `0`/`1` ∉ RFC 4648 base32
1111            "s-short",                      // body < 10
1112            "s-abcdefghijk",                // 11 chars — between the two forms
1113            "s-01arz3ndeilov4rrffq69g5fav", // 26 chars but carries `i`/`l`/`o` ∉ Crockford
1114            "s-ABCDEFGHIJ",                 // uppercase not allowed
1115            "s-abc.def012",                 // `.` outside both alphabets
1116            "s-",                           // empty body
1117        ] {
1118            assert!(
1119                matches!(
1120                    ProposalId::parse_str(bad),
1121                    Err(IdValidationError::InvalidFormat { .. })
1122                ),
1123                "expected InvalidFormat: {bad:?}",
1124            );
1125        }
1126        for bad in TRAVERSAL_VECTORS {
1127            assert!(
1128                ProposalId::parse_str(bad).is_err(),
1129                "traversal not rejected: {bad:?}"
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn deserialize_rejects_malformed_ids() {
1136        // The validating Deserialize impl is the on-read guard: a tampered
1137        // projection file whose key no longer validates must fail to parse.
1138        assert!(serde_json::from_str::<NodeId>("\"n-0001\"").is_ok());
1139        assert!(serde_json::from_str::<NodeId>("\"../../etc\"").is_err());
1140        assert!(serde_json::from_str::<DiscussionId>("\"d-../escape\"").is_err());
1141        // Canonical 10-char RFC 4648 base32 body deserializes; the old
1142        // loose-charset `s-0123456789` (carries `0`/`1`) no longer does.
1143        assert!(serde_json::from_str::<ProposalId>("\"s-abcdefghij\"").is_ok());
1144        assert!(serde_json::from_str::<ProposalId>("\"s-0123456789\"").is_err());
1145    }
1146
1147    #[test]
1148    fn serialize_round_trips_as_bare_string() {
1149        let nid = NodeId::parse_str("n-0042").unwrap();
1150        let json = serde_json::to_string(&nid).unwrap();
1151        assert_eq!(json, "\"n-0042\"");
1152        let back: NodeId = serde_json::from_str(&json).unwrap();
1153        assert_eq!(back, nid);
1154        assert_eq!(nid.as_str(), "n-0042");
1155        assert_eq!(nid.to_string(), "n-0042");
1156    }
1157
1158    #[test]
1159    fn error_exposes_kind_and_expected() {
1160        let err = NodeId::parse_str("n-x").unwrap_err();
1161        assert_eq!(err.kind(), "node");
1162        assert_eq!(err.expected(), "n-NNNN (n- followed by 4-10 ASCII digits)");
1163    }
1164
1165    #[test]
1166    fn event_deserialize_validates_envelope_ids() {
1167        // The whole `events.jsonl` envelope is now validated on read: the
1168        // typed `run_id` / `node_id` fields parse through the id newtypes, so
1169        // a malformed envelope id fails the deserialize rather than being
1170        // carried downstream as an unchecked string.
1171        let ok = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.created","run_id":"01jxsnap000000000000000000","node_id":"n-0001","data":{}}"#;
1172        assert!(serde_json::from_str::<Event>(ok).is_ok());
1173
1174        // Invalid `run_id` (not a 26-char ULID) fails the parse.
1175        let bad_run = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"run.status","run_id":"not-a-ulid","data":{}}"#;
1176        assert!(serde_json::from_str::<Event>(bad_run).is_err());
1177
1178        // Invalid top-level `node_id` (too few digits) also fails the parse.
1179        let bad_node = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.status","run_id":"01jxsnap000000000000000000","node_id":"n-1","data":{}}"#;
1180        assert!(serde_json::from_str::<Event>(bad_node).is_err());
1181    }
1182
1183    #[test]
1184    fn from_str_and_ord_delegate_to_inner() {
1185        use std::str::FromStr;
1186        // `FromStr` mirrors `parse_str`, so the `str::parse` ecosystem works.
1187        assert!(RunId::from_str("01jxsnap000000000000000000").is_ok());
1188        assert!("n-0001".parse::<NodeId>().is_ok());
1189        assert!("n-x".parse::<NodeId>().is_err());
1190
1191        // `Ord` is lexicographic over the inner string; for ULIDs that is the
1192        // natural time-encoded order.
1193        let a = RunId::parse_str("01jxsnap000000000000000000").unwrap();
1194        let b = RunId::parse_str("02jxsnap000000000000000000").unwrap();
1195        assert!(a < b);
1196        let mut v = vec![b.clone(), a.clone()];
1197        v.sort();
1198        assert_eq!(v, vec![a, b]);
1199    }
1200}