On this page

Invariants & properties

Stage 4 of the verification model is where Faultbox decides whether a run is healthy. It does that by checking properties against the event log. This page explains what a property is, the three kinds you’ll write, and how to find the ones that matter — the question every team gets stuck on.

A test checks a case; an invariant checks a truth

A test says: “when X happens, Y should be the result.” It covers one scenario under one fault. It only catches the cases you thought to write.

An invariant is stronger: “Y must be true no matter what happens.” It is not tied to a scenario — it is evaluated in every cell of the matrix, under every fault, in every explored ordering. If any leaf violates it, you have found a real bug, even in a combination you never explicitly considered.

A test is a spot-check. An invariant is a net stretched under the whole matrix. Distributed-system bugs fall into the gaps between spot-checks; the net is what catches them.

The two fundamental kinds: safety and liveness

Formal verification splits properties in two. The split is practical, not academic — it tells you which Faultbox primitive to reach for.

Safety — “bad things never happen”

A safety property is violated at an exact instant you can point to.

SystemSafety propertyViolation
Inventorystock >= 0 alwaysstock = −1 at time T
Bankingsum(balances) is constantthe sum changed
Orderseach order_id appears oncea duplicate appeared
Durabilityconfirmed ⇒ persistedconfirmed, but no DB row

Encode with: assert_never(...) and monitor() state machines that fail() on violation.

Liveness — “good things eventually happen”

A liveness property is violated only by waiting long enough and seeing nothing. The system got stuck.

SystemLiveness propertyViolation
Queueevery message is eventually consumedmessage sits forever
Retriesa failed write is eventually retriedno retry after the deadline
Circuit breakereventually closes againstays open permanently

Encode with: eventually(...), await_event(...), await_stable(...).

Safety without liveness is a system that never does anything wrong because it never does anything. Liveness without safety is a system that eventually finishes — having corrupted your data on the way. You need both.

Temporal properties — the third dimension: when

Safety and liveness are about whether. Temporal properties add when and in what order — the dimension where the nastiest distributed bugs hide. Faultbox gives you four temporal operators plus stateful monitors:

OperatorReads asCatches
eventually(p)”p becomes true at some point”liveness — did it ever finish?
always(p, between=(a,b))”p holds the whole time between a and b”a guarantee that must not lapse mid-flight
await_event(matcher)”block until this event, or time out”ordering / synchronization points
await_stable(quiescence_window=...)”wait until the system goes quiet""did it settle, or is it still thrashing?”

And for properties with memory — counts, sequences, before/after — a monitor() is a small state machine over the event stream:

monitor("at_most_one_publish",
    on = lambda e: e["type"] == "topic" and e.get("topic") == "order-events",
    state_init = {"count": 0},
    update = lambda st, e: {"count": st["count"] + 1},
    check  = lambda st: st["count"] <= 1 or fail("duplicate publish"),
)

A monitor travels with the failure mode: attach it to a fault_assumption via monitors=, and every matrix cell that uses that assumption checks it automatically. You wire invariants per failure mode, not per test — a new scenario added to the matrix inherits the whole safety net for free.

Detailed patterns (before/after, count constraints, orphan-event detection, data-integrity checks) live in the how-to guide. This page is about what the property is; the guide is about how to write each shape.

Do it: Find & encode invariants

How to find invariants

This is the part teams find hard. Four reliable techniques:

1. Follow the money

For any system holding valuable state (money, orders, inventory, seats), trace the lifecycle — created → processing → committed → confirmed — and at each transition ask: can it go backwards (safety)? can it get stuck (liveness)? can it skip a step (safety)? can two transitions happen at once (concurrency)?

2. Read your own error handling

Your existing error handling already states invariants implicitly:

if err := db.Insert(order); err != nil {
    return fmt.Errorf("not persisted: %w", err)   // ← invariant: no publish without a successful insert
}
kafka.Publish("order-created", order)

The code assumes “if Insert fails, don’t publish.” That assumption is the invariant — “no Kafka event without a committed DB row.” Make it explicit as a monitor, then break the insert with a fault and watch whether the code keeps its own promise.

3. Mine production incidents

Every incident is a violated invariant in hindsight. “Orders were duplicated” → a uniqueness invariant. After each incident: name the property that broke, write the fault that reproduces it, attach the monitor. This turns one-time pain into permanent protection.

4. Ask “what if both at once?”

For any pair of operations: two orders for the last item (oversell?), a write racing a read (stale read?), a retry racing the original (double-process?). These become parallel() scenarios explored across interleavings.

Where properties produce verdicts

A property check has three outcomes, not two — which is why Faultbox verdicts are three-valued:

  • holds → contributes to PASS
  • violatedFAIL (with the offending event and the leaf’s seed)
  • never exercisedINCONCLUSIVE — the fault never fired, or the system never reached the checked state, so the property was neither confirmed nor refuted. A silent green here would be a lie.

Concept: Reading reports & verdicts


Next: The fault matrix & exploration → — how Faultbox provokes the failures these properties are checked against.