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}