Skip to content

Settlement system

The settlement system decides when and how authoritative state changes. Its unit of work is the boundary: one settlement pass at one simulation time. A boundary admits the work that is due, runs plugin systems in fourteen fixed phases, and then commits all of their changes together or rolls all of them back. Read this page when you write a boundary system or need to know when a write becomes visible to other systems.

The host application starts a boundary with settle_boundary(BoundaryRequest), or calls advance_canonical and step_canonical to settle queued input on the simulation clock. Terms such as ingress, admission, and holder are defined in the terminology reference.

Path of a queued command through one boundary, ending in commit or rollback. View diagram source.
View diagram source
flowchart LR
  Host["Host application<br/>enqueues a command"] --> Queue["Canonical ingress queue"]
  Queue -- "due at the boundary time" --> Admit["Phase 1<br/>admit due work"]
  Admit --> Snapshot["Phase 2<br/>fix the read baseline"]
  Snapshot --> Systems["Phases 3–14<br/>read, allocate, stage,<br/>validate, commit, hash"]
  Systems --> Check{"Every phase<br/>succeeded?"}
  Check -- "yes" --> Commit["Boundary committed;<br/>settle_boundary returns a receipt"]
  Check -- "no" --> Rollback["Whole boundary rolled back;<br/>settle_boundary returns the error"]
  1. The engine runs internal scheduled work that is due before the requested time. It then takes the canonical ingress that is due, in queue order. Canonical ingress is the persisted, ordered input queue described in the event system.
  2. It builds the admission set for this boundary: command attempts, accepted commands, admitted ingress and events, and the calendar cadences that apply, such as Daily or Monthly, with their calendar marks.
  3. It copies the current state as the boundary snapshot. Systems in phases 1 to 8 read this snapshot, so they all start from the same baseline.
  4. It runs the fourteen phases in order. Inside one phase, systems run in (plugin name, system name) order. A system runs when the request lists its cadence; an EventDriven system runs when the boundary admitted any event or ingress.
  5. It records the boundary evidence: admitted inputs, allocations, state changes, random draws, transition audits, evaluation traces, knowledge publications, and the boundary hash.
  6. If any step fails, it restores time, queues, state, journals, random streams, counters, and boundary records to their values before the boundary, and returns the error.

The phases fix the order in which work happens. Your plugin registers each boundary system in one phase. Systems may declare state writes only in phases 7, 10, 12, and 13. Phases 9 and 11 are where the kernel commits staged writes.

# Phase What happens
1 EventIngress Admit due commands, events, and plugin input
2 BoundarySnapshot Fix the read baseline for this boundary
3 DerivedFieldSolve Compute declared derived fields
4 PerceptionAndAttentionRefresh Refresh actor perception and attention
5 DecisionAndAcceptedEffectIntake Read-only plugin slot for decision and effect intake; decision ingress is admitted in phase 1
6 ReservationAndAllocation Offer resources, request them, and let the kernel allocate
7 DomainDeltaProposal Stage component, record, and event changes
8 InvariantValidation Check the staged changes against invariants
9 AtomicDomainCommit Commit the phase-7 changes together
10 HistoricalCandidateEvaluation Evaluate historical transitions; manifest participants stage their writes
11 ConditionalTransitionCommit Audit ready transition manifests, then commit the phase-10 changes
12 StrategicAggregation Build strategic-level aggregates; read transition audits
13 PerspectiveAndReportMaterialization Build actor perspectives and reports
14 SaveReplayAndDiagnosticHashing Record evidence and update replay and diagnostic hashes

The phases are an ordering. Underneath them, settlement uses four building blocks:

Building block What it does Writes state
Immediate write A command handler or legacy event reactor applies its effect at once, without staging it for a boundary commit. Legacy compatibility code, such as the Command::OrderMovement path, uses it, as do a few plugin commands such as canwu-society’s set_institutional_policy. Yes
Reservation and allocation Phase-6 systems offer supply and request it; the kernel computes who receives how much. No; it produces an allocation result
Staged boundary commit Systems stage changes against the boundary snapshot; the kernel validates them and commits them as one bundle. New mechanics use this path. Yes
Visibility Each boundary system declares SameBoundary or NextBoundary for the writes it stages. The choice decides when other systems can read them. No; it controls timing

