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 /// Number of nodes created in this run (denormalized counter).
582 pub node_count: u32,
583 /// Count of currently open discussions (denormalized counter).
584 pub open_discussions: u32,
585 /// Count of currently pending spin-off proposals (denormalized counter).
586 pub pending_spinoffs: u32,
587 /// Run that spawned this run, if it is itself a child.
588 pub parent_run_id: Option<RunId>,
589 /// Node in the parent run that spawned this run, if any.
590 pub parent_node_id: Option<NodeId>,
591}
592
593/// `(child_run_id, child_node_id)` pointer recorded in `Node::children`.
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595pub struct ChildRef {
596 /// Run id of the spawned child. Validated on read.
597 pub run_id: RunId,
598 /// Node id within the child run. Validated on read.
599 pub node_id: NodeId,
600}
601
602/// `nodes/<node-id>.json` (design.md §1.3).
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct Node {
605 /// State-schema version this file was written with.
606 pub schema_version: u32,
607 /// Unique node identifier within its run (e.g. `n-0001`). Validated on
608 /// read; this is the projection's filename key, so it can never name a
609 /// path outside `nodes/`.
610 pub node_id: NodeId,
611 /// Run this node belongs to. Validated on read.
612 pub run_id: RunId,
613 /// Parent node within the same run, if this is a sub-node.
614 pub parent_node_id: Option<NodeId>,
615 /// Kind of work this node performs.
616 pub kind: Kind,
617 /// Current node status.
618 pub status: Status,
619 /// Task description / prompt driving the node, if recorded.
620 pub task: Option<String>,
621 /// Filesystem path of the node's git worktree, if created.
622 pub worktree_path: Option<String>,
623 /// Git branch the node works on, if any.
624 pub branch: Option<String>,
625 /// The commit SHA the node's branch/worktree was forked from at spawn
626 /// (the branch tip the moment `create.sh` materialized the worktree). It
627 /// is the fixed reference point that lets the supervisor tell "this branch
628 /// produced work that merged into source" from "this branch never diverged
629 /// from its fork point": a branch still at `base_sha` is trivially an
630 /// ancestor of its source branch but has merged nothing, so it must NOT be
631 /// reconciled to success or torn down (that would drop a live agent's
632 /// uncommitted work). Only a branch whose tip has moved past `base_sha`
633 /// *and* is now an ancestor of the run's `source_branch` is a confirmed
634 /// merge (issues `false-failed-after-merge` /
635 /// `supervisor-stuck-pending-after-self-merge`). `#[serde(default)]` keeps a
636 /// node written before this field existed readable (`None` → the
637 /// git-reconcile fallback simply does not fire for it).
638 #[serde(default)]
639 pub base_sha: Option<String>,
640 /// tmux window hosting the node's agent, if interactive. This is the
641 /// human-readable window *name* — not unique across sessions and blind to
642 /// non-default sockets. Kept for display and as the legacy liveness key;
643 /// prefer [`Node::tmux_identity`] when present.
644 pub tmux_window: Option<String>,
645 /// Fully-qualified tmux identity (`session:window_id` + socket path)
646 /// captured at spawn time. `None` for nodes registered before create.sh
647 /// emitted the qualified fields — those fall back to bare-name matching on
648 /// [`Node::tmux_window`]. New spawns always populate this when create.sh
649 /// returns it.
650 #[serde(default)]
651 pub tmux_identity: Option<TmuxIdentity>,
652 /// PID of the running agent process, if live.
653 pub agent_pid: Option<i32>,
654 /// Start time of the agent process, used to detect PID reuse.
655 pub agent_pid_start_time: Option<DateTime<Utc>>,
656 /// PID of the supervisor watching this node, if live.
657 pub supervisor_pid: Option<i32>,
658 /// Children this node has spawned.
659 #[serde(default)]
660 pub children: Vec<ChildRef>,
661 /// When the node started executing, if it has.
662 pub started_at: Option<DateTime<Utc>>,
663 /// When the node file was last modified.
664 pub updated_at: DateTime<Utc>,
665 /// The `node.report` payload that drove this node to its terminal status.
666 /// Set only by the report that actually transitions the node (Done /
667 /// Failed / Cancelled). Once the node is terminal it is frozen: a late
668 /// report against an already-settled node is dropped without overwriting
669 /// this field (see `reducer::apply_node_report`). So for a node cancelled
670 /// by `run cancel`, this holds the synthesized cancel report, not a
671 /// later-arriving agent report — that payload remains only in
672 /// `events.jsonl`.
673 pub last_report: Option<Value>,
674 /// Highest report `seq` consumed per child run id, for idempotent
675 /// report processing across supervisor restarts.
676 #[serde(default)]
677 pub last_processed_report_seq_by_child: Map<String, Value>,
678 /// Number of times the supervisor has auto-retried this node after an
679 /// empty-handed `agent-died` (issue `autoretry-agent-died-worker`). The
680 /// DURABLE, restart-safe bound on the bounded-retry loop: each `node.retry`
681 /// event increments it, and the watchdog terminalizes the run `failed` once
682 /// it reaches `RETRY_MAX_ATTEMPTS`. `#[serde(default)]` keeps a node written
683 /// before this field existed readable (`0` — never retried).
684 #[serde(default)]
685 pub retry_attempts: u32,
686}
687
688/// A fully-qualified tmux window identity recorded at spawn time.
689///
690/// `tmux_window` (the human name) is not unique across sessions, and a bare
691/// `tmux list-windows -a` cannot see windows on a non-default socket. This
692/// triple pins the exact window the agent runs in — `session:window_id` is
693/// unique per server, `window_id` (the `@NNNN` form) survives renames, and
694/// `socket` disambiguates multiple tmux servers. The watchdog matches on this
695/// when present (design.md §8.1).
696///
697/// `pane_id` (the `%NN` form) pins the agent's *specific* pane within that
698/// window, recorded at spawn. Window-owning operations (`kill-window` teardown —
699/// the supervisor owns the whole window per the cleanup invariants) key off
700/// `window_id`; only per-pane operations that must not follow the window's
701/// *active* pane — chiefly `pipe-pane` agent-log capture — use `pane_id`. It is
702/// `None` for a run spawned before create.sh emitted the field; capture then
703/// falls back to `window_id` (issue `capture-agent-pane-by-pane-id`).
704///
705/// The watchdog's liveness probe still keys off `window_id` (correct for the
706/// single-pane autonomous path). A pane-aware liveness probe — needed so a split
707/// interactive window whose agent pane dies while a user shell pane survives is
708/// still seen as dead — is a follow-up (`watchdog-pane-aware-liveness`), not this
709/// change.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711pub struct TmuxIdentity {
712 /// Server socket path (`#{socket_path}`). `None` if create.sh could not
713 /// read it; the watchdog then queries tmux on its default socket.
714 #[serde(default)]
715 pub socket: Option<String>,
716 /// Session that owns the window (`#{session_name}`).
717 pub session: String,
718 /// Stable window id in `@NNNN` form (`#{window_id}`). Survives renames and
719 /// is unique within the server.
720 pub window_id: String,
721 /// Stable pane id in `%NN` form (`#{pane_id}`), recorded at spawn — the
722 /// agent's own pane. `None` for a run whose create.sh predates the field
723 /// (back-compat: old state deserializes with `pane_id: None`). Prefer
724 /// [`TmuxIdentity::capture_target`] over reading this directly.
725 #[serde(default)]
726 pub pane_id: Option<String>,
727}
728
729impl TmuxIdentity {
730 /// The tmux target for a per-pane operation that must hit the agent's own
731 /// pane, not the window's *active* pane: the recorded `pane_id` when
732 /// present, else the `window_id` (which resolves to the active pane).
733 ///
734 /// Used by agent-log capture (`pipe-pane`). Window-level operations
735 /// (`kill-window`, liveness) must NOT use this — they key off `window_id`
736 /// directly so they act on the whole window.
737 ///
738 /// A recorded `pane_id` is preferred only when non-empty; an empty string
739 /// (a directly-deserialized/corrupt state that the reducer/spawn normalizers
740 /// never produce) is treated as absent so capture never targets `-t ""`.
741 pub fn capture_target(&self) -> &str {
742 self.pane_id
743 .as_deref()
744 .filter(|id| !id.is_empty())
745 .unwrap_or(&self.window_id)
746 }
747}
748
749/// `discussions/<discussion-id>.json` (design.md §1.5).
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct Discussion {
752 /// State-schema version this file was written with.
753 pub schema_version: u32,
754 /// Unique discussion identifier. Validated on read; this is the
755 /// projection's filename key, so it can never name a path outside
756 /// `discussions/`.
757 pub discussion_id: DiscussionId,
758 /// Run this discussion belongs to. Validated on read.
759 pub run_id: RunId,
760 /// Node that opened the discussion. Validated on read.
761 pub node_id: NodeId,
762 /// When the discussion was opened.
763 pub opened_at: DateTime<Utc>,
764 /// Severity tag (e.g. `critical`, `normal`) driving alerting.
765 pub severity: String,
766 /// Short summary of what needs deciding.
767 pub topic: String,
768 /// Optional longer context for the decision.
769 pub context: Option<String>,
770 /// Candidate choices offered to the resolver.
771 #[serde(default)]
772 pub options: Vec<String>,
773 /// Open vs resolved.
774 pub status: DiscussionStatus,
775 /// The chosen resolution, once resolved.
776 pub resolution: Option<String>,
777 /// Free-form note accompanying the resolution.
778 #[serde(default)]
779 pub note: Option<String>,
780 /// When the discussion was resolved, if it has been.
781 pub resolved_at: Option<DateTime<Utc>>,
782}
783
784/// `spinoffs/<proposal-id>.json` (design.md §1.5).
785#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct SpinoffProposal {
787 /// State-schema version this file was written with.
788 pub schema_version: u32,
789 /// Unique proposal identifier. Validated on read; this is the projection's
790 /// filename key, so it can never name a path outside `spinoffs/`.
791 pub proposal_id: ProposalId,
792 /// Run this proposal belongs to. Validated on read.
793 pub run_id: RunId,
794 /// Node that proposed the spin-off. Validated on read.
795 pub node_id: NodeId,
796 /// When the proposal was made.
797 pub proposed_at: DateTime<Utc>,
798 /// Suggested title for the spun-off work.
799 pub proposed_title: String,
800 /// Suggested kind for the spun-off run.
801 pub proposed_kind: Kind,
802 /// Why the agent proposed this spin-off.
803 pub rationale: Option<String>,
804 /// Proposed / approved / rejected.
805 pub status: SpinoffStatus,
806 /// Issue slug the proposal was promoted to, once approved.
807 pub accepted_as_issue_slug: Option<String>,
808 /// Reason recorded when the proposal is rejected.
809 pub rejected_reason: Option<String>,
810 /// When the proposal was approved or rejected, if it has been.
811 pub resolved_at: Option<DateTime<Utc>>,
812}
813
814/// One event-log line (design.md §1.4).
815///
816/// `run_id` / `node_id` are the typed id newtypes, so deserializing an
817/// `events.jsonl` line validates the whole envelope on read: a malformed
818/// `run_id` or `node_id` fails the `serde` parse at the read boundary (the
819/// id newtypes' validating `Deserialize`) rather than being carried as an
820/// unvalidated `String` until some later path helper. The parse failure
821/// surfaces as whatever error the reader maps a bad line to — e.g. a
822/// newline-terminated bad line is [`Error::CorruptEventLog`] from both
823/// [`read_all_events`] and [`find_prior_with_key`], which share one physical
824/// reader and torn-tail policy. The reducer still performs its own per-event
825/// checks (envelope `run_id` matches the run it is folded into; `data`-borne
826/// ids parse), but the envelope ids can no longer be the unvalidated party.
827///
828/// [`read_all_events`]: crate::events::read_all_events
829/// [`find_prior_with_key`]: crate::events
830/// [`Error::CorruptEventLog`]: crate::Error::CorruptEventLog
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct Event {
833 /// Wall-clock timestamp the event was appended.
834 pub ts: DateTime<Utc>,
835 /// Monotonic per-run sequence number (recovered on append).
836 pub seq: u64,
837 /// Event kind discriminator (e.g. `node.created`, `discussion.opened`).
838 pub kind: String,
839 /// Run the event belongs to. Validated on read.
840 pub run_id: RunId,
841 /// Node the event concerns, when applicable. Validated on read.
842 #[serde(skip_serializing_if = "Option::is_none", default)]
843 pub node_id: Option<NodeId>,
844 /// Caller-supplied key used to dedupe retried appends.
845 #[serde(skip_serializing_if = "Option::is_none", default)]
846 pub idempotency_key: Option<String>,
847 /// Kind-specific payload applied by the reducer.
848 #[serde(default)]
849 pub data: Value,
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 /// `Kind::wire_name` (and thus `Kind::WIRE_NAMES`) must stay identical
857 /// to what serde actually (de)serializes. If the `rename_all` routing
858 /// or a variant name ever diverges from `wire_name`, this fails — which
859 /// is what keeps the report validator's `expected` hint honest.
860 #[test]
861 fn wire_names_match_serde_round_trip() {
862 for &name in Kind::WIRE_NAMES {
863 let kind: Kind = serde_json::from_value(Value::String(name.to_string()))
864 .unwrap_or_else(|_| panic!("WIRE_NAMES entry {name:?} is not a valid Kind"));
865 assert_eq!(
866 serde_json::to_value(kind).unwrap(),
867 Value::String(name.to_string()),
868 "serde round-trip diverged from wire_name for {name:?}",
869 );
870 }
871 }
872
873 /// The bounded auto-retry eligibility gate (issue `autoretry-agent-died-worker`)
874 /// must include exactly the autonomous single-node worker kinds and exclude
875 /// interactive kinds, the fan-out driver, and the DAG child.
876 #[test]
877 fn autonomous_single_node_worker_set_is_exact() {
878 for k in [
879 Kind::Spinoff,
880 Kind::Research,
881 Kind::TechnicalDecision,
882 Kind::MakeSkill,
883 Kind::Bugfix,
884 ] {
885 assert!(
886 k.is_autonomous_single_node_worker(),
887 "{k:?} should be retry-eligible"
888 );
889 assert_eq!(k.lifecycle(), Lifecycle::Autonomous);
890 }
891 for k in [
892 Kind::Code, // interactive — a human drives
893 Kind::Orchestrate, // interactive driver
894 Kind::FanOut, // multi-unit driver
895 Kind::Orchestrated, // DAG child — parent owns its retry policy
896 ] {
897 assert!(
898 !k.is_autonomous_single_node_worker(),
899 "{k:?} must NOT be retry-eligible"
900 );
901 }
902 }
903
904 /// Back-compat acceptance criterion (issue `capture-agent-pane-by-pane-id`):
905 /// a `TmuxIdentity` persisted before `pane_id` existed — with the field
906 /// entirely absent, or written as an explicit `null` — must still
907 /// deserialize, yielding `pane_id: None` and a `window_id` capture target.
908 #[test]
909 fn tmux_identity_deserializes_legacy_state_without_pane_id() {
910 // Field entirely absent (a state file written by an older binary).
911 let absent: TmuxIdentity = serde_json::from_value(serde_json::json!({
912 "socket": null,
913 "session": "octl",
914 "window_id": "@42",
915 }))
916 .expect("legacy identity without pane_id must deserialize");
917 assert_eq!(absent.pane_id, None);
918 assert_eq!(absent.capture_target(), "@42");
919
920 // Field present but explicitly null.
921 let null: TmuxIdentity = serde_json::from_value(serde_json::json!({
922 "socket": null,
923 "session": "octl",
924 "window_id": "@42",
925 "pane_id": null,
926 }))
927 .expect("identity with explicit null pane_id must deserialize");
928 assert_eq!(null.pane_id, None);
929 assert_eq!(null.capture_target(), "@42");
930 }
931
932 /// `capture_target` prefers a recorded `pane_id` (`%NN`) over the window id,
933 /// but treats an empty `pane_id` as absent (never targets `-t ""`).
934 #[test]
935 fn capture_target_prefers_nonempty_pane_id() {
936 let with_pane = TmuxIdentity {
937 socket: None,
938 session: "octl".into(),
939 window_id: "@42".into(),
940 pane_id: Some("%7".into()),
941 };
942 assert_eq!(with_pane.capture_target(), "%7");
943
944 let empty_pane = TmuxIdentity {
945 pane_id: Some(String::new()),
946 ..with_pane.clone()
947 };
948 assert_eq!(empty_pane.capture_target(), "@42");
949 }
950}
951
952#[cfg(test)]
953mod id_tests {
954 use super::*;
955
956 /// Inputs every id type must reject — the path-traversal vectors plus the
957 /// generic malformed cases called out in the issue's success criteria.
958 const TRAVERSAL_VECTORS: &[&str] = &[
959 "..",
960 "../etc",
961 "a/b",
962 "a/../b",
963 ".hidden",
964 "./x",
965 "foo/bar.json",
966 "n-0001/../../etc",
967 "",
968 ];
969
970 #[test]
971 fn run_id_accepts_generator_output_and_rejects_malformed() {
972 let id = crate::new_run_id();
973 assert!(
974 RunId::parse_str(&id).is_ok(),
975 "generator must validate: {id}"
976 );
977 for bad in [
978 "tooshort",
979 "01jxsnap0000000000000000000", // 27 chars
980 "01JXSNAP000000000000000000", // uppercase
981 "01jxiiiiiiiiiiiiiiiiiiiiii", // `i` not in Crockford
982 "80000000000000000000000000", // first char exceeds ULID range
983 "n-0001", // wrong shape entirely
984 ] {
985 assert!(RunId::parse_str(bad).is_err(), "expected reject: {bad:?}");
986 }
987 for bad in TRAVERSAL_VECTORS {
988 assert!(
989 RunId::parse_str(bad).is_err(),
990 "traversal not rejected: {bad:?}"
991 );
992 }
993 }
994
995 #[test]
996 fn node_id_accepts_canonical_and_rejects_malformed() {
997 for ok in ["n-0001", "n-0010", "n-123456"] {
998 assert!(NodeId::parse_str(ok).is_ok(), "expected accept: {ok}");
999 }
1000 // Wrong prefix is its own error variant.
1001 assert!(matches!(
1002 NodeId::parse_str("d-0001"),
1003 Err(IdValidationError::WrongPrefix { .. })
1004 ));
1005 assert!(matches!(
1006 NodeId::parse_str("0001"),
1007 Err(IdValidationError::WrongPrefix { .. })
1008 ));
1009 for bad in [
1010 "n-1", // too few digits
1011 "n-abcd", // non-digit body
1012 "n-", // empty body
1013 "n-00a1", // mixed
1014 "n-00000000000", // 11 digits — over the 10-digit ceiling
1015 ] {
1016 assert!(
1017 matches!(
1018 NodeId::parse_str(bad),
1019 Err(IdValidationError::InvalidFormat { .. })
1020 ),
1021 "expected InvalidFormat: {bad:?}",
1022 );
1023 }
1024 for bad in TRAVERSAL_VECTORS {
1025 assert!(
1026 NodeId::parse_str(bad).is_err(),
1027 "traversal not rejected: {bad:?}"
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn discussion_id_accepts_both_canonical_forms_and_rejects_malformed() {
1034 let gen = crate::new_discussion_id();
1035 assert!(
1036 DiscussionId::parse_str(&gen).is_ok(),
1037 "generator must validate: {gen}"
1038 );
1039 // 26-char lowercase Crockford ULID form.
1040 assert!(DiscussionId::parse_str("d-01arz3ndektsv4rrffq69g5fav").is_ok());
1041 // 10-char RFC 4648 base32 deterministic-id form (contains i/l/o/u and
1042 // 2-7), which the supervisor actually emits — must validate.
1043 assert!(DiscussionId::parse_str("d-ilou234567").is_ok());
1044 assert!(DiscussionId::parse_str("d-abcdefghij").is_ok());
1045 // A 10-char body of all-`z` is a legitimate RFC 4648 base32 string
1046 // (`z` is in `a-z2-7`), so it is accepted — same class as `ilou234567`.
1047 // The issue's success-criteria example listing `d-zzzzzzzzzz` among
1048 // "now-invalid" forms is imprecise: it IS canonical RFC 4648 base32 and
1049 // there is no charset-based rule that rejects it without also rejecting
1050 // the supervisor's real deterministic ids.
1051 assert!(DiscussionId::parse_str("d-zzzzzzzzzz").is_ok());
1052 assert!(matches!(
1053 DiscussionId::parse_str("s-0123456789"),
1054 Err(IdValidationError::WrongPrefix { .. })
1055 ));
1056 for bad in [
1057 "d-0123456789", // 10 chars but `0`/`1` ∉ RFC 4648 base32
1058 "d-short", // body < 10
1059 "d-abcdefghijk", // 11 chars — between the two forms
1060 "d-01arz3ndektsv4rrffq69g5fa", // 25 chars — one short of a ULID
1061 "d-0123456789012345678901234567", // body > 26
1062 "d-01arz3ndeilov4rrffq69g5fav", // 26 chars but carries `i`/`l`/`o` ∉ Crockford
1063 "d-ABCDEFGHIJ", // uppercase not allowed
1064 "d-abc_def012", // `_` outside both alphabets
1065 "d-", // empty body
1066 ] {
1067 assert!(
1068 matches!(
1069 DiscussionId::parse_str(bad),
1070 Err(IdValidationError::InvalidFormat { .. })
1071 ),
1072 "expected InvalidFormat: {bad:?}",
1073 );
1074 }
1075 for bad in TRAVERSAL_VECTORS {
1076 assert!(
1077 DiscussionId::parse_str(bad).is_err(),
1078 "traversal not rejected: {bad:?}"
1079 );
1080 }
1081 }
1082
1083 #[test]
1084 fn proposal_id_accepts_both_canonical_forms_and_rejects_malformed() {
1085 let gen = crate::new_proposal_id();
1086 assert!(
1087 ProposalId::parse_str(&gen).is_ok(),
1088 "generator must validate: {gen}"
1089 );
1090 // 26-char lowercase Crockford ULID form.
1091 assert!(ProposalId::parse_str("s-01arz3ndektsv4rrffq69g5fav").is_ok());
1092 // 10-char RFC 4648 base32 deterministic-id form (the supervisor's
1093 // actual output); `u` is valid RFC 4648 base32.
1094 assert!(ProposalId::parse_str("s-uuuuuuuuuu").is_ok());
1095 assert!(matches!(
1096 ProposalId::parse_str("d-0123456789"),
1097 Err(IdValidationError::WrongPrefix { .. })
1098 ));
1099 for bad in [
1100 "s-0123456789", // 10 chars but `0`/`1` ∉ RFC 4648 base32
1101 "s-short", // body < 10
1102 "s-abcdefghijk", // 11 chars — between the two forms
1103 "s-01arz3ndeilov4rrffq69g5fav", // 26 chars but carries `i`/`l`/`o` ∉ Crockford
1104 "s-ABCDEFGHIJ", // uppercase not allowed
1105 "s-abc.def012", // `.` outside both alphabets
1106 "s-", // empty body
1107 ] {
1108 assert!(
1109 matches!(
1110 ProposalId::parse_str(bad),
1111 Err(IdValidationError::InvalidFormat { .. })
1112 ),
1113 "expected InvalidFormat: {bad:?}",
1114 );
1115 }
1116 for bad in TRAVERSAL_VECTORS {
1117 assert!(
1118 ProposalId::parse_str(bad).is_err(),
1119 "traversal not rejected: {bad:?}"
1120 );
1121 }
1122 }
1123
1124 #[test]
1125 fn deserialize_rejects_malformed_ids() {
1126 // The validating Deserialize impl is the on-read guard: a tampered
1127 // projection file whose key no longer validates must fail to parse.
1128 assert!(serde_json::from_str::<NodeId>("\"n-0001\"").is_ok());
1129 assert!(serde_json::from_str::<NodeId>("\"../../etc\"").is_err());
1130 assert!(serde_json::from_str::<DiscussionId>("\"d-../escape\"").is_err());
1131 // Canonical 10-char RFC 4648 base32 body deserializes; the old
1132 // loose-charset `s-0123456789` (carries `0`/`1`) no longer does.
1133 assert!(serde_json::from_str::<ProposalId>("\"s-abcdefghij\"").is_ok());
1134 assert!(serde_json::from_str::<ProposalId>("\"s-0123456789\"").is_err());
1135 }
1136
1137 #[test]
1138 fn serialize_round_trips_as_bare_string() {
1139 let nid = NodeId::parse_str("n-0042").unwrap();
1140 let json = serde_json::to_string(&nid).unwrap();
1141 assert_eq!(json, "\"n-0042\"");
1142 let back: NodeId = serde_json::from_str(&json).unwrap();
1143 assert_eq!(back, nid);
1144 assert_eq!(nid.as_str(), "n-0042");
1145 assert_eq!(nid.to_string(), "n-0042");
1146 }
1147
1148 #[test]
1149 fn error_exposes_kind_and_expected() {
1150 let err = NodeId::parse_str("n-x").unwrap_err();
1151 assert_eq!(err.kind(), "node");
1152 assert_eq!(err.expected(), "n-NNNN (n- followed by 4-10 ASCII digits)");
1153 }
1154
1155 #[test]
1156 fn event_deserialize_validates_envelope_ids() {
1157 // The whole `events.jsonl` envelope is now validated on read: the
1158 // typed `run_id` / `node_id` fields parse through the id newtypes, so
1159 // a malformed envelope id fails the deserialize rather than being
1160 // carried downstream as an unchecked string.
1161 let ok = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.created","run_id":"01jxsnap000000000000000000","node_id":"n-0001","data":{}}"#;
1162 assert!(serde_json::from_str::<Event>(ok).is_ok());
1163
1164 // Invalid `run_id` (not a 26-char ULID) fails the parse.
1165 let bad_run = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"run.status","run_id":"not-a-ulid","data":{}}"#;
1166 assert!(serde_json::from_str::<Event>(bad_run).is_err());
1167
1168 // Invalid top-level `node_id` (too few digits) also fails the parse.
1169 let bad_node = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.status","run_id":"01jxsnap000000000000000000","node_id":"n-1","data":{}}"#;
1170 assert!(serde_json::from_str::<Event>(bad_node).is_err());
1171 }
1172
1173 #[test]
1174 fn from_str_and_ord_delegate_to_inner() {
1175 use std::str::FromStr;
1176 // `FromStr` mirrors `parse_str`, so the `str::parse` ecosystem works.
1177 assert!(RunId::from_str("01jxsnap000000000000000000").is_ok());
1178 assert!("n-0001".parse::<NodeId>().is_ok());
1179 assert!("n-x".parse::<NodeId>().is_err());
1180
1181 // `Ord` is lexicographic over the inner string; for ULIDs that is the
1182 // natural time-encoded order.
1183 let a = RunId::parse_str("01jxsnap000000000000000000").unwrap();
1184 let b = RunId::parse_str("02jxsnap000000000000000000").unwrap();
1185 assert!(a < b);
1186 let mut v = vec![b.clone(), a.clone()];
1187 v.sort();
1188 assert_eq!(v, vec![a, b]);
1189 }
1190}