1use serde_json::Value;
70use sha2::{Digest, Sha256};
71
72use crate::canonical::to_canonical_json;
73use crate::flow::FlowIR;
74use crate::primitives::{Hash, IrVersion};
75use crate::step::StepIR;
76
77pub fn ir_hash_domain_tag() -> String {
80 format!("pointlock-ir/{}/irHash", IrVersion::VALUE)
81}
82
83pub fn effect_hash_domain_tag(kind: &str) -> String {
86 format!("pointlock-ir/{}/effectHash/{kind}", IrVersion::VALUE)
87}
88
89pub fn judge_hash_domain_tag(kind: &str) -> String {
92 format!("pointlock-ir/{}/judgeHash/{kind}", IrVersion::VALUE)
93}
94
95pub fn domain_hash(domain_tag: &str, subtree: &Value) -> Hash {
99 let mut hasher = Sha256::new();
100 hasher.update(domain_tag.as_bytes());
101 hasher.update(b"\n");
102 hasher.update(to_canonical_json(subtree).as_bytes());
103 let digest = hasher.finalize();
104 let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
105 Hash::new(format!("sha256:{hex}")).expect("a sha256 hex digest is always grammatical")
106}
107
108fn effect_domain_fields(kind: &str) -> &'static [&'static str] {
110 match kind {
111 "action" => &[
112 "kind",
113 "effect",
114 "idempotent",
115 "binding",
116 "outputs",
117 "outputSchema",
118 ],
119 "assert" => &["kind"],
120 "call" => &["kind", "flowRef", "inputs"],
121 "human" => &[
122 "kind",
123 "mode",
124 "prompt",
125 "presents",
126 "decisions",
127 "outputSchema",
128 ],
129 "if" => &["kind", "cond"],
130 "foreach" => &["kind", "items", "as"],
131 "let" => &["kind", "bindings"],
132 other => unreachable!("StepIR::kind() is a closed 7-value set, got {other:?}"),
133 }
134}
135
136fn judge_domain_fields(kind: &str) -> &'static [&'static str] {
138 match kind {
139 "action" => &["preflight", "assertions"],
140 "assert" => &["preflight", "observe", "assertions"],
141 "call" | "human" | "if" | "foreach" | "let" => &["preflight"],
142 other => unreachable!("StepIR::kind() is a closed 7-value set, got {other:?}"),
143 }
144}
145
146fn step_domain_subtree(step: &StepIR, fields: &[&str]) -> Value {
150 let Value::Object(mut map) =
151 serde_json::to_value(step).expect("a StepIR always serializes to a JSON object")
152 else {
153 unreachable!("StepIR wire form is a kind-discriminated JSON object");
154 };
155 let mut domain = serde_json::Map::new();
156 for &field in fields {
157 if let Some(value) = map.remove(field) {
158 domain.insert(field.to_owned(), value);
159 }
160 }
161 Value::Object(domain)
162}
163
164pub fn effect_hash(step: &StepIR) -> Hash {
170 let kind = step.kind();
171 domain_hash(
172 &effect_hash_domain_tag(kind),
173 &step_domain_subtree(step, effect_domain_fields(kind)),
174 )
175}
176
177pub fn judge_subdomain_sans_preflight(step: &StepIR) -> Value {
186 let fields: Vec<&str> = judge_domain_fields(step.kind())
187 .iter()
188 .copied()
189 .filter(|field| *field != "preflight")
190 .collect();
191 step_domain_subtree(step, &fields)
192}
193
194pub fn judge_hash(step: &StepIR) -> Hash {
198 let kind = step.kind();
199 domain_hash(
200 &judge_hash_domain_tag(kind),
201 &step_domain_subtree(step, judge_domain_fields(kind)),
202 )
203}
204
205pub fn ir_hash(flow: &FlowIR) -> Hash {
214 let mut value =
215 serde_json::to_value(flow).expect("a FlowIR always serializes to a JSON object");
216 let root = value
217 .as_object_mut()
218 .expect("FlowIR wire form is a JSON object");
219 root.remove("irHash");
220 root.remove("sourceMap");
221 domain_hash(&ir_hash_domain_tag(), &value)
222}
223
224#[cfg(test)]
225mod tests {
226 use serde_json::{Value, json};
227
228 use super::*;
229 use crate::assertion::PredicateIR;
230 use crate::expr::Expr;
231 use crate::primitives::{Identifier, StepId};
232 use crate::step::StepIR;
233 use crate::vocab::{CanonicalVerb, ElementState};
234
235 fn h64(c: char) -> String {
238 format!("sha256:{}", c.to_string().repeat(64))
239 }
240
241 fn fixture_value() -> Value {
242 json!({
243 "irVersion": 1,
244 "flowId": "checkout",
245 "irHash": h64('a'),
246 "provider": { "name": "devicerail", "version": "0.4.2" },
247 "requiredFeatures": ["device.semanticActions.v1"],
248 "lockfileDigest": h64('b'),
249 "params": [
250 { "name": "ssid", "schema": { "type": "string", "minLength": 1 }, "required": true }
251 ],
252 "outputs": [
253 { "name": "wifi_verdict", "schema": { "enum": ["pass", "fail", "unknown"] },
254 "from": { "ref": "steps.open_wifi.verdict" } }
255 ],
256 "body": [
257 {
258 "kind": "action",
259 "stepId": "open_wifi",
260 "effectHash": h64('c'),
261 "judgeHash": h64('d'),
262 "checkpoint": true,
263 "effect": "mutating",
264 "idempotent": true,
265 "binding": { "attempts": [ {
266 "channel": "uiTree",
267 "actionName": "tapElement",
268 "args": { "elementId": { "lit": "wifi_row" } },
269 "acceptExecutionModes": ["nativeSemantic"],
270 "protection": "standard"
271 } ] },
272 "assertions": [ {
273 "assertId": "wifi_toggle_visible",
274 "predicate": { "type": "elementState",
275 "selector": { "identifier": "wifi_toggle" },
276 "state": "visible" },
277 "verifyVia": ["uiTree"],
278 "onMissingInput": "unknown"
279 } ]
280 },
281 {
282 "kind": "call",
283 "stepId": "login",
284 "effectHash": h64('e'),
285 "judgeHash": h64('f'),
286 "checkpoint": true,
287 "flowRef": { "flowId": "ensure_logged_in", "irHash": h64('1') },
288 "inputs": { "user": { "ref": "params.ssid" } }
289 }
290 ],
291 "verdictPolicy": "strict",
292 "sourceMap": [
293 { "irPath": "/body/0", "file": "checkout.yaml",
294 "span": { "startLine": 3, "startCol": 1, "endLine": 9, "endCol": 20 } }
295 ],
296 "subflows": {
297 "ensure_logged_in": { "flowId": "ensure_logged_in", "irHash": h64('1') }
298 }
299 })
300 }
301
302 fn fixture() -> FlowIR {
303 serde_json::from_value(fixture_value()).expect("fixture is schema-valid FlowIR")
304 }
305
306 fn action_step_mut(flow: &mut FlowIR) -> &mut crate::step::ActionStepIR {
307 match &mut flow.body[0] {
308 StepIR::Action(step) => step,
309 other => panic!(
310 "fixture body[0] must be an action step, got {}",
311 other.kind()
312 ),
313 }
314 }
315
316 #[test]
317 fn domain_hash_matches_known_vector() {
318 assert_eq!(
321 domain_hash("t", &json!({})).as_str(),
322 "sha256:53483cb46c6e871463e91efe3683f0ad7de603f6fff8eafbb2c42f5fef6d124e"
323 );
324 }
325
326 #[test]
327 fn domain_tags_separate_hash_domains() {
328 let subtree = json!({});
329 let e = domain_hash(&effect_hash_domain_tag("assert"), &subtree);
330 let j = domain_hash(&judge_hash_domain_tag("assert"), &subtree);
331 let i = domain_hash(&ir_hash_domain_tag(), &subtree);
332 assert_ne!(e, j);
333 assert_ne!(e, i);
334 assert_ne!(j, i);
335 assert_eq!(e, domain_hash(&effect_hash_domain_tag("assert"), &subtree));
337 }
338
339 #[test]
342 fn reordered_object_keys_do_not_move_ir_hash() {
343 const FLOW_KEYS_A: &str = r#"{
344 "irVersion": 1,
345 "flowId": "mini",
346 "irHash": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
347 "provider": { "name": "devicerail", "version": "1.0.0" },
348 "requiredFeatures": [],
349 "lockfileDigest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
350 "params": [ { "name": "label", "schema": { "type": "string", "minLength": 1 }, "required": false, "default": "x" } ],
351 "outputs": [],
352 "body": [ { "kind": "let", "stepId": "bind_label",
353 "effectHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
354 "judgeHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
355 "checkpoint": true, "bindings": { "label": { "lit": "x" } } } ],
356 "verdictPolicy": "standard",
357 "sourceMap": [],
358 "subflows": {}
359 }"#;
360 const FLOW_KEYS_B: &str = r#"{
362 "subflows": {},
363 "sourceMap": [],
364 "verdictPolicy": "standard",
365 "body": [ { "bindings": { "label": { "lit": "x" } }, "checkpoint": true,
366 "judgeHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
367 "effectHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
368 "stepId": "bind_label", "kind": "let" } ],
369 "outputs": [],
370 "params": [ { "default": "x", "required": false, "schema": { "minLength": 1, "type": "string" }, "name": "label" } ],
371 "lockfileDigest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
372 "requiredFeatures": [],
373 "provider": { "version": "1.0.0", "name": "devicerail" },
374 "irHash": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
375 "flowId": "mini",
376 "irVersion": 1
377 }"#;
378 let flow_a: FlowIR = serde_json::from_str(FLOW_KEYS_A).expect("valid FlowIR");
379 let flow_b: FlowIR = serde_json::from_str(FLOW_KEYS_B).expect("valid FlowIR");
380 assert_eq!(flow_a, flow_b);
381 assert_eq!(ir_hash(&flow_a), ir_hash(&flow_b));
382 }
383
384 #[test]
388 fn assertion_change_moves_judge_hash_only() {
389 let flow = fixture();
390 let (e1, j1) = (effect_hash(&flow.body[0]), judge_hash(&flow.body[0]));
391
392 let mut changed = fixture();
393 {
394 let step = action_step_mut(&mut changed);
395 match &mut step.assertions[0].predicate {
396 PredicateIR::ElementState { state, .. } => *state = ElementState::Enabled,
397 other => panic!("fixture assertion must be elementState, got {other:?}"),
398 }
399 }
400 assert_eq!(e1, effect_hash(&changed.body[0]));
401 assert_ne!(j1, judge_hash(&changed.body[0]));
402 assert_ne!(ir_hash(&flow), ir_hash(&changed));
404 }
405
406 #[test]
409 fn argument_change_moves_effect_hash_only() {
410 let flow = fixture();
411 let (e1, j1) = (effect_hash(&flow.body[0]), judge_hash(&flow.body[0]));
412
413 let mut changed = fixture();
414 {
415 let step = action_step_mut(&mut changed);
416 step.binding.attempts[0].args.insert(
417 Identifier::new("elementId").expect("valid identifier"),
418 Expr::lit("bluetooth_row"),
419 );
420 }
421 assert_ne!(e1, effect_hash(&changed.body[0]));
422 assert_eq!(j1, judge_hash(&changed.body[0]));
423 }
424
425 #[test]
428 fn preflight_change_moves_judge_hash_only() {
429 let flow = fixture();
430 let (e1, j1) = (effect_hash(&flow.body[0]), judge_hash(&flow.body[0]));
431
432 let mut changed = fixture();
433 {
434 let step = action_step_mut(&mut changed);
435 let probe = step.assertions[0].clone();
436 step.base.preflight = Some(vec![probe]);
437 }
438 assert_eq!(e1, effect_hash(&changed.body[0]));
439 assert_ne!(j1, judge_hash(&changed.body[0]));
440 }
441
442 #[test]
446 fn identity_and_budget_fields_move_neither_hash() {
447 let flow = fixture();
448 let (e1, j1) = (effect_hash(&flow.body[0]), judge_hash(&flow.body[0]));
449
450 let mut changed = fixture();
451 {
452 let step = action_step_mut(&mut changed);
453 step.base.step_id = StepId::new("open_wifi_renamed").expect("valid step id");
454 step.base.checkpoint = false;
455 step.base.timeout_ms = Some(9999);
456 step.verb = Some(CanonicalVerb::Tap);
457 }
458 assert_eq!(e1, effect_hash(&changed.body[0]));
459 assert_eq!(j1, judge_hash(&changed.body[0]));
460 assert_ne!(ir_hash(&flow), ir_hash(&changed));
461 }
462
463 #[test]
466 fn call_inputs_change_moves_effect_hash_only() {
467 let flow = fixture();
468 let (e1, j1) = (effect_hash(&flow.body[1]), judge_hash(&flow.body[1]));
469
470 let mut changed = fixture();
471 match &mut changed.body[1] {
472 StepIR::Call(call) => {
473 call.inputs.insert(
474 Identifier::new("user").expect("valid identifier"),
475 Expr::lit("admin"),
476 );
477 }
478 other => panic!("fixture body[1] must be a call step, got {}", other.kind()),
479 }
480 assert_ne!(e1, effect_hash(&changed.body[1]));
481 assert_eq!(j1, judge_hash(&changed.body[1]));
482 }
483
484 #[test]
487 fn human_prompt_change_moves_effect_hash_only() {
488 let human = |prompt: &str| -> StepIR {
489 serde_json::from_value(json!({
490 "kind": "human", "stepId": "approve",
491 "effectHash": h64('2'), "judgeHash": h64('3'), "checkpoint": true,
492 "mode": "judge", "prompt": prompt, "presents": [],
493 "decisions": ["pass", "fail", "unknown"],
494 "timeoutMs": 3600000, "onTimeout": "unknown"
495 }))
496 .expect("valid human step")
497 };
498 let a = human("Approve the run?");
499 let b = human("Approve the release?");
500 assert_ne!(effect_hash(&a), effect_hash(&b));
501 assert_eq!(judge_hash(&a), judge_hash(&b));
502 }
503
504 #[test]
507 fn container_hashes_exclude_the_subtree() {
508 let if_step = |child_value: &str| -> StepIR {
509 serde_json::from_value(json!({
510 "kind": "if", "stepId": "branch",
511 "effectHash": h64('4'), "judgeHash": h64('5'), "checkpoint": true,
512 "cond": { "lit": true },
513 "then": [ { "kind": "let", "stepId": "bind",
514 "effectHash": h64('6'), "judgeHash": h64('7'),
515 "checkpoint": false,
516 "bindings": { "x": { "lit": child_value } } } ]
517 }))
518 .expect("valid if step")
519 };
520 let a = if_step("a");
521 let b = if_step("b");
522 assert_eq!(effect_hash(&a), effect_hash(&b));
523 assert_eq!(judge_hash(&a), judge_hash(&b));
524 let mut cond_changed = if_step("a");
526 match &mut cond_changed {
527 StepIR::If(s) => s.cond = Expr::lit(false),
528 _ => unreachable!(),
529 }
530 assert_ne!(effect_hash(&a), effect_hash(&cond_changed));
531 }
532
533 #[test]
537 fn assert_step_effect_domain_is_kind_only() {
538 let assert_step = |observe: Value, state: &str| -> StepIR {
539 serde_json::from_value(json!({
540 "kind": "assert", "stepId": "check",
541 "effectHash": h64('8'), "judgeHash": h64('9'), "checkpoint": true,
542 "observe": observe,
543 "assertions": [ {
544 "assertId": "toggle_state",
545 "predicate": { "type": "elementState",
546 "selector": { "identifier": "wifi_toggle" },
547 "state": state },
548 "verifyVia": ["uiTree"],
549 "onMissingInput": "unknown"
550 } ]
551 }))
552 .expect("valid assert step")
553 };
554 let a = assert_step(json!("fresh"), "visible");
555 let b = assert_step(
556 json!({ "fromStep": "open_wifi", "which": "after" }),
557 "enabled",
558 );
559 assert_eq!(effect_hash(&a), effect_hash(&b));
560 assert_eq!(
562 effect_hash(&a).as_str(),
563 "sha256:9c76efbea403620940da9e87d44460e22c1b6f6081c9a95821838d1d34991d67"
564 );
565 assert_ne!(judge_hash(&a), judge_hash(&b));
567 }
568
569 #[test]
572 fn ir_hash_excludes_ir_hash_and_source_map() {
573 let flow = fixture();
574
575 let mut cosmetic = fixture();
576 cosmetic.ir_hash = Hash::new(h64('9')).expect("valid hash literal");
577 cosmetic.source_map.clear();
578 assert_eq!(ir_hash(&flow), ir_hash(&cosmetic));
579
580 let mut step_hash_changed = fixture();
582 action_step_mut(&mut step_hash_changed).base.effect_hash =
583 Hash::new(h64('9')).expect("valid hash literal");
584 assert_ne!(ir_hash(&flow), ir_hash(&step_hash_changed));
585 }
586
587 #[test]
590 fn ir_hash_covers_subflow_pins() {
591 let flow = fixture();
592 let mut repinned = fixture();
593 let callee = crate::primitives::FlowId::new("ensure_logged_in").expect("valid flow id");
594 repinned
595 .subflows
596 .get_mut(&callee)
597 .expect("fixture has the subflow entry")
598 .ir_hash = Hash::new(h64('2')).expect("valid hash literal");
599 assert_ne!(ir_hash(&flow), ir_hash(&repinned));
600 }
601}