Skip to content

feat: platform capability expansion — viewer stats, broadcast lifecycle, and the evidence behind them - #416

Merged
rainmanjam merged 67 commits into
mainfrom
feat/platform-capability-expansion
Aug 17, 2026
Merged

feat: platform capability expansion — viewer stats, broadcast lifecycle, and the evidence behind them#416
rainmanjam merged 67 commits into
mainfrom
feat/platform-capability-expansion

Conversation

@rainmanjam

Copy link
Copy Markdown
Owner

56 commits. Every platform claim traces to a dated, quoted source in
docs/evidence/, and several of them overturned what this repository believed.

What it does

Viewer stats on three platforms. YouTube and Twitch implemented; Kick fixed.
LiveStats.ViewerCount is a *int because all three have a way of declining to
answer and no int can say "not told" — YouTube omits the key under three
indistinguishable conditions, Kick documents 0 as its opt-out, Twitch sends an
empty array. A UI surface renders "not reported", never a zero.

Broadcast lifecycle. A coordinator in internal/api drives YouTube through
testing → live → complete, wired from main.go as a third consumer of the edges
observeLoop already derives. Facebook's end call reaches a route and the UI.
broadcastLifecycle becomes the eighth capability column.

A YouTube stream per destination. Every YouTube destination shared one key,
which capped an install at 3 concurrent rather than 10. Each destination after
the first now gets its own ingest stream.

Bulk start/stop, paced, reported per destination.

Three bugs found in shipped code, by reading the platforms' own docs

  • A Kick streamer who hides their viewer count was reported offline. Kick
    documents 0 as the opt-out value; the code read > 0 as proof of life.
  • Kick's stats fallback could never have worked — it read a platform-wide
    total_count as one channel's viewership. Its tests passed because the
    fixture was shaped like the struct rather than like the endpoint.
  • Every piece of Facebook's error advice was dead for real Meta errors.
    statusError.Body is truncated to 300 characters for display and was being
    parsed; a realistic refusal is 363 bytes, so every graph-code branch was
    silently skipped.

Two verdicts this repository held that were wrong

  • X was believed to have no live-video API. Its served OpenAPI spec declares
    149 paths, a Broadcasts family of 13 operations and a Chat family of 16,
    under broadcast.read/broadcast.write — verified by fetching and counting,
    not by reading. Its cells stay Unverified because the provider is written
    and unregistered.
  • Facebook was believed undocumented. Two documentation trees exist and both
    earlier passes read only the dead one. End, health, scheduling and polls are
    all documented.

The safety property, traced rather than asserted

A lifecycle failure can never stop a broadcast. An FFmpeg crash emits reason
stopped, which is dropped at the observer and cannot reach the END branch
because the sweep re-reads Enabled. A destination edit fires teardownDest on
a command-line change and the coordinator holds no process handle. Both pinned
by named tests; the END-on-refusal mutation was run independently by a reviewer.

This matters because a completed YouTube broadcast cannot return to live
ending on a fault would turn a recoverable crash into a destroyed show.

Verification

go build ./... && go vet ./internal/... && go test ./internal/... green;
ui: 210 tests, typecheck and build green; site builds 38 pages; i18n drift
across all 15 locales green.

Note for reviewers: npx tsc --noEmit is a no-op in this project —
ui/tsconfig.json is a solution file with no files of its own. Use
tsc -p tsconfig.app.json --noEmit, or npm run build, which runs tsc -b.

https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

… say it goes

Groundwork for platform capability expansion, and it changes no behaviour.

LiveStatter was declared in internal/api/oauth_handlers.go, next to the handler
that wanted it, with a payload type called KickStats. Both were reasonable when
Kick was the only platform that could answer a viewer count. Neither survives a
second implementor:

  - A cross-platform capability named after one platform reads as Kick-specific
    to anyone deciding whether to implement it.
  - An interface declared outside internal/oauth cannot carry the Set twin that
    endpoints.go:153-162 requires of every capability the package grows -- so a
    caller holding a stubbed Set would silently fall through to the production
    providers for viewer numbers alone, which is the exact failure that comment
    exists to prevent.

So internal/oauth/stats.go now holds LiveStats -- the shape is unchanged, it was
already neutral: Live, ViewerCount, Title, Category, Language, Slug, StartedAt,
Source -- plus the LiveStatter interface and the package-level StatsFor lookup.
endpoints.go gains the Set.StatsFor twin. The handler resolves the capability
through that twin rather than by asserting on a provider it fetched itself.

Kick's Stats method is untouched apart from its return type. The rule its comment
carried moves with the type, because every consumer has to hold it: a count of
zero and a failure to ask look identical in a number and mean opposite things to
an operator.

Checked while here and NOT changed: CredentialChecker does have a Set twin, in
credcheck.go rather than endpoints.go. A review suspected that was a gap. It is
not.

go build ./... clean, go test ./internal/oauth ./internal/api ok.
endpoints.go:153 says "Every capability the package grows needs its twin here"
and nothing checked it. The cost of forgetting is specific: a caller holding a
stubbed Set falls back to the package-level lookup, which reads the PRODUCTION
providers -- so one capability quietly talks to the real internet while every
other call in the same test is correctly aimed at the stub, and the test still
passes.

Asserts three things. The twin resolves for a platform that implements the
capability. It answers false, rather than a nil interface, for one that does not
-- internal/api branches on that bool to answer supported:false, and nil-with-
true would crash the handler instead. And nothing escaped to a real host, reusing
the hostGuard the file already has.

Mutation-tested: deleting Set.StatsFor fails the build at the test, which is the
strongest form of catching it.
…2026-08-16 (Phase 0)

The plan's own gate: no provider code until every "believed" row resolves
against live documentation. This is that check, and it struck features.

Two absences are now established by enumeration rather than by failing to
find a page, which is the standard capabilities.go asks for before any cell
may read "Not possible":

  Twitch has no lifecycle API. All 149 endpoints in the Helix reference were
  enumerated (1,407,883 bytes, byte-identical across two reads) and swept for
  start/stop/begin/end/live/broadcast/transition. The complete Streams
  resource is five read endpoints plus Create Stream Marker. PATCH
  /helix/channels updates metadata and does not go live. Liveness on Twitch
  can only be OBSERVED, never commanded.

  Kick has no lifecycle API, no clips, no markers, no ads. Every embedded
  OpenAPI block on all 25 documented pages was parsed: 27 operations, 11
  scopes, zero matches for "go live", "start stream", "clip", "marker",
  "advertis". channel:write is metadata only. The docs are five days old, so
  this is current rather than stale.

TWO FETCHERS WOULD HAVE CONCLUDED "ABSENT" FROM A PAGE NEVER SERVED.
docs.kick.com answers HTTP 200 with a GitBook "Page Not Found" body;
developers.facebook.com answers HTTP 200 with a ~138 KB "Page Not Found"
body. A 200 is not evidence that a page exists, and an absence read off one
is not evidence of anything. Both traps are recorded in the file.

What survives: YouTube's full lifecycle is documented and every call needed
through Phase 4 -- including thumbnails.set -- is covered by the `youtube`
scope already granted, so the plan's expected YouTube reconnect does not
happen. Transitions are a server-enforced state machine with named refusals
(errorStreamInactive, invalidTransition, redundantTransition) and the
concurrency ceiling refuses at TRANSITION, not at create.

Numeric limits the platforms decline to publish -- YouTube's concurrent
broadcast cap, Twitch's commercial cooldown, Kick's request budget -- stay
unstated in code. Guessing one would be a claim no source supports.

Facebook's per-endpoint reference 404s in four URL forms while Meta still
links to it, so six Facebook questions are listed under UNRESOLVED with what
would settle each, rather than answered from inference.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… offline

Phase 0 read Kick's livestreams documentation and found the sentence this code
had never accounted for, verbatim: "Viewer count will be 0 if the streamer has
opted not to share their viewer count."

Two things followed from reading 0 as an audience of none.

viewers() returned l.ViewerCount unconditionally and Stats gated liveness on
`stats.Live && stats.ViewerCount > 0`, so a live channel with a withheld count
fell through to a fallback that could not fix it and was reported as offline.
The operator most likely to hide the number -- someone who does not want their
audience size public -- is exactly the operator polyemesis told the wrong thing.

LiveStats.ViewerCount is now *int, because no int can say "not told". All three
platforms have a way of declining to answer: YouTube omits concurrentViewers
entirely under three separate conditions (no viewers, count hidden, broadcast
ended), Kick sends the 0 above, and Twitch returns an empty array for a channel
that is not live. omitempty drops the key, so the wire says what the platform
said: nothing. This lands now rather than after the UI renders the field, and
the plan's own UI rule needs it -- a false zero on a live stream is worse than
a blank, and a component cannot render "not reported" from a type without it.

THE FALLBACK ENDPOINT COULD NEVER HAVE WORKED, AND ITS TESTS HID THAT.
GET /public/v1/livestreams/stats returns one platform-wide `total_count` --
every livestream on Kick -- and nothing per channel. This code decoded it as a
livestream list and read viewer_count off it. In production that silently
contributed nothing; had the body ever matched the struct it would have
reported the number of concurrent broadcasts on Kick to one operator as their
own audience. The tests passed because the fixture was shaped like the struct
instead of like the endpoint, which proves the decoder agrees with itself.

Replaced with GET /public/v1/channels: stream.is_live is an authoritative
liveness boolean rather than an inference from a count, stream.viewer_count is
genuinely per-channel, channel:read is already in Scopes() so no token is
reissued, and k.channel already exists for Account and Ingest. It may promote
liveness the livestream list missed but never demote it -- the reads are
seconds apart and a just-started stream appears in the list first.

Mutation-checked: restoring the old viewers() semantics fails
"a streamer who hides the count is live with no number, not offline with zero"
on the exact assertion, and nothing else.

Sources read 2026-08-16, recorded in
docs/evidence/platform-lifecycle-apis-2026-08-16.md.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…onger lie about who does

Phase 1 of the platform capability expansion. Get Streams needs no scope of its
own -- verbatim, "Requires an app access token or user access token" -- so
ScopeVersion does not move and no operator reconnects for this.

Stats reads liveness, viewer count, title, category, language and start time.
Two round trips, because Get Streams is keyed by broadcaster and a bearer token
does not name one; Account resolves the id from /users exactly as Ingest does.

type=live IS SENT EXPLICITLY, AND THE DEFAULT IS THE BUG IT AVOIDS. Verbatim
from the query-parameter table: "Possible values are: all, live. The default is
all." Liveness is read off PRESENCE in the response -- not off the type field,
which Twitch documents as "set to an empty string" when an error occurs -- so
leaving the default would have let any non-live entry report the channel live
with that entry's viewer count.

A count of zero is passed through here and discarded on Kick, which looks
inconsistent until you read both references: Twitch describes viewer_count as
"The number of users watching the stream" and documents no opt-out, while Kick
documents 0 AS the opt-out. Same number, opposite meanings, and the pointer is
what lets each platform say what it actually said.

StartedAt is now *time.Time, because omitempty does nothing to a time.Time.
encoding/json honours it for empty scalars, maps and slices; a struct is never
empty to it, so every offline channel shipped "startedAt":"0001-01-01T00:00:00Z"
under a tag that read as though it were handling the case. A consumer correctly
branching on the absence of viewerCount got a confidently wrong start time in
the same payload. TestAWithheldNumberIsAbsentFromTheJSONRatherThanZero now
asserts on the bytes rather than the struct, which is where that gap lived --
every existing test checked the Go value, where nil is obvious.

