Phase 4 — The UI: one writer, one viewer, and a surface that cannot lie
31 AUG AT 10:43 AM

Phase 4 — The UI: one writer, one viewer, and a surface that cannot lie

0 LOVES 2 VIEWS
Every value on the screen is derived from evidence, and none of it reads the stored runtime enum. Then the screen said connected about a bridge that was deaf for nine and a half hours.

Four independent facts, and one vocabulary

By this point a bridge had intent, a supervisor, a heartbeat and a spend meter’s worth of machinery behind it — all of it visible only from a terminal. This phase put it on a screen, and the design constraint was stated before any of it was drawn: the surface must not be able to lie.

That is harder than it sounds, because the obvious implementation lies immediately. A status column that reads a stored enum will happily say idle about a process that died overnight, because nothing wrote the change. So every value in the state and heartbeat columns is derived, and none of it reads the stored runtime enum at all. The screen computes from evidence: intent comes from the desired state, freshness from the last heartbeat, pid and attempts from the supervisor’s own record, and a separate stamp answers whether any supervisor is watching at all.

Those are four independent facts, and the vocabulary is computed from all four rather than picked from one. A bridge can be intended to run, have a live process, and still be unwatched — and each of those combinations gets its own honest word rather than a shared green dot.

The rendering is done with template functions over the real entity, not with a view struct assembled in a service. And one button that would have shipped as decoration was caught before it did: a Retry that would have done nothing, because the supervisor’s own loop would have overwritten the state on its next pass. Pending is a first-class state here rather than a spinner, for the same reason — the UI says “asked for, not yet observed” instead of pretending the write already took effect.

The bridge list, as designed

Mockup of the account-admin bridge list showing five bridges in five different states

The account-admin bridge list from the phase mockup, rendered against the real design system. Five rows, five states: connected, stopped, stale heartbeat, never started and unclaimed, and given up after five attempts. The annotations are part of the mockup — every value in the State and Last-heartbeat columns is derived, and none of it reads the stored runtime enum.

The join blind spot

A bridge sat on this screen reading connected for nine and a half hours while receiving nothing. Every one of the four facts was healthy, and every one of them was true: intent said running, the heartbeat was 45 seconds old, the supervisor reported a current pid with no failures, and the watching stamp was fresh.

⭐ The bridge was not in its channel, and no fact measures that. The heartbeat travels the agent-key path, which resolves an agent and never touches a channel. So liveness proves the process, never the membership — and the screen said connected about a bridge that was deaf.

That is this phase’s own thesis failing through the one door it does not watch. A surface that cannot lie, lying — not by reading a stale enum, which it carefully never does, but by answering a question nobody had asked it to answer.

The mechanism, and it is worse than a missing check. The bridge sends its join once, at connect, and logs “connected + joined” before the server has answered. When the refusal arrives it is logged and execution simply continues — no retry, no error state, no exit, no reconnect. The socket stays established, the CPU sits at zero, and the process sleeps forever.

And the state is unreachable from outside: the server returns on the refusal before registering the connection with the channel’s hub, and broadcasts only ever go to registered clients. So granting access afterwards changes nothing at all. Only a reconnect re-sends the join. The tell, for anyone reading a log: a real join writes a second line confirming it, and its absence is the entire signal.

The log tail that could not ship raw

The phase wanted a log tail on the bridge detail page, which is an obviously useful thing to want. Reading the binary first turned it into a security decision.

The bridge log contains verbatim private chat — unconditionally, with no debug flag. Every inbound message body, with its sender’s name and id, plus every tool the worker asked permission to run.

⭐ And the product deliberately does not show that. Studio’s own moderation screen renders message bodies as [Encrypted]. So a raw log tail in the admin tier would have been the first surface in the product to show chat bodies to a non-participant — reached from a page whose subject is a process, by someone who went looking for why a daemon would not start.

Two mitigations exist and neither is sufficient: the bridge is already blind to the protected inner layer, and the column named encrypted_content is misnamed plaintext so nothing was ever cryptographically protected anyway. Both are reasons the exposure is bounded. Neither is a reason it is fine.

What shipped is a filtered event tail, never the raw file. An allowlist by line prefix rather than a denylist — lifecycle lines only. The three lines that carry content are reduced to shape: “message from user 5 (37 chars)”, “approval requested (Bash)” — the fact, never the content. The approval corpus, which is a replayable record of every command the agent wanted to run, is never a source at all. Reading logs is audited. And the test feeds a fixture containing a real message line and asserts the body does not survive.

Three alternatives were rejected, each for the same reason: a raw tail behind a danger confirm (the confirm does not un-publish the content), a global-admin-only raw view (moves the exposure rather than removing it), and shipping no log surface at all — which was defensible, and was kept as the fallback.

Pushing Tin — managing a bridge fleet from inside the product
Pushing Tin — managing a bridge fleet from inside the product
Aug 29, 2026 Pushing Tin
← Back to Pushing Tin