waitprims_core/
contract.rs1use 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
16pub const CAPABILITY: &str = "contract: agent-wait/v0";
18
19pub const PINNED_CRUCIBLE_SHA: &str = "f1912957cde19b2b1e7809e430cc28dc417287cc";
21
22const BUNDLED_CONTRACT: &str = include_str!("../bundled/contract.json");
25const BUNDLED_ENTRY_SCHEMA: &str = include_str!("../bundled/agent-wait-message.schema.json");
26
27#[derive(Debug, Clone, Deserialize)]
29pub struct ContractManifest {
30 pub capability: String,
32 pub entry_schema: String,
34}
35
36#[derive(Debug, Clone)]
38pub struct ResolvedContract {
39 pub capability: String,
41 pub entry_schema_name: String,
43 pub entry_schema: Value,
45}
46
47pub 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
61pub 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
109pub fn bundled_entry_schema() -> Result<Value> {
111 Ok(resolve_bundled(CAPABILITY)?.entry_schema)
112}
113
114pub 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}