THE MATRIX AND THE CODE COULD DISAGREE FOREVER AND NOTHING WOULD HAVE SAID SO.
Four surfaces describe a capability and every drift test compares a document to
another document. None compares any of them to the provider. Adding Stats left
all four saying Twitch could not report viewers, all four internally consistent,
every drift test green, while the endpoint answered with a real count -- because
internal/api resolves the capability by type assertion through StatsFor, not by
reading the matrix. TestTheViewerStatsCellAgreesWithWhichProvidersActually-
ImplementStats is the join nobody wrote, and it is a biconditional: a cell
claiming "Works" over a provider with no Stats method is the worse direction,
promising an operator a number the API will refuse.

Mutation-checked: reverting the cell to SupportUnknown fails that test on
twitch alone; restoring the old viewers() semantics fails Kick's hidden-count
case; both restored from backup with git diff clean.

Field names, the type default, the empty-string-on-error behaviour and the
RFC3339 format were read off the Get Streams Response Body table at
dev.twitch.tv/docs/api/reference on 2026-08-16 rather than inferred -- the
adversarial review flagged all four as unsourced, and checking them is what
turned three plausible comments into quoted ones.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ady holds

Phase 1 completes. Three of four integrated platforms now report viewers; only
Facebook does not, and Phase 0 lists six unresolved questions that must be
answered before it can.

THE MISSING LINK WAS AN ENDPOINT PHASE 0 NEVER CHECKED. The first pass verified
the viewer-count READ -- videos?part=liveStreamingDetails&id=<videoId> -- and
never asked where the id comes from. polyemesis stores no video id and no
broadcast id, so the verified endpoint had nothing to point at. A second
research-and-refute pass settled it, and the whole method rests on one sentence
from Google's Live Streaming API overview, read 2026-08-16: "In fact, the
liveBroadcast resource and the video resource share the same ID." The
liveBroadcasts resource page does NOT state that identity -- its own gloss is
weaker -- so the citation matters as much as the fact. That refuted claim, and
five others, are recorded in the evidence file rather than deleted.

broadcastType=all is sent deliberately: the default is "event", which returns
only scheduled event broadcasts and would have reported a persistent live
channel as dark. broadcastStatus and mine are mutually exclusive -- "specify
exactly one of the following parameters" -- so ownership cannot be requested in
the same breath as liveness, and "owned by the authenticated user" appears
exactly once on that page, in the mine row. Whether broadcastStatus=active can
return somebody else's broadcast is therefore UNVERIFIED, and the channelId
comparison is the defence. It costs one comparison; the alternative is showing
an operator a stranger's audience as their own.

concurrentViewers accepts a quoted string AND a bare number, because the
reference states the logical type ("unsigned long") and never the encoding,
while Google's JSON convention serialises 64-bit values as strings. Betting on
one does not degrade gracefully: a type mismatch fails the entire videos
response, so a wrong guess about the viewer count would take the title, the
start time and the liveness answer down with it.

Absent is not zero, and YouTube omits the key under three conditions that are
indistinguishable from one another -- no current viewers, the owner has hidden
the count, and after the broadcast ends. A failed videos.list costs the number
and not the answer: liveness was already established by the first call, and
erroring would discard a correct read to report the failure of an advisory one.

No quota number appears in the code. "quota" occurs zero times in the
liveBroadcasts.list reference so its cost is undocumented; videos.list documents
1 unit; the project ceiling is 10,000 units per day shared with metadata push
and chat. A caller polling this hard does not just slow the viewer count down,
it takes title push down with it -- so the refusal to handle is quotaExceeded
and the interval is not derived from a guessed cost.

TWO TESTS THAT NAMED A PLATFORM ARE NOW DRIVEN OFF THE MATRIX. The Set-twin
test hardcoded "YouTube has no Stats method" and the API absence test connected
a YouTube account to prove supported:false; both broke on this commit, the
second with a 412 about missing credentials that said nothing about what
changed. An assertion re-aimed every time the thing it guards changes is one
somebody eventually re-aims wrongly, and its negative half dies silently once
every platform implements the capability. Both now ask which platform lacks it,
and both fail loudly rather than passing over an empty set.

Mutation-checked: reporting an absent count as zero fails
"an absent count is not a count of zero"; a filter that never rejects fails
"a broadcast belonging to another channel is ignored" on both Live and Title.
Restored from backup, git diff clean.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… unit test can

Every stats test in internal/oauth answers from an httptest stub whose body
this repository wrote. All of them prove the decoder matches the fixture; none
prove the fixture matches the platform. That gap is not hypothetical -- Kick's
stats fallback shipped against a fixture shaped like the struct instead of like
the endpoint, stayed green for as long as it existed, and could never have
worked against the real response.

This step is the only place the real body is read. It runs from
oauth-live.yml's weekly cron, skips without POLY_OAUTH_* credentials, and is
not on the required matrix -- correct, because it measures somebody else's
server.

LIVENESS IS REPORTED, NOT ASSERTED. Whether the connected account happens to be
streaming at 3am on a Sunday is not this suite's business, and a check that
failed because nobody was live would be switched off within a week. What is
asserted is the shape: the call succeeds, and the viewer count is either a real
number or honestly absent. "not-reported" is emitted as a string rather than a
numeric zero for the same reason the field is a pointer -- a zero on the wire
would make the two indistinguishable in exactly the place built to tell them
apart.

Asserted on the provider the driver built rather than through oauth.StatsFor,
which resolves the package singleton and would ignore it -- the same
distinction endpoints.go draws with the Set twin.

EXPECTED_CHECKS 46 -> 50: one new check per platform, and the skip branch gains
a line too. That floor is fixed rather than a range on purpose, per its own
comment -- every branch has to contribute the same count, or a floor that moved
with which credentials happened to be in the environment would be no floor at
all. Verified with no credentials present: 28 passed, 22 skipped, 50 total, no
floor warning.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…latform can hold

Phase 2a. Pure motion: no features, no capability, no behaviour change, and
above all NO CHANGE TO A SINGLE STORED BYTE. It exists so that the commit
adding YouTube scheduling does not also have to invent a storage shape, and so
that a regression there bisects to a commit that changed only names and places.

The three announcement fields move out of db.FacebookSettings into
db.AnnouncementSet in a new announcement.go, with the methods -- merged,
AnnouncedFor, AnnouncementFor, Announce, Forget, mirror -- moving as methods on
it. Nothing about a per-show marker was ever specific to Facebook.

EMBEDDED ANONYMOUSLY AND LEFT AT ITS DECLARATION POSITION, and both halves are
load-bearing on the wire. Anonymous with no JSON tag is what keeps
`announcements`, `scheduledFor` and `broadcastId` as top-level keys of the
facebook blob; the position is what keeps marshal order identical. So every
stored row decodes the same, re-encodes byte-for-byte, and DOWNGRADES cleanly.

Go's field promotion means the ten announcement call sites in preannounce.go
change zero times, along with engine/status.go, db/destinations.go and the two
handlers. The plan estimated ~40 call sites; the real count is 10 and none of
them needed touching. Only four test composite literals had to change, because
promoted fields cannot be set positionally in a literal.

A real column was considered and rejected. Announcements are a variable-length
list with Go-side retention, intent-only eviction, and a compare-and-set inside
UpdateAnnouncement's transaction; a column means a child table and a rewrite of
that CAS. The precedent for promoting a field out of this blob --
MigrateDestinationExpertArgs, for a scalar bool -- needed a backfill, a
dedicated atomicity test, and left the tombstone comment still sitting at the
top of FacebookSettings. The migration risk it would reintroduce is specific:
a backfill copying only $.announcements orphans every pre-Announcements install,
whose rows hold the legacy scheduledFor/broadcastId pair folded lazily by
merged() and never rewritten to disk. Orphaned means AnnouncedFor returns false
and the next sweep creates a SECOND public event page for every scheduled show.
Zero feature gain for that door.

TWO WALKER DEFECTS FOUND BY REVIEW, NEITHER REACHABLE TODAY, BOTH FIXED.
settings_drift_test.go and redact_drift_test.go each walk stored types to
decide which leaves must be classified, and each computed `inlined` BEFORE a
deref loop that strips slices and maps as well as pointers -- so an embedded
named slice of structs would have been walked as inlined, though encoding/json
nests it under the type name. Each also skipped unexported embedded structs,
whose exported fields encoding/json promotes onto the wire. A walker that is
wrong here does not fail; it checks the wrong paths and passes, and in
redact_drift_test.go setLeaf then plants its scrub probe on a leaf that does
not exist while the real one goes unredacted.

Both now have a self-test asserting the walk against encoding/json ITSELF
rather than a hand-written path list -- a list would encode the same
understanding that produced the defect. The first version of that test passed
against the bug it was written for, because its fixture embedded a []string:
the walker only consults `inlined` once the derefed type is a struct, so a
slice of scalars never reaches the branch. Fixed to embed a named slice OF
STRUCTS, which is the reachable case. All four mutations now fail on the right
assertion; both files restored by md5.

The skip added in the Phase 1 commit is removed rather than ratcheted. The
census in internal/testenv states the rule: "If it fires because the thing
under test CHANGED, it is not a skip at all -- it is a failure." A test that
can no longer find a platform without viewer stats has lost its subject, and
that is a human decision, not an automatic ok.

Reviewed by four independent passes -- fable design, opus implementation, codex
on correctness, agy on operator impact, and an adversarial opus skeptic that
proved the migration empirically against five hand-built legacy shapes. No two
reviewers contradicted each other on a code fact. One review finding was
rejected: the ScheduledFor paragraph flagged as diff narration is durable
semantics -- "zero means live now" is what every caller depends on.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…g tree

The first pass recorded six unresolved Facebook questions and a reproduced 404
on the LiveVideo node reference. Most are now answered, and the reason matters
more than the answers: THERE ARE TWO FACEBOOK DOCUMENTATION TREES AND THE PASS
ONLY KNOCKED ON ONE.

  /docs/graph-api/reference/live-video   -- genuinely gone. A real 404 on
                                            re-check, not the soft variety.
                                            Meta still links to it.
  /documentation/live-video-api/...      -- a separate, maintained guide tree
                                            updated 2026-07-02 that answers
                                            most of what the node reference
                                            would have. Never requested.

The lesson generalises past Facebook, and this file's own enumeration rule now
has to name the tree it enumerated: an absence established against one URL
prefix is an absence in that prefix, not in the platform.

RESOLVED FROM UNRESOLVED:

  end a broadcast   POST /<LIVE_VIDEO_ID>?end_live_video=true -> status VOD.
                    The plan recorded "Facebook creates live_videos but has no
                    end call"; it has one.
  stream health     ingest_streams -> stream_health, with bitrates and frame
                    rates. Facebook publishes encoder health and Twitch does
                    not -- the word "bitrate" appears zero times in 1.4 MB of
                    Helix reference.
  scheduling        the scalar form wins: a literal copy-pasteable request in
                    the guide sends event_params=1541539800.
  polls             POST /LIVE_VIDEO_ID/polls.

STRUCK, in Facebook's own words. The live-video comments edge has a "Creating"
section whose entire content is "You can't perform this operation on this
endpoint." That is a stated refusal rather than a missing page, which is the
strongest negative evidence available. Narrow scope, recorded as such: it
settles the LIVE-VIDEO comments edge, the one a chat pane would use. Whether
the associated post object accepts a comment on its own edge is a different
object and was not checked.

THREE OPERATIONAL FACTS NOTHING IN THE TREE KNOWS, all stated verbatim:

  A stream URL expires unused after 24 HOURS and, once used, accepts data for
  at most 8 HOURS. polyemesis advertises 24/7 playout channels; a Facebook
  destination cannot be one, and the operator gets cut off by the platform
  rather than by us.

  Stream health refreshes every 2 seconds with an instruction not to poll
  faster, and a stream timeout is reported after 4 seconds of no data. Stated
  numbers, so unlike YouTube's concurrency cap these MAY be encoded.

  Going live requires an account at least 60 days old and a Page or
  professional-mode profile with at least 100 followers. Every permission
  granted and a new account still cannot go live, and the refusal arrives as a
  generic API error. That belongs in the setup guide, not a retry loop.

