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}