Skip to main content

Cross-Canister Scheduling

Sector9's Viper lane does not model cross-canister message ordering or FIFO guarantees. The verifier uses a conservative interference model that assumes any interleaving can occur at await boundaries.

What This Means​

The Internet Computer has specific message scheduling semantics:

  • FIFO ordering: Messages to a canister are processed in order
  • One-at-a-time: Only one message executes at a time per canister
  • Callback ordering: Callbacks execute in a predictable order

Sector9 does not model these guarantees. Instead, it uses a conservative approximation:

Actual IC BehaviorVerifier Assumption
FIFO message orderingNo ordering assumed
One message at a timeArbitrary interleaving
Caller restrictionsAny caller possible
Rate limitsUnbounded calls

This means the verifier may reject programs that are safe at runtime. The tradeoff is conservative local reasoning: if Sector9 verifies a property, that property holds under the verifier's over-approximation of possible schedules, including schedules the IC does not produce.

The Interference Model​

When your actor awaits an async call, execution suspends. During this suspension, the verifier assumes:

  1. Any public method in your actor could execute
  2. Methods could run multiple times
  3. Called by any principal
  4. In any order

This is called the "Release-Assume" model:

  1. Prove the actor invariant holds before the await
  2. Release state (other actors may run)
  3. Assume the invariant holds when execution resumes

What You CAN Verify​

Invariant properties are preserved across await boundaries:

scheduling-invariant-preserved.sr9
// What CAN be verified: invariants preserved across await
persistent actor {
var counter : Int = 0;

invariant counter >= 0;

private func increment() : async () modifies counter {
counter += 1;
};

public func safeIncrement() : async () modifies counter {
try {
await increment();
} catch (_) {};
// Invariant counter >= 0 is assumed here
assert counter >= 0;
};
}

After the await, you can assume counter >= 0 because:

  • The invariant was proven before the await
  • Any methods that ran during the await also maintained the invariant
  • The invariant is assumed when execution resumes

What You CANNOT Verify​

Exact values or ordering-dependent properties fail verification:

scheduling-exact-value_unsat.sr9
// What CANNOT be verified: exact values after await
persistent actor {
var counter : Int = 0;

invariant counter >= 0;

private func increment() : async () modifies counter {
counter += 1;
};

public func checkExact() : async () modifies counter {
let before = counter;
try {
await increment();
} catch (_) {};
// FAILS: other actors could have changed counter
assert counter == before + 1;
};
}

The assertion fails because:

  • During the await, deposit, withdraw, or increment could have run
  • The verifier does not know the IC processes messages in order
  • counter could be any value satisfying the invariant

Understanding Conservative Interference​

The conservative model is illustrated by this example:

scheduling-interference-model.sr9
// The conservative interference model
persistent actor {
var balance : Int = 100;

invariant balance >= 0;

// Any public method could run during await
public func deposit(amount : Int) : async ()
modifies balance
entry_requires amount >= 0;
requires amount >= 0;
{
balance += amount;
};

public func withdraw(amount : Int) : async ()
modifies balance
entry_requires amount >= 0;
entry_requires amount <= balance;
requires amount >= 0;
requires amount <= balance;
{
// Safe: invariant guarantees balance >= 0
balance -= amount;
};

public func transfer(amount : Int) : async ()
modifies balance
entry_requires amount >= 0;
entry_requires amount <= balance;
requires amount >= 0;
requires amount <= balance;
{
let _before = balance;
balance -= amount;
try {
await deposit(0); // Any method could run here
} catch (_) {
()
};
// Cannot assume balance == before - amount
// Only know: balance >= 0 (from invariant)
assert balance >= 0;
};
}

In transfer:

  • Before the await, we subtract amount from balance
  • During the await, any public method could run (deposit, withdraw, transfer)
  • After the await, we only know balance >= 0 (the invariant)

The verifier cannot prove balance == before - amount because it does not model that:

  • No other messages arrived during the await
  • Messages are processed in FIFO order
  • Only one message runs at a time

Why This Limitation Exists​

Cross-canister scheduling involves:

  • Message queues: Multiple canisters sending messages
  • Ingress throttling: Rate limits on external calls
  • Callback chains: Complex ordering of responses
  • Failure modes: Rejected messages, timeout, traps

Modeling these semantics requires a different verification lane. Sector9's Viper backend focuses on local correctness: each method preserves contracts and invariants between await boundaries under conservative interference. The active TLA+ backend is the protocol-level lane for interleavings, failure schedules, scoped liveness, and cross-canister temporal claims.