Also: ending has TWO mechanisms and only one is an API call -- "stop streaming
live video data from your encoder to the stream URL OR send a request to..."
So on Facebook an encoder crash already ends the show, and the "leave it live
and let it recover" policy written for YouTube has nothing to preserve here.

Still unresolved: live_views. The node reference carrying the field list is a
real 404 in every form tried across both passes. Do not build Facebook viewer
stats.

Found because the maintainer supplied the URL. Two automated passes had
concluded the documentation was unreadable.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…g users it does not

Seven agents: three researching, three refuting independently, one composing.
The headline overturns a verdict this repository has shipped for months.

X (TWITTER) IS NOT A PASTE-THE-KEY PLATFORM. Every X capability cell reads
SupportNo, under a summary asserting "There is no API to connect: X's developer
platform covers posts, users, media and the post firehose, not live-video
ingest or its viewer numbers." That is false, and the refutation is machine
checkable rather than a matter of reading comprehension.

Independently re-derived by fetching https://api.x.com/2/openapi.json and
counting, rather than by trusting the agent: 856,423 bytes, "X API v2" version
2.167, 149 paths, 178 operations, 21 tags, tag counts Broadcasts=13 and
Chat=16, scopes broadcast.read and broadcast.write. Every figure the agent
reported matched exactly. The nine broadcast paths:

  GET            /2/broadcasts
  GET,POST       /2/broadcasts/scheduled
  DELETE,GET,PUT /2/broadcasts/scheduled/{id}
  POST           /2/broadcasts/scheduled/{id}/live     <- go live
  GET            /2/broadcasts/{id}
  GET,POST       /2/broadcasts/{id}/chat               <- read AND send
  POST           /2/broadcasts/{id}/chat/mutes
  DELETE         /2/broadcasts/{id}/chat/mutes/{user_id}
  DELETE         /2/broadcasts/{id}/chat/{message_id}

So X documents scheduling, going live, chat read, chat send and three
moderation actions -- a surface comparable to Kick's, on a platform the matrix
describes as having none of it.

WHAT THE SWARM DECLINED TO CLAIM IS THE BETTER SIGNAL. total_watching and
total_watched exist on the Broadcast object, and viewer stats was still held at
SupportUnknown rather than flipped. Verified why: EVERY field in X's Broadcast
schema is undescribed -- all 26 of them, including those two, are bare strings
with no description key at all. The fields are readable; their semantics are
not documented, and a viewer number shown to an operator asserts a meaning. A
sweep of all 178 operations also confirms no end-broadcast command exists.

Facebook chat send is STRUCK on stronger evidence than the first pass had: not
a missing page but a stated refusal, "You can't perform this operation on this
endpoint.", found under Creating on the live-video comments edge and confirmed
by sweeping all five readable LiveVideo edge references. The refute pass killed
the earlier framing that the docs contradict each other -- per-endpoint
references govern, and the generic /{object-id}/comments page only IMPLIES a
live-video path it never actually writes.

Rumble is settled and it is mostly negative, established by enumerating a
158-article knowledge base: no OAuth (one API article, stating "Authentication
is not required for this version of the API"), no chat send, no moderation API
(its moderation article contains the word API zero times), metadata by UI
template only. watching_now IS in the same get-data snapshot the chat poller
already fetches, so viewer stats is documented -- and stays Unknown until
something reads it, per the biconditional the stats drift test enforces.

Five fetch traps are catalogued in the preamble, three of them new: Meta's
~138 KB soft-404, a Meta 200 that renders to 438 characters of nav chrome,
rumble.support's KB shell, rumble.com's inverse channel-slug trap, and
docs.x.com's 4-byte `null` 404s.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…X can do most of it

Eleven cells across three platforms, all four mirrors, driven by
docs/evidence/facebook-chat-rumble-x-2026-08-16.md.

X: SIX CELLS WERE WRONG, NOT STALE. The row read SupportNo across the board
under a summary asserting "There is no API to connect: X's developer platform
covers posts, users, media and the post firehose, not live-video ingest", plus
a code comment claiming everything "hangs off a live broadcast object that the
X API does not expose to third parties in the first place". GET
/2/broadcasts/{id} is that object.

Re-derived by counting rather than reading, so the correction does not rest on
anyone's comprehension: api.x.com/2/openapi.json is 856,423 bytes, "X API v2"
2.167, 149 paths, 178 operations, Broadcasts=13, Chat=16, scopes
broadcast.read and broadcast.write. Nine broadcast paths exist including POST
/2/broadcasts/scheduled/{id}/live and GET+POST /2/broadcasts/{id}/chat with
mute, unmute and delete beside them. sso, metadata, chatRead, chatSend and
moderation all become SupportYes.

Viewer stats stays SupportUnknown, and that restraint is the load-bearing part.
total_watching and total_watched are on the broadcast object -- and all 26
fields in that schema are undescribed strings. The numbers are readable; their
unit, freshness, and whether either counts unique people are unstated. A viewer
count shown to an operator asserts a meaning, so a SupportYes here would be the
same overclaim in the opposite direction from the one being fixed.

The stream key stays SupportManual on a narrower reason than before: X CONSUMES
a key (source_id is required at create) and echoes it back on every broadcast
object, but publishes nothing that mints or enumerates one. polyemesis can now
verify a binding rather than trust its stored copy; it still cannot obtain one.

Facebook chatSend: SupportUnknown -> SupportNo. Refused in Facebook's own
words rather than merely missing -- the live-video comments edge has a
"Creating" section whose entire content is "You can't perform this operation on
this endpoint." All five readable LiveVideo edge references were swept. The
generic /{object-id}/comments page lists Live Video among its nodes but never
writes that path, and a page-level implication does not outrank a per-endpoint
refusal.

Rumble: sso, chatSend and moderation SupportUnknown -> SupportNo, metadata ->
SupportManual. The previous Unknown was RIGHT and its argument is preserved in
the diff: "'I looked and did not find it' on an API this thinly documented is
not the same as reading a published spec and finding the thing absent." That
bar is now met by affirmative evidence rather than more looking -- a complete
158-article knowledge base, one API article stating "Authentication is not
required for this version of the API", an honest 404 at
rumble.com/oauth/authorize, and a moderation article that describes clicking
three dots and never mentions an API. The published API is a single read-only
request, so the surface is small and fully read rather than large and sampled.

Rumble viewer stats stays Unknown deliberately: watching_now is in the same
get-data response the chat poller already fetches, so it is a field read away
-- but the stats drift test is a biconditional and a SupportYes with no Stats
method would fail it, correctly.

Four pinned assertions in capabilities_test.go are rewritten to carry the
history rather than just the new value, because three of them were previously
correct and a future reader deserves to know what changed the answer.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Both were mine, both in the commit that added the Facebook re-check, and both
would have mis-prioritised the work.

"Nothing in the tree currently knows this" is false. internal/db/platforms.go
line 540 has carried "Eight hours maximum" in Facebook's video guidance note
since 2026-08-06, sourced to facebook.com/business/help/162540111070395. What
is actually new is the 24-hour expiry on an UNUSED stream URL, and that the
eight hours runs from first use of the URL rather than from the broadcast --
which is the detail that matters for a channel that creates a URL early.

"polyemesis advertises 24/7 playout channels" is also false. That pitch lives
in docs/internal/features-page-gaps.md as a PROPOSED /features section and in
the keyword research; web/src contains no such claim. So there is no live
broken promise here, and the priority drops accordingly: this is a constraint
on copy that has not shipped, not a bug in shipped behaviour.

What survives is narrower and still worth acting on. The guidance note is prose
attached to a preset, not a check, so an operator building a continuous channel
with a Facebook leg gets no warning -- and a 24/7 section listing Facebook
without carving out the cap would be false the day it ships.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ility gate

Since 2024-06-10 Meta requires the Facebook account to be at least 60 days
old and the Page or professional-mode profile to have at least 100
followers. Neither is a permission or a scope, so an operator holding every
scope, a valid token and a correct stream key can still be refused -- and
Graph names neither in the error it sends back. That is the debugging
session where everything is correct and nothing works.

fbCreateAdvice appends the pair to a refused create. It APPENDS rather than
diagnoses: no code, subcode or message marker identifies this cause, so
asserting it would be wrong exactly as often as it would be useful, and an
operator sent to count followers over a crossposting typo loses more than
the sentence saved. The note says out loud that the refusal above may have
nothing to do with either.

It is withheld unless Facebook itself refused -- not on a transport failure
(nothing reached Graph), not on a 5xx (Meta failing, not Meta refusing),
not on a body that is not Meta's envelope (a proxy knows nothing about
follower counts), and not on code 190, which fbAdvice already diagnoses
exactly and whose advice mentions the OTHER sixty days in this file.

Only the CREATE gets it. A broadcast that already exists is proof the
account was eligible when it was made, so editing, listing and rescheduling
are untouched.

No retry: neither requirement is transient, and waiting does not fix a
40-day-old account. No threshold is checked anywhere -- Graph does not
report either count on the surfaces we call, so the numbers are quoted, not
compared against.

Mutations, all red: dropping the 5xx half of the guard; routing
RescheduleBroadcast through fbCreateAdvice; removing the 190 exclusion;
dropping the note entirely.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…st it

capabilities.go says X's platform covers "posts, users, media and the post
firehose, not live-video ingest", and db/platforms.go's "x" preset says "none
is planned". Both were read off X's navigation index, which has no Broadcasts
section. The served spec does: GET https://api.x.com/2/openapi.json (read
2026-08-16, "X API v2" 2.167) publishes 149 paths, nine of them broadcast
paths carrying thirteen operations tagged Broadcasts, and two scopes nothing
else in the spec uses -- broadcast.read and broadcast.write.

FOUNDATION ONLY: the Provider interface plus the two broadcast reads. Chat
send, chat read, moderation and go-live are documented and are follow-on
commits.

Four decisions worth the reviewer's attention, each argued in place:

  * Ingest REFUSES with ErrNoStreamKeyAPI and implements ManualKey. X requires
    source_id at create and echoes it back on every broadcast, describing it as
    "same as sources rtmp_stream_key" -- but there is no sources collection
    among the 149 paths and no ingest host anywhere in the spec. A key readable
    off a broadcast that already exists is not a key polyemesis can obtain.

  * PKCE is OFF, as on Twitch. The spec's authorizationCode flow declares no
    RFC 7636 parameter of any kind; sending one on a hunch can be refused
    outright at the consent screen. The counter-argument -- that X may REQUIRE
    code_challenge, which breaks sign-in the other way -- is recorded with the
    single live request that decides it.

  * Nothing decodes total_watching or total_watched. All 26 Broadcast
    properties are undescribed and "viewer" appears zero times in the spec, so
    concurrent-versus-cumulative is a reading of two names. A test enforces the
    absence; ms-suffixed timestamps stay strings for the same reason.

  * Every Broadcasts operation declares only success and a generic default,
    "The request has failed." So failures carry X's own words and add no cause
    -- plus the note that no X pricing or tier page names this family, so a
    refusal may be about the app's access rather than the request.

NOT REGISTERED in ProvidersWith, deliberately: that needs a db.Platform
constant, a capability row keyed to it, a setup guide, a credential-check
verdict and a regenerated provider-scopes.json, several of which live in files
this commit may not touch. The compile-time Provider/ManualKey assertions make
registration the one-liner it looks like once those land.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
watching_now is one of the 15 fields Rumble publishes, and it rides on the
same get-data response internal/chat/rumble.go has always polled for chat. So
this is a field read rather than an integration: no second request against an
endpoint whose rate limit Rumble declines to state, and no second credential.

