A warlord asks a neighbor for military aid
The scenario
Section titled “The scenario”Warlord A is fighting and asks neighboring Warlord B to send troops. At first Warlord B knows they share an enemy but has no confirmation that the supply route is safe, so the only option is to decline. When the route is confirmed, “send aid” becomes a second option. Warlord B’s AI scores both options, picks one, and the choice moves Warlord B’s army.
The question the example answers: how does an AI, player, or model choose an action in a way that is checked against the actor’s authority, recorded, and replayed without asking the decision maker again?
The setting is a synthetic, Beiyang-era-style fixture. It does not depict named people or recorded events.
What this example shows
Section titled “What this example shows”- A decision ticket (
DecisionTicket): one persisted question with a versioned list of decision options (DecisionOption). - A controller (
DecisionControllerBinding): who decides, with which policy, and on whose authority. - A utility policy (
WeightedUtilityPolicy) that scores options with integer weights and records each factor in a decision trace (DecisionTrace). - The selected option turning into a command that goes through the same validation as any other command.
- Snapshot restore and exact replay of the whole decision.
Run it
Section titled “Run it”cargo run -p canwu-api --example decision_ticketThe program prints the decision trace as JSON, then confirms persistence. The
output below is trimmed; the factor lists are shown for send-aid only:
{ "id": 1, "ticket_id": 1, "ticket_version": 2, "controller_id": "warlord-b-ai", "policy": { "kind": "utility", "id": "aid-utility", "version": "1" }, "outcome": { "type": "selected", "option_id": "send-aid" }, "summary": "utility policy selected send-aid", "evaluations": [ { "option_id": "decline", "available": true, "score": -40, ... }, { "option_id": "send-aid", "available": true, "score": 250, "factors": [ { "factor": "alliance", "value": 90, "weight": 3, "contribution": 270 }, { "factor": "home_defense", "value": -20, "weight": 1, "contribution": -20 } ] } ], "command_request_id": 1}snapshot_restore=ok exact_replay=okticket_version is 2 because the options were replaced once. decline
scores -40 × 3 + 80 × 1 = -40; send-aid scores 90 × 3 − 20 × 1 = 250.
How the decision flows
Section titled “How the decision flows”View diagram source
sequenceDiagram
participant Host as Host application
participant Canwu
participant Policy as WeightedUtilityPolicy
Host->>Canwu: register controller, open ticket (decline only)
Note over Canwu: boundary 1: ticket version 1
Host->>Canwu: ReplaceOptions (route confirmed)
Note over Canwu: boundary 2: ticket version 2
Host->>Canwu: drive_decision(policy)
Canwu->>Policy: score ticket version 2
Policy-->>Canwu: select send-aid
Note over Canwu: boundary 3: record trace, run the move command
Walkthrough
Section titled “Walkthrough”All code is in
decision_ticket.rs.
It uses the deprecated compatibility scenario from Canwu::demo(1918), which
supplies a commander, an army, and territories through Canwu::demo_ids().
1. Register the controller
Section titled “1. Register the controller”open_aid_request binds the controller warlord-b-ai to a policy identity,
the commander whose authority it uses, and the army it may command:
let controller = DecisionControllerBinding::new( "warlord-b-ai", DecisionPolicyIdentity::new(DecisionPolicyKind::Utility, "aid-utility", "1"), DecisionAuthority::Actor { actor: ids.commander, },).with_command_subject(EntityRef::Army(ids.army));It queues the binding as DecisionMutation::RegisterController. The policy
itself receives only the ticket. The issuer, authority, and command subject
stay in the persisted binding.
2. Open the ticket with one option
Section titled “2. Open the ticket with one option”The same function queues DecisionMutation::Open with a
DecisionTicketDraft. The context records what Warlord B knows, and the only
option is decline:
context: DecisionContext::new( "beiyang.aid-request.v1", json!({ "requester": "warlord-a", "battle": "ongoing-front", "common_enemy": true }),),options: vec![DecisionOption { action: DecisionAction::None, utility_inputs: BTreeMap::from([ ("home_defense".to_owned(), 80), ("alliance".to_owned(), -40), ]), ..DecisionOption::new("decline", "Decline aid")}],deadline: Some(now + SimDuration::days(2)),Both mutations go through enqueue_decision, and step_canonical() settles
the boundary that opens the ticket at version 1. In a real application, build
the context from Warlord B’s actor knowledge
so the policy sees only what Warlord B knows.
3. Replace the options when the route is confirmed
Section titled “3. Replace the options when the route is confirmed”refresh_aid_options sends DecisionMutation::ReplaceOptions with
expected_version: 1, a context that adds "route_confirmed": true, and two
options. The new send-aid option carries a serialized command:
DecisionOption { action: DecisionAction::Command { command: serde_json::to_value(Command::OrderMovement { subject: EntityRef::Army(ids.army), destination: ids.eastern_territory, cargo: Vec::new(), })?, }, utility_inputs: BTreeMap::from([ ("home_defense".to_owned(), -20), ("alliance".to_owned(), 90), ]), ..DecisionOption::new("send-aid", "Send the neighboring army")},The ticket moves to version 2. A Human, External, or LLM answer prepared for version 1 is rejected, so a stale answer cannot select from the new option set. In this fixture the route confirmation is authored scenario input; a research application would cite an evidence record for it.
Command::OrderMovement is a legacy command variant kept for this
compatibility fixture. New applications encode MovementCommand from
canwu-reference-world or their own plugin command; see
Move an army. A ticket option can carry either,
because it stores the command as JSON.
4. Score the options
Section titled “4. Score the options”resolve_aid_request builds the policy:
let policy = WeightedUtilityPolicy::new( "aid-utility", "1", UtilityProfile { weights: BTreeMap::from([("alliance".to_owned(), 3), ("home_defense".to_owned(), 1)]), },);For each available option, the score is the sum of value * weight over its
utility_inputs. A factor without a weight counts as weight 0. The highest
score wins, and equal scores go to the lowest option ID. What “alliance” and
“home defense” mean, and how much they weigh, is up to your domain code.
5. Resolve the ticket and run the command
Section titled “5. Resolve the ticket and run the command”let evaluation = canwu.drive_decision( canwu.time(), 0, DecisionRequestId::new(4), Some(CommandRequestId::new(1)), DecisionTicketId::new(1), &policy,)?;assert!(matches!(evaluation, DecisionEvaluation::Prepared(_)));canwu.step_canonical()?.expect("decision resolution boundary");drive_decision runs the policy against the current ticket and queues the
result as decision ingress. At the next boundary, Canwu records the trace and
submits the send-aid command with the controller’s authority. That command
still passes the normal authority, revision, time, and domain checks. The
policy returns only an option ID, so swapping in a player, external service,
or LLM does not let it write new commands or act as someone else.
6. Check persistence and replay
Section titled “6. Check persistence and replay”verify_persistence_and_replay restores the snapshot from JSON and rebuilds
the run with Canwu::replay_from_journal, asserting that both match the
original snapshot. Replay uses the recorded decision ingress and attempts; it
does not call the policy.
To read results in your own code, use Canwu::decision_trace(id) for a trace
and Canwu::decision_attempt(request_id) for the accepted or rejected result
of one decision request. A request with a stale revision or ticket version, or
for a closed ticket, is recorded as a rejected decision attempt
(DecisionAttemptRecord), and the simulation keeps running.
What to notice
Section titled “What to notice”- The policy picks from options that already exist. It cannot add an option or change a command.
- Changing the options bumps the ticket version, which invalidates answers prepared for the old version.
- The trace explains the choice factor by factor, and replay reuses it.
- To test a different policy on the same situation,
forkfrom a snapshot and submit new decision ingress. The result is an alternative reality, separate from exact replay.
Try changing
Section titled “Try changing”- Weights. Set
home_defenseto 5.declinethen scores-120 + 400 = 280and beatssend-aid(270 - 100 = 170). - Policy. Replace
WeightedUtilityPolicywith another policy type fromcanwu-api:OrderedRulePolicyselects or defers by ordered rules.GuardedUtilityPolicyruns ordered guards that can select, defer, or exclude options, then scores the rest, with an optional random tie-break between near-equal top scores (see Random decisions and LLM selection). Its trace records the decision stage (Guard,Utility, orRandom) and the guards that fired.QueuedHumanPolicy,QueuedExternalPolicy, andQueuedLlmPolicywait for a player, an external service, or a model to answer with an option ID for the current ticket version.
- Unavailable decision maker. If Warlord B dies or is captured, Canwu
cancels the open tickets with Warlord B as decision maker at the end of that
boundary and refuses new ones while Warlord B stays unavailable. If only the
controller’s authority person becomes unavailable, Canwu cancels that
controller’s open tickets instead. To carry on, open a new ticket for an
available controller and set
parent_ticketto the cancelled ticket; the link is copied onto the new trace. See Integrate and drive the simulation. - Seat succession. Bind Warlord B’s controller to a seat such as
faction-b.leaderwithwith_seat. When Warlord B dies, bind the successor’s controller to the same seat and open a new ticket with the successor as decision maker andparent_ticketpointing at the cancelled ticket. Canwu accepts the link because both controllers hold the same seat. A parent ticket from a different decision maker in a different seat is rejected.
Source
Section titled “Source”Open the runnable example
Read the decision framework tests