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 #[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
344pub(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 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
1045pub 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#[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}