WHERE IT LIVES, which was the real decision. Not behind oauth.LiveStatter:
that interface embeds Provider and takes (clientID, accessToken) because it
answers for a connected account, and Rumble has no account to connect. Its
API has no sign-in at all, its whole credential is RUMBLE_CHAT_API_KEY, and
no provider is registered for db.PlatformRumble to hang a Stats method off.
The count arrives through the chat poller, so it is reported through the chat
adapter's Health, next to Quota, which is the same shape for the same reason:
an optional per-platform extra the Hub folds into Status.

ABSENT IS NOT ZERO, and Rumble is where that stops being pedantry. Its
article is explicit that everything under livestreams is "only populated
during a live stream", so a plain int would decode every offline, ended and
not-yet-started broadcast into an audience of exactly none -- a figure the UI
would render as fact and nobody sent. WatchingNow and Health.Viewers are
*int, matching internal/oauth/stats.go's LiveStats.ViewerCount, so an absent
key stays nil and a genuine 0 from a live stream stays 0. setLive is a
separate setter from setHealth so that every other state drops the count with
no code at those call sites and cannot be made to keep a stale one.

The stream key is still never decoded, and the new test asserts that at the
STRUCT level rather than the message level. The existing message-level test
turns out to pass with `StreamKey string` added to rumbleLivestream and
nothing else changed -- measured -- because it only checks what the adapter
emits. Reading one documented field out of this payload buys no licence to
unmarshal the secret beside it. See #310.

Evidence: docs/evidence/facebook-chat-rumble-x-2026-08-16.md, Rumble section.
Capability cell unchanged; see the report for why SupportYes would fail the
stats biconditional.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…hy the scalar start time is the one sent

Meta's guide tree at /documentation/live-video-api/ answers what the 404'd
LiveVideo node reference could not (evidence: docs/evidence/platform-lifecycle-apis-2026-08-16.md,
ADDENDUM 2, read 2026-08-16). Three things follow from it.

EndBroadcast POSTs end_live_video=true to the live video node and confirms the
end by reading the status back, reporting Ended only on VOD. The END policy
YouTube needs is deliberately NOT imported: Facebook documents two ways to end
a broadcast and one of them is the absence of bytes, so an encoder crash has
already ended the show and there is nothing here for "leave it live so it can
recover" to preserve. A refused end is an error rather than a warning, unlike
the privacy push beside it, because a failed end leaves a broadcast on air that
the operator believes is over.

StreamHealth reads ingest_streams. The measurements keep Facebook's own field
names in a map instead of named Go fields: the evidence establishes that
stream_health carries bitrates and frame rates and does not name the keys, and a
misspelt field would read back as zero on a healthy stream. An absent
measurement is an absent key, never a zero. Facebook's stated pacing is encoded
as what it is -- a documented floor, quoted beside the constant, not enforced
inside a stateless provider.

Scheduling was already here (IngestOptions.ScheduledFor and
RescheduleBroadcast); what was missing was the record of WHY event_params goes
out as a bare unix scalar when Meta documents it two ways, and a test pinning
that the create and the move agree on it.

No capability cell moved: capabilities.go has no column for ending, health or
scheduling, and adding one is the orchestrator's to do.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…Meta errors

Found by an adversarial reviewer while checking unrelated work, and it is older
and wider than the change it was reviewing.

statusError.Body is set from snippet(), which cuts at 300 characters so a
platform answering with an HTML error page cannot dump the page into a log
line. fbAdvice then parsed the Graph error code OUT OF THAT SAME FIELD.

Meta's refusals do not fit in 300 characters. Measured with a realistic body --
one sentence of message plus the documentation URL Meta appends, plus type,
code, error_subcode and fbtrace_id -- the real thing is 363 bytes and arrives
as 303 characters of invalid JSON. decodeGraphError returns false, fbAdvice
returns the error untouched, and EVERY code-specific branch is skipped: the App
Review advice, the expired-token advice, all of it. The operator gets raw
truncated JSON and no instruction, which is precisely the case the advice was
written for.

It was invisible because every fixture in the suite was hand-written short
enough to survive the cut. The tests proved the parser works on bodies Meta
does not send -- the same failure as the Kick stats fallback whose fixture was
shaped like the struct instead of like the endpoint.

Truncation is a property of the PRESENTATION, not of the error. Body keeps
truncating for display and Error() still renders it; payload() returns what was
actually received, and parsing uses that. A statusError built without the new
field falls back to Body, because every fixture in this package constructs one
by hand.

Mutation-checked, and the first attempt was itself wrong in an instructive way:
a test calling decodeGraphError directly PASSED with the call site reverted,
because it proved the parser works on a string -- which was never in doubt. The
test now drives fbAdvice itself, and reverting to the truncated field fails
both new tests on the assertion that the advice never reached the operator.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… the merge

Three of the four branches in this round forked from a base that predates the
snippet()/payload() split, so fbCreateAdvice arrived parsing the truncated
display body -- reintroducing on a second call site the exact defect just fixed
on the first.

The consequence is sharper here than in fbAdvice, because this function's
guard is `if !ok || ge.Code == 190 { return advised }`: a decode that FAILS
takes the same branch as a deliberately-excluded error code, so the note is
withheld silently. An operator with a 40-day-old account -- the person the note
exists for -- gets raw truncated JSON and no hint that account age is why.

