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