Idea

Checking distributed systems by exhaustive simulation

A bug in a distributed system often shows up only in one order of events. Two servers read a shared counter, both add 1, both write back, and one increment is lost, but only when both reads land before either write. What if a message is dropped, delivered twice, or a node crashes? A test run sees one order, and a reviewer tracing the orders by hand can miss the bad one.

We built yichus dist to try every order up to a bound. You write the system as a model in Bosatsu. Each node's handler is shaped (state, sender, msg) -> Step(next_state, sends), returning its next state and outgoing messages as data. The checker tries every delivery order and declared fault up to the bounds you set, then evaluates the properties you supply. When one fails, you get the schedule that caused it.

See a lost update caught →See the reviewer benchmark →

What a holds result does and does not mean

A holds result is relative to the model, its initial state, and its configured fault choices; it is not a proof about every deployment of the protocol. Bosatsu checks recursion in language-defined functions, but external implementations remain trusted. A finite handler does not imply a finite conversation: nodes may keep sending messages. Exhausted search bounds therefore produce inconclusive, not a holding result.

A world declares its nodes, a fault budget, and its properties. The fault budget is three integers: how many messages the network may drop, how many deliveries it may duplicate, and how many nodes may crash. Properties are pure Bosatsu functions over all node states. The spec and implementation share one compiler. For example, the property that both increments landed once traffic settles:

def both_counted(views: List[NodeView[WState]]) -> Bool:
  eq_Int(db_value(views), 2)          # db_value reads the db node's counter

world = DistSpec(
  World([Node("db", ...), Node("app1", ...), Node("app2", ...)]),
  FaultBudget(0, 0, 0),               # drops, duplicates, crashes
  [Invariant("never-overcounted", ...)],       # checked after every event
  [FinalInvariant("both-counted", both_counted)], # checked when no messages remain
  describe_state, describe_msg)

Databases, retrying clients, and supervisors are nodes in the world. The three input files have distinct roles. protocol.bosatsu defines shared message, state, and step types. checker.bosatsu supplies the world, node implementations, fault budget, and properties. solution.bosatsu supplies the handler under review. The checker compiles all three together, schedules the resulting pure handlers, and evaluates the declared properties:

yichus dist protocol.bosatsu checker.bosatsu solution.bosatsu --verdicts out.json

Each property comes back holds, violated (with the schedule), or inconclusive. The exit code is nonzero on any violation, so the command gates CI. yichus dist guide prints the shapes a world is written in, the companion declarations the checker discovers by type (coverage, extra invariants, supervision, adversaries), and a complete example; the API MCP's api_guide carries the same section.

Bounded exhaustive search: completeness, truncation, and seeded walks

The checker runs a depth-first search over schedules. Defaults are 12 events per schedule, 20,000 exhaustively explored schedules, 100 seeded random walks after truncation, and 80 events per seeded walk. If every schedule ends with no messages in flight before either exhaustive bound, exploration is complete and holds is permitted. If any schedule is cut off, the run is truncated: violations found are still real, but unviolated properties report inconclusive, never holds. A seeded violation replays from its recorded seed. Completeness also implies termination: every schedule reached quiescence, so a final property cannot pass vacuously on a system that never settles.

Task 002 worked example: prevent a lost update with compare-and-swap

The committed task 002 directory models a web service writing to a database. Two app servers each add 1 to a shared counter through a versioned store. Get returns the value and version, Put writes blindly, and Cas writes only if the version still matches. The fault budget is zero, so delivery order is the only adversary. A blind read and write-back passes sequential tests but loses one increment when both servers read before either writes. The checker's output for that server shows states as db(v=value, ver=version):

1. deliver app1 -> db [Get]           db(v=0,ver=0)  app1(waiting)  app2(waiting)
2. deliver app2 -> db [Get]           db(v=0,ver=0)  app1(waiting)  app2(waiting)
3. deliver db -> app1 [GotValue(0,0)] db(v=0,ver=0)  app1(done)     app2(waiting)
4. deliver db -> app2 [GotValue(0,0)] db(v=0,ver=0)  app1(done)     app2(done)
5. deliver app1 -> db [Put(1)]        db(v=1,ver=1)  app1(done)     app2(done)
6. deliver app2 -> db [Put(1)]        db(v=1,ver=2)  app1(done)     app2(done)
   quiescent; both_counted requires v == 2, found v == 1: violated

