Skip to main content

canwu_sim/runtime/
hashing.rs

1use super::{
2    ArtifactManifest, BOUNDARY_STATE_HASH_V1_PREFIX, BoundaryChange, BoundaryEmission, BoundaryId,
3    BoundaryIngressGeneration, BoundaryRecord, BoundaryStateHashFormat, COMMITMENT_FORMAT_VERSION,
4    CanwuError, CommandAttemptId, CommandAttemptRecord, CommandId, CommandRecord, DecisionState,
5    DomainRecord, DomainRecordChange, EntityRef, ErrorCode, EventId, GENESIS_BOUNDARY_HASH,
6    IngressId, IngressRecord, JournalCommitmentRoots, KnowledgeSnapshot, PluginComponentRecord,
7    PluginDescriptor, RandomDrawId, RandomDrawRecord, RandomStreamState, ReservationAllocation,
8    ReservationOfferRecord, ReservationRequestRecord, RunConfigurationSnapshot, RunManifest,
9    RuntimeDomainCommitmentRoots, STATE_REVISION_FORMAT_VERSION, Scenario, ScheduledRecord,
10    SchemaRegistry, SimEvent, SimTime, SimulationSnapshot, SystemCadence, WorldSnapshot,
11    boundary_state_hash_format, command_attempt_id_slice_is_empty, command_attempt_slice_is_empty,
12    component_key, domain_record_change_slice_is_empty, domain_record_slice_is_empty,
13    ingress_record_slice_is_empty, invalid_snapshot, invalid_snapshot_error, is_one_u64,
14    is_zero_u64, maintenance_change_slice_is_empty, manifest, policy,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::{BTreeMap, BTreeSet};
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20/// Canonical roots for independent authoritative state and evidence domains.
21pub struct CommitmentRoots {
22    pub world: String,
23    pub knowledge: String,
24    pub plugin_components: String,
25    pub domain_records: String,
26    #[serde(default, skip_serializing_if = "String::is_empty")]
27    pub decisions: String,
28    pub scheduler: String,
29    pub commands: String,
30    pub events: String,
31    pub ingress: String,
32    pub random: String,
33    pub boundary_chain: String,
34    pub identity: String,
35    pub control: String,
36}
37
38#[derive(Serialize)]
39pub(super) struct StateHashMaterial<'a> {
40    pub(super) engine_version: &'a str,
41    pub(super) snapshot_format_version: u32,
42    pub(super) run_manifest: &'a RunManifest,
43    pub(super) run_manifest_hash: &'a str,
44    pub(super) initial_time: SimTime,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub(super) initial_scenario: Option<&'a Scenario>,
47    pub(super) now: SimTime,
48    pub(super) plugin_registration_closed: bool,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub(super) entities: Option<&'a [EntityRef]>,
51    pub(super) world: &'a WorldSnapshot,
52    #[serde(skip_serializing_if = "person_availability_is_empty")]
53    pub(super) person_availability: &'a BTreeMap<super::PersonId, super::PersonAvailability>,
54    #[serde(skip_serializing_if = "created_person_slice_is_empty")]
55    pub(super) created_persons: &'a [super::CreatedPerson],
56    pub(super) knowledge: &'a KnowledgeSnapshot,
57    pub(super) events: &'a [SimEvent],
58    pub(super) commands: &'a [CommandRecord],
59    #[serde(skip_serializing_if = "command_attempt_slice_is_empty")]
60    pub(super) command_attempts: &'a [CommandAttemptRecord],
61    #[serde(skip_serializing_if = "ingress_record_slice_is_empty")]
62    pub(super) ingress: &'a [IngressRecord],
63    pub(super) plugin_components: &'a [PluginComponentRecord],
64    #[serde(skip_serializing_if = "domain_record_slice_is_empty")]
65    pub(super) domain_records: &'a [DomainRecord],
66    #[serde(skip_serializing_if = "DecisionState::is_empty")]
67    pub(super) decisions: &'a DecisionState,
68    pub(super) plugin_descriptors: &'a [PluginDescriptor],
69    pub(super) schema: &'a SchemaRegistry,
70    pub(super) scheduled: &'a [ScheduledRecord],
71    pub(super) root_seed: u64,
72    pub(super) authority_root_seed: u64,
73    pub(super) random_streams: &'a [RandomStreamState],
74    pub(super) random_draws: &'a [RandomDrawRecord],
75    pub(super) next_event_id: u64,
76    pub(super) next_command_id: u64,
77    #[serde(skip_serializing_if = "is_one_u64")]
78    pub(super) next_command_attempt_id: u64,
79    #[serde(skip_serializing_if = "is_one_u64")]
80    pub(super) next_ingress_id: u64,
81    pub(super) next_boundary_id: u64,
82    pub(super) next_random_draw_id: u64,
83    #[serde(skip_serializing_if = "is_one_u64")]
84    pub(super) next_knowledge_record_id: u64,
85    pub(super) next_schedule_sequence: u64,
86    pub(super) next_correlation_id: u64,
87    #[serde(skip_serializing_if = "is_one_u64")]
88    pub(super) next_decision_trace_id: u64,
89    #[serde(skip_serializing_if = "is_zero_u64")]
90    pub(super) next_person_id: u64,
91}
92
93#[allow(clippy::trivially_copy_pass_by_ref)]
94fn person_availability_is_empty(
95    value: &&BTreeMap<super::PersonId, super::PersonAvailability>,
96) -> bool {
97    value.is_empty()
98}
99
100fn created_person_slice_is_empty(value: &&[super::CreatedPerson]) -> bool {
101    value.is_empty()
102}
103
104#[derive(Serialize)]
105struct WorldCommitmentMaterial<'a> {
106    people: String,
107    governments: String,
108    territories: String,
109    routes: String,
110    armies: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    entities: Option<&'a [EntityRef]>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    person_availability: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    created_persons: Option<String>,
117}
118
119#[derive(Serialize)]
120struct SchedulerCommitmentMaterial {
121    now: SimTime,
122    scheduled: String,
123}
124
125#[derive(Serialize)]
126struct CommandCommitmentMaterial {
127    commands: String,
128    attempts: String,
129}
130
131#[derive(Serialize)]
132struct RandomCommitmentMaterial {
133    root_seed: u64,
134    streams: String,
135    draws: String,
136}
137
138#[derive(Serialize)]
139struct IdentityCommitmentMaterial<'a> {
140    engine_version: &'a str,
141    snapshot_format_version: u32,
142    run_manifest: &'a RunManifest,
143    run_manifest_hash: &'a str,
144    initial_time: SimTime,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    initial_scenario: Option<&'a Scenario>,
147    authority_root_seed: u64,
148    plugin_descriptors: String,
149    schema: &'a SchemaRegistry,
150}
151
152#[derive(Serialize)]
153pub(super) struct ControlCommitmentMaterial {
154    pub(super) plugin_registration_closed: bool,
155    pub(super) next_event_id: u64,
156    pub(super) next_command_id: u64,
157    pub(super) next_command_attempt_id: u64,
158    pub(super) next_ingress_id: u64,
159    pub(super) next_boundary_id: u64,
160    pub(super) next_random_draw_id: u64,
161    #[serde(skip_serializing_if = "is_one_u64")]
162    pub(super) next_knowledge_record_id: u64,
163    pub(super) next_schedule_sequence: u64,
164    pub(super) next_correlation_id: u64,
165    #[serde(skip_serializing_if = "is_one_u64")]
166    pub(super) next_decision_trace_id: u64,
167    #[serde(skip_serializing_if = "is_zero_u64")]
168    pub(super) next_person_id: u64,
169}
170
171#[derive(Serialize)]
172struct CheckpointHashMaterialV1<'a> {
173    state_hash: &'a str,
174    boundary_head: Option<&'a str>,
175}
176
177#[derive(Serialize)]
178struct CheckpointHashMaterialV2<'a> {
179    state_hash: &'a str,
180    boundary_head: Option<&'a str>,
181    run_manifest_hash: &'a str,
182}
183
184#[derive(Serialize)]
185struct CheckpointHashMaterialV3<'a> {
186    state_hash: &'a str,
187    boundary_head: Option<&'a str>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    run_manifest_hash: Option<&'a str>,
190    revision_format_version: u32,
191    state_revision: u64,
192    replay_revision_format_version: u32,
193}
194
195#[derive(Serialize)]
196struct CheckpointHashMaterialV4<'a> {
197    commitments: &'a CommitmentRoots,
198    run_manifest_hash: &'a str,
199    commitment_format_version: u32,
200    revision_format_version: u32,
201    state_revision: u64,
202    replay_revision_format_version: u32,
203}
204
205pub(super) fn state_hash(material: &StateHashMaterial<'_>) -> Result<String, CanwuError> {
206    canonical_hash("canwu.boundary-state.v1", material)
207}
208
209fn canonical_sorted_hash_by<T, K, F>(
210    domain: &str,
211    values: &[T],
212    mut key: F,
213) -> Result<String, CanwuError>
214where
215    T: Serialize,
216    K: Ord,
217    F: FnMut(&T) -> K,
218{
219    let mut ordered: Vec<_> = values.iter().collect();
220    ordered.sort_by_key(|value| key(value));
221    canonical_hash(domain, &ordered)
222}
223
224pub(super) fn committed_entities<'a>(
225    entities: &'a [EntityRef],
226    world: &WorldSnapshot,
227) -> Option<&'a [EntityRef]> {
228    (!entities.is_empty() && entities != super::scenario::legacy_entities(world))
229        .then_some(entities)
230}
231
232pub(super) fn committed_initial_scenario(scenario: Option<&Scenario>) -> Option<Scenario> {
233    scenario.cloned().map(|mut scenario| {
234        if scenario.entities == super::scenario::legacy_entities(&scenario.world) {
235            scenario.entities.clear();
236        }
237        scenario
238    })
239}
240
241pub(super) fn world_commitment_root(
242    world: &WorldSnapshot,
243    entities: &[EntityRef],
244    person_availability: &BTreeMap<super::PersonId, super::PersonAvailability>,
245    created_persons: &[super::CreatedPerson],
246) -> Result<String, CanwuError> {
247    canonical_hash(
248        "canwu.commitment.world.v1",
249        &WorldCommitmentMaterial {
250            people: canonical_sorted_hash_by(
251                "canwu.commitment.world.people.v1",
252                &world.people,
253                |value| value.id,
254            )?,
255            governments: canonical_sorted_hash_by(
256                "canwu.commitment.world.governments.v1",
257                &world.governments,
258                |value| value.id,
259            )?,
260            territories: canonical_sorted_hash_by(
261                "canwu.commitment.world.territories.v1",
262                &world.territories,
263                |value| value.id,
264            )?,
265            routes: canonical_sorted_hash_by(
266                "canwu.commitment.world.routes.v1",
267                &world.routes,
268                |value| value.id,
269            )?,
270            armies: canonical_sorted_hash_by(
271                "canwu.commitment.world.armies.v1",
272                &world.armies,
273                |value| value.id,
274            )?,
275            entities: committed_entities(entities, world),
276            person_availability: (!person_availability.is_empty())
277                .then(|| {
278                    canonical_hash(
279                        "canwu.commitment.world.person-availability.v1",
280                        person_availability,
281                    )
282                })
283                .transpose()?,
284            created_persons: (!created_persons.is_empty())
285                .then(|| {
286                    canonical_hash("canwu.commitment.world.created-persons.v1", created_persons)
287                })
288                .transpose()?,
289        },
290    )
291}
292
293pub(super) fn knowledge_commitment_root(
294    knowledge: &KnowledgeSnapshot,
295) -> Result<String, CanwuError> {
296    canonical_hash("canwu.commitment.knowledge.v1", knowledge)
297}
298
299pub(super) fn plugin_component_commitment_root(
300    components: &[PluginComponentRecord],
301) -> Result<String, CanwuError> {
302    canonical_sorted_hash_by(
303        "canwu.commitment.plugin-components.v1",
304        components,
305        |record| {
306            component_key(
307                &record.plugin,
308                &record.state,
309                &record.entity,
310                &record.component,
311            )
312        },
313    )
314}
315
316pub(super) fn domain_record_commitment_root(
317    records: &[DomainRecord],
318) -> Result<String, CanwuError> {
319    let records = records
320        .iter()
321        .cloned()
322        .map(|record| (record.reference.clone(), record))
323        .collect();
324    super::PersistentDomainRecordStore::from_records(records)?.commitment_root()
325}
326
327pub(super) fn decision_commitment_root(decisions: &DecisionState) -> Result<String, CanwuError> {
328    if decisions.is_empty() {
329        return Ok(String::new());
330    }
331    decisions
332        .authoritative_commitment()
333        .map_err(super::decision::decision_error)
334}
335
336pub(super) fn scheduler_commitment_root(
337    now: SimTime,
338    scheduled: &[ScheduledRecord],
339) -> Result<String, CanwuError> {
340    canonical_hash(
341        "canwu.commitment.scheduler.v1",
342        &SchedulerCommitmentMaterial {
343            now,
344            scheduled: canonical_sorted_hash_by(
345                "canwu.commitment.scheduler.entries.v1",
346                scheduled,
347                |record| record.key.clone(),
348            )?,
349        },
350    )
351}
352
353pub(super) fn random_stream_commitment_root(
354    streams: &[RandomStreamState],
355) -> Result<String, CanwuError> {
356    canonical_sorted_hash_by("canwu.commitment.random.streams.v1", streams, |stream| {
357        stream.key.clone()
358    })
359}
360
361#[allow(clippy::too_many_arguments)]
362pub(super) fn identity_commitment_root(
363    engine_version: &str,
364    snapshot_format_version: u32,
365    run_manifest: &RunManifest,
366    run_manifest_hash: &str,
367    initial_time: SimTime,
368    initial_scenario: Option<&Scenario>,
369    authority_root_seed: u64,
370    plugin_descriptors: &[PluginDescriptor],
371    schema: &SchemaRegistry,
372) -> Result<String, CanwuError> {
373    canonical_hash(
374        "canwu.commitment.identity.v1",
375        &IdentityCommitmentMaterial {
376            engine_version,
377            snapshot_format_version,
378            run_manifest,
379            run_manifest_hash,
380            initial_time,
381            initial_scenario,
382            authority_root_seed,
383            plugin_descriptors: canonical_sorted_hash_by(
384                "canwu.commitment.identity.plugins.v1",
385                plugin_descriptors,
386                |descriptor| descriptor.name.clone(),
387            )?,
388            schema,
389        },
390    )
391}
392
393fn commitment_roots(
394    material: &StateHashMaterial<'_>,
395    boundary_head: Option<&str>,
396    journal_roots: Option<&JournalCommitmentRoots>,
397) -> Result<CommitmentRoots, CanwuError> {
398    let world = world_commitment_root(
399        material.world,
400        material.entities.unwrap_or_default(),
401        material.person_availability,
402        material.created_persons,
403    )?;
404    let knowledge = knowledge_commitment_root(material.knowledge)?;
405    let plugin_components = plugin_component_commitment_root(material.plugin_components)?;
406    let domain_records = domain_record_commitment_root(material.domain_records)?;
407    let decisions = decision_commitment_root(material.decisions)?;
408    let scheduler = scheduler_commitment_root(material.now, material.scheduled)?;
409    let command_root = match journal_roots {
410        Some(roots) => roots.commands.clone(),
411        None => canonical_sorted_hash_by(
412            "canwu.commitment.commands.accepted.v1",
413            material.commands,
414            |record| record.id,
415        )?,
416    };
417    let attempt_root = match journal_roots {
418        Some(roots) => roots.attempts.clone(),
419        None => canonical_sorted_hash_by(
420            "canwu.commitment.commands.attempts.v1",
421            material.command_attempts,
422            |record| record.id,
423        )?,
424    };
425    let commands = canonical_hash(
426        "canwu.commitment.commands.v1",
427        &CommandCommitmentMaterial {
428            commands: command_root,
429            attempts: attempt_root,
430        },
431    )?;
432    let events = match journal_roots {
433        Some(roots) => roots.events.clone(),
434        None => canonical_sorted_hash_by("canwu.commitment.events.v1", material.events, |event| {
435            event.id
436        })?,
437    };
438    let ingress = match journal_roots {
439        Some(roots) => roots.ingress.clone(),
440        None => {
441            canonical_sorted_hash_by("canwu.commitment.ingress.v1", material.ingress, |record| {
442                record.id
443            })?
444        }
445    };
446    let random = canonical_hash(
447        "canwu.commitment.random.v1",
448        &RandomCommitmentMaterial {
449            root_seed: material.root_seed,
450            streams: random_stream_commitment_root(material.random_streams)?,
451            draws: match journal_roots {
452                Some(roots) => roots.random_draws.clone(),
453                None => canonical_sorted_hash_by(
454                    "canwu.commitment.random.draws.v1",
455                    material.random_draws,
456                    |draw| draw.id,
457                )?,
458            },
459        },
460    )?;
461    let boundary_chain = canonical_hash(
462        "canwu.commitment.boundary-chain.v1",
463        boundary_head.unwrap_or(GENESIS_BOUNDARY_HASH),
464    )?;
465    let identity = identity_commitment_root(
466        material.engine_version,
467        material.snapshot_format_version,
468        material.run_manifest,
469        material.run_manifest_hash,
470        material.initial_time,
471        material.initial_scenario,
472        material.authority_root_seed,
473        material.plugin_descriptors,
474        material.schema,
475    )?;
476    let control = canonical_hash(
477        "canwu.commitment.control.v1",
478        &ControlCommitmentMaterial {
479            plugin_registration_closed: material.plugin_registration_closed,
480            next_event_id: material.next_event_id,
481            next_command_id: material.next_command_id,
482            next_command_attempt_id: material.next_command_attempt_id,
483            next_ingress_id: material.next_ingress_id,
484            next_boundary_id: material.next_boundary_id,
485            next_random_draw_id: material.next_random_draw_id,
486            next_knowledge_record_id: material.next_knowledge_record_id,
487            next_schedule_sequence: material.next_schedule_sequence,
488            next_correlation_id: material.next_correlation_id,
489            next_decision_trace_id: material.next_decision_trace_id,
490            next_person_id: material.next_person_id,
491        },
492    )?;
493    Ok(CommitmentRoots {
494        world,
495        knowledge,
496        plugin_components,
497        domain_records,
498        decisions,
499        scheduler,
500        commands,
501        events,
502        ingress,
503        random,
504        boundary_chain,
505        identity,
506        control,
507    })
508}
509
510pub(super) fn runtime_commitment_roots(
511    domain: &RuntimeDomainCommitmentRoots,
512    journal: &JournalCommitmentRoots,
513    root_seed: u64,
514    boundary_head: Option<&str>,
515    control: &ControlCommitmentMaterial,
516) -> Result<CommitmentRoots, CanwuError> {
517    let commands = canonical_hash(
518        "canwu.commitment.commands.v1",
519        &CommandCommitmentMaterial {
520            commands: journal.commands.clone(),
521            attempts: journal.attempts.clone(),
522        },
523    )?;
524    let random = canonical_hash(
525        "canwu.commitment.random.v1",
526        &RandomCommitmentMaterial {
527            root_seed,
528            streams: domain.random_streams.clone(),
529            draws: journal.random_draws.clone(),
530        },
531    )?;
532    Ok(CommitmentRoots {
533        world: domain.world.clone(),
534        knowledge: domain.knowledge.clone(),
535        plugin_components: domain.plugin_components.clone(),
536        domain_records: domain.domain_records.clone(),
537        decisions: domain.decisions.clone(),
538        scheduler: domain.scheduler.clone(),
539        commands,
540        events: journal.events.clone(),
541        ingress: journal.ingress.clone(),
542        random,
543        boundary_chain: canonical_hash(
544            "canwu.commitment.boundary-chain.v1",
545            boundary_head.unwrap_or(GENESIS_BOUNDARY_HASH),
546        )?,
547        identity: domain.identity.clone(),
548        control: canonical_hash("canwu.commitment.control.v1", control)?,
549    })
550}
551
552pub(super) fn boundary_state_hash_for_commitments(
553    commitments: &CommitmentRoots,
554) -> Result<String, CanwuError> {
555    Ok(format!(
556        "{BOUNDARY_STATE_HASH_V1_PREFIX}{}",
557        canonical_hash("canwu.boundary-state.v1", commitments)?
558    ))
559}
560
561pub(super) fn authoritative_run_identity(
562    run_manifest: &RunManifest,
563    run_manifest_hash: &str,
564    run_configuration: &RunConfigurationSnapshot,
565) -> Result<(RunManifest, String), CanwuError> {
566    if !matches!(run_configuration, RunConfigurationSnapshot::Declared(_)) {
567        return Ok((run_manifest.clone(), run_manifest_hash.to_owned()));
568    }
569    let mut authoritative_manifest = run_manifest.clone();
570    let RunManifest::Declared {
571        run_configuration, ..
572    } = &mut authoritative_manifest;
573    **run_configuration = ArtifactManifest::new(
574        "canwu.core",
575        "authoritative-policy-excluded",
576        "1",
577        policy::authoritative_configuration_hash()?,
578    )?;
579    let authoritative_manifest_hash = manifest::hash(&authoritative_manifest)?;
580    Ok((authoritative_manifest, authoritative_manifest_hash))
581}
582
583pub(super) fn snapshot_state_hash(snapshot: &SimulationSnapshot) -> Result<String, CanwuError> {
584    let Some(run_manifest) = &snapshot.run_manifest else {
585        return Err(CanwuError::new(
586            ErrorCode::InvalidRunManifest,
587            "snapshot is missing its run manifest",
588        ));
589    };
590    let run_configuration = snapshot.run_configuration.as_ref().ok_or_else(|| {
591        CanwuError::new(
592            ErrorCode::InvalidRunConfiguration,
593            "snapshot is missing its run configuration",
594        )
595    })?;
596    let (authoritative_manifest, authoritative_manifest_hash) =
597        authoritative_run_identity(run_manifest, &snapshot.run_manifest_hash, run_configuration)?;
598    let initial_scenario = committed_initial_scenario(snapshot.initial_scenario.as_ref());
599    state_hash(&StateHashMaterial {
600        engine_version: &snapshot.engine_version,
601        snapshot_format_version: snapshot.snapshot_format_version,
602        run_manifest: &authoritative_manifest,
603        run_manifest_hash: &authoritative_manifest_hash,
604        initial_time: snapshot.initial_time,
605        initial_scenario: initial_scenario.as_ref(),
606        now: snapshot.now,
607        plugin_registration_closed: snapshot.plugin_registration_closed,
608        entities: committed_entities(&snapshot.entities, &snapshot.world),
609        world: &snapshot.world,
610        person_availability: &snapshot.person_availability,
611        created_persons: &snapshot.created_persons,
612        knowledge: &snapshot.knowledge,
613        events: &snapshot.events,
614        commands: &snapshot.commands,
615        command_attempts: &snapshot.command_attempts,
616        ingress: &snapshot.ingress,
617        plugin_components: &snapshot.plugin_components,
618        domain_records: &snapshot.domain_records,
619        decisions: &snapshot.decisions,
620        plugin_descriptors: &snapshot.plugin_descriptors,
621        schema: &snapshot.schema,
622        scheduled: &snapshot.scheduled,
623        root_seed: snapshot.root_seed,
624        authority_root_seed: snapshot.authority_root_seed,
625        random_streams: &snapshot.random_streams,
626        random_draws: &snapshot.random_draws,
627        next_event_id: snapshot.next_event_id,
628        next_command_id: snapshot.next_command_id,
629        next_command_attempt_id: snapshot.next_command_attempt_id,
630        next_ingress_id: snapshot.next_ingress_id,
631        next_boundary_id: snapshot.next_boundary_id,
632        next_random_draw_id: snapshot.next_random_draw_id,
633        next_knowledge_record_id: snapshot.next_knowledge_record_id,
634        next_schedule_sequence: snapshot.next_schedule_sequence,
635        next_correlation_id: snapshot.next_correlation_id,
636        next_decision_trace_id: snapshot.next_decision_trace_id,
637        next_person_id: snapshot.next_person_id,
638    })
639}
640
641pub(super) fn snapshot_boundary_head_state_hash(
642    snapshot: &SimulationSnapshot,
643) -> Result<String, CanwuError> {
644    let boundary = snapshot
645        .boundaries
646        .last()
647        .ok_or_else(|| invalid_snapshot_error("snapshot has no boundary head commitment"))?;
648    match boundary_state_hash_format(boundary.state_hash.as_deref())? {
649        BoundaryStateHashFormat::LegacyV0 => snapshot_state_hash(snapshot),
650        BoundaryStateHashFormat::CommitmentsV1 => {
651            if snapshot.commitment_format_version != COMMITMENT_FORMAT_VERSION {
652                return invalid_snapshot(
653                    "boundary state commitment v1 requires current domain commitments",
654                );
655            }
656            let mut roots = snapshot.commitment_roots.clone().ok_or_else(|| {
657                invalid_snapshot_error(
658                    "boundary state commitment v1 is missing current domain commitments",
659                )
660            })?;
661            roots.boundary_chain = canonical_hash(
662                "canwu.commitment.boundary-chain.v1",
663                boundary.previous_hash.as_str(),
664            )?;
665            boundary_state_hash_for_commitments(&roots)
666        }
667    }
668}
669
670pub(super) fn snapshot_commitment_roots(
671    snapshot: &SimulationSnapshot,
672) -> Result<CommitmentRoots, CanwuError> {
673    let Some(run_manifest) = &snapshot.run_manifest else {
674        return Err(CanwuError::new(
675            ErrorCode::InvalidRunManifest,
676            "snapshot is missing its run manifest",
677        ));
678    };
679    let run_configuration = snapshot.run_configuration.as_ref().ok_or_else(|| {
680        CanwuError::new(
681            ErrorCode::InvalidRunConfiguration,
682            "snapshot is missing its run configuration",
683        )
684    })?;
685    let (authoritative_manifest, authoritative_manifest_hash) =
686        authoritative_run_identity(run_manifest, &snapshot.run_manifest_hash, run_configuration)?;
687    let initial_scenario = committed_initial_scenario(snapshot.initial_scenario.as_ref());
688    commitment_roots(
689        &StateHashMaterial {
690            engine_version: &snapshot.engine_version,
691            snapshot_format_version: snapshot.snapshot_format_version,
692            run_manifest: &authoritative_manifest,
693            run_manifest_hash: &authoritative_manifest_hash,
694            initial_time: snapshot.initial_time,
695            initial_scenario: initial_scenario.as_ref(),
696            now: snapshot.now,
697            plugin_registration_closed: snapshot.plugin_registration_closed,
698            entities: committed_entities(&snapshot.entities, &snapshot.world),
699            world: &snapshot.world,
700            person_availability: &snapshot.person_availability,
701            created_persons: &snapshot.created_persons,
702            knowledge: &snapshot.knowledge,
703            events: &snapshot.events,
704            commands: &snapshot.commands,
705            command_attempts: &snapshot.command_attempts,
706            ingress: &snapshot.ingress,
707            plugin_components: &snapshot.plugin_components,
708            domain_records: &snapshot.domain_records,
709            decisions: &snapshot.decisions,
710            plugin_descriptors: &snapshot.plugin_descriptors,
711            schema: &snapshot.schema,
712            scheduled: &snapshot.scheduled,
713            root_seed: snapshot.root_seed,
714            authority_root_seed: snapshot.authority_root_seed,
715            random_streams: &snapshot.random_streams,
716            random_draws: &snapshot.random_draws,
717            next_event_id: snapshot.next_event_id,
718            next_command_id: snapshot.next_command_id,
719            next_command_attempt_id: snapshot.next_command_attempt_id,
720            next_ingress_id: snapshot.next_ingress_id,
721            next_boundary_id: snapshot.next_boundary_id,
722            next_random_draw_id: snapshot.next_random_draw_id,
723            next_knowledge_record_id: snapshot.next_knowledge_record_id,
724            next_schedule_sequence: snapshot.next_schedule_sequence,
725            next_correlation_id: snapshot.next_correlation_id,
726            next_decision_trace_id: snapshot.next_decision_trace_id,
727            next_person_id: snapshot.next_person_id,
728        },
729        snapshot
730            .boundaries
731            .last()
732            .map(|record| record.hash.as_str()),
733        None,
734    )
735}
736
737fn checkpoint_hash(state_hash: &str, boundary_head: Option<&str>) -> Result<String, CanwuError> {
738    canonical_hash(
739        "canwu.checkpoint.v1",
740        &CheckpointHashMaterialV1 {
741            state_hash,
742            boundary_head,
743        },
744    )
745}
746
747pub(super) fn checkpoint_hash_for_configuration(
748    state_hash: &str,
749    boundary_head: Option<&str>,
750    run_manifest_hash: &str,
751    run_configuration: &RunConfigurationSnapshot,
752    revision_format_version: u32,
753    state_revision: u64,
754    replay_revision_format_version: u32,
755) -> Result<String, CanwuError> {
756    if revision_format_version == STATE_REVISION_FORMAT_VERSION {
757        return canonical_hash(
758            "canwu.checkpoint.v3",
759            &CheckpointHashMaterialV3 {
760                state_hash,
761                boundary_head,
762                run_manifest_hash: matches!(
763                    run_configuration,
764                    RunConfigurationSnapshot::Declared(_)
765                )
766                .then_some(run_manifest_hash),
767                revision_format_version,
768                state_revision,
769                replay_revision_format_version,
770            },
771        );
772    }
773    if revision_format_version != 0 || state_revision != 0 || replay_revision_format_version != 0 {
774        return Err(CanwuError::new(
775            ErrorCode::UnsupportedSnapshotVersion,
776            format!(
777                "state revision format {revision_format_version} is unsupported; this engine writes format {STATE_REVISION_FORMAT_VERSION}"
778            ),
779        ));
780    }
781    if !matches!(run_configuration, RunConfigurationSnapshot::Declared(_)) {
782        return checkpoint_hash(state_hash, boundary_head);
783    }
784    canonical_hash(
785        "canwu.checkpoint.v2",
786        &CheckpointHashMaterialV2 {
787            state_hash,
788            boundary_head,
789            run_manifest_hash,
790        },
791    )
792}
793
794pub(super) fn checkpoint_hash_for_commitments(
795    commitments: &CommitmentRoots,
796    run_manifest_hash: &str,
797    commitment_format_version: u32,
798    revision_format_version: u32,
799    state_revision: u64,
800    replay_revision_format_version: u32,
801) -> Result<String, CanwuError> {
802    if commitment_format_version != COMMITMENT_FORMAT_VERSION {
803        return Err(CanwuError::new(
804            ErrorCode::UnsupportedSnapshotVersion,
805            format!(
806                "commitment format {commitment_format_version} is unsupported; this engine writes format {COMMITMENT_FORMAT_VERSION}"
807            ),
808        ));
809    }
810    if revision_format_version != STATE_REVISION_FORMAT_VERSION {
811        return Err(CanwuError::new(
812            ErrorCode::UnsupportedSnapshotVersion,
813            format!(
814                "commitment format {commitment_format_version} requires state revision format {STATE_REVISION_FORMAT_VERSION}"
815            ),
816        ));
817    }
818    canonical_hash(
819        "canwu.checkpoint.v4",
820        &CheckpointHashMaterialV4 {
821            commitments,
822            run_manifest_hash,
823            commitment_format_version,
824            revision_format_version,
825            state_revision,
826            replay_revision_format_version,
827        },
828    )
829}
830
831pub(super) fn snapshot_checkpoint_hash(
832    snapshot: &SimulationSnapshot,
833) -> Result<String, CanwuError> {
834    match snapshot.commitment_format_version {
835        COMMITMENT_FORMAT_VERSION => checkpoint_hash_for_commitments(
836            snapshot.commitment_roots.as_ref().ok_or_else(|| {
837                invalid_snapshot_error("current commitment snapshot is missing its domain roots")
838            })?,
839            &snapshot.run_manifest_hash,
840            snapshot.commitment_format_version,
841            snapshot.revision_format_version,
842            snapshot.state_revision,
843            snapshot.replay_revision_format_version,
844        ),
845        0 => {
846            if snapshot.commitment_roots.is_some() {
847                return invalid_snapshot(
848                    "legacy commitment snapshot cannot contain current domain roots",
849                );
850            }
851            let state_hash = snapshot_state_hash(snapshot)?;
852            checkpoint_hash_for_configuration(
853                &state_hash,
854                snapshot
855                    .boundaries
856                    .last()
857                    .map(|record| record.hash.as_str()),
858                &snapshot.run_manifest_hash,
859                snapshot.run_configuration.as_ref().ok_or_else(|| {
860                    CanwuError::new(
861                        ErrorCode::InvalidRunConfiguration,
862                        "snapshot is missing its run configuration",
863                    )
864                })?,
865                snapshot.revision_format_version,
866                snapshot.state_revision,
867                snapshot.replay_revision_format_version,
868            )
869        }
870        version => Err(CanwuError::new(
871            ErrorCode::UnsupportedSnapshotVersion,
872            format!(
873                "commitment format {version} is unsupported; this engine reads legacy format 0 and current format {COMMITMENT_FORMAT_VERSION}"
874            ),
875        )),
876    }
877}
878
879pub(super) fn snapshot_is_at_boundary_head(snapshot: &SimulationSnapshot) -> bool {
880    let Some(last) = snapshot.boundaries.last() else {
881        return false;
882    };
883    if last.at != snapshot.now {
884        return false;
885    }
886    let admitted_attempts: BTreeSet<_> = snapshot
887        .boundaries
888        .iter()
889        .flat_map(|record| record.admitted_attempts.iter().copied())
890        .collect();
891    if admitted_attempts.len() != snapshot.command_attempts.len() {
892        return false;
893    }
894    let admitted_commands: BTreeSet<_> = snapshot
895        .boundaries
896        .iter()
897        .flat_map(|record| record.admitted_commands.iter().copied())
898        .collect();
899    if admitted_commands.len() != snapshot.commands.len() {
900        return false;
901    }
902    let mut settled_ingress: BTreeSet<_> = snapshot
903        .boundaries
904        .iter()
905        .flat_map(|record| record.admitted_ingress.iter().copied())
906        .collect();
907    let boundary_count = u64::try_from(snapshot.boundaries.len()).unwrap_or(u64::MAX);
908    for record in &snapshot.ingress {
909        if let super::IngressPayload::PluginCancellation { cancelled, .. } = &record.payload {
910            // A host cancellation issued after the head boundary changes the
911            // journal past that boundary's recorded state hash.
912            if record.eligible_boundary_count >= boundary_count
913                && !matches!(record.cause, Some(super::CauseRef::Boundary(_)))
914            {
915                return false;
916            }
917            settled_ingress.insert(record.id);
918            settled_ingress.insert(*cancelled);
919        }
920    }
921    if settled_ingress.len() != snapshot.ingress.len() {
922        return false;
923    }
924    let accounted_events: BTreeSet<_> = snapshot
925        .boundaries
926        .iter()
927        .flat_map(|record| {
928            record
929                .admitted_events
930                .iter()
931                .copied()
932                .chain(record.emissions.iter().map(|emission| emission.event))
933        })
934        .collect();
935    accounted_events.len() == snapshot.events.len()
936}
937
938pub(super) fn compute_boundary_hash(record: &BoundaryRecord) -> Result<String, CanwuError> {
939    #[derive(Serialize)]
940    struct BoundaryHashMaterial<'a> {
941        id: BoundaryId,
942        at: SimTime,
943        correlation_id: u64,
944        cadences: &'a [SystemCadence],
945        #[serde(skip_serializing_if = "command_attempt_id_slice_is_empty")]
946        admitted_attempts: &'a [CommandAttemptId],
947        admitted_commands: &'a [CommandId],
948        #[serde(skip_serializing_if = "Option::is_none")]
949        admitted_ingress: Option<&'a [IngressId]>,
950        #[serde(skip_serializing_if = "Option::is_none")]
951        generated_ingress: Option<&'a [BoundaryIngressGeneration]>,
952        admitted_events: &'a [EventId],
953        reservation_offers: &'a [ReservationOfferRecord],
954        reservation_requests: &'a [ReservationRequestRecord],
955        allocations: &'a [ReservationAllocation],
956        random_draws: &'a [RandomDrawId],
957        changes: &'a [BoundaryChange],
958        #[serde(skip_serializing_if = "domain_record_change_slice_is_empty")]
959        record_changes: &'a [DomainRecordChange],
960        #[serde(skip_serializing_if = "maintenance_change_slice_is_empty")]
961        maintenance_changes: &'a [super::MaintenanceChangeRecord],
962        #[serde(skip_serializing_if = "Option::is_none")]
963        maintenance_terminal_root: &'a Option<String>,
964        #[serde(skip_serializing_if = "Option::is_none")]
965        person_availability_changes: Option<&'a [super::BoundaryPersonAvailabilityChange]>,
966        #[serde(skip_serializing_if = "Option::is_none")]
967        created_persons: Option<&'a [super::BoundaryPersonCreation]>,
968        emissions: &'a [BoundaryEmission],
969        state_hash: &'a Option<String>,
970        previous_hash: &'a str,
971    }
972
973    canonical_hash(
974        "canwu.boundary-record.v1",
975        &BoundaryHashMaterial {
976            id: record.id,
977            at: record.at,
978            correlation_id: record.correlation_id,
979            cadences: &record.cadences,
980            admitted_attempts: &record.admitted_attempts,
981            admitted_commands: &record.admitted_commands,
982            admitted_ingress: (!record.admitted_ingress.is_empty())
983                .then_some(record.admitted_ingress.as_slice()),
984            generated_ingress: (!record.generated_ingress.is_empty())
985                .then_some(record.generated_ingress.as_slice()),
986            admitted_events: &record.admitted_events,
987            reservation_offers: &record.reservation_offers,
988            reservation_requests: &record.reservation_requests,
989            allocations: &record.allocations,
990            random_draws: &record.random_draws,
991            changes: &record.changes,
992            record_changes: &record.record_changes,
993            maintenance_changes: &record.maintenance_changes,
994            maintenance_terminal_root: &record.maintenance_terminal_root,
995            person_availability_changes: (!record.person_availability_changes.is_empty())
996                .then_some(record.person_availability_changes.as_slice()),
997            created_persons: (!record.created_persons.is_empty())
998                .then_some(record.created_persons.as_slice()),
999            emissions: &record.emissions,
1000            state_hash: &record.state_hash,
1001            previous_hash: &record.previous_hash,
1002        },
1003    )
1004}
1005
1006/// Computes the engine's canonical JSON commitment for plugin-owned data.
1007///
1008/// The caller supplies a stable, versioned domain string. This is the same
1009/// deterministic encoding and domain separation used by the runtime's own
1010/// commitments, allowing extension operation identifiers to remain replay
1011/// compatible with kernel validation.
1012pub fn canonical_hash<T: Serialize + ?Sized>(
1013    domain: &str,
1014    value: &T,
1015) -> Result<String, CanwuError> {
1016    let encoded = serde_json::to_vec(value).map_err(|error| {
1017        CanwuError::new(
1018            ErrorCode::InvalidSnapshot,
1019            format!("could not encode deterministic hash material: {error}"),
1020        )
1021    })?;
1022    let mut hasher = blake3::Hasher::new();
1023    hasher.update(domain.as_bytes());
1024    hasher.update(&[0]);
1025    hasher.update(&encoded);
1026    Ok(hasher.finalize().to_hex().to_string())
1027}
1028
1029/// Computes a domain-separated BLAKE3 commitment over an already canonical
1030/// byte payload.
1031///
1032/// Extension contracts should prefer [`canonical_hash`] for serde values. This
1033/// raw form exists for frozen binary contracts, such as Merkle interior nodes,
1034/// whose byte encoding is defined independently of JSON.
1035#[must_use]
1036pub fn canonical_byte_hash(domain: &str, payload: &[u8]) -> String {
1037    let mut hasher = blake3::Hasher::new();
1038    hasher.update(domain.as_bytes());
1039    hasher.update(&[0]);
1040    hasher.update(payload);
1041    hasher.finalize().to_hex().to_string()
1042}
1043
1044pub(super) fn is_canonical_hash(value: &str) -> bool {
1045    value.len() == 64
1046        && value
1047            .bytes()
1048            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1049}
1050
1051pub(super) fn commitment_roots_are_canonical(roots: &CommitmentRoots) -> bool {
1052    [
1053        &roots.world,
1054        &roots.knowledge,
1055        &roots.plugin_components,
1056        &roots.domain_records,
1057        &roots.scheduler,
1058        &roots.commands,
1059        &roots.events,
1060        &roots.ingress,
1061        &roots.random,
1062        &roots.boundary_chain,
1063        &roots.identity,
1064        &roots.control,
1065    ]
1066    .into_iter()
1067    .all(|root| is_canonical_hash(root))
1068        && (roots.decisions.is_empty() || is_canonical_hash(&roots.decisions))
1069}