Skip to main content

agentplane/core/
id.rs

1//! Identifiers, digests, and the hash-chain primitive.
2//!
3//! ULIDs are used for run and case ids because they are lexicographically
4//! sortable: a journal scan for one run is a range scan, and cases sort by
5//! creation time for free.
6
7use std::fmt;
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
10use sha2::{Digest as _, Sha256};
11
12/// Wall-clock instant. Only ever obtained through a journaled effect
13/// (`StepCtx::now`), never by reading the ambient clock.
14///
15/// # Format it before it reaches a wire
16///
17/// This is a re-export of [`time::OffsetDateTime`], and that type's **derived**
18/// `Serialize` is not a date. Unless the `time` crate's `serde-human-readable`
19/// feature is on — it is not here, and enabling it would silently change every
20/// stored shape — a bare `Timestamp` field serialises to its **component
21/// array**:
22///
23/// ```text
24/// [2027, 15, 8, 0, 0, 0, 0, 0, 0]
25///  year  ordinal-day  h  m  s  ns  offset-h  offset-m  offset-s
26/// ```
27///
28/// It parses, it round-trips, and every consumer that expected a date gets nine
29/// numbers. A model asked to do arithmetic on one produces confident nonsense; a
30/// dashboard renders `2027` as the hour. The failure is quiet in exactly the way
31/// a format error is not.
32///
33/// So **every timestamp on a wire in this crate is RFC 3339**, and the two ways
34/// to spell that are:
35///
36/// ```
37/// # use agentplane::core::Timestamp;
38/// #[derive(serde::Serialize, serde::Deserialize)]
39/// struct Deadline {
40///     #[serde(with = "time::serde::rfc3339")]
41///     resolved_at: Timestamp,
42///     #[serde(default, with = "time::serde::rfc3339::option")]
43///     warn_at: Option<Timestamp>,
44/// }
45/// ```
46///
47/// and [`format_timestamp`] where the value is being placed into a
48/// `serde_json::json!` literal rather than a struct field — an effect
49/// descriptor, a `CloudEvents` envelope, a log line.
50///
51/// `tests/guards` walks the crate's serialized types and fails on a component
52/// array, so this is a rule the build enforces rather than one a reviewer has to
53/// remember. The hazard is worth stating here anyway, because `Timestamp` is
54/// public API: it lands in **your** tool payloads too, and the crate cannot
55/// check those.
56pub type Timestamp = time::OffsetDateTime;
57
58/// The longest window of seconds this crate will turn into an instant.
59///
60/// Derived from [`Timestamp`] rather than picked: it is the distance between
61/// the first and last instant the type can name, so a window past it is one
62/// there is no `now` to add it to. A declaration carrying such a number has no
63/// correct reading, and refusing it where the document is read is the only
64/// place a person is still holding the file.
65///
66/// `time`'s own arithmetic is why this is a refusal rather than a clamp: the
67/// duration constructors multiply and the instant operators add, and both
68/// **panic** on overflow. A plane hosting many agents aborts the process to
69/// report one document's typo.
70pub const MAX_WINDOW_SECONDS: u64 = 631_107_417_599;
71
72/// The instant `seconds` after `from`, or `None` where that is not an instant.
73///
74/// One spelling, because the conversion fails two ways — a count too large to
75/// be a [`time::Duration`], and a duration too large to add to `from` — and a
76/// caller that remembers one of them panics on the other. Both constructors
77/// panic rather than return, so there is no form of this that is safe by
78/// accident.
79///
80/// Callers that must produce a value rather than a refusal state their own
81/// fallback at the call site; there is deliberately no saturating twin, because
82/// two functions would be two answers to *what does a window that long mean*.
83#[must_use]
84pub fn seconds_after(from: Timestamp, seconds: u64) -> Option<Timestamp> {
85    i64::try_from(seconds)
86        .ok()
87        .map(time::Duration::seconds)
88        .and_then(|delta| from.checked_add(delta))
89}
90
91/// The last instant a [`Timestamp`] can name.
92///
93/// The saturation target for a caller that owes an index an entry rather than a
94/// refusal — see [`seconds_after`].
95#[must_use]
96pub fn last_instant() -> Timestamp {
97    time::PrimitiveDateTime::MAX.assume_utc()
98}
99
100/// The first instant a [`Timestamp`] can name.
101///
102/// The other saturation target: a cutoff that reaches back further than the
103/// calendar goes selects nothing, which is what a caller asking to hold
104/// something *indefinitely* means. Subtracting a duration from an instant
105/// panics on underflow, so a caller computing such a cutoff needs somewhere to
106/// land rather than a `checked_sub` it can forget.
107#[must_use]
108pub fn first_instant() -> Timestamp {
109    time::PrimitiveDateTime::MIN.assume_utc()
110}
111
112/// One instant, RFC 3339, for a place that is not a struct field.
113///
114/// `#[serde(with = "time::serde::rfc3339")]` is the answer wherever there is a
115/// field to attach it to. This is the answer where there is not — inside a
116/// `json!` literal, where a bare `Timestamp` would take the derived component
117/// array with nothing in the source to hint at it. See [`Timestamp`].
118///
119/// Falls back to the numeric Unix second if formatting fails, which needs an
120/// instant outside RFC 3339's representable range. That is not a case worth
121/// failing an effect key over, and a number is at least unambiguous.
122#[must_use]
123pub fn format_timestamp(at: Timestamp) -> String {
124    at.format(&time::format_description::well_known::Rfc3339)
125        .unwrap_or_else(|_| at.unix_timestamp().to_string())
126}
127
128/// Per-run monotonic journal position, starting at 1.
129pub type Seq = u64;
130
131/// Ownership fencing epoch.
132///
133/// Every journal append carries the writer's epoch and the store rejects stale
134/// epochs *in the same transaction that writes*, so a paused or partitioned
135/// instance that wakes up and keeps writing is fenced by the store rather than
136/// by timing. Split-brain cannot corrupt the chain by construction.
137pub type Epoch = u64;
138
139macro_rules! ulid_newtype {
140    ($(#[$m:meta])* $name:ident, $prefix:literal) => {
141        $(#[$m])*
142        ///
143        /// # One spelling, everywhere
144        ///
145        #[doc = concat!(" Prefixed (`", $prefix, "_01J8Z…`) in every direction:")]
146        /// [`Display`](std::fmt::Display), `Serialize`, the store key, the
147        /// column. An id is self-describing wherever it lands — a log line, a
148        /// URL, a JSON field, a SIEM — and grepping one against another finds
149        /// it whichever way round you do it.
150        ///
151        /// What this rules out is a store spelling one id two ways — prefixed in
152        /// its keys and bare in its payloads — which costs a consumer an
153        /// afternoon and looks like two ids until it does.
154        ///
155        /// [`Deserialize`] and [`parse`](Self::parse) accept a bare ULID too,
156        /// because one arrives from somewhere else often enough. The prefix is
157        /// checked rather than stripped blindly, so a `case_…` string is refused
158        /// where a [`RunId`] is wanted instead of silently becoming one.
159        #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
160        pub struct $name(pub ulid::Ulid);
161
162        impl $name {
163            /// Mint a fresh id from the ambient clock and a random tail.
164            ///
165            /// Reserved for the runtime's admission path, which journals the
166            /// result. Skills must never call this — see `clippy.toml`.
167            ///
168            /// **Sorted to the millisecond, not beyond it.** Ordering comes
169            /// from the timestamp prefix, so two ids minted in the same
170            /// millisecond order by their random tails — arbitrarily, and
171            /// differently on each instance. A range scan over one run's
172            /// records is exact because the run id is a *prefix*; "cases sort
173            /// by creation time" is true between milliseconds and a coin flip
174            /// inside one.
175            #[allow(clippy::disallowed_methods)]
176            #[must_use]
177            pub fn generate() -> Self {
178                Self(ulid::Ulid::generate())
179            }
180
181            /// Reconstruct from a stored string.
182            ///
183            /// Accepts both the prefixed form produced by [`std::fmt::Display`]
184            /// and a bare ULID, so ids round-trip through logs, URLs, and
185            /// database columns without the caller having to know which form it
186            /// is holding.
187            ///
188            /// Only the canonical spelling: uppercase Crockford base32 whose
189            /// first character is at most `7`. The decoder also reads
190            /// lowercase, and a first character past `7` overflows 128 bits
191            /// and wraps, so without this one id would have many spellings —
192            /// and every index keyed on the string would hold a different run
193            /// for each.
194            ///
195            /// # Errors
196            ///
197            /// If what remains after the prefix is not a ULID in its canonical
198            /// spelling.
199            pub fn parse(s: &str) -> Result<Self, ulid::DecodeError> {
200                let bare = s.strip_prefix(concat!($prefix, "_")).unwrap_or(s);
201                let id = ulid::Ulid::from_string(bare)?;
202                if id.to_string() != bare {
203                    return Err(ulid::DecodeError::InvalidChar);
204                }
205                Ok(Self(id))
206            }
207        }
208
209        /// The trait an id arrives through: an `axum` path segment, a `clap`
210        /// argument, a TOML key, a `serde` field with `#[serde(with = …)]`.
211        ///
212        /// `.parse()` is where every consumer reaches first, and its absence
213        /// made [`parse`](Self::parse) something each of them had to find.
214        impl std::str::FromStr for $name {
215            type Err = ulid::DecodeError;
216            fn from_str(s: &str) -> Result<Self, Self::Err> {
217                Self::parse(s)
218            }
219        }
220
221        /// The written form, so a record says which kind of id it holds.
222        impl Serialize for $name {
223            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
224                s.collect_str(self)
225            }
226        }
227
228        /// Through [`parse`](Self::parse), so a bare ULID from somewhere else
229        /// still reads.
230        ///
231        /// A refusal names the type and the input: `ulid`'s own error is
232        /// `invalid length`, which says nothing about which field was wrong or
233        /// what it wanted.
234        impl<'de> Deserialize<'de> for $name {
235            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
236                let raw = <std::borrow::Cow<'de, str>>::deserialize(d)?;
237                Self::parse(&raw).map_err(|e| {
238                    D::Error::custom(format!(
239                        concat!("not a ", stringify!($name), " ('{}'): {}"),
240                        raw, e
241                    ))
242                })
243            }
244        }
245
246        impl fmt::Display for $name {
247            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248                write!(f, "{}_{}", $prefix, self.0)
249            }
250        }
251
252        impl fmt::Debug for $name {
253            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254                write!(f, "{self}")
255            }
256        }
257
258        /// The written form: the prefix, an underscore, and a ULID.
259        impl schemars::JsonSchema for $name {
260            fn inline_schema() -> bool {
261                true
262            }
263
264            fn schema_name() -> std::borrow::Cow<'static, str> {
265                stringify!($name).into()
266            }
267
268            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
269                schemars::json_schema!({
270                    "type": "string",
271                    "pattern": concat!("^", $prefix, "_[0-9A-HJKMNP-TV-Z]{26}$"),
272                })
273            }
274        }
275    };
276}
277
278ulid_newtype!(
279    /// A single execution: one goal, one plan, one lifetime.
280    ///
281    /// Runs stay short *by design* — longevity lives in the [`CaseId`], so a
282    /// six-week business process never pins a code version.
283    RunId, "run"
284);
285
286ulid_newtype!(
287    /// A long-lived, correlated business fact spanning many runs and weeks.
288    CaseId, "case"
289);
290
291ulid_newtype!(
292    /// One business act made of many independent ones.
293    ///
294    /// Owns N runs sharing one frozen plan — see [`crate::batch`]. Distinct from
295    /// a [`CaseId`] because the relationship is different in kind: a case is a
296    /// matter that several runs *touch* over weeks, while a batch is a single
297    /// act that several runs *constitute* in one pass.
298    BatchId, "batch"
299);
300
301/// Position of a step within a plan.
302#[derive(
303    Debug,
304    Clone,
305    Copy,
306    PartialEq,
307    Eq,
308    Hash,
309    PartialOrd,
310    Ord,
311    Serialize,
312    Deserialize,
313    schemars::JsonSchema,
314)]
315#[serde(transparent)]
316pub struct StepId(pub u32);
317
318impl fmt::Display for StepId {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        write!(f, "s{}", self.0)
321    }
322}
323
324/// SHA-256 over an artifact's canonical serialization.
325///
326/// Also the hash-chain link type. [`Digest::chain`] is the only way to extend a
327/// chain, and it hashes `prev ‖ bytes` — where `bytes` are the record's *wire*
328/// bytes, never a re-serialized or upcast form. Rehashing after an upcast would
329/// destroy tamper evidence for all history the moment a schema changed.
330#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
331pub struct Digest([u8; 32]);
332
333impl Digest {
334    /// The chain's genesis link.
335    pub const ZERO: Self = Self([0u8; 32]);
336
337    /// Hash a byte string.
338    #[must_use]
339    pub fn of(bytes: &[u8]) -> Self {
340        let mut h = Sha256::new();
341        h.update(bytes);
342        Self(h.finalize().into())
343    }
344
345    /// Extend a hash chain: `H(prev ‖ bytes)`.
346    #[must_use]
347    pub fn chain(prev: Self, bytes: &[u8]) -> Self {
348        let mut h = Sha256::new();
349        h.update(prev.0);
350        h.update(bytes);
351        Self(h.finalize().into())
352    }
353
354    #[must_use]
355    pub const fn from_bytes(b: [u8; 32]) -> Self {
356        Self(b)
357    }
358
359    #[must_use]
360    pub const fn as_bytes(&self) -> &[u8; 32] {
361        &self.0
362    }
363
364    #[must_use]
365    pub fn to_hex(self) -> String {
366        hex::encode(self.0)
367    }
368
369    pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
370        let mut out = [0u8; 32];
371        hex::decode_to_slice(s, &mut out)?;
372        Ok(Self(out))
373    }
374
375    /// First 8 hex chars — enough to identify a record in a log line.
376    #[must_use]
377    pub fn short(self) -> String {
378        hex::encode(&self.0[..4])
379    }
380}
381
382impl fmt::Display for Digest {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        write!(f, "{}", self.to_hex())
385    }
386}
387
388impl fmt::Debug for Digest {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        write!(f, "{}…", self.short())
391    }
392}
393
394impl Serialize for Digest {
395    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
396        s.serialize_str(&self.to_hex())
397    }
398}
399
400/// Lowercase hex, 64 digits.
401impl schemars::JsonSchema for Digest {
402    fn inline_schema() -> bool {
403        true
404    }
405
406    fn schema_name() -> std::borrow::Cow<'static, str> {
407        "Digest".into()
408    }
409
410    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
411        schemars::json_schema!({ "type": "string", "pattern": "^[0-9a-f]{64}$" })
412    }
413}
414
415impl<'de> Deserialize<'de> for Digest {
416    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
417        let s = String::deserialize(d)?;
418        Self::from_hex(&s).map_err(D::Error::custom)
419    }
420}
421
422/// Stable identity of one effect within one run.
423///
424/// `H(step ‖ ordinal ‖ kind ‖ canonical(args))`. Two properties follow:
425///
426/// * **Exactly-once** — the store's unique index on `(run_id, effect_key)` makes
427///   "an effect is started at most once per run" a database invariant.
428/// * **Divergence detection** — on replay the key is recomputed from the
429///   deterministic zone. A mismatch means the code took a different path than
430///   the recorded one, and the run is quarantined rather than allowed to
431///   silently diverge.
432///
433/// Skills never construct these: the runtime derives the key from the effect's
434/// descriptor plus its position, so a skill cannot forge or collide one.
435#[derive(
436    Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
437)]
438#[serde(transparent)]
439pub struct EffectKey(Digest);
440
441/// Which pass of a step an effect belongs to.
442///
443/// A step can run twice for entirely legitimate reasons: once going forward,
444/// and once again in reverse when a later step fails and the saga unwinds. The
445/// two are different work with different effects, and the journal has to be
446/// able to tell them apart.
447#[derive(
448    Debug,
449    Clone,
450    Copy,
451    PartialEq,
452    Eq,
453    PartialOrd,
454    Ord,
455    Default,
456    Serialize,
457    Deserialize,
458    schemars::JsonSchema,
459)]
460#[serde(rename_all = "snake_case")]
461pub enum Phase {
462    /// Doing the work.
463    #[default]
464    Forward,
465    /// Undoing it.
466    Compensating,
467}
468
469impl Phase {
470    #[must_use]
471    pub const fn is_forward(self) -> bool {
472        matches!(self, Self::Forward)
473    }
474
475    /// The spelling every store writes.
476    ///
477    /// One, because three stores keep a phase column and a phase that reads
478    /// back as the other half of the saga hands the unwind logic a compensating
479    /// record wearing the forward pass's name.
480    #[must_use]
481    pub const fn as_str(self) -> &'static str {
482        match self {
483            Self::Forward => "forward",
484            Self::Compensating => "compensating",
485        }
486    }
487
488    /// The inverse of [`as_str`](Self::as_str), written over
489    /// [`ALL`](Self::ALL) for the reason [`CaseStatus::parse`] is.
490    ///
491    /// [`CaseStatus::parse`]: crate::core::CaseStatus::parse
492    #[must_use]
493    pub fn parse(s: &str) -> Option<Self> {
494        Self::ALL.iter().copied().find(|c| c.as_str() == s)
495    }
496
497    /// Both passes.
498    pub const ALL: [Self; 2] = [Self::Forward, Self::Compensating];
499
500    /// By-reference form, for `skip_serializing_if`.
501    #[allow(clippy::trivially_copy_pass_by_ref)]
502    #[must_use]
503    pub const fn is_forward_ref(v: &Self) -> bool {
504        v.is_forward()
505    }
506}
507
508/// The domain an effect key's hash is separated under.
509const EFFECT_KEY_DOMAIN: &[u8] = b"agentplane.effect.key.v1\0";
510
511impl EffectKey {
512    /// `phase` separates the forward pass from the compensating one.
513    ///
514    /// Without it a step's compensation would restart its ordinal at zero and
515    /// collide with the step's own forward effects: replay would read the
516    /// forward result back as the compensation's, and the store's uniqueness
517    /// constraint would reject the second announcement.
518    ///
519    /// `attempt` is 1-based and part of the identity, so a retry is a *new*
520    /// effect in the journal rather than a second record under an existing key.
521    ///
522    /// Without it, attempt 2 would collide with attempt 1's recorded failure:
523    /// replay would read back the failure instead of the retry that followed,
524    /// and the store's uniqueness constraint on `EffectStarted` would reject
525    /// the second attempt outright.
526    pub(crate) fn derive(
527        step: StepId,
528        phase: Phase,
529        ordinal: u32,
530        attempt: u32,
531        kind: &str,
532        canonical_args: &[u8],
533    ) -> Self {
534        let mut h = Sha256::new();
535        // Domain-separated at the hash: the same bytes hashed for another
536        // purpose can never be mistaken for an effect's identity.
537        h.update(EFFECT_KEY_DOMAIN);
538        h.update(step.0.to_be_bytes());
539        h.update([phase as u8]);
540        h.update(ordinal.to_be_bytes());
541        h.update(attempt.to_be_bytes());
542        h.update((kind.len() as u64).to_be_bytes());
543        h.update(kind.as_bytes());
544        h.update(canonical_args);
545        Self(Digest(h.finalize().into()))
546    }
547
548    #[must_use]
549    pub fn to_hex(self) -> String {
550        self.0.to_hex()
551    }
552
553    pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
554        Digest::from_hex(s).map(Self)
555    }
556}
557
558impl fmt::Display for EffectKey {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        write!(f, "ek:{}", self.0.to_hex())
561    }
562}
563
564impl fmt::Debug for EffectKey {
565    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
566        write!(f, "ek:{}…", self.0.short())
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use time::macros::datetime;
573
574    use super::*;
575
576    /// The ceiling is computed from the type, never asserted against itself.
577    ///
578    /// A hand-written figure beside a `Timestamp` whose range changed under it
579    /// would read as derived and be wrong, which is the failure mode this
580    /// project catalogues for every number a document states.
581    #[test]
582    fn the_window_ceiling_is_the_span_the_type_can_name() {
583        let first = time::PrimitiveDateTime::MIN.assume_utc();
584        let span = last_instant().unix_timestamp() - first.unix_timestamp();
585        assert_eq!(
586            MAX_WINDOW_SECONDS,
587            u64::try_from(span).expect("the span of a calendar is positive")
588        );
589    }
590
591    /// Both halves of the conversion refuse rather than panic, and the panics
592    /// are real: `time::Duration::seconds` is total, but the addition is not,
593    /// and a `u64` past `i64` is not a duration at all.
594    #[test]
595    fn a_window_no_instant_can_carry_is_none_not_a_panic() {
596        let now = datetime!(2026-09-13 12:00:00 UTC);
597        assert!(seconds_after(now, 3_600).is_some());
598        assert_eq!(seconds_after(now, MAX_WINDOW_SECONDS), None);
599        assert_eq!(seconds_after(now, u64::MAX), None);
600        // The ceiling is the span of the calendar, so it is reachable from the
601        // first instant and from nowhere later.
602        let first = time::PrimitiveDateTime::MIN.assume_utc();
603        assert!(seconds_after(first, MAX_WINDOW_SECONDS).is_some());
604    }
605
606    #[test]
607    fn chain_is_order_sensitive() {
608        let a = Digest::chain(Digest::ZERO, b"a");
609        let b = Digest::chain(a, b"b");
610        let swapped = Digest::chain(Digest::chain(Digest::ZERO, b"b"), b"a");
611        assert_ne!(b, swapped, "chain must not be commutative");
612    }
613
614    #[test]
615    fn chain_detects_any_mutation() {
616        let genuine = Digest::chain(Digest::ZERO, b"record-1");
617        let tampered = Digest::chain(Digest::ZERO, b"record-2");
618        assert_ne!(genuine, tampered);
619    }
620
621    #[test]
622    fn digest_hex_roundtrips() {
623        let d = Digest::of(b"hello");
624        assert_eq!(Digest::from_hex(&d.to_hex()).unwrap(), d);
625    }
626
627    #[test]
628    fn effect_key_separates_step_phase_ordinal_and_attempt() {
629        let fwd = Phase::Forward;
630        let base = EffectKey::derive(StepId(0), fwd, 0, 1, "tool", b"{}");
631        let other_ordinal = EffectKey::derive(StepId(0), fwd, 1, 1, "tool", b"{}");
632        let other_step = EffectKey::derive(StepId(1), fwd, 0, 1, "tool", b"{}");
633        let other_attempt = EffectKey::derive(StepId(0), fwd, 0, 2, "tool", b"{}");
634        let compensating = EffectKey::derive(StepId(0), Phase::Compensating, 0, 1, "tool", b"{}");
635        assert_ne!(base, other_ordinal, "ordinal must be part of the key");
636        assert_ne!(base, other_step, "step must be part of the key");
637        assert_ne!(
638            base, other_attempt,
639            "attempt must be part of the key, or a retry collides with the \
640             failure it is retrying"
641        );
642        assert_ne!(
643            base, compensating,
644            "phase must be part of the key, or a step's compensation collides \
645             with its own forward pass"
646        );
647    }
648
649    /// Length-prefixing `kind` prevents a boundary-shifting collision: without
650    /// it, `("ab", "c")` and `("a", "bc")` would hash identically.
651    #[test]
652    fn effect_key_kind_is_length_prefixed() {
653        let a = EffectKey::derive(StepId(0), Phase::Forward, 0, 1, "ab", b"c");
654        let b = EffectKey::derive(StepId(0), Phase::Forward, 0, 1, "a", b"bc");
655        assert_ne!(a, b);
656    }
657
658    /// The derivation's literal bytes, pinned.
659    ///
660    /// The tests above prove the inputs are separated from each other; none of
661    /// them notices the derivation changing as a whole — a dropped domain tag,
662    /// a reordered field — because both sides of every comparison move
663    /// together. A history written under one derivation replays against
664    /// another as divergence, so the bytes are the contract.
665    #[test]
666    fn effect_key_derivation_is_pinned_to_its_bytes() {
667        let key = EffectKey::derive(StepId(3), Phase::Forward, 2, 1, "tool.call", b"{\"a\":1}");
668        assert_eq!(
669            key.to_hex(),
670            "fe9b19d07dedb4894e2824916e1ca31860fad50c1065c398222892ef75fee502"
671        );
672    }
673
674    #[test]
675    fn ids_display_with_prefix() {
676        let r = RunId::generate();
677        assert!(r.to_string().starts_with("run_"));
678    }
679
680    /// The property that broke first time round: `Display` and `parse` must be
681    /// inverses, or every id that goes through a database column comes back
682    /// unreadable.
683    #[test]
684    fn ids_round_trip_through_their_displayed_form() {
685        let r = RunId::generate();
686        assert_eq!(RunId::parse(&r.to_string()).unwrap(), r);
687        let c = CaseId::generate();
688        assert_eq!(CaseId::parse(&c.to_string()).unwrap(), c);
689    }
690
691    /// A bare ULID is still accepted, so ids written by other tools parse.
692    #[test]
693    fn bare_ulids_still_parse() {
694        let r = RunId::generate();
695        assert_eq!(RunId::parse(&r.0.to_string()).unwrap(), r);
696    }
697
698    /// **One id, one spelling.** Lowercase and a first character past `7`
699    /// both decode — the second by wrapping past 128 bits — so each was a
700    /// second string for an id some index already holds under the first.
701    #[test]
702    fn a_non_canonical_spelling_of_an_id_is_refused() {
703        const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
704        let r = RunId::generate();
705        let canonical = r.0.to_string();
706        assert_eq!(RunId::parse(&canonical).expect("canonical"), r);
707
708        let lower = canonical.to_ascii_lowercase();
709        assert!(
710            RunId::parse(&lower).is_err(),
711            "{lower} parsed as an alias of {canonical}"
712        );
713        assert!(RunId::parse(&format!("run_{lower}")).is_err());
714
715        // Eight places on, the first character sets bit 128, which falls off
716        // the value: this spelling decodes to the id `canonical` names.
717        let first = ALPHABET
718            .iter()
719            .position(|&c| c == canonical.as_bytes()[0])
720            .expect("crockford");
721        let wrapped = format!("{}{}", char::from(ALPHABET[first + 8]), &canonical[1..]);
722        assert!(
723            RunId::parse(&wrapped).is_err(),
724            "{wrapped} wrapped to an alias of {canonical}"
725        );
726    }
727
728    /// Prefixes are not interchangeable in *meaning*, but parsing is lenient by
729    /// design: a `CaseId` column holds case ids, and the prefix is a display
730    /// affordance rather than a type check.
731    #[test]
732    fn parsing_rejects_garbage() {
733        assert!(RunId::parse("run_not-a-ulid").is_err());
734    }
735
736    /// **One spelling, in every direction.**
737    ///
738    /// `Display`, `Serialize`, the store key and the column all write the
739    /// prefixed form, so the same id greps against itself wherever it is read.
740    /// They did not always: keys carried the prefix and record payloads did
741    /// not, so a consumer who stored the string an operator sees in a log could
742    /// not deserialize it — `invalid length`, from a check inside `ulid`, naming
743    /// neither the field nor the fact that there were two forms.
744    #[test]
745    fn one_spelling_is_written_and_both_are_read() {
746        let r = RunId::generate();
747
748        assert_eq!(
749            serde_json::to_string(&r).expect("serialises"),
750            format!("\"{r}\""),
751            "the written form is the one Display shows"
752        );
753
754        for text in [r.to_string(), r.0.to_string()] {
755            let json = serde_json::to_string(&text).expect("a string");
756            assert_eq!(
757                serde_json::from_str::<RunId>(&json).expect("both forms read"),
758                r,
759                "the {text} spelling did not deserialize"
760            );
761        }
762    }
763
764    /// Leniency stops at the prefix: a case id is not a run id.
765    ///
766    /// The half that makes accepting two spellings safe. `parse` strips only
767    /// *this* type's prefix, so `case_…` fails the ULID decode rather than
768    /// quietly becoming a `RunId` — which a blind `strip_prefix` up to the
769    /// underscore would have allowed.
770    #[test]
771    fn another_types_prefix_is_refused_rather_than_stripped() {
772        let case = CaseId::generate();
773        assert!(
774            serde_json::from_str::<RunId>(&format!("\"{case}\"")).is_err(),
775            "a case id deserialized as a run id"
776        );
777        assert!(RunId::parse(&case.to_string()).is_err());
778        // And the positive half, so the negative one is not vacuous.
779        assert!(CaseId::parse(&case.to_string()).is_ok());
780    }
781
782    /// `.parse()` is where an id arrives — a path segment, a CLI argument.
783    #[test]
784    fn ids_arrive_through_from_str() {
785        let r = RunId::generate();
786        assert_eq!(r.to_string().parse::<RunId>().unwrap(), r);
787        assert_eq!(r.0.to_string().parse::<RunId>().unwrap(), r);
788        assert!("nope".parse::<RunId>().is_err());
789    }
790
791    /// **The digest is SHA-256, pinned to values computed outside this crate.**
792    ///
793    /// Every effect key, chain link, Merkle leaf and blob address in the system
794    /// comes out of these two functions, so what they emit is a durable format
795    /// even though nothing declares it one: change a byte and every historical
796    /// run becomes unverifiable, silently, because the whole suite would agree
797    /// with itself under the new value.
798    ///
799    /// That is what makes a *self-consistent* test worthless here and why these
800    /// literals were produced by `shasum -a 256` and Python's `hashlib` rather
801    /// than by running this code and writing down the answer. They survived a
802    /// `sha2` 0.10 → 0.11 upgrade, which is the class of change they exist for:
803    /// the hasher was swapped underneath and the bytes had to be shown, not
804    /// assumed, to be the same ones.
805    ///
806    /// What this does not pin: the canonicalization that decides *which* bytes
807    /// reach the hasher. That is `canon`'s own contract.
808    #[test]
809    fn the_digest_matches_sha256_computed_elsewhere() {
810        assert_eq!(
811            Digest::of(b"").to_hex(),
812            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
813            "the empty digest moved, so every digest in every journal moved with it"
814        );
815        assert_eq!(
816            Digest::of(b"agentplane").to_hex(),
817            "c0f8f77669f4860960387db0dc9984894587bcfcc75d1846ffd3574563833443"
818        );
819        // `chain` is `H(prev ‖ bytes)`, and the concatenation order is the half
820        // a reimplementation gets wrong — reversed, it still hashes, still
821        // verifies against itself, and agrees with no other reader.
822        assert_eq!(
823            Digest::chain(Digest::of(b""), b"agentplane").to_hex(),
824            "171c5eddf30189b0efde9c71eb14f77685fab77d7750152d032f9e7cf4181159"
825        );
826    }
827}