boundgraph-core 0.1.0

Core primitives for the BoundGraph runtime: content-addressed digests, work orders, context, condensates, evidence, and the ledger.
Documentation
//! Identifiers.
//!
//! A [`StepId`] and a [`RunId`] mean different things and are deliberately not
//! interchangeable. A step is identified by *what it is* — the digest of its
//! specification, so the same step in two runs has the same id. A run is
//! identified by *when it happened* — a ULID, which sorts chronologically.
//!
//! Mixing them up would silently corrupt the ledger, so the type system stops
//! it: neither converts to the other, and neither exposes the inner value in a
//! way that invites a cast.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};
use ulid::Ulid;

use crate::digest::{Digest, DigestParseError};

/// Identifies a step by the digest of its specification.
///
/// Content-addressed, so it is stable across runs: re-running the same step
/// yields the same `StepId`, which is what makes caching and replay matching
/// possible.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StepId(Digest);

impl StepId {
    /// Wrap the digest of a step specification.
    pub const fn new(digest: Digest) -> Self {
        Self(digest)
    }

    /// The underlying digest.
    pub const fn digest(&self) -> &Digest {
        &self.0
    }
}

impl fmt::Display for StepId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "step:{}", self.0)
    }
}

impl fmt::Debug for StepId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "StepId({}…)", self.0.short(12))
    }
}

impl FromStr for StepId {
    type Err = DigestParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self(Digest::from_str(
            s.strip_prefix("step:").unwrap_or(s),
        )?))
    }
}

/// Identifies one execution of a flow.
///
/// A ULID rather than a digest: two runs of the same flow are different events
/// and must be distinguishable, and lexicographic order matches time order,
/// which makes ledger scans cheap.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RunId(Ulid);

impl RunId {
    /// Mint an id for a run starting now.
    pub fn new() -> Self {
        Self(Ulid::new())
    }

    /// Wrap an existing ULID, for replaying or importing a recorded run.
    pub const fn from_ulid(ulid: Ulid) -> Self {
        Self(ulid)
    }

    /// The underlying ULID.
    pub const fn ulid(&self) -> Ulid {
        self.0
    }
}

impl Default for RunId {
    fn default() -> Self {
        Self::new()
    }
}

impl fmt::Display for RunId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "run:{}", self.0)
    }
}

impl fmt::Debug for RunId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "RunId({})", self.0)
    }
}

impl FromStr for RunId {
    type Err = ulid::DecodeError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self(Ulid::from_string(
            s.strip_prefix("run:").unwrap_or(s),
        )?))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canonical::Canonical;

    #[test]
    fn step_id_is_content_addressed_and_stable() {
        let spec = ("reasoner-a", vec!["tool-x"], "checker-y");
        let a = StepId::new(spec.digest().unwrap());
        let b = StepId::new(spec.digest().unwrap());
        assert_eq!(a, b, "the same step specification must yield the same id");
    }

    #[test]
    fn different_specs_give_different_step_ids() {
        let a = StepId::new(("reasoner-a",).digest().unwrap());
        let b = StepId::new(("reasoner-b",).digest().unwrap());
        assert_ne!(a, b);
    }

    #[test]
    fn run_ids_are_unique() {
        let first = RunId::new();
        let second = RunId::new();
        assert_ne!(first, second);
    }

    #[test]
    fn run_ids_sort_chronologically() {
        use std::time::{Duration, SystemTime};

        let t0 = SystemTime::now();
        let earlier = RunId::from_ulid(Ulid::from_datetime(t0));
        let later = RunId::from_ulid(Ulid::from_datetime(t0 + Duration::from_millis(1)));

        assert!(
            earlier < later,
            "a later run must sort after an earlier one"
        );
    }

    #[test]
    fn run_ids_never_go_backwards_in_time() {
        let first = RunId::new();
        let second = RunId::new();
        assert!(first.ulid().timestamp_ms() <= second.ulid().timestamp_ms());
    }

    // Deliberately not asserted: the ordering of two ULIDs minted within the
    // *same* millisecond. They share a timestamp and their remaining 80 bits
    // are independently random, so their relative order is a coin flip. Only
    // ordering across distinct milliseconds is a real guarantee, and that is
    // what the tests above check. Asserting the within-millisecond case would
    // be a test that fails roughly half the time.

    #[test]
    fn ids_round_trip_through_strings() {
        let step = StepId::new(Digest::of(b"x"));
        assert_eq!(StepId::from_str(&step.to_string()).unwrap(), step);

        let run = RunId::new();
        assert_eq!(RunId::from_str(&run.to_string()).unwrap(), run);
    }

    #[test]
    fn ids_are_prefixed_so_they_cannot_be_confused_in_logs() {
        assert!(
            StepId::new(Digest::of(b"x"))
                .to_string()
                .starts_with("step:")
        );
        assert!(RunId::new().to_string().starts_with("run:"));
    }
}