Skip to content

A warlord asks a neighbor for military aid

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.

  • 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.
Terminal window
cargo run -p canwu-api --example decision_ticket

The 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=ok

ticket_version is 2 because the options were replaced once. decline scores -40 × 3 + 80 × 1 = -40; send-aid scores 90 × 3 − 20 × 1 = 250.

Sequence of the warlord aid decision across three boundaries. View diagram source.
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

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().

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.

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.

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.

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.

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.

  • 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, fork from a snapshot and submit new decision ingress. The result is an alternative reality, separate from exact replay.
  • Weights. Set home_defense to 5. decline then scores -120 + 400 = 280 and beats send-aid (270 - 100 = 170).
  • Policy. Replace WeightedUtilityPolicy with another policy type from canwu-api:
    • OrderedRulePolicy selects or defers by ordered rules.
    • GuardedUtilityPolicy runs 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, or Random) and the guards that fired.
    • QueuedHumanPolicy, QueuedExternalPolicy, and QueuedLlmPolicy wait 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_ticket to 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.leader with with_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 and parent_ticket pointing 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.

Open the runnable example

Read the decision framework tests