1use super::{
8 BoundaryPhase, BoundaryRecord, BoundarySystemContract, CanwuError, CommandAuthority,
9 DecisionAttemptErrorCode, DecisionAuthority, DecisionControllerBinding, DecisionMutation,
10 DecisionOrigin, DecisionState, DecisionTicket, DecisionTicketId, EntityRef, ErrorCode,
11 EvidenceRef, GovernmentId, Issuer, Person, PersonId, PluginRegistry, SimTime, Simulation,
12 SimulationSnapshot, StateKey, StateVisibility, TerritoryId, canonical_text, claim_counter,
13 invalid_snapshot, invalid_snapshot_error, runtime_entity_exists,
14};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17
18pub const DECISION_MAKER_UNAVAILABLE_REASON: &str = "decision_maker_unavailable";
21
22pub const CONTROLLER_AUTHORITY_UNAVAILABLE_REASON: &str = "controller_authority_unavailable";
41
42const MAX_PERSON_CORRELATION_BYTES: usize = 256;
43
44#[derive(
46 Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
47)]
48#[serde(rename_all = "snake_case")]
49pub enum LifeState {
50 #[default]
51 Alive,
52 Dead,
53 Missing,
54}
55
56#[derive(
59 Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
60)]
61#[serde(rename_all = "snake_case")]
62pub enum CustodyState {
63 #[default]
64 Free,
65 Detained,
66 Hostage,
67 Captive,
68 Hiding,
69 Exile,
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct PersonAvailability {
79 pub life: LifeState,
80 pub custody: CustodyState,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub custodian: Option<EntityRef>,
83 pub since: SimTime,
84}
85
86impl PersonAvailability {
87 #[must_use]
88 pub const fn new(life: LifeState, custody: CustodyState, since: SimTime) -> Self {
89 Self {
90 life,
91 custody,
92 custodian: None,
93 since,
94 }
95 }
96
97 #[must_use]
98 pub fn with_custodian(mut self, custodian: EntityRef) -> Self {
99 self.custodian = Some(custodian);
100 self
101 }
102
103 #[must_use]
105 pub const fn is_available(&self) -> bool {
106 matches!(self.life, LifeState::Alive)
107 && !matches!(self.custody, CustodyState::Detained | CustodyState::Captive)
108 }
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct PersonDraft {
114 pub name: String,
115 pub government: GovernmentId,
116 pub current_location: TerritoryId,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub roles: Vec<String>,
119 pub availability: PersonAvailability,
120 pub provenance: EvidenceRef,
122}
123
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126pub struct BoundaryPersonAvailabilityChange {
127 pub plugin: String,
128 pub system: String,
129 pub phase: BoundaryPhase,
130 pub person: PersonId,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub previous: Option<PersonAvailability>,
133 pub availability: PersonAvailability,
134 pub visibility: StateVisibility,
135 pub summary: String,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub cancelled_tickets: Vec<DecisionTicketId>,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub cancelled_controller_tickets: Vec<DecisionTicketId>,
147}
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct BoundaryPersonCreation {
152 pub plugin: String,
153 pub system: String,
154 pub correlation: String,
155 pub person: Person,
156 pub availability: PersonAvailability,
157 pub provenance: EvidenceRef,
158 pub summary: String,
159}
160
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
163pub struct CreatedPerson {
164 pub plugin: String,
165 pub system: String,
166 pub correlation: String,
167 pub person: PersonId,
168}
169
170impl From<&BoundaryPersonCreation> for CreatedPerson {
171 fn from(value: &BoundaryPersonCreation) -> Self {
172 Self {
173 plugin: value.plugin.clone(),
174 system: value.system.clone(),
175 correlation: value.correlation.clone(),
176 person: value.person.id,
177 }
178 }
179}
180
181pub(super) fn person_is_available(
182 availability: &BTreeMap<PersonId, PersonAvailability>,
183 person: PersonId,
184) -> bool {
185 availability
186 .get(&person)
187 .is_none_or(PersonAvailability::is_available)
188}
189
190pub(super) fn plugin_owned_boundary_writes(
198 contract: &BoundarySystemContract,
199) -> Result<Vec<StateKey>, CanwuError> {
200 let availability = StateKey::core_person_availability();
201 let people = StateKey::core_people();
202 let mut owned = Vec::with_capacity(contract.writes.len());
203 for key in &contract.writes {
204 let allowed = if *key == availability {
205 matches!(
206 contract.phase,
207 BoundaryPhase::DomainDeltaProposal | BoundaryPhase::HistoricalCandidateEvaluation
208 )
209 } else if *key == people {
210 contract.phase == BoundaryPhase::DomainDeltaProposal
211 } else {
212 owned.push(key.clone());
213 continue;
214 };
215 if !allowed {
216 return Err(CanwuError::new(
217 ErrorCode::InvalidPluginRegistration,
218 format!(
219 "boundary system {} cannot write core state {}.{} in phase {:?}",
220 contract.name, key.namespace, key.name, contract.phase
221 ),
222 ));
223 }
224 }
225 Ok(owned)
226}
227
228pub(super) fn validate_availability_value(
229 person: PersonId,
230 availability: &PersonAvailability,
231 at: SimTime,
232 entity_exists: &dyn Fn(&EntityRef) -> bool,
233) -> Result<(), CanwuError> {
234 if availability.since > at {
235 return Err(CanwuError::new(
236 ErrorCode::InvalidBoundary,
237 format!("availability of person {person} cannot start after its boundary time"),
238 ));
239 }
240 if let Some(custodian) = &availability.custodian {
241 if availability.custody == CustodyState::Free || *custodian == EntityRef::Person(person) {
242 return Err(CanwuError::new(
243 ErrorCode::InvalidBoundary,
244 format!("person {person} names an invalid custodian"),
245 ));
246 }
247 if !entity_exists(custodian) {
248 return Err(CanwuError::new(
249 ErrorCode::EntityNotFound,
250 format!("person {person} names missing custodian {custodian}"),
251 )
252 .with_entity(custodian.clone()));
253 }
254 }
255 Ok(())
256}
257
258fn require_core_write(
259 plugin: &str,
260 contract: &BoundarySystemContract,
261 key: &StateKey,
262 phases: &[BoundaryPhase],
263) -> Result<(), CanwuError> {
264 if !contract.writes.contains(key) || !phases.contains(&contract.phase) {
265 return Err(CanwuError::new(
266 ErrorCode::UndeclaredStateWrite,
267 format!(
268 "boundary system {plugin}.{} did not declare core write {}.{}",
269 contract.name, key.namespace, key.name
270 ),
271 ));
272 }
273 Ok(())
274}
275
276pub(super) fn validate_availability_directive(
277 plugin: &str,
278 contract: &BoundarySystemContract,
279 now: SimTime,
280 person: PersonId,
281 availability: &PersonAvailability,
282 summary: &str,
283 entity_exists: &dyn Fn(&EntityRef) -> bool,
284) -> Result<(), CanwuError> {
285 require_core_write(
286 plugin,
287 contract,
288 &StateKey::core_person_availability(),
289 &[
290 BoundaryPhase::DomainDeltaProposal,
291 BoundaryPhase::HistoricalCandidateEvaluation,
292 ],
293 )?;
294 if !canonical_text(summary) {
295 return Err(CanwuError::new(
296 ErrorCode::InvalidBoundary,
297 "person availability summaries must be canonical text",
298 ));
299 }
300 let entity = EntityRef::Person(person);
301 if !entity_exists(&entity) {
302 return Err(CanwuError::new(
303 ErrorCode::EntityNotFound,
304 format!(
305 "boundary system {plugin}.{} set availability of missing person {person}",
306 contract.name
307 ),
308 )
309 .with_entity(entity));
310 }
311 validate_availability_value(person, availability, now, entity_exists)
312}
313
314pub(super) struct PersonDraftContext<'a> {
315 pub(super) plugin: &'a str,
316 pub(super) contract: &'a BoundarySystemContract,
317 pub(super) now: SimTime,
318 pub(super) government_exists: &'a dyn Fn(GovernmentId) -> bool,
319 pub(super) territory_exists: &'a dyn Fn(TerritoryId) -> bool,
320 pub(super) entity_exists: &'a dyn Fn(&EntityRef) -> bool,
321}
322
323pub(super) fn validate_person_draft(
324 context: &PersonDraftContext<'_>,
325 draft: &PersonDraft,
326 correlation: &str,
327 summary: &str,
328) -> Result<(), CanwuError> {
329 require_core_write(
330 context.plugin,
331 context.contract,
332 &StateKey::core_people(),
333 &[BoundaryPhase::DomainDeltaProposal],
334 )?;
335 if !canonical_text(summary)
336 || !canonical_text(correlation)
337 || correlation.len() > MAX_PERSON_CORRELATION_BYTES
338 || !canonical_text(&draft.name)
339 || draft.roles.iter().any(|role| !canonical_text(role))
340 {
341 return Err(CanwuError::new(
342 ErrorCode::InvalidBoundary,
343 "person drafts require canonical name, roles, correlation, and summary",
344 ));
345 }
346 if !(context.government_exists)(draft.government)
347 || !(context.territory_exists)(draft.current_location)
348 {
349 return Err(CanwuError::new(
350 ErrorCode::EntityNotFound,
351 "person draft references a missing government or location",
352 ));
353 }
354 validate_availability_value(
357 PersonId::new(0),
358 &draft.availability,
359 context.now,
360 context.entity_exists,
361 )
362}
363
364#[derive(Default)]
366pub(super) struct BoundaryPersonWrites {
367 availability_writers: BTreeMap<PersonId, (String, String)>,
368 correlations: BTreeSet<(String, String, String)>,
369}
370
371impl BoundaryPersonWrites {
372 pub(super) fn stage(
373 &mut self,
374 plugin: &str,
375 system: &str,
376 directives: &[super::BoundaryDirective],
377 ) -> Result<(), CanwuError> {
378 for directive in directives {
379 match directive {
380 super::BoundaryDirective::SetPersonAvailability { person, .. } => {
381 if let Some((existing_plugin, existing_system)) = self
382 .availability_writers
383 .insert(*person, (plugin.to_owned(), system.to_owned()))
384 {
385 return Err(CanwuError::new(
386 ErrorCode::DuplicateBoundaryWriter,
387 format!(
388 "availability of person {person} is written by both {existing_plugin}.{existing_system} and {plugin}.{system} in one boundary"
389 ),
390 )
391 .with_entity(EntityRef::Person(*person)));
392 }
393 }
394 super::BoundaryDirective::CreatePerson { correlation, .. } => {
395 let key = (plugin.to_owned(), system.to_owned(), correlation.clone());
396 if !self.correlations.insert(key) {
397 return Err(CanwuError::new(
398 ErrorCode::InvalidBoundary,
399 format!(
400 "person creation correlation {correlation} is duplicated by {plugin}.{system} in one boundary"
401 ),
402 ));
403 }
404 }
405 _ => {}
406 }
407 }
408 Ok(())
409 }
410}
411
412fn command_persons(issuer: &Issuer, authority: &CommandAuthority) -> BTreeSet<PersonId> {
413 let mut persons = BTreeSet::new();
414 if let Issuer::Actor(actor) = issuer {
415 persons.insert(*actor);
416 }
417 match &authority.decision_origin {
418 DecisionOrigin::Actor { actor }
419 | DecisionOrigin::Institution {
420 responsible_actor: Some(actor),
421 ..
422 } => {
423 persons.insert(*actor);
424 }
425 DecisionOrigin::Institution {
426 responsible_actor: None,
427 ..
428 }
429 | DecisionOrigin::Council { .. }
430 | DecisionOrigin::NoResponsibleActor { .. } => {}
431 }
432 persons
433}
434
435pub(super) fn validate_command_issuer_availability(
438 availability: &BTreeMap<PersonId, PersonAvailability>,
439 issuer: &Issuer,
440 authority: &CommandAuthority,
441) -> Result<(), CanwuError> {
442 for person in command_persons(issuer, authority) {
443 if !person_is_available(availability, person) {
444 return Err(CanwuError::new(
445 ErrorCode::IssuerUnavailable,
446 format!("command issuer person {person} is dead, missing, detained, or captive"),
447 )
448 .with_entity(EntityRef::Person(person)));
449 }
450 }
451 Ok(())
452}
453
454const fn authority_person(authority: &DecisionAuthority) -> Option<PersonId> {
455 match authority {
456 DecisionAuthority::Actor { actor } => Some(*actor),
457 DecisionAuthority::Institution {
458 responsible_actor, ..
459 } => *responsible_actor,
460 DecisionAuthority::Council { .. } | DecisionAuthority::NoResponsibleActor { .. } => None,
461 }
462}
463
464fn decision_maker_message(person: PersonId) -> String {
465 format!("decision maker person {person} is dead, missing, detained, or captive")
466}
467
468fn controller_issuer_message(controller: &str, person: PersonId) -> String {
469 format!(
470 "decision controller {controller} acts for person {person}, who is dead, missing, detained, or captive"
471 )
472}
473
474fn controller_authority_availability_error(
475 decisions: &DecisionState,
476 controller_id: &str,
477 availability: &BTreeMap<PersonId, PersonAvailability>,
478) -> Option<(DecisionAttemptErrorCode, String)> {
479 decisions
480 .controller(controller_id)
481 .and_then(|controller| authority_person(&controller.authority))
482 .filter(|person| !person_is_available(availability, *person))
483 .map(|person| {
484 (
485 DecisionAttemptErrorCode::IssuerUnavailable,
486 controller_issuer_message(controller_id, person),
487 )
488 })
489}
490
491pub(super) fn decision_mutation_availability_error(
499 mutation: &DecisionMutation,
500 decisions: &DecisionState,
501 availability: &BTreeMap<PersonId, PersonAvailability>,
502) -> Option<(DecisionAttemptErrorCode, String)> {
503 match mutation {
504 DecisionMutation::Open { ticket } => match ticket.decision_maker {
505 EntityRef::Person(person) if !person_is_available(availability, person) => Some((
506 DecisionAttemptErrorCode::DecisionMakerUnavailable,
507 decision_maker_message(person),
508 )),
509 _ => controller_authority_availability_error(
510 decisions,
511 &ticket.assigned_controller,
512 availability,
513 ),
514 },
515 DecisionMutation::Resolve { controller_id, .. } => {
516 controller_authority_availability_error(decisions, controller_id, availability)
517 }
518 DecisionMutation::RegisterController { .. }
519 | DecisionMutation::ReplaceOptions { .. }
520 | DecisionMutation::Cancel { .. } => None,
521 }
522}
523
524pub(super) fn validate_decision_preparation(
527 availability: &BTreeMap<PersonId, PersonAvailability>,
528 ticket: &DecisionTicket,
529 controller: &DecisionControllerBinding,
530) -> Result<(), CanwuError> {
531 if let EntityRef::Person(person) = ticket.decision_maker
532 && !person_is_available(availability, person)
533 {
534 return Err(CanwuError::new(
535 ErrorCode::DecisionMakerUnavailable,
536 decision_maker_message(person),
537 )
538 .with_entity(EntityRef::Person(person)));
539 }
540 if let Some(person) = authority_person(&controller.authority)
541 && !person_is_available(availability, person)
542 {
543 return Err(CanwuError::new(
544 ErrorCode::IssuerUnavailable,
545 controller_issuer_message(&controller.id, person),
546 )
547 .with_entity(EntityRef::Person(person)));
548 }
549 Ok(())
550}
551
552fn cancel_open_tickets(
555 decisions: &mut DecisionState,
556 matches: impl Fn(&DecisionState, &DecisionTicket) -> bool,
557 reason: &str,
558 at: SimTime,
559) -> Result<Vec<DecisionTicketId>, CanwuError> {
560 let state: &DecisionState = decisions;
561 let open: Vec<_> = state
562 .open_tickets()
563 .filter(|ticket| matches(state, ticket))
564 .map(|ticket| (ticket.id, ticket.version))
565 .collect();
566 for (ticket_id, expected_version) in &open {
567 decisions
568 .apply(
569 DecisionMutation::Cancel {
570 ticket_id: *ticket_id,
571 expected_version: *expected_version,
572 reason: reason.to_owned(),
573 },
574 at,
575 None,
576 )
577 .map_err(super::decision::decision_error)?;
578 }
579 Ok(open.into_iter().map(|(ticket_id, _)| ticket_id).collect())
580}
581
582pub(super) fn sweep_unavailable_person_tickets(
593 decisions: &mut DecisionState,
594 changes: &mut [BoundaryPersonAvailabilityChange],
595 at: SimTime,
596) -> Result<(), CanwuError> {
597 for change in changes.iter_mut() {
598 let maker = EntityRef::Person(change.person);
599 change.cancelled_tickets = if change.availability.is_available() {
600 Vec::new()
601 } else {
602 cancel_open_tickets(
603 decisions,
604 |_, ticket| ticket.decision_maker == maker,
605 DECISION_MAKER_UNAVAILABLE_REASON,
606 at,
607 )?
608 };
609 }
610 for change in changes.iter_mut() {
611 let person = change.person;
612 change.cancelled_controller_tickets = if change.availability.is_available() {
613 Vec::new()
614 } else {
615 cancel_open_tickets(
616 decisions,
617 |decisions, ticket| {
618 decisions
619 .controller(&ticket.assigned_controller)
620 .and_then(|controller| authority_person(&controller.authority))
621 == Some(person)
622 },
623 CONTROLLER_AUTHORITY_UNAVAILABLE_REASON,
624 at,
625 )?
626 };
627 }
628 Ok(())
629}
630
631pub(super) fn first_runtime_person_id(
633 initial_entities: &[EntityRef],
634 initial_people: &[Person],
635) -> Result<u64, CanwuError> {
636 initial_entities
637 .iter()
638 .filter_map(|entity| match entity {
639 EntityRef::Person(person) => Some(person.get()),
640 _ => None,
641 })
642 .chain(initial_people.iter().map(|person| person.id.get()))
643 .max()
644 .unwrap_or(0)
645 .checked_add(1)
646 .ok_or_else(|| {
647 CanwuError::new(
648 ErrorCode::IdentifierExhausted,
649 "person identifier space is exhausted",
650 )
651 })
652}
653
654impl Simulation {
655 #[must_use]
658 pub fn person_availability(&self, person: PersonId) -> Option<&PersonAvailability> {
659 self.state.current.person_availability.get(&person)
660 }
661
662 pub fn person_availabilities(&self) -> impl Iterator<Item = (&PersonId, &PersonAvailability)> {
664 self.state.current.person_availability.iter()
665 }
666
667 pub(super) fn validate_command_issuer(
668 &self,
669 issuer: &Issuer,
670 authority: &CommandAuthority,
671 ) -> Result<(), CanwuError> {
672 validate_command_issuer_availability(
673 &self.state.current.person_availability,
674 issuer,
675 authority,
676 )
677 }
678
679 pub(super) fn apply_person_availability(
680 &mut self,
681 staged: (&str, &str, BoundaryPhase, StateVisibility),
682 person: PersonId,
683 availability: PersonAvailability,
684 summary: String,
685 ) -> Result<BoundaryPersonAvailabilityChange, CanwuError> {
686 let (plugin, system, phase, visibility) = staged;
687 let now = self.state.scheduler.now;
688 let entity = EntityRef::Person(person);
689 if !runtime_entity_exists(&self.state, &entity) {
690 return Err(CanwuError::new(
691 ErrorCode::EntityNotFound,
692 format!("boundary stage {plugin}.{system} references unavailable person {person}"),
693 )
694 .with_entity(entity));
695 }
696 validate_availability_value(person, &availability, now, &|entity| {
697 runtime_entity_exists(&self.state, entity)
698 })?;
699 self.invalidate_commitments(super::CommitmentDomains::WORLD);
700 let previous = self
701 .state
702 .current
703 .person_availability
704 .insert(person, availability.clone());
705 Ok(BoundaryPersonAvailabilityChange {
706 plugin: plugin.to_owned(),
707 system: system.to_owned(),
708 phase,
709 person,
710 previous,
711 availability,
712 visibility,
713 summary,
714 cancelled_tickets: Vec::new(),
715 cancelled_controller_tickets: Vec::new(),
716 })
717 }
718
719 pub(super) fn cancel_unavailable_person_tickets(
724 &mut self,
725 changes: &mut [BoundaryPersonAvailabilityChange],
726 ) -> Result<(), CanwuError> {
727 if changes
728 .iter()
729 .all(|change| change.availability.is_available())
730 {
731 return Ok(());
732 }
733 let mut decisions = self.state.current.decisions.clone();
734 sweep_unavailable_person_tickets(&mut decisions, changes, self.state.scheduler.now)?;
735 if changes.iter().any(|change| {
736 !change.cancelled_tickets.is_empty() || !change.cancelled_controller_tickets.is_empty()
737 }) {
738 self.invalidate_commitments(super::CommitmentDomains::DECISIONS);
739 self.state.current.decisions = decisions;
740 }
741 Ok(())
742 }
743
744 pub(super) fn apply_person_creation(
745 &mut self,
746 plugin: &str,
747 system: &str,
748 draft: PersonDraft,
749 correlation: String,
750 summary: String,
751 ) -> Result<BoundaryPersonCreation, CanwuError> {
752 let now = self.state.scheduler.now;
753 if !self
754 .state
755 .current
756 .governments
757 .contains_key(&draft.government)
758 || !self
759 .state
760 .current
761 .territories
762 .contains_key(&draft.current_location)
763 {
764 return Err(CanwuError::new(
765 ErrorCode::EntityNotFound,
766 "person draft references a missing government or location",
767 ));
768 }
769 let next = if self.state.counters.next_person_id == 0 {
770 let scenario = self
771 .state
772 .metadata
773 .initial_scenario
774 .as_ref()
775 .ok_or_else(|| {
776 CanwuError::new(
777 ErrorCode::InvalidSnapshot,
778 "runtime person creation requires the bound initial scenario",
779 )
780 })?;
781 first_runtime_person_id(&scenario.entities, &scenario.world.people)?
782 } else {
783 self.state.counters.next_person_id
784 };
785 let (id, next_id) = claim_counter(next, "person ID")?;
786 let id = PersonId::new(id);
787 let entity = EntityRef::Person(id);
788 if self.state.current.entities.contains(&entity)
789 || self.state.current.people.contains_key(&id)
790 {
791 return Err(CanwuError::new(
792 ErrorCode::InvalidSnapshot,
793 "runtime person allocation collided with an existing identity",
794 ));
795 }
796 validate_availability_value(id, &draft.availability, now, &|entity| {
797 runtime_entity_exists(&self.state, entity)
798 })?;
799 let person = Person {
800 id,
801 name: draft.name,
802 government: draft.government,
803 current_location: draft.current_location,
804 roles: draft.roles,
805 transit: None,
806 };
807 self.invalidate_commitments(super::CommitmentDomains::WORLD);
808 self.state.counters.next_person_id = next_id;
809 self.state.current.entities.insert(entity);
810 self.state.current.people.insert(id, person.clone());
811 self.state
812 .current
813 .person_availability
814 .insert(id, draft.availability.clone());
815 let creation = BoundaryPersonCreation {
816 plugin: plugin.to_owned(),
817 system: system.to_owned(),
818 correlation,
819 person,
820 availability: draft.availability,
821 provenance: draft.provenance,
822 summary,
823 };
824 self.state
825 .current
826 .created_persons
827 .push(CreatedPerson::from(&creation));
828 Ok(creation)
829 }
830}
831
832pub(super) struct PersonReplayCut {
836 pub(super) availability: BTreeMap<PersonId, PersonAvailability>,
837 runtime_created: BTreeSet<PersonId>,
838 created: BTreeSet<PersonId>,
839}
840
841impl PersonReplayCut {
842 pub(super) fn new(snapshot: &SimulationSnapshot) -> Self {
843 Self {
844 availability: BTreeMap::new(),
845 runtime_created: snapshot
846 .boundaries
847 .iter()
848 .flat_map(|boundary| &boundary.created_persons)
849 .map(|creation| creation.person.id)
850 .collect(),
851 created: BTreeSet::new(),
852 }
853 }
854
855 pub(super) fn runtime_person_exists(&self, person: PersonId) -> Option<bool> {
858 self.runtime_created
859 .contains(&person)
860 .then(|| self.created.contains(&person))
861 }
862
863 pub(super) fn apply_boundary(
866 &mut self,
867 record: &BoundaryRecord,
868 decisions: &mut DecisionState,
869 ) -> Result<(), CanwuError> {
870 let mut expected = record.person_availability_changes.clone();
871 sweep_unavailable_person_tickets(decisions, &mut expected, record.at)?;
872 if expected != record.person_availability_changes {
873 return invalid_snapshot(
874 "person availability changes do not match their same-boundary ticket cancellations",
875 );
876 }
877 for change in &record.person_availability_changes {
878 self.availability
879 .insert(change.person, change.availability.clone());
880 }
881 for creation in &record.created_persons {
882 self.created.insert(creation.person.id);
883 self.availability
884 .insert(creation.person.id, creation.availability.clone());
885 }
886 Ok(())
887 }
888}
889
890pub(super) fn validate_snapshot_entity_registry(
893 snapshot: &SimulationSnapshot,
894) -> Result<(), CanwuError> {
895 let Some(initial) = snapshot.initial_scenario.as_ref() else {
896 return Ok(());
897 };
898 let mut expected_entities: BTreeSet<_> = initial.entities.iter().cloned().collect();
899 let mut expected_people: BTreeSet<_> = initial
900 .world
901 .people
902 .iter()
903 .map(|person| person.id)
904 .collect();
905 for creation in snapshot
906 .boundaries
907 .iter()
908 .flat_map(|boundary| &boundary.created_persons)
909 {
910 let person = creation.person.id;
911 if !expected_entities.insert(EntityRef::Person(person)) || !expected_people.insert(person) {
912 return invalid_snapshot("runtime-created person reuses an existing identity");
913 }
914 }
915 if expected_entities.into_iter().collect::<Vec<_>>() != snapshot.entities
916 || expected_people
917 != snapshot
918 .world
919 .people
920 .iter()
921 .map(|person| person.id)
922 .collect::<BTreeSet<_>>()
923 {
924 return invalid_snapshot(
925 "snapshot entity registry does not match its manifest-bound initial scenario and committed person creations",
926 );
927 }
928 Ok(())
929}
930
931fn snapshot_availability_is_valid(
932 snapshot: &SimulationSnapshot,
933 person: PersonId,
934 availability: &PersonAvailability,
935 at: SimTime,
936) -> bool {
937 validate_availability_value(person, availability, at, &|entity| {
938 super::validation::snapshot_entity_identity_exists(snapshot, entity)
939 })
940 .is_ok()
941}
942
943fn snapshot_core_writer<'a>(
944 plugins: &'a PluginRegistry,
945 record: &BoundaryRecord,
946 source: (&str, &str),
947 key: &StateKey,
948 phases: &[BoundaryPhase],
949) -> Option<&'a BoundarySystemContract> {
950 super::validation::snapshot_boundary_contract(plugins, source.0, source.1).filter(|contract| {
951 contract.writes.contains(key)
952 && phases.contains(&contract.phase)
953 && super::boundary_system_due(
954 contract,
955 &record.cadences,
956 super::boundary_has_event_ingress(record),
957 )
958 })
959}
960
961pub(super) fn validate_snapshot_persons(
964 snapshot: &SimulationSnapshot,
965 plugins: &PluginRegistry,
966) -> Result<(), CanwuError> {
967 let Some(initial) = snapshot.initial_scenario.as_ref() else {
968 return invalid_snapshot("person evidence requires the manifest-bound initial scenario");
969 };
970 let first_id = first_runtime_person_id(&initial.entities, &initial.world.people)
971 .map_err(|error| invalid_snapshot_error(error.message))?;
972 let mut next_id = first_id;
973 let mut availability = BTreeMap::new();
974 let availability_key = StateKey::core_person_availability();
975 let people_key = StateKey::core_people();
976 let evidence = super::validation::SnapshotValidationContext::new(snapshot);
977 for record in &snapshot.boundaries {
978 let mut touched = BTreeSet::new();
979 for change in &record.person_availability_changes {
980 let Some(contract) = snapshot_core_writer(
981 plugins,
982 record,
983 (&change.plugin, &change.system),
984 &availability_key,
985 &[
986 BoundaryPhase::DomainDeltaProposal,
987 BoundaryPhase::HistoricalCandidateEvaluation,
988 ],
989 ) else {
990 return invalid_snapshot("person availability change has no declared writer");
991 };
992 if change.phase != contract.phase
993 || change.visibility != contract.visibility
994 || !canonical_text(&change.summary)
995 || !touched.insert(change.person)
996 || !super::validation::snapshot_entity_exists(
997 snapshot,
998 &EntityRef::Person(change.person),
999 )
1000 || change.previous.as_ref() != availability.get(&change.person)
1001 || !snapshot_availability_is_valid(
1002 snapshot,
1003 change.person,
1004 &change.availability,
1005 record.at,
1006 )
1007 || [
1008 &change.cancelled_tickets,
1009 &change.cancelled_controller_tickets,
1010 ]
1011 .into_iter()
1012 .any(|tickets| tickets.windows(2).any(|pair| pair[0] >= pair[1]))
1013 {
1014 return invalid_snapshot("person availability change evidence is inconsistent");
1015 }
1016 availability.insert(change.person, change.availability.clone());
1017 }
1018 let mut correlations = BTreeSet::new();
1019 for creation in &record.created_persons {
1020 let person = &creation.person;
1021 let valid_writer = snapshot_core_writer(
1022 plugins,
1023 record,
1024 (&creation.plugin, &creation.system),
1025 &people_key,
1026 &[BoundaryPhase::DomainDeltaProposal],
1027 )
1028 .is_some();
1029 if !valid_writer
1030 || person.id.get() != next_id
1031 || person.transit.is_some()
1032 || !canonical_text(&person.name)
1033 || person.roles.iter().any(|role| !canonical_text(role))
1034 || !canonical_text(&creation.summary)
1035 || !canonical_text(&creation.correlation)
1036 || creation.correlation.len() > MAX_PERSON_CORRELATION_BYTES
1037 || !correlations.insert((
1038 creation.plugin.as_str(),
1039 creation.system.as_str(),
1040 creation.correlation.as_str(),
1041 ))
1042 || touched.contains(&person.id)
1043 || snapshot.world.person(person.id).is_none()
1044 || initial.world.government(person.government).is_none()
1045 || initial.world.territory(person.current_location).is_none()
1046 || !snapshot_availability_is_valid(
1047 snapshot,
1048 person.id,
1049 &creation.availability,
1050 record.at,
1051 )
1052 || super::validation::resolve_evidence_reference(&evidence, &creation.provenance)
1053 != super::validation::EvidenceAvailability::Retained
1054 || availability
1055 .insert(person.id, creation.availability.clone())
1056 .is_some()
1057 {
1058 return invalid_snapshot("person creation evidence is inconsistent");
1059 }
1060 next_id = next_id.checked_add(1).ok_or_else(|| {
1061 invalid_snapshot_error("runtime person identifier space is exhausted")
1062 })?;
1063 }
1064 }
1065 let registry: Vec<_> = snapshot
1066 .boundaries
1067 .iter()
1068 .flat_map(|boundary| &boundary.created_persons)
1069 .map(CreatedPerson::from)
1070 .collect();
1071 if registry != snapshot.created_persons {
1072 return invalid_snapshot(
1073 "boundary person creations do not reconstruct the persisted created-person registry",
1074 );
1075 }
1076 let expected_counter = if next_id == first_id { 0 } else { next_id };
1077 if snapshot.next_person_id != expected_counter {
1078 return invalid_snapshot("runtime person counter does not follow committed creations");
1079 }
1080 if availability != snapshot.person_availability {
1081 return invalid_snapshot(
1082 "boundary availability changes do not reconstruct the persisted person availability",
1083 );
1084 }
1085 Ok(())
1086}