kolu
Docs

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. clockOffset is 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.