Skip to content

Owner Private Stream

An agent's owner can watch their own agent play — live, on any table, without anyone else seeing a thing.

Accepted 2026-07-15. Closes the gap left open by the hosted-agent information boundary.

The problem

The information boundary correctly stopped broadcasting agent reasoning to every seated player and to the public observer stream, and re-addressed it to the owner with sendToUser(ownerUserId, …).

But sendToUser iterates WebSocket connections, and an agent's owner has none. selectTransport grants a WS only to seat controllers — and controlsSeat compares seat.playerId, which for a hosted agent is the agent's id. The owner doesn't control the seat; their agent does. So the send reached nobody, and on any table without showThoughts an owner watching their own agent saw no reasoning at all and never saw its cards.

The leak and the feature had been the same wire. Splitting them was the whole job.

Transport by stake

The boundary that decides how you connect is stake, not money and not merely being signed in.

Today's line was drawn at the bot: the human whose money is in the seat was classed as an anonymous spectator. That was arguably the original defect, and this corrects it.

The gateway keeps every unbounded anonymous watcher — that is what it exists for, and it stays untouched: no JWT, no private channel, no database. Owners are seat-bounded (≤9 per table, the same order as the agent sockets game-server already holds), and sharding is per-table, so an owner lands on their table's shard exactly like its participants.

Who sees what

The rule is an ordering, and getting it backwards is the mistake to avoid:

agent  ⊂  spectator  ⊆  owner
ViewerOwn agent's cardsOther seats' live cardsReasoning
The agent itselfyesnevernever
A spectatoronly if showHandsonly if showThoughts
The owneryes, alwayswhatever showHands exposestheir own agent's, always

So the owner's frame is the public view plus their own agent's hole cardsbuildPublicView() with a holeCards overlay. It reuses the public view broadcastState already computed, so an owner frame costs zero extra view builds, and a table nobody is watching costs nothing at all.

Two tempting alternatives are both wrong, and both read plausibly:

AttemptWhy it fails
buildGameState(ownerUserId)Cards resolve via pm.get(viewerId) → seat. The owner is not a participant — the whole premise — so this returns no cards at all.
buildGameState(agentId)"The owner sees what their agent sees." On a showHands table, rule 2 requires viewerId === null for the face-up reveal, so an agent-keyed frame carries no revealedHoleCards — the owner would watch an exhibition table with every card hidden while anonymous spectators saw them.

reveal-policy and buildGameState are untouched: the owner frame is composed from the viewerId === null path the policy already serves. The owner isn't seated, so composing from the public view is not a loophole — it's the policy applied to the viewer class they actually are.

The stream is a read, never presence

The owner's SSE creates no participant and enters no ParticipantManager state. No enter, no seat, no sybil check; invisible to disconnect timers, sit-out timeouts and reconnect-rebind.

This is a fund-and-fairness rule, not a style preference. An agent's participant carries the owner's userId, so any participant-shaped treatment of the owner's connection collides with their own agent — handleEnter's sybil check would reject an owner from their own table. And if the stream were ever wired into the timers, an owner closing their laptop would fold their agent's hand.

Live streams therefore live in a plain per-table registry, deliberately not the PM.

Honest connection state

An owner is never silently moved to the public stream. If the private stream isn't connected, the UI says so, with the reason — otherwise the failure is indistinguishable from "my agent stopped thinking", which is the confusion this whole thing exists to end.

CaseServerOwner sees
no tokenpublic stream, from the gatewaythe public felt, labelled as such
invalid / expired tokenexplicit coded closea brief, honest reconnect state
reconnect succeedsserves the owner viewtheir agent, back
refresh fails (session gone)why, and a sign-in prompt
their agent leaves the seatexplicit closeagent_left_tablethe stream ended, and why
the room is evicted or removedexplicit closeroom_evicted / table_removedthe stream ended, and why

Expiry is not sign-out. Browsers throttle timers in backgrounded tabs, so an owner who leaves their agent's table open routinely returns with a dead token — token-dead-on-resume is the common case. Recovery is automatic (refresh, reconnect); a sign-in prompt appears only once the refresh itself has failed.

A grant is a claim about the future, so the stream's lifetime is bound to the entitlement and the room — not to the client's socket. The entitlement is checked once, at connect; those last two rows are what keeps that check honest afterwards. Without them the stream outlives its own truth in two ways, and both look identical to the owner: their agent leaves the seat and the frame silently becomes the public felt; or the room is evicted (the manager counts WebSockets, and an owner's SSE is not one) and the stream sits open forever, frozen on its last frame — still reporting live in both cases.

Falsification found this by asking the question the original tests never did: they asked does a refusal tell the truth? and never does a grant stay true?

Owner streams deliberately do not keep a room alive. An owner watching must never pin a table nobody is playing; the fix is to end the read honestly, not to extend the room's life.

What the gateway never sees

The observer-gateway sees no private data, by any path — the existing E-OBS-2, unchanged, and scoped to the component: it's the process that fans out to anonymous spectators, so anything it can see is one bug away from public. scripts/check-observer-privacy.sh gates it.

The invariant is not "no private data in Redis". Agent decision capture legitimately stores rendered prompts — including an agent's own hole cards — in a dedicated, agent-scoped capture Redis the gateway never reads. The rule is:

Private data may be stored where its readers are scoped to the owner (or an operator). It must never be published where its reader is the public fan-out.

A gate written on the technology axis would indict the feature it exists to protect.

Rejected

OptionWhy not
A private Redis channel relayed by the gatewayThis plan's own first direction. Elegant, and rejected on new information: it forced a chain — publish-only → no cost bound → publish a delta → two independently-written keys → cross-key skew → hand-binding → a gateway JWT → a dispatch collision. Every link traced to one root: game-server deliberately never learns a viewer exists. Connecting the owner removes the ignorance and dissolves all of it.
An owner WebSocketStructurally blocked: an agent's participant carries the owner's userId, so the sybil check rejects an owner from their own table. Making it work means weakening a security gate or skipping enter. Its one real advantage — absorbing the seat verbs — solves a different defect and should be decided on its own merits.
Do nothing (post-hoc review only)/agents traces are review, not watching. Owners could watch before the audit; they should be able to after.