boundgraph-core 0.1.0

Core primitives for the BoundGraph runtime: content-addressed digests, work orders, context, condensates, evidence, and the ledger.
Documentation
//! The Work Order (`ω`) — a proposal, never a command.
//!
//! Law III begins here. When a reasoning engine decides to do something, that
//! decision becomes a [`WorkOrder`]: a typed, immutable, reviewable record of
//! what was *asked for*. Nothing has run, nothing has been authorised, and no
//! permission has been granted.
//!
//! The type enforces that reading. There is no `execute`, no mutation, and the
//! permissions field is named [`requests`](WorkOrder::requests) rather than
//! `grants` — deciding what is actually permitted is the compiler's job
//! (US-0403), not the model's.

use serde::{Deserialize, Serialize};

use crate::canonical::{Canonical, CanonicalError};
use crate::digest::Digest;
use crate::grant::GrantSet;

/// What a work order intends to achieve.
///
/// The payload is schema-validated at construction. Whether intents come from
/// a registered catalogue or stay free-form is still open (`ARCHITECTURE.md`
/// §17), so the kind is a string for now and the payload is opaque CBOR.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Intent {
    /// Names the kind of effect intended, e.g. `patch.apply`, `refund.issue`.
    pub kind: String,
    /// Structured detail, already validated against `kind`'s schema.
    pub payload: ciborium::Value,
}

impl Intent {
    /// Build an intent from any serialisable payload.
    ///
    /// # Errors
    ///
    /// Returns [`CanonicalError::Encode`] if the payload cannot be represented
    /// as CBOR. This is a typed error rather than a panic: a malformed
    /// proposal is an expected runtime condition, not a bug.
    pub fn new<T: Serialize>(kind: impl Into<String>, payload: &T) -> Result<Self, CanonicalError> {
        Ok(Self {
            kind: kind.into(),
            payload: ciborium::Value::serialized(payload)
                .map_err(|e| CanonicalError::Encode(e.to_string()))?,
        })
    }
}

/// One tool invocation a work order proposes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Which tool, by registered name.
    pub tool: String,
    /// The arguments proposed for it.
    pub args: ciborium::Value,
}

impl ToolCall {
    /// Build a call from any serialisable arguments.
    ///
    /// # Errors
    ///
    /// Returns [`CanonicalError::Encode`] if the arguments cannot be
    /// represented as CBOR.
    pub fn new<T: Serialize>(tool: impl Into<String>, args: &T) -> Result<Self, CanonicalError> {
        Ok(Self {
            tool: tool.into(),
            args: ciborium::Value::serialized(args)
                .map_err(|e| CanonicalError::Encode(e.to_string()))?,
        })
    }
}

/// Where a proposal came from.
///
/// Enough to trace a work order back to exactly what produced it. The prompt
/// itself is referenced by digest rather than inlined, so the ledger stays
/// small and the prompt stays retrievable from the content-addressed store.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalOrigin {
    /// The model that produced this, e.g. `claude-opus-5`.
    pub model: String,
    /// Sampling parameters, recorded so a run can be reproduced.
    pub sampling: Vec<(String, String)>,
    /// Digest of the exact prompt sent.
    pub prompt: Digest,
}

impl ProposalOrigin {
    /// Record the origin of a proposal.
    ///
    /// Sampling parameters are sorted, so two origins that differ only in the
    /// order parameters were supplied share a digest.
    pub fn new(
        model: impl Into<String>,
        sampling: impl IntoIterator<Item = (String, String)>,
        prompt: Digest,
    ) -> Self {
        let mut sampling: Vec<_> = sampling.into_iter().collect();
        sampling.sort();
        Self {
            model: model.into(),
            sampling,
            prompt,
        }
    }
}

