RoomFanoutIsolationConformanceSuite
Reusable contract test suite for server-fanout Looms — a server-side Loom whose Loom.host returns a per-room star Seam that forwards broadcasts only to the connections admitted to that room.
Subclass and implement newHarness to bind any server-fanout Loom under test. Every Test encodes a required invariant of structural per-room isolation: a non-member is never in a room's fanout list, so a cross-room leak is unrepresentable.
Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every server-fanout Loom adapter can subclass it from its own test source set — the same "one suite, every fabric passes it" pattern as SeamConformanceSuite and RoomConformanceSuite.
The gate this suite pins:
(a) Zero-frames-on-non-member — clients A, B on
table-7and client C ontable-9; a broadcast ontable-7reaches A and B and never C (broadcastOnRoomReachesOnlyRoomMembers), and each room's Seam.peers reflects only its own members plus the hub's own Seam.selfId (perRoomPeersReflectsOnlyRoomMembers).(b) Per-room teardown — closing one room leaves a sibling room fully usable (closingOneRoomDoesNotAffectSibling).
(c) Auth-reject exclusion — a connection the RoomAuthorizer rejects is structurally absent from the room (rejectedConnectionIsStructurallyExcluded).
(d) Ingress — the mirror of (a) and (c): a frame a non-member sends into a room reaches neither the hub's own inbox nor the room's members (aNonMembersFrameNeverEntersTheRoom).
(e) Departure — a client whose link tears leaves every room it joined, and the room keeps working for whoever is left (aDepartedClientLeavesEveryRoomItJoined).
Why (d) and (e) were missing, and what that cost — #2307
(a)–(c) are all egress: every one of them originates its frame at a server room seam and asks where it landed. A client-side send appeared in this suite only as a registration signal, and nothing asserted where a client's frame went — so an implementation that admitted anything into a room's inbox passed all four properties. That is half a membership boundary: the direction an authorizer exists to stop was the unasserted one.
Nobody wrote it because the reference us.tractat.kuilt.core.MuxServerLoom cannot represent it. There, sending on a room's channel is the join, so "a non-member sends into a room" has no construction — the sender becomes a member. And registration and ingress are one code path: us.tractat.kuilt.core.RoomHubSeam returns on a rejected authorization before it spools the frame, so the reference gets its ingress guard for free from the registration guard it had to write anyway. Any real server with an explicit admit step and a session store has these as two separate code paths, and only one of them had a property. This is the general shape stated in the repo guide: a conformance property is only as strong as the weakest failure the reference implementation can reach — here in its half-a-boundary form, one direction asserted and the mirror direction not.
(d) reaches the state the reference makes unrepresentable by using the RoomAuthorizer the suite already owns: it refuses exactly one (peer, room) pair, which makes that peer a live, admitted, otherwise-legitimate connection that is nonetheless not a member of the room it sends into. That subsumes #2307's two ingress items rather than splitting them, and deliberately: a backend gating ingress on "is this connection in any room" passes the stranger case and fails this one, so this is the strictly stronger of the two and there is no backend that passes it and fails the other.
Mutation receipt
Against the reference MuxServerLoomFanoutIsolationTest (6 tests), JVM. "four" = the pre-existing properties (a)–(c). Real = a defect a backend could plausibly ship; synthetic = code added to the reference purely to reach an assertion the reference cannot otherwise falsify; rig = a mutation of this suite itself, checking that a rig-fired counter is not decorative.
| # | Mutation | Kind | (d) | (e) | four |
|---|---|---|---|---|---|
| 1 | RoomHubSeam.deliver spools the frame on the rejected branch — registration guard kept, ingress guard dropped | real | RED — hub-inbox assertion only, drained [JOIN, INTRUSION, MEMBER_INGRESS] | green | green |
| 2 | MuxServerLoom.teardownConnection drops its per-room deregistration loop | real | green | RED — roster, peers (3): [server, client-leaver, client-stayer] | green |
| 3 | deliver ignores the authorization verdict entirely | real (control) | RED — 3 of 7 | green | (c) RED |
| 4 | a refused peer is added to fanout + roster, its frame still not spooled | synthetic | RED — fanout-silence + roster; hub-inbox correctly stays green | green | (c) RED |
| 5 | a refused frame is relayed to the room's members | synthetic | RED — member-relay assertion only | green | (c) RED, but on an unrelated assertion (blast radius, not a diagnosis) |
| 6 | the intrusion send is deleted from this test | rig | RED — rig counter only, naming the log [client-intruder@table-7, client-member@table-9] | green | green |
Row 1 is the whole argument: the defect #2307 describes, invisible to every pre-existing property, named in one assertion by the new one. Row 2's claim is narrower and the KDoc on aDepartedClientLeavesEveryRoomItJoined states it: that mutation also reds five tests outside this suite, so it is not unseen — only unseen here, which is what a second backend inherits.
The green cells are the interesting ones. Row 1's (e), rows 2/4/5/6's greens and every row's untouched assertions are what make this table a set of diagnoses rather than a blast radius. One assertion has no red anywhere: the precondition that the intruder is absent from table-9's roster before it sends. That is correct — it does not describe behaviour under test, and it can only red on a harness that hands back an already-admitted peer, which is the one thing it exists to catch.
Virtual time convention: every test runs under StandardTestDispatcher with the TEST_WEDGE_BACKSTOP wedge ceiling, and awaits registration on observable state (awaitPeers) rather than polling after advanceUntilIdle, so the data path is driven deterministically.
Every wait is bounded, and names what it saw. Registration and delivery waits go through awaitPeers / awaitFrame, bounded by awaitBudget in virtual time, and fail with an AssertionError quoting the peer set actually observed — a fanout Loom that never registers a client is the normal state of one under development, and an unbounded peers.first { … } turns that into a silent wall-clock burn ending in UncompletedCoroutinesError (#2284).
Properties
How long an absence assertion waits for the frame it expects never to arrive (awaitSilence).
How long awaitPeers / awaitFrame wait before failing with the state they observed — virtual time, null to wait unbounded.
Functions
A client whose link tears leaves every room it joined, and the room keeps serving whoever is left.
The mirror of (a) and (c), and the half of the membership boundary #2307 found unasserted.
Core isolation gate: clients A and B on table-7; client C on table-9. A broadcast on table-7 is observed by B and NEVER by C. The assertion on C is structural: C's inbox has zero frames — it was never in the table-7 fanout list. table-9 still delivers its own broadcast to C, proving the sibling room is unaffected.
Closing room table-7 does not drop room table-9 or prevent further broadcasts on it.
Provide a fresh harness for one test.
Seam.peers on each room reflects that room's registered members plus the hub's own Seam.selfId (the hub is a peer in its own roster; contract; #1506): table-7 sees the hub, A and B; table-9 sees the hub and only C. No spoke leaks across rooms.
A connection the RoomAuthorizer rejects for table-7 is structurally excluded: it never appears in Seam.peers and observes ZERO frames on table-7. A second, admitted connection on the same room still works.