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)]
20pub 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 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
1006pub 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#[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}