Workarounds​

Strengthen Invariants​

Express ordering-independent properties as invariants:

scheduling-strengthen-invariant.sr9
// Workaround: strengthen invariants for specific properties
persistent actor {
var balance : Int = 100;
var minBalance : Int = 0;

// Stronger invariant captures the property we need
invariant balance >= minBalance;
invariant minBalance >= 0;

public func deposit(amount : Int) : async ()
reads minBalance
modifies balance
entry_requires amount >= 0;
requires amount >= 0;
{
balance += amount;
};

public func setMinimum(min : Int) : async ()
reads balance
modifies minBalance
entry_requires min >= 0;
entry_requires min <= balance;
requires min >= 0;
requires min <= balance;
{
minBalance := min;
};

public func safeTransfer(amount : Int) : async ()
reads minBalance
modifies balance
entry_requires amount >= 0;
entry_requires amount <= balance - minBalance;
requires amount >= 0;
requires amount <= balance - minBalance;
{
balance -= amount;
try {
await deposit(0);
} catch (_) {
()
};
// Now we can prove balance >= minBalance
assert balance >= minBalance;
};
}

Instead of tracking exact values, the invariant balance >= minBalance captures the property you need regardless of interleaving.

Use Fresh Local State​

Fresh allocations that do not escape to actor fields remain stable. Every await in verified code is still wrapped in try/catch; the point here is that the local object itself is not reachable by other actor methods:

scheduling-fresh-local.sr9
// Fresh local allocations remain stable across await
persistent actor {
private func tick() : async () { () };

public func freshLocalStable() : async Int {
// Fresh allocation in current method
let local = { var x = 42 };
try {
await tick();
} catch (_) {};
// local.x is preserved - it cannot be accessed by other actors
assert local.x == 42;
local.x
};
}

Local objects created in the current method cannot be accessed by other actors, so they are not subject to interference.

Design for Commutativity​

Structure operations so the result is independent of execution order:

  • Deposits commute: Order does not affect final balance
  • Counters commute: Increments can happen in any order
  • Sets commute: Add/remove operations are order-independent

When operations commute, the conservative model does not over-approximate.

Accept the Approximation​

For properties that truly depend on IC scheduling:

  • Keep the Viper proof in recorded-observation form
  • Add or update a *.protocol.sr9 root for the TLA+ property
  • Mark only the narrow local surface as trusted when there is no verified implementation path
  • Test runtime behavior with PIC scenarios when the property has an executable runtime surface

What Is Modeled​

For clarity, here is what Sector9 does model about async execution:

AspectModeledNotes
Atomicity between awaitsYesCode between awaits runs without interleaving
Invariant preservationYesInvariants proven before await, assumed after
Fresh allocation stabilityYesLocal objects that do not escape remain stable
Reentrancy-sensitive invariantsYesConservative: any method could be called
Shared state invalidationYesActor fields are havoced at await

And what is not modeled:

AspectModeledNotes
FIFO message orderingNoConservative approximation
Callback orderingNoNo assumptions about response order
Cross-canister schedulingNoOut of scope for Viper backend
Rate limitsNoUnbounded calls assumed
Failure recoveryNoSee Runtime Traps

Practical Guidance​

SituationRecommendation
Proving exact values after awaitStrengthen invariants or accept approximation
Ordering-dependent logicDesign for commutativity or move the claim to the protocol lane
Cross-canister protocolsUse TLA+ protocol roots for cross-canister temporal claims
Complex async patternsUse fresh locals for intermediate state
Performance testingTest at runtime, outside verification scope

Protocol-Level Lane​

Protocol-level verification is not a Viper feature. Use the TLA+ backend for claims that cross await boundaries or depend on multiple canisters:

  • *.protocol.sr9 roots define protocol catalogs and properties
  • --verify --tla verifies trusted local companions first, then runs TLC
  • PIR fail-closed checks reject missing or ambiguous method/action linkage
  • TLA+ owns scoped liveness, async request/response/rollback structure, and protocol-visible interleavings

SimPy remains a future simulation backend for economic and timing behavior.

These tools complement Viper's local correctness proofs with protocol-level analysis.

Summary​

  • Cross-canister scheduling and FIFO ordering are not modeled
  • The verifier uses conservative interference: any interleaving at await boundaries
  • Invariant properties are preserved and can be proven
  • Exact values after await cannot be verified
  • Workarounds: strengthen invariants, use fresh locals, design for commutativity
  • Use TLA+ protocol roots for cross-await and cross-canister claims