Skip to main content

pointlock_ir/
hash.rs

1//! Content addressing: `irHash` / `effectHash` / `judgeHash` (02 §12,
2//! spine §3 dual-hash constitutional clause).
3//!
4//! All three hashes share one construction (02 §12.2):
5//!
6//! ```text
7//! sha256( utf8( domainTag + "\n" + JCS(subtree) ) )   →   "sha256:<hex>"
8//! ```
9//!
10//! where `JCS(subtree)` is [`crate::canonical::to_canonical_json`] and the
11//! domain tag both separates hash domains (no cross-domain collisions) and
12//! carries the `irVersion` (02 §11: a version bump changes every hash, so
13//! cross-generation resume alignment naturally reports everything
14//! `effectDirty`):
15//!
16//! | hash | domain tag | input subtree |
17//! |---|---|---|
18//! | `irHash` | `pointlock-ir/1/irHash` | whole `FlowIR` minus `irHash` (self-reference) and `sourceMap` (pure diagnostics) |
19//! | `effectHash` | `pointlock-ir/1/effectHash/<kind>` | the step's "what it does to the world" field domain (02 §12.3) |
20//! | `judgeHash` | `pointlock-ir/1/judgeHash/<kind>` | the step's "how it is judged" field domain (02 §12.3) |
21//!
22//! Per-step `effectHash`/`judgeHash` stay inside the `irHash` input (they
23//! are deterministic derived values; keeping them makes a single file
24//! self-checkable — `pointlock inspect` recomputes and compares). `irHash`
25//! covers every callee transitively through the `subflows.*.irHash` pins —
26//! the link-closure property (02 §6).
27//!
28//! ## The per-kind dual-hash domain table (02 §12.3, transcribed verbatim)
29//!
30//! | kind | effectHash domain | judgeHash domain |
31//! |---|---|---|
32//! | `action` | `kind` `effect` `idempotent` `binding` `outputs` `outputSchema` | `preflight` `assertions` |
33//! | `assert` | `kind` | `preflight` `observe` `assertions` |
34//! | `call` | `kind` `flowRef` `inputs` | `preflight` |
35//! | `human` | `kind` `mode` `prompt` `presents` `decisions` `outputSchema` | `preflight` |
36//! | `if` | `kind` `cond` | `preflight` |
37//! | `foreach` | `kind` `items` `as` | `preflight` |
38//! | `let` | `kind` `bindings` | `preflight` |
39//!
40//! Adjudications this table encodes (02 §12.3, items 1–6):
41//!
42//! 1. `stepId` enters neither hash — identity vs. content, the pivot of
43//!    resume alignment.
44//! 2. `retry` / `timeoutMs` / `checkpoint` / `handlers` / `verb` enter
45//!    neither hash: budgets, checkpoint granularity, error strategy and
46//!    report labels do not touch the validity of an already-recorded
47//!    execution. They still enter `irHash`.
48//! 3. `outputs` / `outputSchema` sit in `effectHash` (conservative ruling:
49//!    the output projection is the step's data contract downstream
50//!    `resolvedInputs` depend on; offline re-projection is deferred to
51//!    v0.2).
52//! 4. A human step's entire question domain (`mode` `prompt` `presents`
53//!    `decisions` `outputSchema`) is in `effectHash`: an answer binds to
54//!    the question asked, so any question change must re-ask. Its
55//!    `judgeHash` domain is `preflight` only. (`onTimeout` is const
56//!    `"unknown"` in v0.1 and `timeoutMs` is a budget per item 2.)
57//! 5. Container steps (`if`/`foreach`) hash WITHOUT their subtrees
58//!    (`then`/`else`/`body` are absent from both domains): child steps
59//!    carry their own identity and hashes, the container answers only for
60//!    its control decision (`cond` / `items`+`as`).
61//! 6. `preflight` is the sole non-assertion member of every judge domain;
62//!    the aligner's preflight-only `judgeDirty` reuse rule (02 §12.3 item
63//!    6) relies on comparing exactly that subdomain.
64//!
65//! Absence-by-omission carries through: an optional domain field that is
66//! absent from the step is absent from the subtree (never `null`), matching
67//! canonical-form rule 3.
68
69use 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
77/// The domain tag of `irHash` for this crate's `irVersion`:
78/// `pointlock-ir/1/irHash`.
79pub fn ir_hash_domain_tag() -> String {
80    format!("pointlock-ir/{}/irHash", IrVersion::VALUE)
81}
82
83/// The domain tag of `effectHash` for a step kind:
84/// `pointlock-ir/1/effectHash/<kind>`.
85pub fn effect_hash_domain_tag(kind: &str) -> String {
86    format!("pointlock-ir/{}/effectHash/{kind}", IrVersion::VALUE)
87}
88
89/// The domain tag of `judgeHash` for a step kind:
90/// `pointlock-ir/1/judgeHash/<kind>`.
91pub fn judge_hash_domain_tag(kind: &str) -> String {
92    format!("pointlock-ir/{}/judgeHash/{kind}", IrVersion::VALUE)
93}
94
95/// The generic hash construction of 02 §12.2:
96/// `sha256(utf8(domainTag + "\n" + JCS(subtree)))`, rendered as
97/// `"sha256:<64 lowercase hex>"`.
98pub 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
108/// The wire-field allowlist of a step kind's `effectHash` domain (02 §12.3).
109fn 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
136/// The wire-field allowlist of a step kind's `judgeHash` domain (02 §12.3).
137fn 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
146/// Projects a step's wire form onto a field allowlist. Fields absent from
147/// the step (unset optionals) stay absent from the subtree — the
148/// absence-by-omission rule carries into the hash input.
149fn 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
164/// Computes a step's `effectHash`: the canonical hash of "what this step
165/// does to the world" (02 §12.3; see the module docs for the per-kind
166/// domain table). Ignores the `effectHash`/`judgeHash` values currently
167/// stored on the step, so it serves both sealing (compute) and inspection
168/// (recompute and compare).
169pub 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
177/// Projects a step's judge domain WITHOUT its `preflight` sub-domain
178/// (02 §12.3 ruling 6): the part of "how this step is judged" that an
179/// offline re-judge would actually re-evaluate. Two steps whose values here
180/// are byte-equal differ — if their `judgeHash`es differ at all — only in
181/// `preflight`, and a preflight change never invalidates an archived
182/// verdict (probes run before the act; the judgment judged the same
183/// question). The comparison needs both IRs, which is exactly what
184/// `ResumeOptions::old_flow_ir` exists to supply.
185pub 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
194/// Computes a step's `judgeHash`: the canonical hash of "how this step is
195/// judged" (02 §12.3; see the module docs for the per-kind domain table).
196/// Ignores the hash values currently stored on the step.
197pub 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
205/// Computes a flow's `irHash`: the canonical whole-tree hash, excluding
206/// exactly two root fields (02 §12.2) — `irHash` itself (self-reference)
207/// and `sourceMap` (pure diagnostics: moving a comment or a macro call site
208/// must not invalidate resume history). Everything else participates,
209/// including each step's stored `effectHash`/`judgeHash` and the
210/// `subflows.*.irHash` pins that extend coverage over the whole link
211/// closure (02 §6). Ignores the `irHash` value currently stored on the
212/// flow, so it serves both sealing and `pointlock inspect` recomputation.
213pub 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    /// `"sha256:"` + 64 repetitions of `c` — placeholder hashes for
236    /// fixtures (the functions under test ignore stored hash values).
237    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        // sha256("t\n{}") — pins the exact construction
319        // domainTag + "\n" + JCS(subtree).
320        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        // Deterministic.
336        assert_eq!(e, domain_hash(&effect_hash_domain_tag("assert"), &subtree));
337    }
338
339    /// Task check 1: the same IR with reordered object keys hashes
340    /// identically end to end (parse → FlowIR → irHash).
341    #[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        // Same document, object member order permuted at every level.
361        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    /// Task check 2: changing only an assertion moves judgeHash and leaves
385    /// effectHash untouched (spine §3: repair only the judgment → history
386    /// stays re-judgeable offline).
387    #[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        // The whole-tree hash still moves: new IR is a different flow.
403        assert_ne!(ir_hash(&flow), ir_hash(&changed));
404    }
405
406    /// Task check 3: changing only an argument expression moves effectHash
407    /// and leaves judgeHash untouched.
408    #[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    /// Adjudication 6 groundwork: preflight lives in the judge domain (of
426    /// every kind), never in the effect domain.
427    #[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    /// Adjudications 1–2: identity (stepId) and budget/strategy/label
443    /// fields (checkpoint, timeoutMs, verb) move neither hash — but still
444    /// move irHash.
445    #[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    /// Call steps: flowRef + inputs are effect; the judge domain is
464    /// preflight only.
465    #[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    /// Adjudication 4: a human step's question domain (here: prompt) is
485    /// effect — old answers do not transfer to a changed question.
486    #[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    /// Adjudication 5: container hashes exclude the subtree — changing a
505    /// child step moves neither container hash.
506    #[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        // But the control decision is effect:
525        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    /// The assert kind's effect domain is `kind` alone: two structurally
534    /// different assert steps share one effectHash (and it is pinned by the
535    /// known vector for `{"kind":"assert"}`).
536    #[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        // sha256("pointlock-ir/1/effectHash/assert\n{\"kind\":\"assert\"}")
561        assert_eq!(
562            effect_hash(&a).as_str(),
563            "sha256:9c76efbea403620940da9e87d44460e22c1b6f6081c9a95821838d1d34991d67"
564        );
565        // observe is judge-side (offline re-judgeability declaration):
566        assert_ne!(judge_hash(&a), judge_hash(&b));
567    }
568
569    /// irHash exclusions (02 §12.2): the stored irHash value and the whole
570    /// sourceMap are outside the hash; everything else is inside.
571    #[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        // Stored per-step hashes DO participate (self-checkable artifact):
581        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    /// Link closure (02 §12.2/§6): a callee pin change in `subflows`
588    /// changes the caller's irHash.
589    #[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}