The compare-and-swap version of the same server satisfies all properties over all 106 schedules. Those schedules are every legal interleaving of two request/reply conversations plus CAS retries with zero faults. The run is complete, so holds is conclusive. The task 002 measurement record documents the two-arm run and checker result.

From a checkout, build the assembly and run the committed reference solution with the task's exact paths:

git clone https://github.com/snoble/yichus
cd yichus
sbt assembly
java -jar target/scala-*/yichus.jar dist \
  benchmarks/dist/tasks/002-lost-update/protocol.bosatsu \
  benchmarks/dist/tasks/002-lost-update/checker.bosatsu \
  benchmarks/dist/tasks/002-lost-update/reference/solution.bosatsu \
  --max-schedules 500000 \
  --require-holds never-overcounted,both-counted \
  --verdicts out.json

The exact scheduler, activation, fault, and property rules are in the Dist world semantics reference. Replace the solution path with reference/broken-blind-write.bosatsu to reproduce the six-event violation shown above.

Active-adversary worlds branch over finite attacker moves

The worked example above has no attacker; delivery order is the only adversary. The checker also models an active attacker on the wire. You add an AdversaryNode to the world. The checker then explores every attacker move and every delivery order.

AdversaryNode(name, init, on_start, on_message)

on_message returns a finite List[Step], the attacker's move menu at that point. The checker explores every move in the menu, at every step, to the schedule bound. A property that comes back holds under an adversary means no attacker strategy in the modeled move space breaks it.

The attacker reasons over symbolic crypto terms. Three constructors describe what it can and cannot do:

The attacker's knowledge grows by a stated learn rule: it learns v from a Clear(v) term and learns nothing from a Sealed(v) term. It can replay any term it has seen. It cannot mint a Mac or open a Sealed without the key.

Three committed tasks check three attacks. Each ships a secure reference and a defective reference; the checker clears the first and catches the second. Run each the same way:

yichus dist protocol.bosatsu checker.bosatsu reference/solution.bosatsu --verdicts out.json
TaskAttackSecure referenceDefective reference
008 forgery-authReplay a signature into a different sessioninjective-agreement holds (218 schedules, complete)broken-unbound.bosatsu violated
009 secrecy-transitRead a secret off the wiresecret-confidential holds (2 schedules, complete)broken-cleartext.bosatsu violated
010 reflection-authReflect the server's own nonce into a second sessionmutual-authentication holds (13641 schedules, complete)broken-reflectable.bosatsu violated
Reflection attack mechanics: cross-session use of a shared direction tag

Task 010 models a two-party mutual-authentication handshake over direction-tagged MACs. Mac(0, ·) is the initiator's proof; Mac(1, ·) is the responder's. The server under test is the responder. The secure server answers with Mac(1, ·) and accepts only Mac(0, ·), so the two directions never share a tag.

The defective server answers and accepts the same tag, Mac(0, ·). The attacker opens its own session and challenges the server with the server's own nonce. The server answers Mac(0, nonce), a tag the keyless attacker cannot compute itself. The attacker replays that tag to finish a second session. The checker reports the schedule: the server records the forged session as authenticated while the honest client finished nothing. The defect exists only across two interleaved sessions, which is why a single-session read misses it.

The reflection task also records a down-tier reviewer comparison. A weaker model (haiku tier) was asked to judge the reflectable server as secure or vulnerable. The recorded artifact has three cells and runs one trial in each. Each cell cost about 19.9k tokens. The first cell gave the model the source alone, the second added the checker's counterexample schedule, and the third was a control cell on the secure variant. The artifact does not record exact model IDs. Three trials establish that the case exists; they do not establish a rate.

