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}