runAssociativeLaws
Assert both laws that relate the two bracketings of a join, in one pass over causalPool: for every ordered triple, (a ⊔ b) ⊔ c and a ⊔ (b ⊔ c) must be equal (associativity), and — only once they are equal — must encode to the same bytes (canonicality).
Associativity is a different property from run, and the difference is the whole point. run folds a fixed set of replica states in every order — ((i⊔a)⊔b)⊔c versus ((i⊔b)⊔a)⊔c — which is commutativity plus one fixed left-nested bracketing. It never forms a ⊔ (b ⊔ c), so a join that is order-sensitive but self-healing passes it: the states it compares have all absorbed the same three operands, and a lost contribution comes back the moment the missing operand is merged in again. Real deployments do not enjoy that. A peer that receives b ⊔ c as one anti-entropy digest, and a peer that receives b then c, must land on the same state — and until the next round they are what a user reads and what Quilter.stateRoot() hashes.
The second thing run cannot see is causal relation. Its replicas each fork from initial under their own replica id, so no replica's context ever witnesses another's dots. Bugs that need one operand to retire a tag a second operand still carries are structurally unreachable there. causalPool restores that dimension by snapshotting each replica's own running history, so s, s.remove(k) and s.remove(k).put(k, v) all sit in the pool.
Canonicality is checked second, and only on equal values, so the two failures never blur. An inequality is an associativity defect; differing bytes over states that already compare equal is a canonicality defect, and the messages say so in different words on purpose. The byte law matters because Quilter's root-hash gate (#1955) compares digests, not values: a pair of peers that agree on the state but disagree on its bytes reads as diverged and skips the fast path.
Why one pass and not two. The two laws were once two methods, each rebuilding the pool from the same seed and each recomputing both bracketings for every one of ~45,797 triples; only the second went on to encode. Splitting them cost a full duplicate set of joins — 18% of this module's Kotlin/Native test budget — and bought nothing, because the encoding pass already had to compare values to know which triples it could speak about.