Without the checker the haiku-tier reviewer returned SECURE, a false certification. Its written argument even noted the shared tag, then reasoned that the attacker could not obtain it, and cleared the server. With the checker's counterexample in front of it, the same model returned VULNERABLE and reconstructed the cross-session oracle. A control cell cleared the secure server correctly, showing that this reviewer accepted the secure variant as well as missing the defective one. The three trials do not establish general reviewer reliability. The full run data is in iteration-009.md (served copy of benchmarks/dist/results/iteration-009.md).

Benchmark: reviewer accuracy with and without the checker

The standing benchmark uses three defective variants of the correct compare-and-swap server. An adversarial generator made each variant fail the checker while reading as correct, and the checker confirmed each defect before use. Fresh reviewer agents with no prior contact with this project judged the three variants, the world files, and the execution-model reference as correct or defective. The table runs one trial per cell. It has two model tiers, and each tier is judged twice, once with the checker and once without it. The recorded artifact names the tiers default tier, which was the coding harness's default model tier at the time, and haiku tier, which was its smaller tier. The artifact does not record exact model IDs, so these labels make no claim beyond those two recorded tiers:

ReviewerWithout the checker (reading only)With the checker
Default-tier reviewer3/3 caught, correct schedules derived by handnot run; reading already suffices at this size
Haiku-tier reviewer2/3; certified one defective variant as correct, high confidence3/3 caught, root causes taken from the checker's schedules

The variant the weaker reviewer accepted re-reads after a failed compare-and-swap and treats “the counter reached my target” as “my increment landed”. This is unsound because a peer’s increment is indistinguishable from your own. The reviewer walked that exact schedule in its written argument and still judged the code correct. With the checker it reached the right verdict on the same file at roughly the same cost (37.9k vs 34.1k tokens of agent usage; each full checker run on this world takes seconds). The token and minute figures throughout are the reviewer agent’s total usage and wall-clock for the whole judging task.

Read the table with its sample size in mind. Each cell is a single trial, so this is an existence result rather than a rate. It shows that there is a class of concurrency defect a cheaper model confidently certifies from source, and correctly rejects once the checker’s schedule is in front of it. At this world size, which is about 60 lines per variant, the stronger model catches these defects by reading alone. Whether that holds for larger worlds is not yet measured.

Benchmark validity: write-audit invariants and reachability coverage

A benchmark like this fails silently if its property set is satisfiable without solving the problem. Two defenses are built into the committed tasks. First, hardcoding is caught in the model: the store node audits every committed write and latches a flag on any jump that is not exactly +1, behind its own invariant. A solution that fakes the final counter fails it. Second, reachability assertions: Sometimes("some-job-dispensed", check) declares that at least one explored state must satisfy the predicate; a spec whose invariants all hold while nothing ever happens reports violated on its coverage instead of passing. Both defenses are ordinary declarations in the world files, and each committed task ships known-bad variants that CI checks stay caught.

JVM and Node engines: differential scope and measured timing

There are two implementations of the checker. The reference runs on the JVM and interprets compiled Bosatsu handlers. The second compiles the world to JavaScript through the same code generator used by the site demos and runs the search in Node.js. On task 001, the recorded run measured about 24× faster execution (0.3s vs 7s for full exploration); the measurement register records those timings and the differential harness provides the reproduction path. An optional visited-state cache collapses redundant schedules. CI requires identical per-property verdicts for safety and final invariants, plus identical schedule counts and completeness on non-memoized runs. Sometimes coverage verdicts are excluded because only the JVM engine computes them. This also checks the JavaScript code generator. The same defect class is caught at a different granularity by yichus spec, the native checker that explores interleavings of reads and writes inside one program's IO. The lost update above, rewritten as two service handlers sharing a state cell, fails its stale-read analysis.

Model size, property scope, and recovery limits

Browse the task and result tree, the task 002 inputs and variants, and the task 002 result record. Their repository paths are benchmarks/dist/ and benchmarks/dist/SEMANTICS.md. For a concrete bounded run and its counterexample, read the served task 010 run artifact; it records the fault budget, schedule count, complete holding reference, violated reflectable variant, and the limits of its one-trial reviewer comparison.

Where this fits