Skip to main content

canwu_sim/runtime/
persons.rs

1//! Core person availability and runtime person creation.
2//!
3//! Availability is a separate ordered core map keyed by [`PersonId`]. It is
4//! deliberately not a field of the legacy [`Person`] projection, so the map
5//! applies to every person entity and survives a later world-model move.
6
7use 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
18/// Cancellation reason recorded on an open ticket whose person decision maker
19/// became unavailable.
20pub const DECISION_MAKER_UNAVAILABLE_REASON: &str = "decision_maker_unavailable";
21
22/// Cancellation reason recorded on an open ticket whose assigned controller's
23/// authority person became unavailable.
24///
25/// The authority person is the actor of [`DecisionAuthority::Actor`], or the
26/// responsible actor of [`DecisionAuthority::Institution`] when one is named.
27/// Council and no-responsible-actor authorities never trigger this reason.
28/// The cancellation happens at the end of the boundary that makes the person
29/// unavailable, after the boundary's random decisions are materialized, and
30/// the IDs are recorded in
31/// [`BoundaryPersonAvailabilityChange::cancelled_controller_tickets`]. A
32/// ticket whose decision maker became unavailable in the same boundary is
33/// cancelled with [`DECISION_MAKER_UNAVAILABLE_REASON`] instead.
34///
35/// A ticket cannot be reassigned to another controller, and decision ingress
36/// refuses to open a ticket for a controller whose authority person is
37/// unavailable ([`ErrorCode::IssuerUnavailable`]). To continue the decision,
38/// open a successor ticket for a different, available controller with
39/// `parent_ticket` naming the cancelled ticket.
40pub const CONTROLLER_AUTHORITY_UNAVAILABLE_REASON: &str = "controller_authority_unavailable";
41
42const MAX_PERSON_CORRELATION_BYTES: usize = 256;
43
44/// Whether a person is alive. Absent availability means [`LifeState::Alive`].
45#[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/// Whether a person is free to act. Absent availability means
57/// [`CustodyState::Free`].
58#[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/// Core life and custody state of one person.
73///
74/// A person is unavailable for command issuance and decision making when it
75/// is not alive or is detained or captive. Hostage, hiding, and exile remain
76/// admissible by default; applications may restrict them further.
77#[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    /// Returns whether the person may issue commands or make decisions.
104    #[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/// Application-supplied content for a person created at a boundary.
112#[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    /// Committed evidence that justifies the creation.
121    pub provenance: EvidenceRef,
122}
123
124/// Committed availability change evidence in a boundary record.
125#[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    /// Open tickets closed in the same boundary because this person, their
137    /// decision maker, became unavailable, in ticket-ID order.
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub cancelled_tickets: Vec<DecisionTicketId>,
140    /// Open tickets closed in the same boundary with
141    /// [`CONTROLLER_AUTHORITY_UNAVAILABLE_REASON`] because this person, the
142    /// authority person of their assigned controller, became unavailable, in
143    /// ticket-ID order. A ticket closed for its decision maker by any change
144    /// of the boundary is listed only in that change's `cancelled_tickets`.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub cancelled_controller_tickets: Vec<DecisionTicketId>,
147}
148
149/// Committed person-creation evidence in a boundary record.
150#[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/// Receipt entry binding a creation correlation to its engine-allocated ID.
162#[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
190/// Returns the plugin-owned subset of a boundary contract's writes after
191/// checking the kernel-guarded core keys a boundary system may declare.
192///
193/// `canwu.core.person_availability` is writable in phases 7 and 10 and
194/// `canwu.core.people` (person creation) in phase 7. Neither is owned by a
195/// plugin, so several systems may declare them; per-person conflicts fail the
196/// boundary at settlement.
197pub(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    // The ID is not allocated yet; validate against an impossible identity so
355    // a draft cannot name itself as custodian.
356    validate_availability_value(
357        PersonId::new(0),
358        &draft.availability,
359        context.now,
360        context.entity_exists,
361    )
362}
363
364/// Boundary-wide staging guard for person directives.
365#[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
435/// Rejects a command whose issuing person is dead, missing, detained, or
436/// captive. Non-person issuers and authorities are unaffected.
437pub(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
491/// Deterministic availability admission rule for decision ingress, shared by
492/// the runtime and snapshot reconstruction.
493///
494/// `Open` refuses an unavailable person decision maker first, then an
495/// assigned controller whose authority person is unavailable, so a ticket is
496/// never opened for a controller that could not resolve it. `Resolve` refuses
497/// a controller whose authority person is unavailable.
498pub(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
524/// Availability guard for resolving one ticket through its controller, used
525/// by host decision preparation and by boundary random decision resolutions.
526pub(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
552/// Cancels the open tickets selected by `matches`, in ticket-ID order, with
553/// `reason` and returns the cancelled IDs.
554fn 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
582/// End-of-boundary sweep shared by the runtime and snapshot reconstruction.
583///
584/// For every change that leaves its person unavailable, first cancels the
585/// open tickets whose decision maker is that person
586/// ([`DECISION_MAKER_UNAVAILABLE_REASON`]); then, for every such change,
587/// cancels the remaining open tickets whose assigned controller's authority
588/// person is that person ([`CONTROLLER_AUTHORITY_UNAVAILABLE_REASON`]). The
589/// decision-maker pass runs for all changes first, so a ticket that qualifies
590/// for both reasons carries the decision-maker reason. Both lists are
591/// rewritten on every change, in ticket-ID order.
592pub(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
631/// First runtime-allocated person ID: one past every initial person identity.
632pub(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    /// Returns committed availability for a person. `None` means no change
656    /// has been committed: the person is alive and free.
657    #[must_use]
658    pub fn person_availability(&self, person: PersonId) -> Option<&PersonAvailability> {
659        self.state.current.person_availability.get(&person)
660    }
661
662    /// Returns every committed person availability in person-ID order.
663    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    /// Closes, at the end of the boundary and after random-decision ingress is
720    /// materialized, every open ticket whose person decision maker or whose
721    /// assigned controller's authority person became unavailable in this
722    /// boundary. The IDs are recorded on each change.
723    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
832/// Person state reconstructed boundary by boundary during snapshot
833/// validation, so decision-ingress reconstruction sees the same availability
834/// and person identities that the runtime saw at admission.
835pub(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    /// Returns `None` for identities not created at runtime, whose existence
856    /// is then decided by the persisted entity registry.
857    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    /// Advances through one committed boundary, re-deriving the same-boundary
864    /// ticket cancellations on the reconstructed decision state.
865    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
890/// Checks the persisted entity registry: the initial scenario registry plus
891/// exactly the persons created by committed boundaries.
892pub(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
961/// Validates committed availability and creation evidence and proves that it
962/// reconstructs the persisted availability map and person counter.
963pub(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}