0153 — A watched read is one read per question, and the question is not a function
Decision
A watched read is a GET the server re-runs when something it depends on announces a change, pushing the new answer to everyone watching it.
createWatchHub (server, stitchkit/application) holds one source per
(service, action, arguments digest). Every subscriber asking the same question
joins that source, so eight panels showing one conversation are eight
subscriptions and one read.
createWatchClient (browser-safe, stitchkit/live) is built from the
contract, giving watch.action(args) — mirroring createUrlBuilder.
Single-flight is the ordering guarantee, not an optimisation
A key has at most one read in flight. An invalidation arriving during a read marks the key dirty; the loop reads again when the first finishes.
Two overlapping reads finish in either order, and the slow one carries the older
world. A hub that published both would leave the older value standing as
current — with the phase still live, the value entirely plausible, and nothing
anywhere to indicate a problem — until an unrelated invalidation happened to
correct it. Serialising per key removes that race rather than detecting it, and
the dirty bit coalesces a burst of invalidations into one re-read instead of one
per announcement.
The revision on the wire is the value’s version, and the client drops a frame no newer than what it holds. Ordering is already guaranteed at the hub; the revision keeps the guarantee legible at the other end.
Why the client is built from the contract
watch(api.notes.list, args) is the shape one writes first, and it cannot work.
A generated client method is a bare closure carrying a single withOptions
property — no name, no path, no service — and deliberately so, because it has to
survive being handed to a map. Passing one to watch yields an anonymous
function, while the server needs to know what to re-read.
So the identity is the contract’s own (prefix, endpoint key) — the same pair
the server labels every request with (ADR 0022) — and the arguments are reduced
to an order-independent digest, because {a,b} and {b,a} are the same question
and a key that disagreed would silently split one shared read into two. That
digest is the same function the MCP round already keys retries with; it was
module-private and is now shared, rather than written a second time.
What the hub does not know
How to read. The application supplies read. A hub calling handlers itself
would be a second dispatch path — one that skips the authorization the first one
has.
What may be watched. A predicate over the operation’s identity, supplied by
the caller. Not a field in meta: the core attaches no meaning to meta, which
is what makes it an escape hatch (ADR 0002/0021), and a core that started reading
meta.uiRead would end that.
Failure is said in words, and there are three answers, not two
A failed read publishes unavailable with the error’s own code and message. A
flag would make “the database is down” and “you are not allowed” the same fact.
The hub retries on its own schedule with the existing createBackoff, and a
successful read clears the state without anyone asking.
The vocabulary is LiveStatePhase and LiveStateStopReason — the ones the live
state controller already publishes — and not a second, poorer pair. Beyond
comparability, it supplies the third answer a two-word vocabulary loses:
opening, meaning subscribed and nothing read yet. That is neither healthy nor
broken, and rendering it as unavailable tells a user something is wrong when
the truth is that it is early.
What it cannot claim
One source per process. Two processes behind a balancer are two reads, and no test in this repository can show otherwise. The claim is “one read per question per process”, and it is written that way.
Consequences
- The last value of a key is retained while anyone holds it and for
holdMsafter the last subscriber leaves, so a component that unmounts and remounts inside that window paints from memory. The key digest is cached alongside, so that second subscription resolves synchronously — a value arriving on a later microtask is not “before the network” in any sense a renderer can use. - A source is not dropped while a read is in flight, even with no subscribers left: dropping it would publish the result into nothing and let the next subscriber start a second read for the same question.
- Both limits are declared and observable: watches per connection, and the read counter the “two browsers, one read” claim is measured with. The counter is exercised in both directions, because one that could only ever reach one would make that claim true of a hub that never reads at all.