The feature shipped with a 74-byte fixture describing itself as "the unhelpful
(#100) an ineligible account actually gets". It is not what an account gets; it
is a version short enough to survive the cut that was the whole problem. The
new test uses a 363-byte body of realistic shape and asserts on the eligibility
note reaching the operator, and it fails when the call site is reverted.

Recorded because it will happen again: a worktree that forks from an older base
does not merely miss a fix, it can re-add the bug on new code and merge cleanly
while doing it. No conflict marker appears, because the two call sites are in
different functions.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Caught by asking what UI the new backend work needs. The answer for X was
"none, because nothing can reach it" -- and the matrix said otherwise.

Two hours ago X's row was seven SupportNo cells on the false belief that the
platform had no live-video API. Correcting that was right. Setting five cells
to SupportYes was the opposite error, and a worse one: X's provider exists in
internal/oauth/x.go but is NOT registered, capabilities.ts has no connect key
for it, and the summary I wrote said "Sign in and polyemesis can schedule the
broadcast". There is no sign-in to offer. An operator reading Works would go
looking for a button that is not there.

THE RULE WAS ALREADY WRITTEN DOWN AND I BROKE IT IN THE COMMIT THAT APPLIED IT.
Rumble's viewer-stats cell says it in as many words -- a capability nothing
implements is not a capability -- and stayed Unknown for exactly this reason,
because watching_now is documented but unread. X's sso got SupportYes on the
same evidentiary footing. The difference was that I wanted the X reversal to be
true more than I checked whether it was reachable.

Set to SupportUnknown, which is the least wrong of four values that cannot say
what is actually the case. "Documented at the platform, unbuilt here" has no
representation in the Support enum -- the same expressive gap that got Twitch
predictions cut from this plan, arriving from the other direction. Unknown
renders as Unverified, which is fail-open: it invites the operator to try
rather than refusing them or lying to them.

The summary now says what is true: X publishes a live-video API, polyemesis has
not wired it up, and this is a paste-the-key destination that streams exactly
as well as any other.

These cells become SupportYes the day the provider is registered and a connect
affordance exists, and not before.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… as an audience of nobody

Three platforms implement GET /platforms/accounts/{id}/stats, the route
answered, and nothing in ui/ called it -- so the capability matrix said
"Viewers: Works" while no operator could see a number anywhere.

The distinction the round is about, and the only one that matters:
viewerCount is ABSENT rather than zero when the platform declined to say.
internal/oauth/stats.go makes it a *int with omitempty for exactly that,
and names the three ways YouTube produces an absent key -- nobody
watching, the OWNER HAS HIDDEN THE COUNT, the broadcast ended. The second
is what turns a false zero from pedantry into a bug report: a streamer
with an audience, told nobody is there.

So the type is `number | undefined` (a `number` would let any call site
write `?? 0` and compile), the branch is taken ONCE in lib/viewerCount.ts
as data rather than per component, and both halves are asserted:

  absent + live -> "Viewer count not reported", and no digit and no dash
                   appears anywhere in the rendered text
  0 + live      -> renders 0, because on a live stream that is a fact
  supported:false -> the server's own sentence, naming the platform
  live:false    -> offline, which is an answer and not an error

Both directions were verified by mutation. `?? 0` renders "Live 0
watching" and fails three tests; an em dash renders "Live —" and fails
the one that matters. A fifteen-locale check asserts no translation of
"not reported" contains a digit or a dash, because that is where a false
zero could re-enter past correct TypeScript.

Conventions this follows, each read out of the tree rather than assumed:

  types.ts, not the page -- a result names these fields so the shape is
  an API contract, the arrangement MetaField already uses (types.ts:409).

  A POLL, not a new event type -- PlayoutPage.tsx:101, MonitoringPage
  .tsx:434. 60 seconds, and the number is a quota decision: one YouTube
  stats read is three requests against a PROJECT-WIDE 10,000/day shared
  with metadata push, compliance and chat, so polling harder takes title
  push down with it rather than merely slowing this panel. Kick publishes
  no rate limit at all, which is a reason to be conservative and not a
  licence. Polls only while the tab is visible, and stops for good once a
  platform has said it cannot answer.

  NOTHING ANIMATES ITS ARRIVAL -- DESIGN-SYSTEM.md:104 names the viewer
  count as its example. No count-up, no fade, and the pending state is a
  word rather than a skeleton that never resolves. Asserted.

  Colour through toneForState/toneBadge, never a hand-written class
  (DestinationCard.tsx:302). "Cannot ask this platform" and "the poll
  failed" stay NEUTRAL: they are properties of the read, not of a
  destination, and the five saturated tokens already mean destination
  state -- Experimental.tsx's reasoning. Asserted.

  LABEL, NEVER GATE. Nothing is hidden and nothing is disabled; a
  platform that cannot answer says so in the server's words.

  Fifteen locales, all of them, per internal/web/i18n_drift_test.go.
  The one long explanatory sentence is the server's and stays English.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…d Rumble uses another

The biconditional added this morning asserts that a viewerStats cell reading
SupportYes and a Provider implementing LiveStatter are two spellings of one
fact. That was true when it was written and stopped being true within hours.

Rumble's viewer count arrives on the CHAT POLLER -- watching_now on the get-data
snapshot, internal/chat/rumble.go -- because Rumble has no OAuth provider at all
to hang a Stats method on. So Rumble can genuinely show an operator a live
viewer count while StatsFor(rumble) stays false forever.

The failure this creates is a trap rather than a bug. Nothing is wrong today:
Rumble's cell is Unknown and the test is quiet. But the first person to set that
cell to Works gets told "its provider has no Stats method satisfying
LiveStatter", which is TRUE and MISLEADING -- it points at implementing an
interface Rumble cannot implement, when the real question is which surface the
matrix is describing. The /stats route does answer supported:false for Rumble,
so a Yes would be true of the chat pane and false of the API in the same breath.

The message now says which mechanism it knows about, names Rumble as the
counter-example, and tells the reader to widen the test rather than route around
it. The conservative cell stays conservative, and the reason it is conservative
is written down instead of being re-derived.

No behaviour change; this is a comment and an error string.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
internal/oauth/facebook.go grew EndBroadcast and StreamHealth and nothing
could call either. This is the destination-side half.

NO HTTP ROUTE EXISTS FOR EITHER, AND NONE WAS ADDED HERE. internal/api
mentions neither EndBroadcast, StreamHealth nor end_live_video anywhere.
Adding a handler that reaches a live platform is not a side effect of
building a card, so the two routes the UI needs are written down as the
contract request in ui/src/lib/api.ts, with their shapes, next to the
functions that call them. Until they land the health pane treats 404 and
501 as "this server does not expose it" -- stated in muted text, not a
fault -- and stops polling rather than knocking every two seconds.

ENDING DOES NOT SET requireTyping, DELIBERATELY. It would have been the
first non-deletion in the tree to do so, and the argument both ways is
written at the call site in DestinationCard.tsx. Short version: on
Facebook the end is a transition, not a deletion -- "This ends your
broadcast and saves it as a video on demand (VOD)" -- so the artefact and
the link on the card survive; and the identical outcome is already one
unconfirmed click away on the same card, because Facebook documents
stopping the encoder as the other way to end a broadcast. A typed
challenge in front of a consequence that a plain button also produces is
not a control, it is the reflex ConfirmDestructive's own note warns about.
The friction is the dialog and its numbers instead, since the real mistake
is ending the WRONG destination's broadcast.

ConfirmDestructive gained consequencesLabel because its panel said "This
also removes", which is right for the five deletions that were its only
callers and false here: ending removes nothing.

STREAM HEALTH IS FACEBOOK-ONLY AND SAYS WHY. Twitch publishes no bitrate
or frame rate anywhere in its reference, so the pane carries one literal
English sentence stating that the blank on the other cards is the
platform and not the destination -- otherwise a dashboard with numbers on
one card and nothing on two reads as two broken platforms. Gated on
platform per the "Refresh stream key" precedent one item up the same
kebab: hidden, never disabled.

Facebook's own field names are rendered verbatim rather than mapped onto
labels of ours, for the reason the Go side keeps them in a map: the node
reference that would settle the spellings 404s, and a mapping that misses
drops a real measurement off the screen.

AN ABSENT MEASUREMENT RENDERS AS ABSENT. Never 0, never a dash that reads
as zero, never a skeleton that cannot resolve. lib/stream-health.ts holds
that rule as pure functions so lib/stream-health.test.ts can assert it,
including the half that a defensive filter gets backwards: a zero
Facebook actually sent is a real reading about a stalled ingest and is
kept. The same rule governs the confirmation's consequences panel -- a
row appears only when its number is known, so a stream forty seconds old
shows no "Minutes on air" row rather than a 0.

The poll interval is Facebook's published floor, quoted beside the
constant: "Stream health data refreshes every 2 seconds, so limit queries
to no more than once every 2 seconds. A stream timeout will be detected
and reported after 4 seconds of no data being received." A published
floor may be encoded; the test asserts the number so nobody makes it
snappier.

Polled rather than given an event type, per PlayoutPage.tsx:101 and
MonitoringPage.tsx:434 -- internal/events AllTypes() is AST-guarded and a
missing entry is a Type nobody was forced to classify.

Contract shapes are in lib/types.ts, not in the page. Fifteen labels,
buttons and toasts across all fifteen locales; the long explanatory prose
beside the control is left as literal English per
DestinationDialog.tsx:1861-1863. ui/src/lib/i18n.test.ts caught spliced
Latin in the Japanese and it was fixed.

Also adds the missing "facebook" entry to PLATFORM_LABEL, which had been
rendering a bare lowercase "facebook" beside four capitalised siblings.

This branch was cut from a stale base and was merged with
feat/platform-capability-expansion before any of the above was written.

Verified: npx tsc --noEmit clean; npm test 184 passed (13 files);
npm run build ok; npm run lint clean; go test ./internal/web/ ok.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
The UI for ending a broadcast and reading stream health was built against two
routes nobody had written. Shipping it as-is would have put a menu item reading
"End broadcast" on the destination card that 404s while the stream stays live,
which is worse than no menu item -- so the routes land in the same commit.

  POST /destinations/{id}/facebook/end-broadcast
  GET  /destinations/{id}/facebook/stream-health

SCOPED TO A DESTINATION, NOT AN ACCOUNT. One Facebook account can hold several
broadcasts, so keying these by account would make "end the broadcast" ambiguous
in exactly the situation an operator reaches for it -- several destinations live
at once. The live video acted on is the one recorded against this destination.

The end answers 409, not 404, when no broadcast exists: the route and the
destination are both there and what is missing is something to act on, whereas
404 reads as "polyemesis does not support this". Health answers 200 with
supported:false in the same case, copying handleAccountStats, because there is
nothing wrong with a destination that has not gone live yet.

THE ROUTE LEDGER CAUGHT A REAL BUG, WHICH IS WHAT IT IS FOR. The first version
handed every failure to writeStoreError, so asking a TWITCH destination for
Facebook stream health answered 500 with "destination \"twitch\" is not a
Facebook destination" -- a client mistake stated as a server fault, and one
anything retrying on 5xx would keep retrying. Found because the read-scope sweep
drives every route against its own fixture, where destination 1 is Twitch. Each
failure now carries an honest status.

Classified as denied-to-read-tokens rather than swept, matching the stats route:
the reason is the outbound call, not the bytes. Reaching either spends the
operator's Graph budget on somebody holding a read token. Both documented in
docs/API.md, and counterpartlessExcusedCeiling raised 25 -> 26 by hand, which
the ledger requires as a reviewable act rather than a regeneration.

TWO UI DEFECTS FIXED, BOTH THE EXACT CLASS THIS ROUND EXISTED TO PREVENT.

formatHealthValue printed a real measurement as "0": toFixed(2) turns 0.001 into
"0.00" and the trailing-zero strip turns that into "0". On a stream-health pane
that is the difference between "your bitrate is tiny" and "your bitrate is
zero", and the second sends an operator to restart a working encoder. Now
"< 0.01", sign preserved. Same defect as the viewer count, arriving through
arithmetic instead of a nullish default: absent is not zero, and neither is
small.

The panel had NO render test, so mutating its "not reported" to 0 passed all 184
tests. It now renders through renderToStaticMarkup and asserts on stripped text,
matching AccountLiveStats.test.tsx. Both mutations fail it.

One assertion was dropped rather than weakened. The viewer-count test forbids
"—" outright because its output is three words; this pane's output is a
paragraph containing a legitimate em dash, and two positional regexes both
failed on correct prose. Once markup is flattened there is nothing left to
separate a dash used as a value from a dash in a sentence, so the rule went
rather than being tuned until it passed. The digit assertion is the one with
teeth and it is exact.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… checking nothing

The two strings were the whole remaining i18n gap in the component tree:
ChatUserCard's "Showing the most recent only." and ChatSearchResults' "No
matches in the retained scrollback." Both are load-bearing rather than
decorative -- each is the caveat that stops a moderator misreading a window
onto a purged table as a fact about a person -- so leaving them English-only
meant fourteen locales lost the correction and kept the claim.

Translated into all fifteen, not copied. Coverage was already complete at 1250
keys per locale and genuinely translated (94-99% of strings differ from
English; the rest are loanwords like "Bitrate" and "Chat" that ARE the local
word). The driver.js tour was already fully keyed -- tourSteps.ts carries
titleKey/bodyKey rather than literal text, 23 keys, zero missing anywhere.

`npx tsc --noEmit` IS A NO-OP IN THIS PROJECT AND I HAD BEEN RUNNING IT ALL DAY.

ui/tsconfig.json is a solution file: it lists two project references and
includes no files of its own. `tsc --noEmit` against it therefore checks
nothing and exits 0, which is exactly what it did for a version of this change
where `t` was called in two components that never declared it and imported a
hook they never used. `tsc -p tsconfig.app.json --noEmit` reports both errors
immediately.

What saves it is that `npm run build` is `tsc -b && vite build`, so every
verification that ran the build was real. But the standalone typecheck was
theatre, and it is the cheaper command, so it is the one that gets run when
something looks small -- which is precisely when a mistake gets through.

Recorded here rather than only fixed, because the instruction to run
`npx tsc --noEmit` was also given to every UI agent this session. Their work
was independently covered by `npm run build`; the guidance was still wrong.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…gest stream

YouTube applies two concurrency limits together: one on broadcasts sharing a
stream key, one on broadcasts on a channel, and the per-key one is the smaller.
internal/oauth/youtube.go's Ingest takes no destination identity, so every
YouTube destination in an install has been handed the SAME reusable stream --
which makes them one ingestion source, puts the whole install under the per-key
ceiling, and refuses the next show to start with
sharedIngestionBroadcastsExceedLimit. youtube_lifecycle.go already classifies
that refusal as RefusalSharedIngestionFull precisely because it is ours.

The first destination on an account keeps today's behaviour exactly -- the
channel's existing reusable stream, whose key the operator's Studio-scheduled
events are bound to. Every later one is given a liveStream of its own, named
after the destination so a channel with five of them is still readable in
YouTube Studio.

The decision cannot live in the provider: answering "is somebody else already
using this account's shared stream" means reading the destination table, and a
provider has never heard of a destination. So IngestOptions gains
DedicatedIngest and internal/api sets it, keyed on the LOWEST destination id on
the account -- a row id rather than a count, because a count is decided at
refresh time and two destinations refreshed together would both answer "first".

Two more options carry what the provider cannot see: HeldKey, the key this
destination is already publishing with, and IngestLabel, its name. HeldKey is
matched before DedicatedIngest is consulted, so a destination that already
holds a stream keeps it whatever the caller decides -- a refresh is never a
rotation, and deleting the first destination cannot re-point an established
neighbour onto the shared stream.

No number is added anywhere. Neither YouTube ceiling is published; nothing
counts, caps or pre-flights, and the refusal stays the platform's to give. The
one number in the change -- a 128-character stream title -- is documented in
docs/evidence/platform-lifecycle-apis-2026-08-16.md.

Orphaned liveStreams left by a deleted destination are NOT cleaned up here:
deleting one may unbind a broadcast, which is a separate decision.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… the ceiling is the channel's

Every YouTube destination shared one stream key, and YouTube allows 3 live
streams per key against 10 per channel -- so an install was capped at three
simultaneous YouTube destinations by polyemesis's own doing. Each destination
after the first now gets its own liveStream. The first keeps the channel's
existing reusable stream, because that is the key an operator's
Studio-scheduled events are bound to.

THREE DEFECTS FOUND BY ADVERSARIAL REVIEW, TWO OF THEM FATAL.

The feature was INERT on every install that had the problem. HeldKey was
matched before the dedicated branch, and in production every YouTube
destination already holds the same shared key -- that IS the defect -- so the
match always won and nothing was ever created. Measured, not argued: three
destinations driven through the real refresh handler produced zero new streams.
Hazard 1 ("never move a key") and the feature were the same line of code, and
hazard 1 won.

The two requirements cannot both hold for a destination on the shared key. What
separates them is not a rule, it is WHO ASKED. A five-minute pre-announce sweep
has no right to rotate a key an encoder is publishing with; an operator
pressing Refresh stream key has asked for exactly that. So IngestOptions gains
RotateKey, set on the explicit path and nowhere else.

Being asked turned out to be necessary and not sufficient. Setting it
unconditionally moved a destination that already had its own perfectly good
stream onto a fresh one, leaving an orphan -- caught by the end-to-end refresh
test. It is now set only when the key is genuinely shared with a sibling, which
is the condition that actually distinguishes an upgraded install from a
correctly provisioned one. A store failure reads as "not shared": the cost of
being wrong that way is a ceiling that stays put, and the other way is a live
encoder publishing to a stream nothing watches.

The recovery path RECREATED THE DEFECT. Taking "the first RTMP stream on the
channel" was safe when a channel held one polyemesis stream and is not now that
it holds one per destination. A destination whose stream was deleted in Studio
fell through and adopted a SIBLING'S -- two destinations, one ingestion source,
measured. The shared stream is now only for a destination that has never held a
key.

The stubs could not tell "found the stream I made" from "made another": GET
returned a fixed list and POST appended nothing, so two refreshes produced two
creates and the tests still passed. A stateful stub replaces them, and the
tests that matter are written against it.

Mutation-checked, and the first attempt was itself insufficient: a test where
neither call exercised "holds the shared key while explicitly refreshing"
PASSED against the inert version. The named upgrade-case tests now fail all
three regressions.

docs/PLATFORMS.md rewritten. It no longer prints a single number, because which
limit binds now depends on the install: destinations created before this
version still share one key until Refresh stream key is pressed on them, and
saying "ten" would be a promise those installs do not keep.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
The Facebook row's ReadFirst covered App Review, which is a permissions
problem, and said nothing about the other gate. Since 2024-06-10 Meta also
requires the account to be at least 60 days old and the Page or
professional-mode profile to have at least 100 followers.

Neither is a permission. An operator can hold every scope, a valid token and a
correct stream key, and still be refused -- and the Graph error names neither
condition. That is the most expensive debugging session polyemesis currently
offers, because everything the UI can show them is green.

It belongs on ReadFirst rather than a capability cell: no scope change
satisfies it, so it is not a property of any capability, and the same gate is
what internal/api/preannounce.go already suppresses as log spam when it fires
on the pre-announce path -- the same refusal seen from the other side.

Requested by the agent that built the runtime advice for this refusal, which
appends the same two facts to Facebook's error when a create is refused. The
error tells an operator who has already hit it; this tells one who has not.

No capability cell moves. Facebook's new EndBroadcast and StreamHealth have no
cell to move: the seven capabilities are sso, streamKey, metadata, chatRead,
chatSend, moderation and viewerStats, and lifecycle is none of them. That is
the broadcastLifecycle column's job and it lands next.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…differs per platform

The eighth capability: whether polyemesis can tell the PLATFORM to start and
stop, as opposed to merely sending it video. This is what "platform sign-in"
has always implied and never delivered, and it is the gap
docs/internal's expansion doc opened with.

IT EARNS A COLUMN BECAUSE THE ANSWER GENUINELY DIFFERS, which is this matrix's
own bar for one. Twitch and Kick publish nothing at all -- established by
enumerating all 149 endpoints in the Helix reference and parsing all 27
operations in Kick's published API, not by failing to find a page -- so on those
two the stream IS the trigger and liveness can only be observed, never
commanded. Facebook can be told to end. YouTube documents a full state machine.
X documents one too and has none of it built. An operator choosing where to run
an unattended channel is choosing on exactly this, and until now the matrix had
nowhere to say so.

The values, and the rule that produced them:

  facebook  Works       end_live_video is documented, wired to a route, and
                        reachable from the destination card. The only one an
                        operator can use today.
  youtube   Unverified  TransitionBroadcast exists as a provider method with
                        NO CALLER -- no route, no UI, nothing that can invoke
                        it.
  x         Unverified  documented in X's served spec, provider unregistered.
  twitch    Not possible
  kick      Not possible
  rumble    Not possible
  others    Unverified  nobody has read those APIs for a lifecycle call, and a
                        No must trace to something checked.

YouTube reading Unverified while its code is merged is deliberate and is the
same rule applied three times today: a capability nothing implements is not a
capability. It held Rumble's viewer stats at unverified when watching_now was
documented but unread, and it is why X's five cells were rolled back an hour
after being set to yes. A column whose only Works is Facebook's is an honest
column; one that claimed YouTube would be a promise the UI cannot keep.

Five files, as the plan predicted: capabilities.go, ui/src/lib/capabilities.ts,
docs/PLATFORMS.md, capabilities_test.go, and platforms_doc_drift_test.go's cols
slice -- where the count is load-bearing twice, because len(cells) == len(cols)
is also how the capability table is told apart from every other table on the
page. A column added to the document without being added there does not merely
go unchecked; it can make a different table parse as this one.

Eight of the fourteen rows inherit the cell from manualUnverified(), which is
the reason that helper exists.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… and ends when the operator says so

internal/oauth/youtube_lifecycle.go's TransitionBroadcast had no caller at all.
This is the caller: a coordinator in internal/api that is a THIRD CONSUMER of
the edges internal/engine's observeLoop already derives.

Not in startDest -- YouTube answers errorStreamInactive until data reaches the
bound ingest, and proc.Start() is startDest's last statement, so succeeding
there would need a wait-for-ingest loop on the path between a button press and
a viewer, which multitrackDeadline forbids. Not in teardownDest -- its own
noteReload fires on a COMMAND-LINE CHANGE, so an END there would mean nudging a
bitrate ends the show and mints a new watch URL mid-stream.

THE RULE, which has a test named after it: a transition failure never stops the
stream. YouTube requires an active ingest to transition, so stopping on failure
destroys the only condition under which a retry could succeed. A failure
escalates -- a fault on the row, an alert, a webhook -- and nothing else. Three
structural properties enforce it: the coordinator holds no process handle
(pinned by parsing its own file), its only writer persists one column and
discards Enabled/StreamKey/URL, and the transition to `complete` has one call
site gated on a fresh !Enabled read inside the writing transaction.

reason "stopped" MUST NOT END. A completed broadcast cannot return to live, so
ending on an FFmpeg crash permanently destroys a show the supervisor was about
to reconnect. enableAutoStop is the platform's own answer if it never does.

The edge is a wakeup; durable state lives in the row and a ~15s sweep re-drives
it, so a dropped edge, a partial failure, a token expiry and a daemon restart
are one code path. Idempotency comes from asking: every transition is preceded
by a state read, and refusals are classified rather than lumped -- streamInactive
is expected, redundantTransition is success, and the two ceilings get different
sentences because telling an operator to stop a broadcast when they have hit
polyemesis's own shared-ingest limit sends them to fix what is not their fault.

observeWanted gains a third bool. Without it a default install -- no alert
rules, no webhooks -- builds no snapshot, crosses no edge, and every broadcast
on the box sits in "testing" for ever with no error anywhere.

Storage is a platform-neutral db.BroadcastControl on db.Destination in its own
`lifecycle` column, excluded from destSpec and destArgs and pinned by a drift
test that mutates every field and requires both hashes byte-identical. That
exclusion is why this may write to a LIVE destination when preannounce may not.
Neither CreateDestination nor UpdateDestination mentions the column, so an
operator's edit cannot revert it and no handler guard is needed.

Shutdown gets a whole-drain budget that ends what was already asked for and
never goes live on the way out; unclean shutdown is covered by enableAutoStop
plus the boot sweep the loop runs before its first tick.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…path gives up

Two HIGH findings from the review of the lifecycle coordinator, both in one
switch, neither able to stop a broadcast -- which is why the reviewer whose
only question was "can this kill a stream" did not find them and the
adversarial one did.

A DISABLED ROW WHOSE BROADCAST HAS ALREADY COMPLETED HAD NO TERMINAL CASE. It
fell through to tokenFor plus BroadcastState -- two API calls -- every fifteen
seconds, for the life of the daemon. That is the same arithmetic the
settled-live case spells out in its own comment, over eleven thousand units a
day against YouTube's default ten thousand, except this one never stops and
never had a reason to run: complete and revoked are terminal, and a completed
YouTube broadcast cannot return to live. One dead row exhausts the install's
quota and leaves the coordinator unable to end anything that matters.

It pinned the observe loop on, too. Wanted() is true while anything is tracked,
so a row nobody would ever act on kept the engine building snapshots for a
consumer that had nothing left to do. Untracking releases both.

THE DISABLED PATH HAD NO BOUND AT ALL. Exempting it from the enabled hold is
right -- an operator who gives up on going live and switches the destination
off must still have their broadcast ended -- but "not held" was implemented as
"retried every fifteen seconds for ever". A permanently failing state read, a
revoked token or a broadcast deleted in Studio, then retried until the process
died while noteFailure logged "giving up for now", which was untrue.

The new bound is twice the enabled one, deliberately: a broadcast left live is
worse than one left un-started, so this tries considerably harder before it
stops. endOrphan already worked this way; this is the same rule for the row
that still exists.

Both proven by running rather than by an existing assertion, so both now have
tests. Mutation-checked: removing either case fails its named test.

The invariant that decides whether any of this may ship still holds, re-run
here: a transition failure never ends the broadcast of an enabled destination,
across all seven refusal classes, and a crashed destination is never ended on
the strength of its crash.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ecause it now does

The column shipped this afternoon with YouTube at Unverified, and that was the
correct value at the time: TransitionBroadcast existed as a provider method with
no caller -- no route, no loop, nothing that could invoke it. The house rule
this matrix keeps is that a capability nothing implements is not a capability,
and it has now been applied in both directions in one day.

The caller exists. cmd/polyemesis/main.go wires SetLifecycle and starts
LifecycleLoop; observeWanted carries the third bool so an install with no alert
rules and no webhooks still sweeps; and internal/api/lifecycle.go transitions at
three sites. So the cell moves.

The Reason says what an operator actually gets, including the part that is a
refusal: it goes live when video starts arriving rather than when the button is
pressed, and it ends when they disable or delete the destination -- NEVER when
the encoder merely crashes. That asymmetry is the whole design. A completed
YouTube broadcast cannot return to live, so ending on a fault would turn a
recoverable FFmpeg crash into a permanently destroyed show, while leaving it
live costs nothing but a watch page that keeps waiting.

Four mirrors moved together, as they must.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
An operator with eight destinations was pressing eight buttons. POST
/destinations/start-all and /stop-all act on the whole install, with a
matching pair of buttons beside the destination list on the dashboard.
No id list, no per-card selection: the routes act on everything.

Every row is driven through applyDestinationEnabled, factored out of the
existing setDestinationEnabled, so the bulk control is exactly N presses
of the per-destination button and cannot be more destructive than it.

The answer is a list, never a boolean -- one row per destination naming
which it was, what happened (started/stopped/warned/failed/skipped) and
why when something did not. Eight destinations of which two refuse is
not "failed". Same doctrine the metadata composer states at
Dashboard.tsx:140.

Starts are paced by bulkStartPacing, one destination at a time. That is
a pacing choice about this box -- spreading encoder spawns and reconciles
over time, and not handing a platform a clap of simultaneous connections
-- and encodes no platform's published ceiling. Nothing counts anything
or caps anything. Stops are not paced: tearing down is local.

Ledger: both routes classify denied-by-method / zeroSource guarded,
driven not asserted, matching /destinations/{id}/start. Artifact
regenerated with -update-coverage; the diff is the route list, the
derived totals, and guardedFloor 20 -> 22.

CORRECTION TO A PREMISE IN THE BRIEF, worth a maintainer's eye. The
brief holds that start/stop controls the process while enable/disable is
the persisted intent, and that wiring the bulk control to start/stop
therefore leaves YouTube broadcasts recoverable. In this tree they are
one thing:

  handlers.go setDestinationEnabled -> db.SetDestinationEnabled
  ("flips the run/stop intent", destinations.go:1491)
  -> destinations.enabled

and lifecycle.go:804 planLifecycle(enabled=false, ...) is THE END BRANCH,
sending PhaseComplete to any broadcast in testing/live/testStarting/
liveStarting. So stop-all does end every YouTube broadcast on the
install, permanently -- exactly the outcome the brief intended to avoid.
There is no process-only stop to wire to instead.

The decisions are implemented as taken (start/stop verb, same handler
code, plain confirmation without requireTyping). What changed is that
the comments and the confirmation prose say what the code does rather
than the opposite: destinations_bulk.go's header states the consequence
in full, and the stop-all dialog names it instead of reassuring the
operator that they can simply start again. A comment asserting the
safety property backwards would have been worse than no comment.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…d per row

POST /destinations/start-all and /destinations/stop-all, beside the
per-destination pair and behind the same middleware. Starts are paced two
seconds apart; stops are not, because tearing down is local and a gap there is
pure delay in front of an operator who has decided to come off air.

The pacing encodes no platform ceiling and the comment says so in those terms.
It spreads a local burst -- each start is a row write, a manager reconcile and
an FFmpeg spawn contending with the ingest already running -- and neither
YouTube figure appears anywhere in the change.

Reported per destination, never as one boolean, which is the doctrine the
metadata composer already states. Eight destinations where two refuse must read
as neither "worked" nor "failed".

MY PREMISE FOR THIS FEATURE WAS WRONG AND BOTH REVIEWERS CAUGHT IT. I described
/destinations/{id}/start|stop as process control, distinct from the persisted
enabled bit, and recommended it on that basis. It is not: handleStopDestination
calls setDestinationEnabled, which writes destinations.enabled, and
internal/api/lifecycle.go's END branch keys on exactly that. So stop-all ends
every YouTube broadcast on the install, permanently -- and so does the
per-destination Stop button that has been there all along. There is no
process-only stop to wire to instead. The distinction came from a design
document rather than from the code, which is the failure this repository's
evidence discipline exists to prevent, arriving from the inside.

The behaviour ships as decided, because bulk is exactly N presses of a button
that already does this. What changed is that nothing pretends otherwise: the
file header states the real consequence and the confirmation names it.

THREE REVIEW FINDINGS, ALL ABOUT TESTS THAT DID NOT TEST.

The reporting test asserted only SHAPE -- row count, ids, that the outcome was
one of five known words -- across three identical fixtures that all took one
branch. Two mutations survived: a refusing destination reporting as cleanly
started, and every row reporting failed. Either would have shipped a control
whose per-row reporting, the entire reason it is not one boolean, was
decorative. The classification is now a function tested against constructed
effects, because the interesting cases are the ones a healthy test rig will not
produce on demand. Both mutations now fail.

The pacing test bounded itself by the constant it was measuring, so weakening
the interval a hundredfold -- two seconds to twenty milliseconds -- destroyed
the property and still passed. Literal wall-clock floors now, changed by hand
if the interval is, which is the reviewable act.

And the one string telling an operator the stop is permanent was hardcoded
English while the button above it was translated into fifteen locales. A
Japanese operator got a translated button and an English warning, which is
precisely backwards: the warning is the part that must survive translation.
Now dash.stopAllConsequence, in all fifteen, with {count} preserved.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ions with none

The maintainer read the bulk control as YouTube-scoped, which it never was --
destinations_bulk.go reads whatever ListDestinations returns and filters
nothing; Platform appears there only as a field on the result row.

The impression came from how the change was DESCRIBED, not from the code. The
confirmation copy necessarily says a lot about YouTube, because ending a
YouTube broadcast is permanent in a way that stopping a Twitch push is not, so
the warning leads with the irreversible part. A reader of that copy can
reasonably wonder whether the control is shaped around it.

So the property is pinned rather than asserted. The test drives stop-all and
start-all across YouTube, Twitch, Kick and Facebook destinations plus a plain
RTMP target belonging to no platform at all -- the case a platform-keyed
implementation would silently skip, and the one an operator would notice last,
because their own box is the destination they trust to have stopped.

Mutation-checked with the exact defect in question: filtering rows to
PlatformYouTube fails the test naming every skipped destination, including the
one with no platform.

No behaviour change.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Copilot AI lite review requested due to automatic review settings August 17, 2026 04:00

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 wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

…ntion it

Two of the 2d review follow-ups, and the second turned out to be a real hazard
rather than a comment fix.

endOrphan sends complete to a broadcast whose destination is ABSENT from
ListDestinations. That is an inference about a QUERY, not a fact about a row,
and it is correct only while the listing is unfiltered and whole-table -- which
it is, and which nothing enforced. Scope that query later, to one source or to
enabled rows or to a page, and every live broadcast outside the scope looks
deleted and gets completed. Silent, permanent on YouTube, and arriving as "why
did half my broadcasts end".

So the deletion is now CONFIRMED. lifecycleStore gains GetDestination, and
endOrphan asks directly before acting: only a clean db.ErrNotFound proceeds. A
store error does nothing and the next sweep asks again -- the same asymmetry
every other decision in this file makes, because declining to end a broadcast
that should have ended costs a watch page left open, and ending one that should
not costs the show.

It is one indexed read on a path that already spends two platform calls, and it
turns a whole class of future refactor from dangerous into merely wrong.

Also corrected a comment that claimed end was "the ONLY place in this process
that sends oauth.PhaseComplete". It was false when written -- endOrphan is the
second -- and three separate comments repeated it. What matters is not the count
but the GATE on each, so both are now named: end is compare-and-set inside the
writing transaction; endOrphan is confirmed absence. Their gates differ because
endOrphan has no row left to compare against, which is why it is a separate
function rather than a branch.

Mutation-checked: removing the confirmation ends the broadcast and fails
TestABroadcastIsNotEndedBecauseAListingWasIncomplete, which makes the listing
lie in exactly the way a scoped query would.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ce suite got a budget that fits

Two CI failures on #416, both real, and neither reproducible by the commands I
had been running locally.

THE BROWSER FAILURE WAS A REGRESSION OF MINE. The stream-health pane polls, and
for a Facebook destination with no connected account the handler answered 412.
That is an ordinary, permanent, entirely valid configuration, so the browser
logged "Failed to load resource: 412" once per poll, and
live-status-rendering.spec.ts asserts a destination card logs nothing. The
assertion is right: an operator opening devtools on a working install should
not find errors from a panel that has nothing to report.

It is also the doctrine this handler's own comment cites and then failed to
follow -- handleAccountStats answers 200 with supported:false precisely because
"we cannot ask" and "something went wrong" are different problems with
different fixes. The read now does the same. The WRITE next door keeps its
error statuses deliberately: an end-broadcast the operator asked for and did
not get is a real failure and must not be reported as a shrug.

THE GO FAILURE WAS A BUDGET, AND THE EVIDENCE SAYS SO. internal/api hit the 15m
wall reporting 900.034s. Raising a timeout is usually the lazy answer, so the
panic's own output decided it -- Go's timeout is kept below the job's
timeout-minutes exactly so this dump exists:

    running tests:
      TestAMutationReconcilesEveryProgrammeRatherThanTheDefault (0s)

Zero seconds. Nothing was stuck; the package had simply spent fifteen minutes
getting there. A hang would have shown a large number there, and the fix for
that is never a bigger budget.

So both halves: the race run gets 20m, still five minutes under the job's own
timeout so the goroutine dump survives -- and the tests that were spending real
wall clock on a two-second pacing constant now only do so in the one test that
MEASURES the pacing. bulkStartPacing became a var for that; the two tests that
assert which destinations are reached, and what the rows say, set it to a
millisecond because neither is about timing.

Verified against the suites that actually caught these rather than the ones I
had been running: the containerised browser suite passes 97, and the race run
was re-measured under POLYEMESIS_LEDGER=strict.

Mutation-checked: reverting the supported:false branch fails the named test
with "status = 412, want 200".

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…easurement

The first PR analysed after the swap to CI-based analysis failed its quality
gate on

    ERROR  new_coverage  actual=0.0  LT  80

Nothing was producing a Go coverage profile and nothing pointed the scanner at
a report, so Sonar was told nothing and recorded a zero -- on a repository
whose test suite is the largest thing in it. Measured while writing this:
internal/oauth is at 86.9% and internal/db at 78.5%.

The workflow's own comment enumerates the conditions this project's gate was
believed to carry -- new_reliability_rating, new_security_rating,
new_maintainability_rating, new_duplicated_lines_density,
new_security_hotspots_reviewed -- and new_coverage is not among them. It is,
and the swap is what made it bind. That belief is corrected in the file rather
than deleted, because the next person will form it too.

A gate reading a fabricated zero either blocks every change or teaches everyone
to ignore red, and both are worse than having no gate.

WITHOUT -race, deliberately. The detector costs this suite roughly six times
its runtime -- internal/api alone measured 702s under it against 116s without
-- and detects nothing a coverage profile records. The race run lives in
ci.yml, where it belongs.

continue-on-error, also deliberately. A profile that could not be produced
should leave the analysis to run without it, exactly as it did before this step
existed. Turning a reporting gap into a red build for a reason nobody can act
on is the failure this whole change is about.

sonar.test.inclusions is set alongside it, or the suites -- again, the largest
body of code here -- are counted as uncovered production code and drag down the
very numbers they exist to raise.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…ot alert

GHSA-2v37-7h3g-55p8, the one alert open on the default branch and reported on
every push since it appeared.

A lockfile refresh rather than a version bump: nanoid arrives through postcss,
which asks for ^3.3.17, and the patched 3.3.18 already satisfies that range.
Nothing in web/package.json changes and the diff is three lines -- version,
resolved, integrity -- because --package-lock-only was used rather than a plain
install. An earlier install in this repository rewrote unrelated entries when a
newer npm dropped `libc` fields from optional Linux dependencies, which would
have buried a security fix inside a diff nobody could review.

Scope, stated because "high" invites assuming the worst: postcss is a
build-time dependency of the Astro site, so nanoid runs on the machine doing
the build and is not part of what dist/ serves to a visitor. Worth fixing
promptly, not worth alarm.

npm audit now reports 0 vulnerabilities and the site builds unchanged at 38
pages.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
… deleted destination's stream is named rather than deleted

The settled-live branch returned for ever on the claim that "nothing is lost
by not asking: the next thing that can matter is the operator disabling or
deleting the row, and both change the row". That sentence was false, and the
comment is corrected rather than just the code. A broadcast can end with the
row untouched: YouTube's own enableAutoStop fires when the ingest stops -- the
END policy leans on it BY NAME as the backstop for a crash -- an operator can
end it in Studio, an administrator can revoke it, and a token can be revoked.
The skip was permanent, so the row said `live` for the rest of the daemon's
life, on the card operators are told to read and in the phase the END policy
consults.

Re-reading every sweep is not the fix either. BroadcastState is two calls, so
5,760 sweeps a day is 11,520 per destination against Google's published
default project allocation of 10,000 a day, shared with metadata push, chat
and stats. One destination would exhaust the install by mid-afternoon and it
could then not END anything, which is strictly worse than a stale label.

So: one read every fortieth sweep -- 144 reads x 2 calls = 288 a day per
destination, under 3% of the allocation, a wrong label standing at most ten
minutes. Counted rather than timed, for lifecycleGiveUpAfter's reason, and in
memory with absent meaning due, which finally makes the boot pass the
reconciliation its own comment already claimed it was.

Ten minutes is the ceiling, not the latency. An UP edge spends the budget
immediately, and that edge costs nothing -- the engine derives it either way
and Observe has already woken the sweep. It is also the right moment: an UP
edge means the ingest stopped and came back, and a stopped ingest is exactly
what fires enableAutoStop.

ORPHANED liveStreams: NOT DELETED, AND THE COMMENT SAYS WHY.

Every destination after the first has a liveStream of its own and deleting the
row strands it. A cleanup was designed against the documentation and refused,
because three things must hold and polyemesis can prove none:

  - That the stream is ours. Nothing records it. The row stores the KEY, not
    the stream id, and no column carries provenance. The only mark is the
    title, which ytStreamTitle documents as display metadata that nothing
    matches on -- so a rename in Studio would become a silent failure one way
    and a creator's own stream would be deleted the other.
  - That it is not the channel's shared stream, which is chosen positionally
    and is therefore not identifiable afterwards, and which an operator's
    Studio-scheduled events are bound to.
  - That nothing is bound to it. YouTube's refusal IS documented for a
    broadcast "that has still not completed", but whether a broadcast that is
    LIVE RIGHT NOW is inside that condition is an inference their docs do not
    make, and the cost of it being wrong is a show going dark. Proving it
    ourselves needs a whole-channel liveBroadcasts.list scan with
    broadcastType=all (the default `event` silently omits persistent
    broadcasts) whose per-call cost is unpublished.

An unused stream is clutter; a wrongly deleted one is a broadcast off air, so
every ambiguity resolves toward leaving it alone. What is built instead costs
no API call: the delete path NAMES the stream by the title createStream
actually sends, and only when the row supports the claim -- never for the
shared-stream holder, never when a sibling still publishes with that key.
TestNothingSendsADeleteToTheLiveStreamsEndpoint keeps it that way; whoever
settles the open questions has to delete a test named that to ship a cleanup.

TestAConfirmedLiveBroadcastIsNotAskedAboutAgain asserted the defect. Its quota
property is kept and sharpened -- exactly one read per forty sweeps, where the
old branch gave zero and re-reading gives forty.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…t is not

The Go coverage report took new_coverage from 0.0 to 77.1 against a threshold
of 80. The remaining gap is partly the same defect on the other half of the
tree: several UI modules are fully covered by vitest -- viewerCount.ts and
stream-health.ts are both at 100%, FacebookStreamHealth at 77% -- and Sonar was
told nothing, so every line of them counted against the gate.

@vitest/coverage-v8 was absent, so there was no report to give. It is added as
a dev dependency, vitest.config.ts gains an lcov reporter, and the workflow
runs `npm run coverage` before the scan.

WHAT THIS DOES NOT CLAIM, and the number is worth stating rather than hiding:
overall vitest coverage of ui/src is about 4%. That is honest rather than
alarming. This UI is exercised by the Playwright suite in ui/e2e, which drives
the real container and which Sonar cannot see -- Dashboard.tsx reads 0% here
and is among the most heavily exercised files in that suite, and the browser
suite is what caught the 412-on-every-poll regression that no unit test did.

So this raises the numbers it can legitimately raise and leaves the rest
visibly unmeasured. Reporting e2e as unit coverage would have been the
dishonest fix; leaving fully-tested modules reading zero was simply the wrong
one.

The exclusions in the coverage config are files with nothing to measure rather
than files with something to hide: type declarations carry no statements, and
e2e belongs to Playwright, which reports its own way.

continue-on-error, like the Go step: a report that could not be produced must
leave the analysis running rather than turn a reporting gap into a red build.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
@socket-security

socket-security Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​vitest/​coverage-v8@​4.1.10991007999100
Addednpm/​@​testing-library/​react@​16.3.210010010087100
Addednpm/​jsdom@​30.0.19810010095100

View full report

…tination spend the quota

Two lifecycle follow-ups. The first shipped; the second correctly did not.

A BROADCAST CAN END WITHOUT THE ROW CHANGING, which the settled-live skip's
comment denied: "Nothing is lost by not asking: the next thing that can matter
is the operator disabling or deleting the row." YouTube's own enableAutoStop
fires when ingest stops, an operator can end a broadcast in Studio, and a token
can be revoked. The skip was permanent, so the row read "live" for ever after --
on the card the design tells operators to read, and in the state the END policy
consults. The comment is corrected, not just the code.

Re-reading every sweep was never an option: BroadcastState is two API calls, and
at the fifteen-second tick that is over eleven thousand units a day per
destination against an allocation of ten thousand SHARED with metadata push and
chat. So a settled row is re-read every fortieth sweep, and an edge pulls that
forward.

THE EDGE PULL WAS A QUOTA REGRESSION AND THE REVIEW MEASURED IT. It deleted the
budget outright, so the next sweep re-read unconditionally -- right once and
ruinous repeatedly, because an edge is not rare. The engine makes UP immediate
while DOWN dwells ten seconds on a two-second tick, so a flapping destination
has a floor of roughly twelve seconds between edges: ~7,200 forced re-reads a
day, ~14,400 units, ABOVE the entire allocation. One flapping destination would
exhaust the install, after which the coordinator cannot END anything -- exactly
the failure the cadence was introduced to prevent. Measured at 33x the
advertised rate.

It clamps now. An edge still means "look sooner", within about a minute rather
than up to ten, and a destination flapping every twelve seconds costs the same
as one flapping once.

A TEST HAD TO CHANGE ITS MIND, and that is the interesting part. It asserted the
edge re-read on the very NEXT sweep -- immediacy -- which is precisely what made
it unbounded. What matters is that an operator does not sit in front of a card
reading "live" for ten minutes after the platform stopped; a minute serves that,
and instantly is not better in any way an operator can perceive while being much
worse in a way their quota can.

ORPHANED liveStreams WERE NOT CLEANED UP, DELIBERATELY. The evidence pass
resolved the crux in the safe direction -- YouTube itself refuses with
liveStreamDeletionNotAllowed, "cannot be deleted because it is bound to a
broadcast that has still not completed" -- but left UNRESOLVED whether a
broadcast that is LIVE right now is covered, and found a fifth refusal off the
error table entirely: a channel's default stream "cannot be deleted" with no
documented error code to branch on. So nothing deletes a stream, and an AST
guard fails the build if anything starts to.

That guard shipped with a hole a reviewer demonstrated: it matched the path
CONSTANT, so a byte-identical delete spelling "/liveStreams" inline walked past
it. It now matches string literals too, and the escaping mutation fails it.

Mutation-checked: restoring the delete-the-budget behaviour fails the flap test
at 50 reads against a bound of 13.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…nd Facebook's "Works" cell promised automation nobody issues (#416)

Three things, all found by checking CI rather than by guessing at it.

THE SECURITY FINDING IS OURS. SonarCloud's quality gate went from
new_security_rating=1 to 3 on this branch, and the single MAJOR
vulnerability was .github/workflows/sonar.yml itself: `npm ci` with no
--ignore-scripts, on a runner holding SONAR_TOKEN. That is the standard
supply-chain hardening and the flag was correct.

Verified rather than assumed safe: installing with --ignore-scripts and
then running the coverage suite passes 210/210 in this tree, so nothing
vitest needs comes from a lifecycle hook. The first attempt at that check
failed 18 tests and DID NOT mean what it looked like -- the copy used to
test it had no Go tree above it, and the failures were repo-drift tests
reading ../internal. --ignore-scripts was never the cause.

FACEBOOK'S broadcastLifecycle CELL SAID "Works" WITH NOTHING BESIDE IT.
Facebook and YouTube both read Works there and they mean different things:
YouTube is DRIVEN by the lifecycle coordinator, Facebook is COMMANDED BY
HAND -- connecting the account creates the live video, "End broadcast" is
a menu item. Both are real, so SupportYes is right for both. But YouTube's
cell carries a Reasons sentence and Facebook's carried none, so an operator
comparing them reads Facebook's bare Works as the same automation, leaves
an unattended channel on it, and no end is ever issued. Both mirrors now
carry the sentence.

(A review pass framed this as the matrix promising what the API refuses,
because LifecycleFor(facebook) is false. That overstates it: the
coordinator is not the only way to command a platform, and Facebook's end
route ships. The defect was the missing explanation, not the cell.)

THE UNTESTED HALF OF THE FACEBOOK ROUTES. facebook_broadcast_test.go
covers every way those two handlers say NO -- wrong platform, no session,
no account, no broadcast -- all of which return before a single Graph call.
So the guards were proven and the behaviour was not. The new file drives
both routes through the REAL provider to a Graph stub, and the branch
worth the most is the one nothing reached: Facebook accepting an end and
not yet reporting VOD is ORDINARY, and must answer 200 with ended:false
plus a warning naming the status. Reporting an error would say the end
failed when it did not; reporting ended:true would tell an operator their
broadcast is over while it is on air.

Mutation-checked: making the unconfirmed branch set Ended=true fails
TestAnAcceptedButUnconfirmedEndIsNotReportedAsEnded by name, and only that.

One test was written and then deleted rather than kept. Building the server
with oauth.NewSet() to reach the "provider not configured" branch does not
reach it -- NewSet() with no options registers every real provider at its
real host, so the test sent a live request to graph.facebook.com and failed
on Facebook's own token rejection. Observed, not assumed. The branch is
left uncovered with a comment saying why: a defensive ok-check on a map
populated at construction is not worth a test that reaches the internet.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…they break, neither of which was checked (#416)

Adds jsdom + @testing-library/react and the first two tests in this tree
that run an effect. AccountLiveStats.tsx goes 8/34 -> 34/34 lines and
useFacebookStreamHealth.ts 0/26 -> 26/26.

PER-FILE ENVIRONMENT, NOT A SUITE-WIDE SWITCH. vitest.config.ts stays on
environment: "node" and the two new files carry `@vitest-environment
jsdom` docblocks. Most of this suite reads the Go tree to check a mirror
has not drifted, and several files render with renderToStaticMarkup;
putting a fake DOM under all sixteen of them to test two hooks would be
the tail wagging the dog. The existing 210 tests are untouched and still
pass.

WHY THESE TWO FILES AND NOT THE OTHER UNCOVERED ONES. Both are polled,
and what was untested in each is not "does it render the number" but
"when does it STOP asking" -- which is the half that fails silently.
AccountLiveStats has two such rules written into it as warnings:

  "A backgrounded tab left open overnight would otherwise spend a YouTube
   project's whole daily quota on a number nobody is reading, and take
   title push down with it."

  "A platform that cannot answer will not start answering, so once it has
   said so the polling stops."

Nothing checked either. Broken, they go red nowhere -- the bill arrives
as a platform disabling the project's API access, which takes title push
and stream-key fetch down with it on an install nobody touched.
useFacebookStreamHealth has the same shape: a 404/501 must STOP the poll
(the route is not coming back before a redeploy) while a 500 must NOT
(the server is merely restarting), and one branch written as a bare
`instanceof ApiError` would swallow both.

The sibling AccountLiveStats.test.tsx is not replaced and was not wrong:
it renders ViewerReadoutLine, the pure half deliberately split out so it
could be rendered from a plain value. renderToStaticMarkup does not run
effects, so the asking half was simply unreachable from it. Hence a
second file rather than an edit.

MUTATION-CHECKED, ALL FOUR RULES, each failing only its own named test:
dropping `!visible` from the effect guard fails "STOPS POLLING while the
tab is hidden"; dropping `settled` fails "STOPS POLLING once the platform
has said it cannot answer"; removing `stopped = true` fails both the 404
and 501 cases of "STOPS POLLING after a %d". Restores verified with an
empty git diff each time.

One test failed first and the failure was mine, not the code's:
ApiError's constructor is (status, message), and I had written
("no such route", 404). It surfaced as the 404 landing in the error
branch instead of unavailable -- which is exactly what the test exists to
catch, arriving a step earlier than expected.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
@socket-security

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm data-urls is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ui/package-lock.jsonnpm/jsdom@30.0.1npm/data-urls@7.0.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/data-urls@7.0.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm jsdom is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ui/package-lock.jsonnpm/jsdom@30.0.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/jsdom@30.0.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@sonarqubecloud

Copy link
Copy Markdown

@rainmanjam
rainmanjam merged commit ac4ec97 into main Aug 17, 2026
33 checks passed
@rainmanjam
rainmanjam deleted the feat/platform-capability-expansion branch August 17, 2026 14:27
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