A state key belongs to one path. Registration fails if an immediate handler and a boundary system declare writes to the same StateKey.

Allocation separates “who runs first” from “who receives resources”. In phase 6, systems declare what they offer and what they request; the kernel then settles all requests for a pool at once. Requests are ordered by pool, then by descending priority, then by tie-break key, then by reservation identity. The result depends only on these keys.

For example, a granary pool offers 100 units, and three requests compete for it:

Request Priority Quantity Result
A 5 80 Fulfilled: 80 granted
B 1 50 Partial: 20 granted
C 1 10 Rejected: 0 granted

B and C share a priority, so their tie-break keys decide which goes first; here B’s key sorts before C’s. A system reads an allocation through SimulationView::reservation and must list it in reservation_reads.

The movement lifecycle extension, canwu-movement, allocates transport capacity differently. In phase 7 it calls the pure function allocate_capacity_bookings from canwu-transport once per capacity pool. A booking is confirmed in full or fails. Bookings are visited by descending priority, then window start, tie-break key, admission sequence, and booking identity. Each result is recorded as CapacityBookingAllocationEvidenceV1, and confirmed capacity stays with its booking.

A boundary system returns a BoundaryProposal. Its directives stage writes, and the kernel commits them later. When a staged write becomes readable depends on the phase and on the visibility the system declared:

Staged in phase SameBoundary write NextBoundary write
7 Readable from phase 8 through the boundary’s overlay of staged values; committed in phase 9 Committed at the end of the boundary; readable from the next boundary
10 Committed in phase 11; readable from phase 11 on Committed at the end of the boundary
12 or 13 Committed at the end of that phase Committed at the end of the boundary

Invariant systems in phase 8 can inspect every staged value, including NextBoundary values, through proposed_component and proposed_domain_record. They still need the matching entries in their declared reads.

Events emitted during a boundary are admitted by the next boundary through the normal admission path.

Most directives write state that a plugin owns. Three directives change state that the kernel owns, and each has fixed rules:

  • SetPersonAvailability replaces one person’s life and custody state. A system in phase 7 or 10 that declares a write to StateKey::core_person_availability() may propose it. Two writes for the same person in one boundary fail the boundary. At the end of the boundary, after random decisions are applied, the kernel cancels open decision tickets whose decision maker or controller authority person became unavailable, and lists them in the boundary record.
  • CreatePerson creates a person. A phase-7 system that declares a write to StateKey::core_people() may propose it. The engine allocates the ID, and the receipt’s created_persons binds that ID to the proposing plugin, system, and correlation string. Systems can see the new person from the next boundary.
    • The PersonDraft may carry a PersonPlacement: a government and a territory that both exist in the WorldSnapshot projection. The person then gets a Person record there.
    • A draft without a placement creates an unplaced person: an identity in the entity registry (the run’s list of entity identities) with person availability and a creation receipt, and no Person record. SimulationView::person and Canwu::world hold nothing for it. Use this when your application keeps governments and places in its own domain records. The draft’s name and roles are kept only on the creation evidence, so store the person’s attributes in your own records.
    • For command authority, decision tickets, custody, and holder knowledge, an unplaced person is a person like any other. The built-in movement command, letters, and army-arrival reports read the projection and do not reach it.
  • CancelPluginIngress withdraws a queued plugin ingress item that the system’s own plugin scheduled, strictly before the item is due. Get targets from SimulationView::cancellable_plugin_ingress, which requires a StateKey::core_ingress() read. The whole boundary fails if the target belongs to another plugin, is already due, admitted, or withdrawn, or if another proposal in the same boundary withdraws it too.

Several systems may declare the two core person keys. The kernel therefore checks for conflicting writes per person during settlement.

Some historical transitions need several plugins to write in the same boundary. A transition manifest lets a coordinating plugin name every participant in advance, so the kernel can catch a participant that stays silent. The kernel audits each manifest in phase 11, before the phase-10 writes commit.

