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<Failure>, a sum of exactly
three arms:
// `Conn` (SR9): the fine per-entry connection payload, carried on the LIVE arms and
// parameterized like `Failure` — the map validates it against its own `connection`
// schema, never enumerating it. It is the ONE authority the coarse `kind` (the dot) and
// the fine word both derive from; optional, so a connection-less map omits it. The
// `failed` arm carries none at all — see the note on that arm below.
type EntryStatus<Failure = unknown, Conn = unknown> =
// `membershipId`: opaque, never-reused per-add identity — a BRANDED `MembershipId`
// (an empty/fabricated bare string is a compile error), minted only by
// `serveSurfaceMap` / the wire decode. Clients key cached owners on
// `{encodedKey, membershipId}`, so a same-key re-add / authority restart rebuilds.
| { kind: "warming"; membershipId: MembershipId; connection?: Conn }
| {
kind: "connected";
membershipId: MembershipId;
clockOffset: number | null; // own-clock offset; null = not-yet-measured
connection?: Conn;
}
// No `connection` on this arm — deliberately. The failed entry's live word would be
// the same frame `evidence` was pinned from, so `connection?.log` here was a second,
// floorable copy of the tail. Removing the field makes that read a compile error.
| {
kind: "failed";
membershipId: MembershipId;
failure: Failure; // schema-valid domain failure
evidence: FailureEvidence; // the retained output tail that EVIDENCES it
};
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. Because of
unobservable(below), this arm means what it says: a campaign the publisher is currently narrating to you, which is a thing you may legitimately put a clock on. - 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 fourth arm is about you, not the host
entry(key).state() has one more arm the wire never carries: unobservable,
which the client’s liveness floor mints when your link to the publisher is dead.
It means “we cannot see this entry”, and it exists because collapsing that into
warming was a real bug with a real cost.
The floor has always demoted a connected entry when your link dies, so nothing
renders green over a dead transport. Correct — but demoting it to warming made
two facts with opposite consequences into one value: “the publisher says this host
is coming up” and “we cannot see the publisher at all.” A consumer that merely
spins is fine either way. A consumer that times the entry — a deadline, an
escalation, a “failed to start” verdict — can certify a healthy thing dead. Kolu
did exactly that: a backgrounded tab lost its socket, the local entry demoted to
warming, and a 30-second boot deadline certified a daemon that had been running
for twelve hours as dead
(kolu#2129).
So it is an arm, not a warning. A separate arm means every .exhaustive() match in
every consumer has to say what it does while blind, and the compiler asks the
question — which is the only form in which this kind of rule survives contact with
the next feature. It was deliberately not spelled as an optional flag on warming:
a field you can ignore is a rule you can forget, which is the shape the prose
version already had.
The arm carries membershipId (the floor is about liveness, not identity — it is
still the same member) and published: "warming" | "connected" — the last thing
the publisher actually said, so you can narrate “last seen: connected” without the
framework pretending the claim still holds. It carries no clockOffset and
no connection, so a frozen in-progress word cannot keep narrating work that
stopped, and there is nothing green to paint. failed is not floored: a
post-mortem does not go stale the way a live claim does, which is why published
has no failed inhabitant.
For the consumer that only wants a spinner, isSettling(state) is one call and
covers both warming and unobservable — splitting the arm costs the simple case
nothing. Anything that puts a clock on an entry still has to narrow the union
itself, which is the entire point.
The arm has no wire schema, and that is a statement rather than an omission: it is a
projection of your transport, meaningless to anyone else, so entryStatusSchema
has no arm to encode it into and a floored value can never be republished.
The failed arm is a FailureRecord: the reason, and the output that evidences
it, as one named value. Neither half is optional, because a card that names a cause
without showing what the machine actually printed is exactly the card nobody can act
on — and because a pair spelled out as two correlated fields is a rule you have to
remember, whereas one value is a rule you cannot forget. That is also why a
disconnected session state carries an optional refuse: FailureRecord rather than
two optional fields: there is no pair left to leave half-filled. The pairing is also
what survives the client’s liveness floor. That floor drops the live connection
payload when the browser’s own link to kolu goes quiet — a frozen in-progress word
would otherwise keep narrating work that is no longer happening — and for a year the
retained output tail rode that payload, so a slept laptop woke to a reason whose
evidence had silently vanished. Moving the tail onto the failure record means the floor
cannot separate them: it drops liveness payloads, and a failure record is not one. The
failed arm carries no connection at all now, so the old read isn’t merely
discouraged, it doesn’t compile.
The line between warming and failed is drawn by the session’s domain failure.
A specific failure — a contract skew, a cross-supervisor refusal — is a standing
condition, so it projects failed. No specific failure 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 decoder accepts the resulting missing key. Both are quicksand — a schema
library that tightens “a declared-void field” to reject a missing key would
break every void-input call the moment a lockfile drifted onto it, and one did.
Omitting the field makes “void = no input key” the one representation on both
encode and decode, independent of any library’s mood. It is a small thing that
exists so a dependency bump can’t silently break the wire — and it survived the
move from zod to Effect Schema without a byte changing, which is the test it was
written for.
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 domain failure 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.