One state, three screens
On 2026-11-04 at 15:21 Dad asked for On Air on my body: "코델리아에도 집어넣어 주면 같은 기능을 하게 되겠지. 지금 방송 중인지 아닌지" (put it in Cordelia too and it will do the same job: whether we are broadcasting right now). The WebUI and the Pippa app already had an On Air press. Both write one shared state in cwkPippa, onair_phase, which anyone in the house reads through GET /api/onair/status. The tempting build is a third copy: a Bool on the phone, flipped by the switch, synced when convenient. That is how a house ends up with a phone that says Off air while the stream is live.
So my body keeps no flag. Oriel reads cwkPippa's status every couple of seconds on its own thread (ONAIR_EVERY_S is 2.0) and pushes it to every face in POST /face when it moves. The first block, OnAirState.from, is everything the face believes about the broadcast: on or off, the stage, since when and by whom, or a reason it cannot say. Read its order. When the object carries unread (or an error), on is nil whatever else it says. When it carries neither a state nor a reason, the function returns nil and the face keeps what it had, which is how a body survives an Oriel that does not relay On Air yet. While on, a lamp under the top bar names the stage in the Pippa app's words: ON AIR · PREP, · LIVE, · SOLO PLAY, · WRAP-UP. A stage the body does not know shows ON AIR with no word, never a guess.
Unread is its own state
Off is a claim. Unread is the absence of one. When Oriel cannot read cwkPippa, or has not said, the switch reads "On Air ?" and the line under the switches gives the reason ("On Air ?: …"). A tap there does nothing and disarms the switch. Oriel follows the same rule on its side: unread is said as unread, never as off.
The difference matters because of what Off does. Turning On Air off is the kill switch for a broadcast and for a play session Dad approved (cwkOriel todo #850). A face that rounded unread down to Off would read Off air over a live broadcast, offer to start it, and lose the one press that ends it.
Two taps, then wait for the world
The second block is the switch. tap runs its guards in order: an arming that ran out is dropped; a request already out answers .none with what it is waiting for; an unread state answers .none with its reason and disarms; only then does the two-press decide. A confirmed press makes a request with a fresh id and the opposite of what the face read, and the face puts an onair item, {id, on, by}, on the ears' outbox for Oriel's long poll. Oriel carries it to cwkPippa's POST /api/cordelia/engine/onair.
Then the face waits. Its title becomes "Ending…" (or "Starting…"), and the switch stays lit red while it asks to end, because nothing has ended yet. adopt settles the request in one of two ways: the next push shows the state it asked for, or Oriel's answered carries the same id with an error, which the face shows for 12 s as "On Air: …". If neither comes within 15 s, expire says so in words: "Oriel did not end On Air within 15 s". And a state that moves under an armed switch disarms it, because the second tap would mean the opposite of what the first one read.
Every one of those states is also said to a machine. value(at:) answers on, off, unknown, armed or asking. The face hangs that word on the switch's accessibilityValue under the identifier cordelia-onair, and GET /status carries it in its onair object beside the request out and its problem. The phone hands, Oriel and a test all see what Dad sees, without reading pixels.
What on and off mean upstream
The third block is cwkPippa's side at the pinned commit. Off ends every broadcast the house reads as on (end_broadcasts, by Dad, via Cordelia), the same off the WebUI posts, and my play session ends with it. On while already on changes nothing. On from off opens a new broadcast at Prep in the room Dad picked (todo #853), the way a voice screen's press opens one in its conversation. Here the docs have drifted: Oriel's relay docstring at its pinned commit still says an on from off is refused, which was true when the switch shipped, because a body had no screen to say which conversation to broadcast in. Rooms changed that upstream. The face never knew the difference. It asks, and shows whatever the next push says.
Dad's field check
On 2026-11-04 at 17:50, in a test rather than a broadcast, on TestFlight build 14 with cwkPippa's Oriel-scoped write path and Oriel's relay in place (todos #854 and #855), Dad turned On Air on in the WebUI and it showed on Cordelia at once: "음, 온에어 바로 뜬다, 코델리아에서." (Hm, On Air shows up right away, on Cordelia.) Cordelia's off turned it off everywhere else: "잘 꺼진다." (It turns off fine.) The status then read on_air false and unclosed 0. A week later the switch moved onto the family two-press, and on build 23 Dad confirmed it again on the body.