Lifecycle of a transition manifest from registration through staging and the phase-11 audit. View diagram source.
View diagram source
flowchart LR
  Register["Phase 7, 10, or 12<br/>coordinator registers the manifest"] --> Stage["Ready boundary, phase 10<br/>listed participants stage writes"]
  Stage --> Audit{"Phase 11 audit"}
  Audit -- "all staged, versions match" --> Committed["Committed<br/>staged writes commit"]
  Audit -- "nobody staged" --> Expired["Expired<br/>nothing written"]
  Audit -- "some staged, or a version differs" --> Failed["Whole boundary fails<br/>and rolls back"]
  Committed --> Read["Phase 11 onward<br/>coordinator and participants read the audit"]
  Expired --> Read
  1. Register. A system of the coordinating plugin, in phase 7, 10, or 12, declares a write to StateKey::core_transitions() and proposes BoundaryDirective::RegisterTransitionManifest. The TransitionManifest holds a lineage_id, an attempt number, the ready_at boundary, and one entry per participant plugin. Each entry lists the record versions that participant expects before the transition (expected_pre) and after it (expected_post). The manifest’s identity is TransitionManifestId { coordinator, lineage_id, attempt }; the kernel fills in the coordinator from the registering plugin. A manifest registered in phase 7 may be ready in the same boundary. One registered in phase 10 or 12 must name a later boundary. No manifest may be ready more than 1,024 boundaries ahead. The coordinator, not the engine, chooses ready_at, and it is a boundary ID, not a day. Staging and commit never span two boundaries: participants stage, and the writes commit, in the ready boundary itself, never one boundary later.
  2. Stage. In phase 10 of the ready boundary, each listed participant’s phase-10 system that declares the same write proposes StageTransitionWrite { manifest_id, writes }. The staged writes are ordinary directives, so the system’s declared writes, ownership, phase rules, and visibility all apply. An empty writes list records that the participant was present without writing anything. If a plugin that is not listed tries to stage, the boundary fails with InvalidAuthority.
  3. Audit. Before phase 11 commits, the kernel checks every manifest that is ready in this boundary. If every participant staged and every expected version matches, the outcome is Committed. If no participant staged, the outcome is Expired and nothing is written for the manifest. If only some participants staged, the whole boundary fails with TransitionParticipantMissing. If an expected version differs, it fails with TransitionVersionMismatch.
  4. Read. The kernel stores a TransitionAuditRecord with the outcome and the number of writes each participant staged. It appears in BoundaryRecord::transition_audits and BoundaryReceipt::transition_audits. Systems of the coordinator and participants that run in phase 11 or later read it through SimulationView::transition_audits.

expected_pre refers to versions in the committed state that phase 10 reads. expected_post is checked in phase 11, after the boundary’s SameBoundary phase-10 writes commit and with its pending NextBoundary phase-7 and phase-10 writes applied. A phase-12 or phase-13 write can still change the record later in the same boundary.

Manifests are bounded, and any rule violation fails the boundary:

  • A coordinator holds at most one pending manifest per lineage and at most 32 pending manifests. All coordinators together hold at most 128. One manifest lists at most 16 participants and 64 expected versions in total. Each participant must be a registered plugin with a phase-10 system that declares core_transitions, and each expected record must be of a registered kind.
  • Only the coordinator and the participants can see a manifest. They read it through SimulationView::transition_manifests, after declaring the canwu.core.transitions read, from the phase after its registration. The host application reads the pending set with Canwu::pending_transition_manifests.
  • There is no withdrawal directive. A manifest expires when nobody stages, so if its ready boundary fails on every retry, settle that boundary with a cadence under which no participant system runs. The manifest expires, and the coordinator can register a new attempt. A manifest ID may be reused after it settles, so (manifest_id, ready_at) identifies an audit.
  • Pending manifests are saved in the snapshot and roll back with a failed boundary. Snapshot validation rebuilds them from the registration and audit evidence. A run that never registers a manifest hashes the same as a run without this feature.
  • Phase-10 directives outside any manifest are unaffected. To catch a missing participant, a manifest needs at least two participants; with only one, silence just expires it.

Some systems carry work into a later boundary: they re-send a packet to a later time, or resolve something whose result settles in a follow-on boundary at the same instant. To keep a period of simulated time within a boundary budget, such a system has to count the boundaries that other sources open as well as its own. A boundary system that declares the canwu.core.boundary_schedule read calls SimulationView::boundaries_between(from, to) for the half-open window from..to. The returned BoundaryWindow has three lists:

  • settled: the boundaries already settled in the window.
  • current: the boundary being settled, when the reader is a boundary system and that boundary’s time is in the window.
  • queued: each distinct due time still queued in the window, from the canonical ingress queue and the scheduled-work queue.

