Entry contracts
The client half rests on a small wire contract: the shape of one entry’s membership and status, and the shape of the frames that carry its calls. This page is the deepest why under that wire. Every choice here is the same principle applied at a different spot — the framework owns the keyed volatility, and the app owns policy — and every one is paid for by giving up an easy shortcut.
Why the status is a sum, not a flag
The map publishes each entry’s status as EntryStatus<Cause>, a sum of exactly
three arms:
type EntryStatus<Cause extends string = string> =
| { kind: "warming" }
| { kind: "connected"; clockOffset: number } // the serving process's own-clock offset
| { kind: "failed"; reason: string; cause: Cause };
There is no fourth arm for absent, and this is the first commitment: absence
means absence. A collection already says “not a member” by not containing the
key. Adding an absent status would be a second way to encode the same fact, and
two encodings of one fact is two writers waiting to disagree. So membership lives
in the collection’s key set, status lives in its value, and one writer publishes
both.
The three live arms exist because the client needs to tell apart states that look alike but demand opposite responses. This is a projection contract, not vibes:
- warming — the system is doing something and will resolve on its own. First provision and a transient reconnect-backoff both land here. The right UI is a spinner; the wrong one is an alarm.
- connected — live.
clockOffsetis this entry’s clock seam (below). - failed — it will not resolve without someone acting: a bounded give-up, or a standing refuse (“another owner holds this host”). The right UI is an actionable card; the wrong one is an eternal spinner.
The line between warming and failed is drawn by the session’s domain cause.
A specific cause — a contract skew, a cross-supervisor refusal — is a standing
condition, so it projects failed. No specific cause is a transient drop, so it
projects warming. Collapsing the two would reproduce two real, opposite bugs:
render a retriable reconnect as a steady red “failed” and a live system looks
dead; render a standing refuse as warming and a permanently-stuck host spins
forever. The sum is the fix that makes both unrepresentable.
Why the fold envelope omits void
Every member’s call folds to one wire shape: { mapKey, input }. One envelope for
any input — an object, a primitive, or none — and because the folded key rides
outside input, an entry input that itself has a mapKey field can’t collide
with it. Misroute-by-collision isn’t unlikely; it is unconstructible.
The subtle clause is void. A member that takes no input carries no input
field at all — { mapKey }, never { mapKey, input: undefined }. The tempting
version relies on two accidents: that JSON drops an undefined value, and that
the validator accepts the resulting missing key. Both are quicksand — a validator
tightened z.object({ input: z.void() }) to reject a missing key, and every
void-input call would have broken the moment a lockfile drifted onto it. Omitting
the field makes “void = no input key” the one representation on both encode and
validate, independent of any validator’s mood. It is a small thing that exists so
a dependency bump can’t silently break the wire.
Why keys are codecs, not strings
A Key can be anything the schema validates — kolu’s HostKey is a
discriminated-sum object, not a string. But every channel name, dedup key, and
membership entry is keyed on a string. KeyCodec<K> bridges the two:
interface KeyCodec<K> { encode(key: K): string; decode(wire: string): K; }
Keeping the codec explicit keeps two boundaries from merging. Parsing loose human
input (localhost, 127.0.0.1, user@host) is a separate function from
decoding a canonical wire key — so a lax parse can never leak into the wire
vocabulary. encode is a bare, cheap call because it sits on the hottest paths
(a per-key cache lookup on every entry(key), a fold on every entries read);
decode is paired with keySchema.parse and need not validate alone, because the
server re-derives and re-validates the real key on arrival.
The typed sub end is the same discipline one level up. A call to an absent key is
not a thrown socket error; it is a typed rejection (MAP_KEY_UNKNOWN) for a
procedure, and an immediate typed end for a stream. And a key that leaves
membership mid-stream ends its subscriptions with a typed { reason: "removed" }
before the session is torn down — so a consumer sees a clean, expected end,
never a socket-error frame arriving after a typed end. Absence is always a value
you handle, never an exception that surprises you.
Why the clock is per entry
A timestamp stamped on a remote host is meaningless read against the browser’s own
wall clock — the two clocks disagree. So connected carries the entry’s measured
clockOffset, and the only honest way to read a host-stamped time is through that
entry’s seam: entry(key).clock.toLocal(remoteMs), which is remoteMs − offset.
The seam’s signature is where the honesty lives. It returns number | null, and
it returns null — never a silent identity — whenever the entry has no offset
yet (warming, failed, not-a-member). The null is deliberately contagious:
now − null is a type error, so a caller cannot forget to handle the pending
case and fall back to the raw remote value. Falling back to the raw value is
precisely the silent foreign-clock lie the seam exists to prevent, and the type
makes it uncompilable rather than merely discouraged. This is
reactive honesty’s rule — a caught absence must
surface, never collapse to a plausible default — pushed into a type.
Where it costs you
The through-line is one trade made five times: the map’s wire refuses the
convenient shortcut in exchange for a lie it cannot tell. No absent arm, so you
read status through a total lens instead of a nullable one. A per-entry clock, so
you thread every host-stamped time through a seam and render a “—” while it warms.
A typed cause on failed, so the app must enumerate what its failures mean
instead of showing one red dot. Each is more ceremony than a single-host client
would need. The bet is that in a fleet — where the set changes underfoot and every
fact belongs to one host — the ceremony is the only thing that stays true. If a
future addition ever encodes an opinion about which host matters, that is policy
wearing a framework costume, and it belongs in the app, not here.