/// `ω` — a typed declaration of intended effect. Immutable.
///
/// A work order is a *proposal*. It commits to nothing, authorises nothing,
/// and grants nothing. It records what the model intends, which tools it wants
/// to call, which permissions it is asking for, and where the proposal came
/// from.
///
/// # Example
///
/// ```
/// use boundgraph_core::{Canonical, Digest, GrantAtom, GrantSet, Intent, ProposalOrigin, WorkOrder};
///
/// let order = WorkOrder::new(
///     Intent::new("patch.apply", &"fix the off-by-one in parse_header")?,
///     vec![],
///     GrantSet::new([GrantAtom::new("fs", "write", "/src/**")]),
///     ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
/// );
///
/// // A proposal asks; it does not receive.
/// assert_eq!(order.requests.len(), 1);
///
/// // And it is content-addressed, so it can be referenced from the ledger.
/// let _id = order.digest()?;
/// # Ok::<(), boundgraph_core::CanonicalError>(())
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkOrder {
    /// What effect is intended.
    pub intent: Intent,
    /// The tool calls proposed, in the order proposed.
    pub calls: Vec<ToolCall>,
    /// The permissions the model is **asking for**.
    ///
    /// Named `requests`, not `grants`: what is actually permitted is decided
    /// by the compiler and the grant gate, never by the proposer.
    pub requests: GrantSet,
    /// What produced this proposal.
    pub origin: ProposalOrigin,
}

impl WorkOrder {
    /// Construct a work order. Complete on construction, immutable thereafter.
    pub fn new(
        intent: Intent,
        calls: Vec<ToolCall>,
        requests: GrantSet,
        origin: ProposalOrigin,
    ) -> Self {
        Self {
            intent,
            calls,
            requests,
            origin,
        }
    }

    /// This order's content address.
    ///
    /// # Errors
    ///
    /// Returns [`CanonicalError`] if the order cannot be canonically encoded.
    pub fn id(&self) -> Result<Digest, CanonicalError> {
        self.digest()
    }
}

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

    fn order() -> WorkOrder {
        WorkOrder::new(
            Intent::new("patch.apply", &"fix parse_header").unwrap(),
            vec![ToolCall::new("write_file", &("/src/a.rs", "contents")).unwrap()],
            GrantSet::new([GrantAtom::new("fs", "write", "/src/**")]),
            ProposalOrigin::new(
                "claude-opus-5",
                [("temperature".into(), "0".into())],
                Digest::of(b"prompt"),
            ),
        )
    }

    #[test]
    fn carries_intent_calls_requests_and_origin() {
        let o = order();
        assert_eq!(o.intent.kind, "patch.apply");
        assert_eq!(o.calls.len(), 1);
        assert_eq!(o.requests.len(), 1);
        assert_eq!(o.origin.model, "claude-opus-5");
    }

    #[test]
    fn origin_traces_back_to_what_produced_it() {
        let o = order();
        assert_eq!(o.origin.prompt, Digest::of(b"prompt"));
        assert_eq!(o.origin.sampling, vec![("temperature".into(), "0".into())]);
    }

    #[test]
    fn sampling_order_does_not_change_identity() {
        let prompt = Digest::of(b"p");
        let a = ProposalOrigin::new(
            "m",
            [("a".into(), "1".into()), ("b".into(), "2".into())],
            prompt,
        );
        let b = ProposalOrigin::new(
            "m",
            [("b".into(), "2".into()), ("a".into(), "1".into())],
            prompt,
        );
        assert_eq!(a.digest().unwrap(), b.digest().unwrap());
    }

    #[test]
    fn has_a_stable_digest() {
        assert_eq!(order().digest().unwrap(), order().digest().unwrap());
    }

    #[test]
    fn changing_the_request_changes_the_digest() {
        let base = order().digest().unwrap();
        let mut widened = order();
        widened.requests = GrantSet::new([GrantAtom::new("fs", "write", "/**")]);
        assert_ne!(base, widened.digest().unwrap());
    }

    #[test]
    fn round_trips_through_canonical_cbor() {
        let o = order();
        let bytes = crate::to_canonical_cbor(&o).unwrap();
        let back: WorkOrder = crate::from_canonical_cbor(&bytes).unwrap();
        assert_eq!(o, back);
    }

    #[test]
    fn unrepresentable_payloads_are_a_typed_error_not_a_panic() {
        // f64::NAN has no canonical CBOR representation that round-trips by
        // equality; the point is that a bad payload returns Err.
        let bad = Intent::new("x", &std::collections::HashMap::<Vec<u8>, u8>::new());
        assert!(bad.is_ok(), "sanity: ordinary payloads succeed");

        struct Fails;
        impl Serialize for Fails {
            fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
                Err(serde::ser::Error::custom("nope"))
            }
        }
        assert!(matches!(
            Intent::new("x", &Fails),
            Err(CanonicalError::Encode(_))
        ));
    }
}