Each BoundarySummary gives the boundary’s time, its cadences, its calendar marks, and its ingress counted by class, kind, and origin. The kind is a command, a plugin packet type, calendar work, a decision, maintenance, or a seat rebinding. The origin (BoundaryIngressOrigin) tells apart ordinary host ingress (Host), work that a boundary system or a command handler scheduled (BoundarySystem, CommandHandler), work recorded with an event as its cause (Event), and engine or host-labelled work such as a calendar request (System, with its name). Payloads are never included. The host application classifies boundaries by its own rules, for example to leave out a boundary that only a player’s command opened. A seat rebinding is also of the command class, so filter player commands by kind, not by class.

  • A system declares how far back it reads settled boundaries as boundary_schedule_horizon on its BoundarySystemContract, the way it declares its reads and random streams. It may look back only that far. A window that starts earlier than the boundary time minus that horizon returns EvidenceUnavailable. Other plugins’ declarations do not widen it, so no plugin depends on another plugin’s horizon. A window whose to precedes from returns ValueOutOfRange. A non-zero horizon requires the canwu.core.boundary_schedule read.
  • The run keeps settled boundaries as far back as the longest horizon any registered system declares, at most MAX_BOUNDARY_SCHEDULE_HORIZON (366 days); with no horizon declared, it keeps none before the current time. The host reads back that far; Canwu::boundary_schedule_horizon reports it.
  • The engine keeps no calendar, but it carries the host’s calendar marks.A host that runs more than one calendar marks a calendar boundary with the calendars whose period starts there, schedule_calendar_marks(due_at, marks), each CalendarMark a cadence and a calendar key (a canonical label of at most 64 bytes); calendar ingress due at one instant is still merged into one boundary, marks included. A boundary system registered with a calendar key (BoundarySystemContract::calendar) runs only in a boundary that carries its own (cadence, key) mark; a system without a key runs in every boundary of its cadence, marked or not, so an unmarked schedule_calendar_boundary wakes only unkeyed systems. SimulationView::calendar_marks reads the marks of the boundary being settled, with no read declaration, and BoundaryRecord and BoundarySummary carry them. The key is part of the plugin descriptor. SimTime::day numbers days of 1,440 minutes from time zero, which gives you a day window directly; a turn, a week, a day that starts at another hour, and which month a mark begins are the application’s to compute.
  • Only phased boundary systems may declare the read. The read names other plugins’ queued packet types, never payloads, so a plugin command or a synchronous event-driven system that declares it is refused at registration, as it is for canwu.core.ingress.
  • The queued list is a lower bound. Work the current boundary proposes is queued only when the boundary commits, so a zero-delay follow-on that will open a second boundary at the same instant is listed from the next boundary on. Work not yet queued, and a boundary the host settles directly, are not listed either.
  • The settled list is the same in a live run, a sealed run, a restored run, and its exact replay. The engine keeps it as an index derived from the boundary and ingress journals and the saved plugin descriptors: it is updated when a boundary commits, kept when evidence is sealed, and rebuilt from the full journal on restore. It adds nothing to snapshots or hashes.
  • Settled summaries come from boundary evidence, which does not record legacy scheduled actions, so only queued summaries count them (scheduled_actions).
  • The host application reads the same ledger, without a current boundary, through Canwu::boundaries_between and CompactedCanwu::boundaries_between. Because the result names every plugin’s queued packet types, do not pass it to player, agent, observer, or remote clients.

