feat: platform capability expansion — viewer stats, broadcast lifecycle, and the evidence behind them - #416
Conversation
… 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
…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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
…nto feat/platform-capability-expansion
…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
|
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.
|
|



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.ViewerCountis a*intbecause all three have a way of declining toanswer and no
intcan say "not told" — YouTube omits the key under threeindistinguishable conditions, Kick documents
0as its opt-out, Twitch sends anempty array. A UI surface renders "not reported", never a zero.
Broadcast lifecycle. A coordinator in
internal/apidrives YouTube throughtesting → live → complete, wired from
main.goas a third consumer of the edgesobserveLoopalready derives. Facebook's end call reaches a route and the UI.broadcastLifecyclebecomes 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
documents
0as the opt-out value; the code read> 0as proof of life.total_countas one channel's viewership. Its tests passed because thefixture was shaped like the struct rather than like the endpoint.
statusError.Bodyis truncated to 300 characters for display and was beingparsed; a realistic refusal is 363 bytes, so every graph-code branch was
silently skipped.
Two verdicts this repository held that were wrong
149 paths, a
Broadcastsfamily of 13 operations and aChatfamily of 16,under
broadcast.read/broadcast.write— verified by fetching and counting,not by reading. Its cells stay
Unverifiedbecause the provider is writtenand unregistered.
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 branchbecause the sweep re-reads
Enabled. A destination edit firesteardownDestona 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 driftacross all 15 locales green.
Note for reviewers:
npx tsc --noEmitis a no-op in this project —ui/tsconfig.jsonis a solution file with no files of its own. Usetsc -p tsconfig.app.json --noEmit, ornpm run build, which runstsc -b.https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX