Skip to content

the pterm: producer — and the consent invariant that was holding by accident - #549

Merged
JasonYeYuhe merged 7 commits into
mainfrom
pterm-producer
Sep 8, 2026
Merged

the pterm: producer — and the consent invariant that was holding by accident#549
JasonYeYuhe merged 7 commits into
mainfrom
pterm-producer

Conversation

@JasonYeYuhe

Copy link
Copy Markdown
Collaborator

Closes the documented gap that the Swift helper ships no pterm: producer, so an ATTACHED external session gets no live mirror. Not the way v0.65 planned, and not without finding a privacy defect on the way.

Why not the planned way

v0.65 had the helper POST directly to /realtime/v1/api/broadcast with a minted r0_broadcast token. That needs r0_broadcast to hold INSERT on realtime.messages, and the owner of a hosted Supabase project cannot grant it (measured, reproduced, written up in migrate_v0.82).

v0.65's own line 65 already recorded the answer and went unread for two months:

"fall back to a service-relay broadcast (helper→edge fn→service-role realtime.send)"

helper ──POST {device_id, helper_secret, session_id, chunks[]}──▶
  edge fn `broadcast-terminal`
     ├─ remote_helper_authorize_broadcast(...)   ← THE boundary
     └─ POST /realtime/v1/api/broadcast as service_role → pterm:<sid>

Proven on production before the code was written:

publish as anon   -> HTTP 202  ->  NOT delivered
publish as secret -> HTTP 202  ->  DELIVERED

The relay works; a 202 is an ack, not a delivery receipt. Realtime's write-side authorization is real (no pterm: injection hole) — it just reports refusal as success.


The defect the review found — a privacy leak I introduced

I changed the drain-loop gate so realtimePrivate: true went from "stay quiet" to "publish, privately." But attachWrappedSession stamps realtimePrivate: true on every attached session at attach time, before any consent exists. Consent is a different field: cloudShared.

The M4.4d local-only invariant was holding by accident — nothing on the broadcast path consulted consent because nothing on that path could publish a private session. I turned the accident into a leak:

never shared output leaves the machine before the RPC denies it, then latches denied for the helper's life
shared then revoked remote_helper_authorize_broadcast has no status or consent predicate, so the relay keeps authorizing and the phone keeps receiving a terminal the user explicitly un-shared

unshareAttachedSession's own contract — "from that instant nothing further uploads" — would have become false.

The tell was one block away: the broker frame two lines below already latched local_only under the lock, and my branch did not.

Also: "ships dark" was false. Only the sink was gated; the drain-loop change wasn't gated at all, so every install would redact and enqueue every attached session's output, then discard it.

Watched fail before being fixed

  • consent bypass reintroduced → 6 failures
  • single-flight latch removed → ordering test fails by losing 7 of 12 chunks, not merely reordering

Also fixed

Permanent 403 latch that the normal flow triggers · 401 wrongly treated as a per-session denial (only the gateway emits it — a global fault) · client bounded batches by bytes while the relay rejects >64 chunks wholesale · the config key silently deleted by the Python helper's config writer, making the flag impossible to persist · unread Realtime response body · a cap documented as decoded bytes while counting base64 chars.

Corrected claims

  • A Python pterm: producer already exists, default-ON since helper 1.24.0, which by this branch's own measurements cannot have delivered a byte since 2026-08-30. Recorded, not fixed.
  • The coalescing numbers were never run: drainIntervalMs is 50, so a 60 ms window batches ~2 chunks — ~10 req/s, not an order-of-magnitude reduction.
  • Nothing enforces that broadcast-terminal is deployed before the flag flips. A 404 is neither 403 nor 5xx → dropped silently forever. Recorded on the flag.

⚠️ Not verified, and it matters

Nothing in the product subscribes to pterm: — or to term:. No Realtime WebSocket client exists in macOS, iOS, or Android. isRealtimePrivate is referenced only by tests; Android's realtimeAccessToken() is never called. All three producers publish into the void.

The remaining R0 work is the consumer, not more producer. This PR is a correct, tested producer for a topic nobody joins yet.

Gates: 785 HelperSwift tests · 63 deno tests · entrypoint type-check clean. Edge function not deployed; no production DDL.

🤖 Generated with Claude Code

JasonYeYuhe and others added 2 commits September 8, 2026 22:43
…y read

The Swift helper has never had a `pterm:` producer. Its own source said so in
three places, and the consequence shipped: an ATTACHED external session is
minted PRIVATE (v0.69, so it is never advertised on the UUID-secrecy `term:`
topic), and then got no live mirror at all — the phone fell back to the durable
event tail at ~3 s. Private and correct, just not live.

This closes that gap, and does NOT close it the way v0.65 planned.

── WHY NOT THE PLANNED WAY ───────────────────────────────────────────

v0.65 had the helper POST directly to /realtime/v1/api/broadcast holding a
minted `r0_broadcast` token, with the realtime.messages WRITE policy as the
boundary. That needs r0_broadcast to hold INSERT on realtime.messages, and the
owner of a hosted Supabase project cannot grant it — measured, reproduced, and
written up in migrate_v0.82. So the direct path is blocked on a support request
that may never be granted.

v0.65's own line 65 already recorded the answer: "fall back to a service-relay
broadcast (helper→edge fn→service-role realtime.send)". It needs no privilege
nobody has. It went unread for two months while the grant was treated as the
blocker.

    helper ──POST {device_id, helper_secret, session_id, chunks[]}──▶
      edge fn `broadcast-terminal`
         ├─ remote_helper_authorize_broadcast(...)   ← THE boundary
         └─ POST /realtime/v1/api/broadcast as service_role → pterm:<sid>

── PROVEN BEFORE IT WAS WRITTEN, NOT AFTER ───────────────────────────

The design's load-bearing unknown was whether Realtime accepts and DELIVERS a
service-role publish to a private topic. Subscribed a WebSocket client to
pterm:<nil-uuid> and published twice:

    publish as anon   -> HTTP 202  ->  NOT delivered
    publish as secret -> HTTP 202  ->  DELIVERED

Two results, and the second is the trap. The relay works. And **HTTP 202 from
the broadcast endpoint does not mean delivered** — the anon publish got the
identical 202 and was silently dropped. Realtime's write-side authorization is
real, so there is no `pterm:` injection hole; it just reports refusal as
success. That is the fourth silent-success trap this session, after the no-op
GRANT, `realtime.send`'s swallow-everything EXCEPTION block, and my own
`realtime.send AS service_role: OK` that inserted nothing. Both new files say
so where someone would otherwise trust a 2xx.

Settled in passing: realtime.messages had no partition newer than 2026-06-28,
so every insert failed 23514 and `realtime.send` swallowed it into a WARNING.
Not a blocker here — partitions are created when a client connects, and the
probe's own join created five including today's (6 -> 11 partitions, newest now
2026_09_11). The HTTP broadcast path does not persist to that table at all.

── THE SECURITY COST, STATED WHERE IT LIVES ──────────────────────────

service_role is rolbypassrls, so the WRITE policy is NOT consulted on this
path. The edge function IS the write-side boundary. Both files say this in the
imperative, and the topic is derived from the session id the RPC authorized —
there is deliberately no caller-supplied topic parameter. The READ side is
untouched and still RLS-governed (v0.81), so subscribers remain restricted to
their own sessions.

── MAKING THE LEAK UNREPRESENTABLE ───────────────────────────────────

The bug worth fearing is a private session's output appearing on the public
topic. Three independent things now have to fail for that:

  * `broadcastVisibility` is THREE-valued. false→public, true→private,
    nil→MUTE. The old gate collapsed true and nil into one silence, which was
    right when there was no private destination and wrong now.
  * `TerminalBroadcastVisibility` owns both prefixes, so the publisher stamps
    the topic and no call site spells "pterm:".
  * `PrivacyRoutingBroadcastSink` dispatches on the TOPIC PREFIX, not on a
    boolean travelling beside it — and refuses a `pterm:` chunk outright when
    no private sink is configured rather than downgrading it. `EdgeRelay
    PrivateBroadcastSink` independently refuses a non-`pterm:` topic.

`pterm:` does not start with `term:`, so the two families are genuinely
disjoint; there is a test that pins exactly that, because it is the kind of
thing a later refactor "simplifies".

── SHIPS DARK ────────────────────────────────────────────────────────

`remote_private_terminal_broadcast_enabled` defaults FALSE, unlike its public
sibling which defaults true. That path has been exercised since v1.25; this one
calls an edge function no install has ever called and spends an invocation per
coalesced batch. Off means no live private mirror — the pre-existing behaviour
— never a fallback to the public topic.

Coalescing (60 ms window, 64 KiB batches, matching helper/realtime_broadcast.py)
keeps one chatty PTY from becoming hundreds of edge invocations a minute.

── NOT VERIFIED ──────────────────────────────────────────────────────

End-to-end with a real phone and a real attached session. The edge function is
not deployed yet. What IS verified: the relay's transport and delivery, by the
probe above; parsing, bounds and the 42501-only-is-denial rule by 7 deno tests;
and topic/routing/fail-closed by 11 Swift tests.