An evaluation trace explains how an application rule produced one number for one subject, term by term. A system in phase 7 or 12 records one by proposing BoundaryDirective::RecordEvaluationTrace { trace }; the system contract needs no extra declaration. An EvaluationTraceRecord carries the rule ID and version, the subject entity, a list of EvaluationTerm values (term ID, integer contribution, and the evidence the term read), the result as the rule computed it, and the current boundary. The kernel checks the shape, that the subject exists, and that every evidence reference is committed evidence the proposal can see. It does not check that the terms add up to the result.

  • A trace is boundary evidence, kept apart from state. The kernel records it, with the producing plugin, system, and phase, in BoundaryRecord::evaluation_traces, includes it in the boundary hash chain, and seals and archives it with the boundary record. Systems, command handlers, and decision policies cannot read traces back to decide an outcome.
  • The run configuration sets the limits with EvaluationLimitsV1 through RunConfiguration::with_evaluation_limits. The default allows 4,096 traces per boundary across all systems and 32 terms per trace; the maximum is 65,536 traces and 256 terms. Each term cites at most 16 evidence references, and each rule ID, rule version, or term ID is at most 256 bytes. A proposal set over any limit fails the boundary with EvaluationTraceLimitExceeded; a committed boundary keeps all of its traces. A limit of zero forbids traces for the run.
  • Phase 13 cannot record traces, so record the trace from the phase-7 or phase-12 system that computed the value. Traces count toward journal size.
  • Players and agents read traces through CanwuViewer::evaluation_traces. That view omits evidence and follows what the holder knows; see reading state.

Your host can time a whole settle_boundary call itself. What it cannot see is how that time divides among plugins. A boundary profile reports that, so you can check each plugin against a time budget.

Profiling is off by default. Turn it on with set_boundary_profiling(true) on Canwu or CompactedCanwu. From then on, every boundary that settles leaves one BoundaryProfile, and take_boundary_profiles() hands over the profiles collected since the last call:

canwu.set_boundary_profiling(true);
canwu.settle_boundary(BoundaryRequest::at(canwu.time()).with_cadence(SystemCadence::Daily))?;
let batch = canwu.take_boundary_profiles();
for profile in &batch.profiles {
println!(
"boundary at {:?}: {} µs in total, {} µs in my-plugin, {} µs in the engine",
profile.settled_at,
profile.total_micros,
profile.plugin_micros("my-plugin"),
profile.remaining_micros(),
);
}

A profile holds:

  • boundary_id and settled_at: which boundary it is, and its simulation time.
  • total_micros: the wall time of the whole settlement, in microseconds.
  • handlers: one BoundaryHandlerTiming for each plugin handler call, in call order, with its kind, phase, plugin, name, and elapsed_micros.
  • engine: one BoundaryEngineTiming per engine phase (BoundaryEnginePhase), the simulation core’s own time split by kind of work: Admission (checking the request, the rollback point, admitting due ingress, commands, decisions, maintenance, and scheduled actions), SystemDispatch (preparing each system’s view and checking its proposal), Staging (overlays, allocation, and transition audits between phases; when every phase-7 record write is visible in the same boundary, most of the record-write work shows here), Commit, Knowledge, Journal, and Hashing. engine_micros(phase) reads one. The handlers and the engine phases partition total_micros: their sum falls short of it by less than one microsecond per listed value.

A boundary runs three kinds of plugin handler, and each has a BoundaryHandlerKind:

Kind What was timed phase
BoundarySystem A boundary system The phase it ran in
CommandHandler The handler of a queued plugin command the boundary admitted None
EventSystem A synchronous event-driven plugin system that such a command, or a scheduled action due at the boundary, triggered None

Command handlers and event systems run while the boundary admits its input, before the first phase, so they have no phase. plugin_micros(plugin) adds up every handler of one plugin. remaining_micros() is what is left, the engine’s own time, and equals the sum of the engine phases within the same tolerance.

To find where an exact replay spends its time, call Canwu::replay_from_journal_with_boundary_profiles(plugins, &journal, archive_provider, on_profile): it replays with profiling on from the first recorded boundary and hands each boundary’s profile to on_profile as the boundary settles, in journal order, so the retained-profile cap does not limit a long replay. The returned run has profiling off. A replay that does not match its journal fails as an unprofiled one does, after on_profile has received the profiles up to and including the mismatching boundary.

  • A profile is a host diagnostic and stays outside the simulation. It is not part of authoritative state, evidence, a snapshot, a checkpoint, or a replay journal, and no plugin can read it. A profiled run produces the same receipts, evidence, and hashes as an unprofiled one, and while profiling is off settlement reads no clock. Profile values differ between two runs of the same input, so do not feed them back into commands or plugin input.
  • Profiling is off in a new, restored, replayed, or forked run; turn it on again after a load. Entering compact mode and sealing evidence keep the setting and the profiles not yet taken.
  • A boundary that fails leaves no profile.
  • The run keeps at most MAX_RETAINED_BOUNDARY_PROFILES (1,024) profiles. If more boundaries settle before you take them, the oldest are dropped, and BoundaryProfileBatch::dropped reports how many.
  • Plugin archive maintenance runs when the host schedules it, outside settlement, and appears in no profile. The boundary only applies the commit it produced, which counts as engine time.

The first-party extensions below use the same phases. The table helps you predict when their state changes relative to your own systems.

Extension Phase and cadence System and work
canwu-movement 7, event-driven movement_lifecycle_apply_v1 applies admitted operations and incidents in admission order, runs one allocation pass per capacity pool, applies due legs, and retires closed executions.
canwu-movement 8, event-driven movement_lifecycle_validate_v1 validates the staged movement runtime.
canwu-movement 13, event-driven movement_report_publish_v1 publishes movement reports that changed, each addressed to the holder allowed to see it.
canwu-culture (CultureBoundaryPlugin) 12, event-driven culture_exposure_intake_v1 queues admitted culture_exposure_v1 batches.
canwu-culture (CultureBoundaryPlugin) 7, Monthly culture_lifecycle_settle_v1 consumes the queue and accepted institutional decisions, settles the lifecycle against the boundary snapshot of society state, saves culture state, and schedules society deltas and signal batches as next-boundary ingress.
canwu-society 12, event-driven intake-society-ingress queues admitted cohort_headcount_rebase_v1, society_lifecycle_delta_v1, and society_pressure_revision_v1 packets.
canwu-society 7, society settlement cadence (Daily by default) settle-social-transitions applies the queue after cohort transfers and before institutional decisions and transitions, then materializes the mobilization candidates, aggregates, and observer projections its changes invalidated, so a settlement writes the root once.
canwu-society 7, event-driven intake-intergroup-relations applies admitted intergroup_relation_revision_v1 packets to the intergroup relations record, each packet as a whole or not at all; a refused packet is one society_intergroup_relation_rejected_v1 event.

Movement legs are timed with the internal scheduled ingress movement_leg_due_v1. An order schedules its first departure, a departure schedules its arrival after the leg’s planned duration, and an arrival schedules the next departure. A due packet takes effect only if it matches the due time saved on the order, so an explicit operation makes older packets stale. Due work never fails a boundary.

Culture and society use intake queues because only one system may write a given state key in a given phase, and their writers run on a coarser cadence. All phase-7 systems read the same boundary snapshot, so every culture hand-off lands in a later boundary: signal batches are admitted at the next boundary, and a society delta is applied at the first society settlement after it is admitted. A culture step therefore reaches society state two boundaries later at the earliest, and a headcount rebase takes effect at the first society settlement after the boundary that admits its packet. The society plugin stays the only writer of canwu.society:state.

Society settles on Daily boundaries unless the host registers the plugin with SocietyPlugin::with_settlement_cadence. On a coarser cadence, everything in the queue waits for the next boundary that carries it; see settle on another cadence.

Each boundary system registers a BoundarySystemContract that declares:

  • its name, phase, and cadence;
  • the StateKey values it reads and writes;
  • the reservation pools it offers and requests, and the allocations it reads (reservation_reads);
  • the random streams it owns;
  • the event types it emits (emits), the knowledge schemas it may publish (knowledge_writes), and the other plugins it may send ingress to (plugin_ingress_targets);
  • the visibility of its writes.

Kernel-owned keys follow the same rule. Transition directives need StateKey::core_transitions(), and person directives need StateKey::core_person_availability() or StateKey::core_people(). Evaluation traces need no declaration because they write no state.

Reading the publication budget needs no declaration either. A boundary has one budget of knowledge bodies, holder entries, and payload bytes, spent by every publishing system in dispatch order (phase 4 before phase 13, then registry order within a phase). A system that publishes reads what is left with SimulationView::knowledge_publication_budget() before it proposes, because a proposal past the budget fails the boundary.

The kernel rejects undeclared reads, writes, reservation reads, and random draws. Plugin registration, snapshot restore, and exact replay all require the same plugin name, version, and semantic hash.