Skip to main content

boundgraph_core/
work_order.rs

1//! The Work Order (`ω`) — a proposal, never a command.
2//!
3//! Law III begins here. When a reasoning engine decides to do something, that
4//! decision becomes a [`WorkOrder`]: a typed, immutable, reviewable record of
5//! what was *asked for*. Nothing has run, nothing has been authorised, and no
6//! permission has been granted.
7//!
8//! The type enforces that reading. There is no `execute`, no mutation, and the
9//! permissions field is named [`requests`](WorkOrder::requests) rather than
10//! `grants` — deciding what is actually permitted is the compiler's job
11//! (US-0403), not the model's.
12
13use serde::{Deserialize, Serialize};
14
15use crate::canonical::{Canonical, CanonicalError};
16use crate::digest::Digest;
17use crate::grant::GrantSet;
18
19/// What a work order intends to achieve.
20///
21/// The payload is schema-validated at construction. Whether intents come from
22/// a registered catalogue or stay free-form is still open (`ARCHITECTURE.md`
23/// §17), so the kind is a string for now and the payload is opaque CBOR.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Intent {
26    /// Names the kind of effect intended, e.g. `patch.apply`, `refund.issue`.
27    pub kind: String,
28    /// Structured detail, already validated against `kind`'s schema.
29    pub payload: ciborium::Value,
30}
31
32impl Intent {
33    /// Build an intent from any serialisable payload.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`CanonicalError::Encode`] if the payload cannot be represented
38    /// as CBOR. This is a typed error rather than a panic: a malformed
39    /// proposal is an expected runtime condition, not a bug.
40    pub fn new<T: Serialize>(kind: impl Into<String>, payload: &T) -> Result<Self, CanonicalError> {
41        Ok(Self {
42            kind: kind.into(),
43            payload: ciborium::Value::serialized(payload)
44                .map_err(|e| CanonicalError::Encode(e.to_string()))?,
45        })
46    }
47}
48
49/// One tool invocation a work order proposes.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct ToolCall {
52    /// Which tool, by registered name.
53    pub tool: String,
54    /// The arguments proposed for it.
55    pub args: ciborium::Value,
56}
57
58impl ToolCall {
59    /// Build a call from any serialisable arguments.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`CanonicalError::Encode`] if the arguments cannot be
64    /// represented as CBOR.
65    pub fn new<T: Serialize>(tool: impl Into<String>, args: &T) -> Result<Self, CanonicalError> {
66        Ok(Self {
67            tool: tool.into(),
68            args: ciborium::Value::serialized(args)
69                .map_err(|e| CanonicalError::Encode(e.to_string()))?,
70        })
71    }
72}
73
74/// Where a proposal came from.
75///
76/// Enough to trace a work order back to exactly what produced it. The prompt
77/// itself is referenced by digest rather than inlined, so the ledger stays
78/// small and the prompt stays retrievable from the content-addressed store.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ProposalOrigin {
81    /// The model that produced this, e.g. `claude-opus-5`.
82    pub model: String,
83    /// Sampling parameters, recorded so a run can be reproduced.
84    pub sampling: Vec<(String, String)>,
85    /// Digest of the exact prompt sent.
86    pub prompt: Digest,
87}
88
89impl ProposalOrigin {
90    /// Record the origin of a proposal.
91    ///
92    /// Sampling parameters are sorted, so two origins that differ only in the
93    /// order parameters were supplied share a digest.
94    pub fn new(
95        model: impl Into<String>,
96        sampling: impl IntoIterator<Item = (String, String)>,
97        prompt: Digest,
98    ) -> Self {
99        let mut sampling: Vec<_> = sampling.into_iter().collect();
100        sampling.sort();
101        Self {
102            model: model.into(),
103            sampling,
104            prompt,
105        }
106    }
107}
108
109/// `ω` — a typed declaration of intended effect. Immutable.
110///
111/// A work order is a *proposal*. It commits to nothing, authorises nothing,
112/// and grants nothing. It records what the model intends, which tools it wants
113/// to call, which permissions it is asking for, and where the proposal came
114/// from.
115///
116/// # Example
117///
118/// ```
119/// use boundgraph_core::{Canonical, Digest, GrantAtom, GrantSet, Intent, ProposalOrigin, WorkOrder};
120///
121/// let order = WorkOrder::new(
122///     Intent::new("patch.apply", &"fix the off-by-one in parse_header")?,
123///     vec![],
124///     GrantSet::new([GrantAtom::new("fs", "write", "/src/**")]),
125///     ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
126/// );
127///
128/// // A proposal asks; it does not receive.
129/// assert_eq!(order.requests.len(), 1);
130///
131/// // And it is content-addressed, so it can be referenced from the ledger.
132/// let _id = order.digest()?;
133/// # Ok::<(), boundgraph_core::CanonicalError>(())
134/// ```
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct WorkOrder {
137    /// What effect is intended.
138    pub intent: Intent,
139    /// The tool calls proposed, in the order proposed.
140    pub calls: Vec<ToolCall>,
141    /// The permissions the model is **asking for**.
142    ///
143    /// Named `requests`, not `grants`: what is actually permitted is decided
144    /// by the compiler and the grant gate, never by the proposer.
145    pub requests: GrantSet,
146    /// What produced this proposal.
147    pub origin: ProposalOrigin,
148}
149
150impl WorkOrder {
151    /// Construct a work order. Complete on construction, immutable thereafter.
152    pub fn new(
153        intent: Intent,
154        calls: Vec<ToolCall>,
155        requests: GrantSet,
156        origin: ProposalOrigin,
157    ) -> Self {
158        Self {
159            intent,
160            calls,
161            requests,
162            origin,
163        }
164    }
165
166    /// This order's content address.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`CanonicalError`] if the order cannot be canonically encoded.
171    pub fn id(&self) -> Result<Digest, CanonicalError> {
172        self.digest()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::grant::GrantAtom;
180
181    fn order() -> WorkOrder {
182        WorkOrder::new(
183            Intent::new("patch.apply", &"fix parse_header").unwrap(),
184            vec![ToolCall::new("write_file", &("/src/a.rs", "contents")).unwrap()],
185            GrantSet::new([GrantAtom::new("fs", "write", "/src/**")]),
186            ProposalOrigin::new(
187                "claude-opus-5",
188                [("temperature".into(), "0".into())],
189                Digest::of(b"prompt"),
190            ),
191        )
192    }
193
194    #[test]
195    fn carries_intent_calls_requests_and_origin() {
196        let o = order();
197        assert_eq!(o.intent.kind, "patch.apply");
198        assert_eq!(o.calls.len(), 1);
199        assert_eq!(o.requests.len(), 1);
200        assert_eq!(o.origin.model, "claude-opus-5");
201    }
202
203    #[test]
204    fn origin_traces_back_to_what_produced_it() {
205        let o = order();
206        assert_eq!(o.origin.prompt, Digest::of(b"prompt"));
207        assert_eq!(o.origin.sampling, vec![("temperature".into(), "0".into())]);
208    }
209
210    #[test]
211    fn sampling_order_does_not_change_identity() {
212        let prompt = Digest::of(b"p");
213        let a = ProposalOrigin::new(
214            "m",
215            [("a".into(), "1".into()), ("b".into(), "2".into())],
216            prompt,
217        );
218        let b = ProposalOrigin::new(
219            "m",
220            [("b".into(), "2".into()), ("a".into(), "1".into())],
221            prompt,
222        );
223        assert_eq!(a.digest().unwrap(), b.digest().unwrap());
224    }
225
226    #[test]
227    fn has_a_stable_digest() {
228        assert_eq!(order().digest().unwrap(), order().digest().unwrap());
229    }
230
231    #[test]
232    fn changing_the_request_changes_the_digest() {
233        let base = order().digest().unwrap();
234        let mut widened = order();
235        widened.requests = GrantSet::new([GrantAtom::new("fs", "write", "/**")]);
236        assert_ne!(base, widened.digest().unwrap());
237    }
238
239    #[test]
240    fn round_trips_through_canonical_cbor() {
241        let o = order();
242        let bytes = crate::to_canonical_cbor(&o).unwrap();
243        let back: WorkOrder = crate::from_canonical_cbor(&bytes).unwrap();
244        assert_eq!(o, back);
245    }
246
247    #[test]
248    fn unrepresentable_payloads_are_a_typed_error_not_a_panic() {
249        // f64::NAN has no canonical CBOR representation that round-trips by
250        // equality; the point is that a bad payload returns Err.
251        let bad = Intent::new("x", &std::collections::HashMap::<Vec<u8>, u8>::new());
252        assert!(bad.is_ok(), "sanity: ordinary payloads succeed");
253
254        struct Fails;
255        impl Serialize for Fails {
256            fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
257                Err(serde::ser::Error::custom("nope"))
258            }
259        }
260        assert!(matches!(
261            Intent::new("x", &Fails),
262            Err(CanonicalError::Encode(_))
263        ));
264    }
265}