Skip to main content

waitprims_core/
contract.rs

1//! Resolve `contract: agent-wait/v0` through the vendored pin.
2//!
3//! The L2 entry is `contract.json`: verify `capability`, then load the
4//! relative `entry_schema`. `$id` is not the contract-entry mechanism.
5
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::Path;
9
10use serde::Deserialize;
11use serde_json::{Map, Value};
12
13use crate::error::{Error, Result, ValidationError};
14use crate::types::MessageType;
15
16/// Opaque capability token for this pin.
17pub const CAPABILITY: &str = "contract: agent-wait/v0";
18
19/// Crucible commit this tree vendors.
20pub const PINNED_CRUCIBLE_SHA: &str = "f1912957cde19b2b1e7809e430cc28dc417287cc";
21
22// Crate-local copies so `cargo package` can verify without the workspace
23// tree. Tests assert these match `schemas/v0`.
24const BUNDLED_CONTRACT: &str = include_str!("../bundled/contract.json");
25const BUNDLED_ENTRY_SCHEMA: &str = include_str!("../bundled/agent-wait-message.schema.json");
26
27/// Capability manifest on disk or bundled with the crate.
28#[derive(Debug, Clone, Deserialize)]
29pub struct ContractManifest {
30    /// Exact capability token.
31    pub capability: String,
32    /// Relative entry schema file name.
33    pub entry_schema: String,
34}
35
36/// A resolved pin: verified capability plus loaded entry schema.
37#[derive(Debug, Clone)]
38pub struct ResolvedContract {
39    /// Verified capability token.
40    pub capability: String,
41    /// Relative entry schema name from the manifest.
42    pub entry_schema_name: String,
43    /// Parsed entry schema document.
44    pub entry_schema: Value,
45}
46
47/// Resolve the bundled pin for `capability`.
48///
49/// Fails closed when the capability does not match the manifest.
50pub fn resolve_bundled(capability: &str) -> Result<ResolvedContract> {
51    let manifest = parse_manifest(BUNDLED_CONTRACT)?;
52    verify_capability(&manifest, capability)?;
53    let entry_schema = parse_schema(BUNDLED_ENTRY_SCHEMA)?;
54    Ok(ResolvedContract {
55        capability: manifest.capability,
56        entry_schema_name: manifest.entry_schema,
57        entry_schema,
58    })
59}
60
61/// Resolve `capability` from a vendored directory containing `contract.json`.
62///
63/// Loads the relative `entry_schema`. Does not look up schema `$id`.
64pub fn resolve_from_dir(dir: &Path, capability: &str) -> Result<ResolvedContract> {
65    let manifest_path = dir.join("contract.json");
66    let raw = fs::read_to_string(&manifest_path).map_err(|_| Error::Contract {
67        path: "contract.json",
68        constraint: "missing_or_unreadable",
69    })?;
70    let manifest = parse_manifest(&raw)?;
71    verify_capability(&manifest, capability)?;
72    if manifest.entry_schema.is_empty()
73        || manifest.entry_schema.contains('\0')
74        || Path::new(&manifest.entry_schema).is_absolute()
75        || manifest.entry_schema.split(['/', '\\']).any(|p| p == "..")
76    {
77        return Err(Error::Contract {
78            path: "entry_schema",
79            constraint: "missing_or_unreadable",
80        });
81    }
82    let schema_path = dir.join(&manifest.entry_schema);
83    let schema_raw = fs::read_to_string(&schema_path).map_err(|_| Error::Contract {
84        path: "entry_schema",
85        constraint: "missing_or_unreadable",
86    })?;
87    let entry_schema = parse_schema(&schema_raw)?;
88    Ok(ResolvedContract {
89        capability: manifest.capability,
90        entry_schema_name: manifest.entry_schema,
91        entry_schema,
92    })
93}
94
95fn parse_manifest(raw: &str) -> Result<ContractManifest> {
96    serde_json::from_str(raw).map_err(|_| Error::Contract {
97        path: "contract.json",
98        constraint: "malformed",
99    })
100}
101
102fn parse_schema(raw: &str) -> Result<Value> {
103    serde_json::from_str(raw).map_err(|_| Error::Contract {
104        path: "entry_schema",
105        constraint: "malformed",
106    })
107}
108
109/// Bundled entry schema document, including `$id`.
110pub fn bundled_entry_schema() -> Result<Value> {
111    Ok(resolve_bundled(CAPABILITY)?.entry_schema)
112}
113
114/// JSON Schema for one admitted `message_type`, selected from `$defs`.
115///
116/// The camel-case def name stays inside this module. The returned document
117/// carries `type`, `properties`, and the referenced `$defs`. It does not
118/// assign `$id`: a schema resource base URI cannot contain a fragment, and
119/// this extraction is not a second registered resource.
120pub fn bundled_message_schema(kind: MessageType) -> Result<Value> {
121    select_message_schema(&bundled_entry_schema()?, kind)
122}
123
124fn def_name_for(kind: MessageType) -> &'static str {
125    match kind {
126        MessageType::RegistrationSet => "registrationSet",
127        MessageType::LiveWaitRequest => "liveWaitRequest",
128        MessageType::LiveWaitOutcome => "liveWaitOutcome",
129        MessageType::PollCycleRequest => "pollCycleRequest",
130        MessageType::PollCycleOutcome => "pollCycleOutcome",
131        MessageType::PollCycleAck => "pollCycleAck",
132    }
133}
134
135fn select_message_schema(entry: &Value, kind: MessageType) -> Result<Value> {
136    let def_name = def_name_for(kind);
137    let defs = entry
138        .get("$defs")
139        .and_then(Value::as_object)
140        .ok_or_else(|| ValidationError::new("/$defs", "missing"))?;
141    let def = defs
142        .get(def_name)
143        .ok_or_else(|| ValidationError::new("/$defs", "missing_kind"))?;
144    let def_obj = def
145        .as_object()
146        .ok_or_else(|| ValidationError::new("/$defs", "missing_kind"))?;
147
148    let mut needed = BTreeSet::new();
149    collect_def_refs(def, &mut needed);
150    let mut stack: Vec<String> = needed.iter().cloned().collect();
151    while let Some(name) = stack.pop() {
152        let Some(node) = defs.get(&name) else {
153            return Err(ValidationError::new("/$defs", "missing_kind").into());
154        };
155        let mut extra = BTreeSet::new();
156        collect_def_refs(node, &mut extra);
157        for name in extra {
158            if needed.insert(name.clone()) {
159                stack.push(name);
160            }
161        }
162    }
163
164    let mut selected = Map::new();
165    for name in &needed {
166        let node = defs
167            .get(name)
168            .ok_or_else(|| ValidationError::new("/$defs", "missing_kind"))?;
169        selected.insert(name.clone(), node.clone());
170    }
171
172    let mut out = Map::new();
173    if let Some(schema) = entry.get("$schema") {
174        out.insert("$schema".to_string(), schema.clone());
175    }
176    for (key, value) in def_obj {
177        if key == "$id" {
178            continue;
179        }
180        out.insert(key.clone(), value.clone());
181    }
182    out.insert("$defs".to_string(), Value::Object(selected));
183    Ok(Value::Object(out))
184}
185
186fn collect_def_refs(value: &Value, out: &mut BTreeSet<String>) {
187    match value {
188        Value::Object(map) => {
189            if let Some(Value::String(reference)) = map.get("$ref") {
190                if let Some(name) = reference.strip_prefix("#/$defs/") {
191                    if !name.is_empty() && !name.contains('/') {
192                        out.insert(name.to_string());
193                    }
194                }
195            }
196            for child in map.values() {
197                collect_def_refs(child, out);
198            }
199        }
200        Value::Array(items) => {
201            for child in items {
202                collect_def_refs(child, out);
203            }
204        }
205        _ => {}
206    }
207}
208
209fn verify_capability(manifest: &ContractManifest, capability: &str) -> Result<()> {
210    if manifest.capability != capability || capability != CAPABILITY {
211        return Err(Error::Contract {
212            path: "capability",
213            constraint: "mismatch",
214        });
215    }
216    if manifest.entry_schema.is_empty() {
217        return Err(Error::Contract {
218            path: "entry_schema",
219            constraint: "missing_or_unreadable",
220        });
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn bundled_pin_resolves_through_contract_json() {
231        let resolved = resolve_bundled(CAPABILITY).unwrap();
232        assert_eq!(resolved.capability, CAPABILITY);
233        assert_eq!(resolved.entry_schema_name, "agent-wait-message.schema.json");
234        assert!(resolved.entry_schema.get("$id").is_some());
235        assert_ne!(
236            resolved.entry_schema["$id"].as_str().unwrap_or(""),
237            CAPABILITY
238        );
239    }
240
241    #[test]
242    fn schema_id_is_not_the_entry_mechanism() {
243        let err = resolve_bundled("contract:agent-wait/v0/agent-wait-message.schema.json");
244        assert!(matches!(
245            err,
246            Err(Error::Contract {
247                path: "capability",
248                constraint: "mismatch"
249            })
250        ));
251    }
252
253    #[test]
254    fn unknown_capability_fails_closed() {
255        let err = resolve_bundled("contract: service-job/v0");
256        assert!(matches!(
257            err,
258            Err(Error::Contract {
259                path: "capability",
260                constraint: "mismatch"
261            })
262        ));
263    }
264
265    #[test]
266    fn bundled_pin_bytes_match_vendored_schemas() {
267        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas/v0");
268        let contract = fs::read_to_string(root.join("contract.json")).unwrap();
269        let entry = fs::read_to_string(root.join("agent-wait-message.schema.json")).unwrap();
270        assert_eq!(BUNDLED_CONTRACT, contract);
271        assert_eq!(BUNDLED_ENTRY_SCHEMA, entry);
272    }
273
274    #[test]
275    fn bundled_entry_schema_is_the_pin_document() {
276        let schema = bundled_entry_schema().unwrap();
277        assert_eq!(
278            schema["$id"],
279            "contract:agent-wait/v0/agent-wait-message.schema.json"
280        );
281        assert!(schema.get("oneOf").is_some());
282        assert!(schema.get("$defs").is_some());
283        assert!(schema.get("properties").is_some());
284        assert_eq!(schema, resolve_bundled(CAPABILITY).unwrap().entry_schema);
285    }
286
287    #[test]
288    fn bundled_message_schema_is_the_kind_definition() {
289        let schema = bundled_message_schema(MessageType::LiveWaitOutcome).unwrap();
290        assert!(
291            schema.get("$id").is_none(),
292            "extracted kind schema must not mint a fragment $id: {schema}"
293        );
294        assert_eq!(schema["type"], "object");
295        assert!(schema.get("properties").is_some());
296        assert_eq!(
297            schema["properties"]["message_type"]["const"],
298            "live_wait_outcome"
299        );
300        let defs = schema["$defs"].as_object().expect("$defs");
301        assert!(defs.contains_key("waitEvent"));
302        assert!(defs.contains_key("outcomeKind"));
303        assert!(!defs.contains_key("liveWaitOutcome"));
304        assert!(!defs.contains_key("pollCycleAck"));
305    }
306
307    #[test]
308    fn bundled_message_schema_compiles_and_admits_each_kind_example() {
309        for kind in MessageType::ALL {
310            let schema = bundled_message_schema(kind).unwrap();
311            assert!(
312                schema.get("$id").is_none(),
313                "{} must omit $id: {schema}",
314                kind.as_str()
315            );
316            let validator = jsonschema::validator_for(&schema).unwrap_or_else(|err| {
317                panic!(
318                    "bundled_message_schema({}) must compile: {err}",
319                    kind.as_str()
320                )
321            });
322            let example = kind_example(kind);
323            assert!(
324                validator.is_valid(&example),
325                "{} schema must admit its pinned example",
326                kind.as_str()
327            );
328        }
329    }
330
331    fn kind_example(kind: MessageType) -> Value {
332        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
333            .join("../../schemas/v0/examples")
334            .join(format!("{}.example.json", kind.as_str()));
335        let raw = fs::read_to_string(&path)
336            .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
337        serde_json::from_str(&raw).unwrap_or_else(|err| panic!("parse {}: {err}", path.display()))
338    }
339}