Gates: 771 HelperSwift tests, 0 failures; 63 deno tests; entrypoint type-check
clean (CI's exact command).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 45-agent adversarial review of the first cut confirmed 28 findings plus 7 the
lenses missed. One is a privacy defect I introduced, and it is the reason this
commit exists.

── THE DEFECT ────────────────────────────────────────────────────────

`attachWrappedSession` stamps `realtimePrivate: true` on EVERY attached
external session, at attach time, before any consent exists — because privacy
there means "never advertise on the public `term:` topic" (v0.69). Consent is a
different field entirely: `cloudShared`, flipped by
`set_wrapped_session_cloud_shared`.

While no `pterm:` producer existed, `allowsPublicBroadcast` collapsed `true` and
`nil` into the same silence, so the local-only invariant held BY ACCIDENT —
nothing on the broadcast path consulted consent because nothing on that path
could publish a private session at all. My change gave `true` a real
destination and inherited the accident as if it were a guarantee.

Two failures, one root cause:
  (A) never shared — output POSTs to the relay and leaves the machine before
      the RPC raises 42501, then the session latches denied for the helper's
      remaining life, so a later legitimate share gets no mirror.
  (B) shared then REVOKED — the serious one. `unshareAttachedSession` flips
      `cloudShared` and retires the row to status='stopped', but
      `remote_helper_authorize_broadcast` filters on
      id/device/user/realtime_private with NO status or consent predicate. So
      the relay keeps authorizing and the phone keeps receiving live output of
      a terminal the user explicitly un-shared. Its own contract — "from that
      instant nothing further uploads" — would have become false, and so would
      `unshareAllAttachedSessions`, the Local-Session-Control kill switch.

Fixed: `broadcastVisibility` now takes `localOnly` and `privateEnabled`, and
the drain loop reads the consent latch ONCE under the lock and uses it for both
the broadcast branch and the broker frame. Previously the broker read it and
the broadcast path did not, which is exactly how this shipped.

── "SHIPS DARK" WAS FALSE ────────────────────────────────────────────

Every lens that noticed the consent gap excused it as dark. It was not. Only
the SINK was gated; the drain-loop change was not gated at all, so on every
install every attached session's output would run redaction, enter the shared
publisher queue and be discarded as a drop. The gate is now injected into
ManagedSessionManager: off means the path is not taken.

── ORDERING: LOSS, NOT JUST LATENESS ─────────────────────────────────

`flush()` cleared `flushTask` at entry and then awaited the network. An actor
is reentrant across `await`, so a chunk arriving mid-flight armed a second
flush and issued a CONCURRENT POST for the same session. Added a single-flight
latch plus a re-arm at the tail so nothing is stranded.

The negative control is worse than the review predicted. With the latch
removed, `test_onlyOneRequestPerSessionIsEverInFlight` fails not by reordering
but by LOSING 7 of 12 chunks:

    ["YzE=","YzI=","YzM=","YzQ=","YzU="] != [... twelve ...]

── ALSO FIXED ────────────────────────────────────────────────────────

* The 403 latch was permanent, and 403 is exactly what the normal flow produces
  (an attached session before opt-in; Remote Control toggled off). Now a
  bounded, expiring suppression.
* 401 was treated as a per-session denial. The relay can never emit 401 — only
  the gateway can, and that is a global config fault identical for every
  session. No longer suppresses.
* The client bounded batches by bytes only; the relay rejects >64 chunks
  WHOLESALE with 400, so a burst of small chunks was total loss. `split` now
  bounds count too, and the test uses distinguishable fixtures — the old one
  used four identical blobs and could not have detected reordering at all.
* `remote_private_terminal_broadcast_enabled` was absent from the Python
  helper's config dataclass, whose loader filters unknown keys and whose every
  save rewrites the file. The flag could be set by hand and would silently
  vanish on the next pairing or toggle — indistinguishable from the feature not
  working.
* The edge function never drained the Realtime response body (a stream leak in
  the hottest path). MAX_TOTAL_B64_BYTES was documented as a decoded-payload
  cap while counting base64 characters — a third off for anyone sizing against
  it.

── CORRECTED CLAIMS ──────────────────────────────────────────────────

* "the `pterm:` producer the Swift helper has been missing" is true of Swift
  and FALSE of the repo: `helper/realtime_broadcast.py` is a complete producer,
  default-ON since helper 1.24.0, which by this branch's own measurements
  cannot have delivered a byte since 2026-08-30. Recorded, not fixed — whether
  the Python helper keeps a terminal path is a larger question.
* The coalescing numbers were never run. `drainIntervalMs` is 50, so a 60 ms
  window batches ~2 chunks — around 10 req/s, not the order-of-magnitude
  reduction the word implies. The single-flight latch does more than the window.
* Nothing enforces that `broadcast-terminal` is deployed before the flag flips.
  A 404 is neither 403 nor 5xx, so it is silently dropped forever. Recorded on
  the flag itself, where someone flipping it will look.
* `shareAttachedSession`'s docstring still said the producer was absent — that
  sentence is the safety argument for minting the row private.

── VERIFICATION ──────────────────────────────────────────────────────

14 new tests, and the two that matter were watched FAIL first:
  * consent bypass reintroduced -> 6 failures in PrivateTerminalBroadcastTests
  * single-flight latch removed  -> the ordering test fails, losing 7 of 12
The relay's HTTP path had NO test before this commit — the only one that
touched it tripped the prefix guard on line one. It now has 9, covering
endpoint, headers, body shape, coalescing, ordering, denial expiry, 401, 5xx
and per-session isolation.

785 HelperSwift tests, 0 failures. 63 deno tests. Entrypoint type-check clean.

STILL NOT VERIFIED: end to end. The function is not deployed, and nothing in
the product subscribes to `pterm:` — see the next commit message or the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 8, 2026 18:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…g the dropping

Two review findings I left open, both in code from this branch.

publishTailSnapshot was still gated on `allowsPublicBroadcast` alone, so a
private session got no snapshot. That was consistent while there was no private
producer and became a contradiction the moment there was one: this sink's own
comment cites "the phone's reconnect tail-snapshot" as the recovery path for
the chunks it drops, and for a private session that path did not exist. A
recovery path wired only for the sessions that were NOT dropping chunks is not
a recovery path. It now uses the same three-valued gate as the drain loop —
including the consent latch — and passes the visibility through.

And the relay was entirely silent. Every failure mode — 4xx, 5xx, transport,
and the suppression latch itself — was a bare `catch {}` with a comment. For a
path whose rollout plan is "turn it on per-machine and see", there was nothing
to see. Added `sentBatches` / `failedBatches` / `suppressedSessions` with a
`stats()` snapshot, plus stderr on the two transitions worth an operator's
attention: a session being suppressed (once per backoff window by
construction), and batch failures at first-and-every-hundredth so a flapping
network cannot turn stderr into the firehose the PTY already is. Session ids
only; never a payload.

The counters are also the first thing this feature has that could answer "is it
working" without a phone, which is the gap #535's latches were built for on the
remote-control side.

786 HelperSwift tests, 0 failures — READ from the runner, not predicted. The
first draft of this message said 789, which I had guessed while the suite was
still running. Small, and exactly the habit that put "0 of 216" into three
files this session while the measured value was 218.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JasonYeYuhe and others added 4 commits September 9, 2026 03:49
A second review, on the fix delta only (1b83c78..HEAD), confirmed 11 findings.
My own recorded lesson said fixes need review too; it was right again.

── THE WORST ONE: FIX 7 MADE THINGS WORSE ────────────────────────────

The previous commit wired `publishTailSnapshot` onto the private topic,
arguing "a recovery path wired only for the sessions that were NOT dropping
chunks is not a recovery path". It sends `event: "tail_snapshot_result"`. The
relay's allowlist was `["stdout", "stderr"]`, with a comment I wrote asserting
"there is no legitimate third value".

`parseBroadcastBody` fails WHOLESALE on the first unrecognized event, so the
batch 400s — and because `pending` is one ordered array per session that
`split` never segregates by event, any live stdout coalesced into the same
60 ms window died with it. So the fix did not deliver the snapshot AND
destroyed output that previously flowed. Strictly worse than the mute it
replaced.

The test could not catch it because it iterated ALLOWED_EVENTS to check that
ALLOWED_EVENTS was accepted. It now hard-codes EMITTED_BY_HELPER and is watched
failing against the old allowlist:

    parse: accepts every event the helper actually emits ... FAILED

── THE `break` I DELETED WHILE FIXING SOMETHING ELSE ─────────────────

Turning the permanent denial latch into an expiring one rewrote the catch arm
and dropped its `break`. `isDenied` is checked once per session per pass, ABOVE
the group loop, so a denied session re-POSTed and re-logged once per group.
Restored, and watched fail: 5 requests instead of 1, suppressed counted 5 times
instead of 1.

── REVOKE HAD A TAIL ─────────────────────────────────────────────────

The consent gate stops NEW chunks correctly — the review looked for a path
where realtimePrivate && !cloudShared publishes and found none. But whatever
was already buffered in the sink would still go out on the next flush.
`setCloudShared(_, false)` now purges: a `PurgeableBroadcastSink` protocol
(separate, because the public sink POSTs synchronously and has nothing to
purge), a publisher purge that FILTERS its shared queue rather than clearing
it, and a sink purge that drops the buffer and the suppression.

── THE ONE UNBOUNDED QUEUE ───────────────────────────────────────────

`TerminalBroadcastPublisher` documents the pipeline's bound — "a laggy sink
cannot back-pressure the drain loop". That bound provably cannot reach the
relay sink's `pending`, because `publish` returns as soon as it appends, so the
publisher counts the chunk delivered and its drop-oldest never fires. A
suppressed session accumulated its entire output in memory. Now capped at 256
per session, drop-oldest, counted separately as `droppedForBackpressure`.

── AND THE COMMENTS THAT OUTLIVED THEIR CODE ─────────────────────────

Three sites still described the permanent `deniedSessions` latch that no longer
exists, one of them as the JUSTIFICATION for the 502 remap. And main.swift
claimed the router's refusal is what protects the OFF state — true of the first
cut, falsified by the same edit that improved it. All corrected, with what
changed and why, because this file family has now had three comments outlive
their fixes.

── COVERAGE ──────────────────────────────────────────────────────────

The enabled path was constructed nowhere: 786 green tests all exercised the OFF
state, which is how the consent bypass survived a green suite in the first
place. Added a test that builds ManagedSessionManager with the gate ON, plus
tests for the denial break, the pending bound, purge-on-revoke, and the tail
snapshot event name.

791 HelperSwift tests, 64 deno tests, entrypoint type-check clean — all read
from the runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lready in this repo

Round 3 reviewed the round-2 fix delta and confirmed 12 findings, which dedupe
to 6. The critic's verdict on the question that matters — is this converging? —
was yes: no new class appeared, and both majors are inside `purge()`, which did
not exist before round 2. They are one class.

── purge WAS A FILTER, NOT A BARRIER ─────────────────────────────────

`flush()` lifts the whole buffer into a local and clears `pending` BEFORE its
first `await send(...)`. So a purge arriving mid-flush removes nothing — and
"whatever a slow relay was holding", the case purge's own doc says it exists to
cover, is definitionally "already out of pending". The review measured it: 5 of
5 chunks POSTed after the revoke returned; with four sessions, three shipped
entirely post-revoke.

THIS REPO ALREADY SOLVED THIS, one file over. `EventUploader.purgeGen` exists
for the SAME M4.4d revoke, and its comment states the rule I broke: a pump
"must NOT hold a local copy across the suspension", because that "RESURRECTS
events removeSession purged (defeating M4.4d's revoke — the user's revoked
output uploads anyway)". It even records that an earlier `removeSession` fix
"was a no-op whenever a pump was in flight, i.e. the common case on a chatty
session." I wrote the same no-op, in the same codebase, for the same feature.

Fixed with that pattern verbatim: a per-session `purgeGen` captured at snapshot
time and re-checked before every send. Watched fail without it — 5 POSTs, the
review's exact number — and pass with it.

The shipped test could not have caught this: it used `coalesce: .seconds(60)`
so no flush was ever in flight. It pinned the idle case only. The new test
puts a POST in flight with a 120 ms responder and revokes 60 ms in.

── THE GLOBAL REVOKE NEVER PURGED AT ALL ─────────────────────────────

`setCloudShared` purged; `revokeAllCloudShares` — the "turn Local Session
Control off" kill switch, whose entire purpose is stop-everything-now — did not.
Wired.

── KNOWN GAP, RECORDED NOT CLOSED ────────────────────────────────────

`remote_helper_authorize_broadcast` has no status or consent predicate, and
`cloudShared` is an in-memory flag never mirrored to the database. So
revocation is enforced ENTIRELY client-side. Closing it is a migration and
owner-gated; the edge function now says so where it calls itself the write-side
boundary, and says not to describe revocation as server-enforced anywhere.

── TESTS THAT PASSED FOR THE WRONG REASON ────────────────────────────

* The ON-path test built a manager with the gate on and then discarded it
  (`_ = mgr`), asserting only the static function the truth table already
  covers. It now drives `publishTailSnapshot` on the instance, with a gate-OFF
  control so it cannot pass for the wrong reason.
* The backpressure test set status 403 as dead setup — with a 60 s window no
  request was ever made, and a suppressed session cannot grow anyway because
  `publish` throws before the append. Renamed and rewritten around the state
  that actually produces it: a slow relay.

── DRIFT ─────────────────────────────────────────────────────────────

The 400 body still told clients "must be stdout or stderr" after
tail_snapshot_result was allowed — now derived from the allowlist rather than
restated. request_test.ts still named `deniedSessions`, a type that no longer
exists. main.swift's corrected comment had been prepended without deleting the
paragraph it superseded, and cited a test class that does not exist; it now
names the test that really pins the refusal, verified present.

792 HelperSwift tests, 64 deno tests, entrypoint type-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t fail

Round 4 was narrow on purpose — only the round-3 delta — because rounds 2 and 3
had each found a major in the immediately preceding fix set. It found two more,
both mine, both measured rather than argued.

── THE BARRIER WAS ARMED AFTER THE THING IT GUARDS ───────────────────

Round 3 added a purgeGen barrier and captured it INSIDE the per-session loop.
The comment said "Captured BEFORE the first suspension". That was true of
exactly one session: `flush()` lifts EVERY session out of `pending` in one
snapshot, so every session after the first-iterated one reads its baseline
AFTER a previous session's `await send(...)` released the actor. A purge landing
in that window has already bumped the generation, so the barrier compares a
post-purge value against itself and can never fire.

Worse than "the first is safe": which session is first is Dictionary iteration
order, so the protected one was nondeterministic — and `revokeAllCloudShares`,
the global kill switch this barrier was added for, is multi-session by
definition.

Measured with two sessions, both purged mid-flush: 6 POSTs, 5 of them after the
revoke. Capture hoisted beside the snapshot; now 1.

The negative control is the point. With the late capture restored:
  * test_purgeStopsATailThatIsALREADYINFLIGHT (one session)  PASSES
  * test_purgeStopsEVERYSessionsTailNotJustTheFirstIterated  FAILS at 6 POSTs
The single-session test I wrote in round 3 could never have caught this. It
tested the one case a late capture still protects.

── THE GATE TEST COULD NOT FAIL FOR ITS STATED REASON ────────────────

Round 3's ENABLED-path test drove `publishTailSnapshot` on an UNKNOWN session.
`sessions[id]` is nil, so the closure holding the gate never runs and both
managers return false before `privateBroadcastEnabled` is read. The review
proved it by mutation: hardcoding the call site to `true` left all 794 tests
green. My own comment claimed the OFF control existed "so the assertion above
cannot pass for the wrong one" — both passed for exactly the same wrong one.

Two attempts at this test have now failed for two different reasons. The third
aims at a real seam: `resolvedBroadcastVisibility(sessionId:)`, the instance
method both call sites now share, driven against a REAL attached record with
consent flipped on and off. Verified by applying the review's exact mutation:

    gate OFF must mute even a consented private session — FAILED

It needs a live tmux for the record (`sessions` is private and `startSession`
spawns a real CLI), the same dependency WrappedSessionVerbsTests already
carries, and skips loudly without it.

── STILL UNCOVERED, SAID PLAINLY ─────────────────────────────────────

The drain-loop consumption site is NOT covered: hardcoding its `privateEnabled:`
to true leaves the suite green. Reaching it needs a live PTY producing output.
The resolver site is covered; the drain loop is not, and claiming otherwise is
what got the last two versions of this test written.

793 HelperSwift tests, 64 deno tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 5 reported the barrier itself CORRECT — ten points of measured reasoning,
including that the capture is recomputed per outer pass, is atomic with the
snapshot, that the `[String: Int?]` + `?? nil` flattening preserves the only
distinction that matters, and that a hoisted capture would have been fail-safe
anyway. It also independently reproduced round 4's negative control: restoring
the late capture fails the multi-session test at exactly 6 POSTs while the
single-session one still passes.

What it found instead was in the test infrastructure, and the important one is
not about this PR at all.

── 19 TESTS SKIP ON CI, AND THEY ARE THE CONSENT TESTS ───────────────

Read off the real CI log for run 34268589946, not inferred:

    Executed 792 tests, with 19 tests skipped and 0 failures

Every one skipped for "tmux not available". macos-15 has no tmux and swift-ci
never installed it. They are not peripheral:

    testTurningLocalControlOffRevokesEverySharedSession
    testShareMintsAPrivateRowAndUnshareRevokes
    testSharedSessionUploadsOutputAndUnsharedStops

That is the M4.4d "revoke actually stops the upload" invariant — the exact
invariant I broke in round 1 of this branch and have spent four rounds
repairing. It has been green-by-skipping since it was written, and a green
suite was read as covering it.

Found only because the gate test I added in round 4 skipped too, so the
mutation it exists to detect — hardcoding the privacy gate ON — still merged
with every check green. A guard that does not run at the merge gate is not a
guard; this repo has a name for that and it happened again.

One `brew install tmux` step un-skips all 19.

── AND THE TEST LEAKED PROCESSES ─────────────────────────────────────

`defer { removeItem(sockDir) }` runs at method return, BEFORE the
`addTeardownBlock { owner.close(oh) }` that kills the sessions — so
`tmux -S <sock> kill-session` hit a socket that no longer existed, failed
silently under `try?`, and stranded two `cat` children per run. Measured: the
newest server had 2 children before the fix and 0 after. Re-registered the
removal as the FIRST teardown block so LIFO runs it last. 19 orphans from
today's runs cleaned up.

── AND MY COMMENT CLAIMED COVERAGE I DO NOT HAVE ─────────────────────

The round-4 test said it aims at "the instance method both real call sites
share". Only ONE does — the drain loop still calls the static inline, and
hardcoding its gate leaves the suite green. My own commit message said the
right thing three paragraphs later; the tree kept the wrong half. That is the
same shape as round 3's "Captured BEFORE the first suspension", which was true
of exactly one session and hid a bug for a whole round.

793 HelperSwift tests locally, 64 deno tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JasonYeYuhe
JasonYeYuhe merged commit 777e6ae into main Sep 8, 2026
43 checks passed
@JasonYeYuhe
JasonYeYuhe deleted the pterm-producer branch September 8, 2026 20:56
JasonYeYuhe added a commit that referenced this pull request Sep 9, 2026
…and close revoke server-side (#550)

* chore: add .imgbotconfig, scoped to the images that actually ship

Imgbot is free on the student pack. The only images in this repo where
compression changes what a user downloads are the 361 pet sprites under
CLIPulseCore/Sources/CLIPulseCore/Resources/Pet (4.7 MB). They are declared
via .process("Resources") in Package.swift, so they go into the app bundle --
and because that is a plain folder rather than an .xcassets catalog, actool's
compression never touches them.

Measured on a 30-file sample with zopflipng: 330.4 KB -> 283.8 KB, 14.1%,
pixel-identical when both are decoded to RGBA. Extrapolated, ~0.66 MB off the
bundle. Modest, but free and unattended.

Excluded on purpose:
  *.xcassets  - the App Store icon has hard requirements around alpha and
                dimensions; a few hundred KB is not worth debugging a rejected
                build, and actool recompresses these anyway.
  screenshots - already uploaded to App Store Connect; keep them byte-identical
                to what was submitted.

schedule=monthly and minKBReduced=50 keep this from becoming PR noise once the
one-time win is banked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* I built a producer for a plane that was retired three weeks ago

Asked to "build the consumer" for `pterm:`, I went to build it and found this,
in the source, measured:

    RemoteSessionPlane.isEnabled = false
    /// `false` — the app offers no remote sessions, TERMINALS or approvals.

    remote_sessions               4   all the owner's own, last 2026-07-16
    remote_session_commands       0   pending rows are never swept — a true never
    remote_permission_requests    0
    app_push_jobs                 0   the durable zero

The remote session plane — starting CLI sessions from the phone, STREAMING THE
TERMINAL, remote approvals — was withdrawn across #499-#514, ~12,000 lines,
after measuring that no non-owner ever used it. The `pterm:` consumer was
removed with it. It has no consumer BY DESIGN, not by oversight.

So "build the consumer" is a request to un-retire that plane, and I am not
doing that as a side effect of a producer task. The replacement is alive and
shipping: the phone reaches the Mac over LAN/tailnet directly
(`LANTerminalHost` streams via `LANSessionControlClient.subscribeEvents`).

I proposed that consumer myself, last session, without checking. Same mistake
as the one in my own notes — count the population before building the
mechanism — made while quoting it.

── SO THE CORRECT ACTION IS THE OPPOSITE ─────────────────────────────

#549's producer consults only `remote_private_terminal_broadcast_enabled`.
`main.swift:496` already gates the cloud task on `RemoteSessionPlane`; the
producer did not. Flipping that ops flag would have redacted, batched and
POSTed a session's output to a topic nothing subscribes to, for a plane both
copies of that file declare retired. Not a leak — the relay authorizes and the
READ policy still scopes subscribers — but real work and real egress in
service of nothing.

`RemoteSessionPlane.shouldRunPrivateTerminalProducer(configEnabled:isPaired:)`
now ANDs the retirement. It lives there, not inline in main.swift, for the
reason that file already gives above its sibling: main.swift is an executable
target with no test bundle, and a predicate a test cannot reach is a predicate
nobody has checked — which is exactly how this one shipped unchecked.

── AND THE PYTHON PRODUCER, WHICH WAS DEFAULT-ON ─────────────────────

`helper/realtime_broadcast.py` is a complete `pterm:` producer whose gate has
DEFAULTED ON since helper 1.24.0. Two independent reasons it delivers nothing,
both measured: no consumer, and its write path (v0.65's direct mint+POST as
`r0_broadcast`) is refused by Realtime because that role holds no INSERT on
realtime.messages — invisibly, since the endpoint answers 202 either way. It
has not delivered a byte since at least 2026-08-30 while still minting a token
and issuing an HTTPS request per coalesced chunk.

New `helper/remote_session_plane.py` mirrors the flag as the third copy, and
`test_remote_session_plane.py` reads BOTH Swift sources and fails if any
disagree — the repo already runs that drift gate between the two Swift copies;
the Python one ships as a separate .pkg and is the copy most likely to be
forgotten.

── WATCHED FAIL ──────────────────────────────────────────────────────

  un-retire the Swift plane        -> "the ops flag must not outvote the
                                      retirement" FAILS
  set the Python copy True         -> 3 tests fail, incl. both drift checks
  drop the gate call from the
  producer construction            -> the wiring test FAILS

That last one matters: a predicate nothing calls gates nothing, and this file
family has shipped that mistake twice.

795 HelperSwift tests, 938 helper pytest (1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* revoke is now enforced on the server too, and v0.82's support ticket should never be filed

── v0.83 APPLIED: the server half of revoke ──────────────────────────

`remote_helper_authorize_broadcast` is the ENTIRE write-side boundary for the
private relay — `broadcast-terminal` runs as service_role, which is
rolbypassrls, so no RLS policy is consulted. It authorized on four predicates:
id, device, user, realtime_private. No status. No consent.

Revocation posts `status='stopped'`. The function never read it. So after a
user un-shared a session, the server kept authorizing writes to that session's
topic, and revocation was enforced entirely by a client that could be stale,
buggy, or replaced.

One predicate: `and rs.status in ('pending','running')`. An allowlist of live
states rather than `<> 'stopped'`, so a future terminal state does not keep
authorizing; 'pending' included so a helper broadcasting before its first
status post does not earn a 42501, which the Swift sink treats as an
authoritative denial and suppresses for 60 s.

Body is the LIVE definition read back with pg_get_functiondef plus the one
predicate — production function bodies drift, so it was not reconstructed from
the repo.

Dry-run first: all assertions passed inside a transaction, then rolled back,
and production was re-checked clean (predicate absent) before the real apply.
APPLIED as ledger 20260909013221, then verified BY HAND — and not only by
grepping the source, which proves nothing about behaviour:

    row_status                stopped
    matched BEFORE v0.83      1      <- would have authorized
    matched AFTER  v0.83      0      <- raises 42501

Blast radius measured first: remote_sessions = 3, all realtime_private, all
'stopped', 0 running. It authorizes strictly less and breaks nothing live.
The three rows it newly refuses are the exact class it exists to refuse.

STILL OPEN, and the edge function now says so precisely: consent.
`cloudShared` is an in-memory helper flag never mirrored to the database, so a
never-shared but running session would still authorize. Revocation is now
enforced on both sides; consent is still client-only. The note that used to say
"do not describe revocation as server-enforced anywhere" was corrected rather
than deleted — it is half right now, and which half matters.

── v0.82: SUPERSEDED, DO NOT ASK ─────────────────────────────────────

That file's whole purpose was to obtain a grant via a Supabase support ticket.
Three things killed the ask:

  1. The write path was BUILT WITHOUT IT — the relay uses service_role, which
     already holds INSERT. Proven end to end before it shipped.
  2. The feature it serves is RETIRED in three packages with a drift gate.
  3. Its own header calls the ask a one-shot favour and says not to spend it
     before knowing the design survives. It did not survive.

Marked superseded, kept not deleted: the measurements in it — the silent no-op
GRANT, the reserved membership, the NOINHERIT probe trap, the definer/bypassrls
trap — are the expensive part, and a deleted dead end gets rediscovered.

Edge function redeployed with the corrected note; boundary re-verified live
after the redeploy (bogus creds -> 403, not 500).

83 migrations, all numbers unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants