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