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.
20///
21/// A successor holder continues the decision as a new ticket of its own. When
22/// the successor's controller is bound to the same seat as the cancelled
23/// ticket's controller, the new ticket may name the cancelled one as its
24/// `parent_ticket`.
25pub const DECISION_MAKER_UNAVAILABLE_REASON: &str = "decision_maker_unavailable";
26
27/// Cancellation reason recorded on an open ticket whose assigned controller's
28/// authority person became unavailable.
29///
30/// The authority person is the actor of [`DecisionAuthority::Actor`], or the
31/// responsible actor of [`DecisionAuthority::Institution`] when one is named.
32/// Council and no-responsible-actor authorities never trigger this reason.
33/// The cancellation happens at the end of the boundary that makes the person
34/// unavailable, after the boundary's random decisions are materialized, and
35/// the IDs are recorded in
36/// [`BoundaryPersonAvailabilityChange::cancelled_controller_tickets`]. A
37/// ticket whose decision maker became unavailable in the same boundary is
38/// cancelled with [`DECISION_MAKER_UNAVAILABLE_REASON`] instead.
39///
40/// A ticket cannot be reassigned to another controller, and decision ingress
41/// refuses to open a ticket for a controller whose authority person is
42/// unavailable ([`ErrorCode::IssuerUnavailable`]). To continue the decision,
43/// open a successor ticket for a different, available controller with
44/// `parent_ticket` naming the cancelled ticket.
45pub const CONTROLLER_AUTHORITY_UNAVAILABLE_REASON: &str = "controller_authority_unavailable";
46
47const MAX_PERSON_CORRELATION_BYTES: usize = 256;
48
49/// Whether a person is alive. Absent availability means [`LifeState::Alive`].
50#[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/// Whether a person is free to act. Absent availability means
62/// [`CustodyState::Free`].
63#[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/// Core life and custody state of one person.
78///
79/// A person is unavailable for command issuance and decision making when it
80/// is not alive or is detained or captive. Hostage, hiding, and exile remain
81/// admissible by default; applications may restrict them further.
82#[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    /// Returns whether the person may issue commands or make decisions.
109    #[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/// Application-supplied content for a person created at a boundary.
117#[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    /// Committed evidence that justifies the creation.
126    pub provenance: EvidenceRef,
127}
128
129/// Committed availability change evidence in a boundary record.
130#[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    /// Open tickets closed in the same boundary because this person, their
142    /// decision maker, became unavailable, in ticket-ID order.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub cancelled_tickets: Vec<DecisionTicketId>,
145    /// Open tickets closed in the same boundary with
146    /// [`CONTROLLER_AUTHORITY_UNAVAILABLE_REASON`] because this person, the
147    /// authority person of their assigned controller, became unavailable, in
148    /// ticket-ID order. A ticket closed for its decision maker by any change
149    /// of the boundary is listed only in that change's `cancelled_tickets`.
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub cancelled_controller_tickets: Vec<DecisionTicketId>,
152}
153
154/// Committed person-creation evidence in a boundary record.
155#[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/// Receipt entry binding a creation correlation to its engine-allocated ID.
167#[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
195/// Returns the plugin-owned subset of a boundary contract's writes after
196/// checking the kernel-guarded core keys a boundary system may declare.
197///
198/// `canwu.core.person_availability` is writable in phases 7 and 10,
199/// `canwu.core.people` (person creation) in phase 7, and
200/// `canwu.core.transitions` (transition manifests) in phases 7, 10, and 12.
201/// None is owned by a plugin, so several systems may declare them. Per-person
202/// conflicts fail the boundary at settlement; a repeated pending lineage or an
203/// exceeded pending bound fails it when the registration is admitted.
204pub(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    // The ID is not allocated yet; validate against an impossible identity so
370    // a draft cannot name itself as custodian.
371    validate_availability_value(
372        PersonId::new(0),
373        &draft.availability,
374        context.now,
375        context.entity_exists,
376    )
377}
378
379/// Boundary-wide staging guard for person directives.
380#[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
450/// Rejects a command whose issuing person is dead, missing, detained, or
451/// captive. Non-person issuers and authorities are unaffected.
452pub(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
506/// Deterministic availability admission rule for decision ingress, shared by
507/// the runtime and snapshot reconstruction.
508///
509/// `Open` refuses an unavailable person decision maker first, then an
510/// assigned controller whose authority person is unavailable, so a ticket is
511/// never opened for a controller that could not resolve it. `Resolve` refuses
512/// a controller whose authority person is unavailable.
513pub(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
539/// Availability guard for resolving one ticket through its controller, used
540/// by host decision preparation and by boundary random decision resolutions.
541pub(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
567/// Cancels the open tickets selected by `matches`, in ticket-ID order, with
568/// `reason` and returns the cancelled IDs.
569fn 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
597/// End-of-boundary sweep shared by the runtime and snapshot reconstruction.
598///
599/// For every change that leaves its person unavailable, first cancels the
600/// open tickets whose decision maker is that person
601/// ([`DECISION_MAKER_UNAVAILABLE_REASON`]); then, for every such change,
602/// cancels the remaining open tickets whose assigned controller's authority
603/// person is that person ([`CONTROLLER_AUTHORITY_UNAVAILABLE_REASON`]). The
604/// decision-maker pass runs for all changes first, so a ticket that qualifies
605/// for both reasons carries the decision-maker reason. Both lists are
606/// rewritten on every change, in ticket-ID order.
607pub(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
646/// First runtime-allocated person ID: one past every initial person identity.
647pub(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    /// Returns committed availability for a person. `None` means no change
671    /// has been committed: the person is alive and free.
672    #[must_use]
673    pub fn person_availability(&self, person: PersonId) -> Option<&PersonAvailability> {
674        self.state.current.person_availability.get(&person)
675    }
676
677    /// Returns every committed person availability in person-ID order.
678    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    /// Closes, at the end of the boundary and after random-decision ingress is
735    /// materialized, every open ticket whose person decision maker or whose
736    /// assigned controller's authority person became unavailable in this
737    /// boundary. The IDs are recorded on each change.
738    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
847/// Person state reconstructed boundary by boundary during snapshot
848/// validation, so decision-ingress reconstruction sees the same availability
849/// and person identities that the runtime saw at admission.
850pub(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    /// Returns `None` for identities not created at runtime, whose existence
871    /// is then decided by the persisted entity registry.
872    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    /// Advances through one committed boundary, re-deriving the same-boundary
879    /// ticket cancellations on the reconstructed decision state.
880    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
905/// Checks the persisted entity registry: the initial scenario registry plus
906/// exactly the persons created by committed boundaries.
907pub(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
976/// Validates committed availability and creation evidence and proves that it
977/// reconstructs the persisted availability map and person counter.
978pub(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}