main has been red since 4f7b4e3 for a reason visible nowhere in its diff.
The player-parity baselines describe what an UN-UPDATED display can do, so
they are judged against the SHIPPED source — `git show <latest tag>:…` —
rather than the working tree. actions/checkout defaults to a shallow clone
with no tags, so that lookup found nothing and the suite fell back to the
working tree. In a tree where today's QA had just fixed the players'
set_volume payload bug, the biconditional then demanded that
BASELINE.web/tizen gain audio.volume — for displays that cannot possibly
have the fix yet. Green locally, red in CI, and the failing assertion names
a baseline rather than the checkout that caused it.
So fetch the tags in the test job, and make the biconditionals SKIP when
there are none instead of asserting against the wrong source. That fallback
was never a slightly-early assertion; it was an inverted one. A skipped
assertion announces itself, a wrong one does not.
Verified both ways: with tags 15/15 pass, in a tagless shallow clone 13
pass + 2 skip + 0 fail (previously 1 fail).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Bold reported loop lag that grew with uptime and reset on restart. The
signature they saw — mean = p50 = p99 = max, identical to two decimals —
is not a fixed cost paid on every cycle. It is what an IntervalHistogram
window reports when it recorded exactly ONE delay: the mean is the raw
value, and every percentile returns the bucket ceiling above it.
Reproduced against their exact numbers (1329.07 / 1329.59). So the loop
took one long turn that swallowed the sampling second, episodically —
which is what they later confirmed independently.
The turn is ours, and it is now measured rather than theorised. Probing
the real worker against a real WAL with one reader mid-transaction: a
single main-thread write blocked for 4,936ms behind the worker's
wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE
is the blocking form and its locks are held ACROSS connections, so moving
it to a worker kept the fsync off the loop but not the lock; and it does
not throw when it cannot get those locks, it returns busy=1 having sat on
SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled
loop for zero benefit, and silent.
It was reached far too easily. The rule was "escalate if the WAL grew
across three consecutive 15s runs" — which any sustained 45-second write
burst satisfies. A customer's fleet powering on in the morning does it
daily.
Two gates, because either alone leaves the hole open. A size FLOOR, so a
WAL in the lower half of its budget can't buy a blocking checkpoint it has
nothing to reclaim from. And a COOLDOWN, because the floor alone fixes
nothing for Bold — their WAL already sits at 6.2MB against a 16MB
high-water, above any sane floor, so every burst would still escalate.
However long the pressure lasts, our own maintenance may now stall the
loop at most once per window. The high-water rule bypasses both and is
untouched: a runaway WAL is the one case worth blocking for, so the
"WAL cannot grow forever" invariant is exactly as strong as before.
A busy TRUNCATE now says so in the log instead of reading like a success.
Also softened the adjacent path: when the worker is declared
unrecoverable, engageFallback() re-arms inline autocheckpoint on the main
connection — a state that is STICKY for the life of the process, i.e.
exactly the shape of "degrades with uptime, a restart fixes it". It used
to also run an unconditional main-thread TRUNCATE on the way in; that now
happens only when the WAL is genuinely over high-water, and the fallback
state is served on /api/status rather than being inferable only from a log
line that may have rolled.
Telemetry, so the next report is self-explanatory: loop_lag carries
`samples` (~50 when healthy, 1 when a single turn swallowed the second),
`tick_gap_ms` measured on the WALL CLOCK independently of the histogram,
and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling
can no longer miss an episode.
Band semantics are deliberately unchanged. A one-sample window during a
real stall is the correct trigger for the shed valve; suppressing it would
blind the protection at exactly the moment it is needed.
Separately, device_telemetry gets the age sweep it never had. The
per-heartbeat row cap only ever trims the device whose heartbeat is being
handled, so a device that STOPS reporting leaves its rows behind forever.
The new sweep is per-device (rides idx_telemetry_device rather than
scanning), chunked and yielding like the device_status_log one, and
defaults to 30 days to match the uptime report's own default window — so
it cannot remove rows that report would have shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
GetStorageStatus() is documented for SD:/SSD:/USB: only, so it can never confirm
internal flash, and roStorageHotplug may be absent entirely. Gating the probe on
it made 'cannot say' read as 'no disk': a player with an NVMe reported 1025 MB,
which is the widget's cache quota arriving through the page-side fallback.
roStorageInfo is asked directly as a second pass, with the mount check kept first
so a removable volume still wins over internal flash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
screen_off blanks a fielded panel for real (owner/admin FORCE_LOCK, else the
accessibility lock); screen_on is a logged no-op. One capability renders both
dashboard buttons, so withholding the pair to hide the dead ON button also takes
blank-at-night — the half that gets scheduled — away from every panel that has
not updated. Panels that have updated declare for themselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two tests disagreed after the QA merges, and both were right about their own
half — which is what made the disagreement worth resolving rather than silencing.
A baseline describes what an UN-UPDATED display can do. The baselines were
justified against `git show v1.9.28:<source>` and then asserted against the
working tree, so the moment a player's payload bug was fixed the biconditional
demanded a baseline change for displays that cannot possibly have the fix yet. A
baseline entry moves when a fix SHIPS. It now reads the newest release tag, and
falls back to the tree when tags are unavailable (a shallow CI clone), because a
missing tag is a worse reason to fail a build than a slightly-early assertion.
While fixing it the helper threw a ReferenceError — the require was missing — and
its own broad catch swallowed it and quietly compared against the working tree
anyway. The catch now rethrows ReferenceError and TypeError. A fallback that
hides a programming error is the same failure shape as everything else this QA
pass found.
The BrightSign assertion encoded the older, more generous baseline: reboot needs
the BrightScript host bridge, and an undeclared unit is precisely the one we
cannot know has it. What that test is really pinning is that the row still
classifies as brightsign rather than decaying to `web` — so it now asserts that,
plus the video playback that is genuinely safe to assume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
#236 gave each video-wall panel a mounting rotation, which for the first time
puts a real rotation on an ancestor of the ExoPlayer TextureView. The screenshot
compositor could not express that: it pasted the video frame with an axis-aligned
Rect built from getLocationInWindow(), so on a rotated panel the frame landed
outside the capture bitmap entirely. What reached the dashboard was the plain
black that view.draw() leaves wherever a TextureView is — a panel that looks dead
while it is playing perfectly, which is the worst thing a diagnostic can say.
The frame is now placed through the same transform chain the hierarchy was drawn
with, accumulated up the parent chain the way the framework does when it draws a
child, so any ancestor rotation/translation is honoured. The bitmap is also
scaled from the surface's own dimensions rather than assumed to match the view.
Measured on the emulator, a wall panel playing video, remote screenshot vs the
adb framebuffer at the same moment (standard deviation — 0 means a flat frame):
before after
rotation 0 sd 0.439 / truth 0.430 sd 0.443 / truth 0.435 (unchanged)
rotation 90 sd 0 / truth 0.461 sd 0.448 / truth 0.448
rotation 90 sd 0 / truth 0.467 sd 0.460 / truth 0.457
rotation 90 sd 0 / truth 0.408 sd 0.463 / truth 0.466
Every rotated capture was #010101 with zero variance before; each now tracks the
real framebuffer. Rotation 0 is unchanged, and so is the ordinary fullscreen
(non-wall) path, re-measured across images and video.
No unit test: this is android.graphics.Matrix semantics against a live view
hierarchy, which the JVM test source set cannot exercise — the evidence is the
before/after measurement above. Android 151/151, server 1298/1298.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two faults that are live on 1.9.29, both silent, both ending in a dark screen.
A missing upload answered 200 OK with Content-Type: text/html and 15KB of the
dashboard, under the immutable/30-day header the mount sets before it knows
whether the file exists. Every downloader here treats 200 as success, so a panel
stores the page AS the video and caches it for a month; Android validates the
byte count, not the type, so a correctly-sized page passes integrity and is
promoted as a valid asset. Reachable exactly when it hurts — a replace writes a
new random filename and unlinks the old one. Now a 404, with the cache header
removed.
And the service worker treated an empty playlist as "keep nothing". But
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and from the catch when a snapshot fails to parse — so
a message that means nothing of the sort deleted every byte of media the panel
held. Only survivable while the uplink is up, i.e. exactly when the cache is
worthless.
Both regression tests drive the whole server or the real worker, because both
bugs live in the relationship between two pieces that are individually correct:
the order of two mounts, and the difference between "needs nothing" and "did not
arrive".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
sw.js said its cache-first widget branch "is what lets a widget keep rendering
when the network is gone". It is not. The player mounts widgets in an iframe
sandboxed to `allow-scripts` with no allow-same-origin, so the frame is an
opaque-origin client, and a service worker does not control those — the
navigation never reaches the handler.
Measured rather than reasoned: a clock widget mounted five times over 25 seconds
of real playback in Chrome while the shell cache held zero widget entries, and a
plain fetch() of the identical URL from the controlled page was intercepted and
stored on the first try. The branch works; the player's own widgets are simply
not what reaches it.
What actually holds widgets through an outage today is the HTTP cache plus the
server's `max-age=31536000, immutable` on a rev-pinned render. That is sound in
a desktop browser and is exactly the store this module's own header says is NOT
persistent on BrightSign, which is why content caching had to exist at all. So
the comment now records the limit and names the two ways out — route the render
through a same-origin fetch and mount it as srcdoc, or grant allow-same-origin
and hand widget scripts the player's origin, which is not a trade worth making
for an offline nicety.
The test pins the security property so nobody buys the cache with it, and pins
the Cache-Control header, which is now known to be load-bearing on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A newly paired panel always learns its playlist BEFORE the media arrives, so
start() finds nothing playable and the 3-second content re-check is what really
begins playback. updatePlaylist() has already seeded currentIndex = 0 for a
playlist that has not started, but the re-check advanced PAST that index — so
the first pass ran 1,2,3,0 and item 1 only appeared after the list wrapped.
On the emulator, a fresh pair with a 4-item playlist reproduced it every time:
Starting playback
Playing: red.png (index 1) <- clip32.mp4 (index 0) never got its turn
Playing: clip7.mp4 (index 2)
Playing: blue.png (index 3)
Playing: clip32.mp4 (index 0) <- 54s late, on the second pass
On a two-item playlist that is indistinguishable from "only one of the two ever
plays", which is how it was reported.
The distinction the re-check was missing is hasContentOnScreen. With content up,
currentIndex is a real position that has had its turn and the scan must move past
it. With nothing up, currentIndex is only where playback INTENDED to start, so
skipping it drops that item. PlaylistSelection.recheckIndex now makes that choice
explicitly, and playableFromIndex treats a negative index as "no position yet"
rather than wrapping onto the last item.
Verified on the emulator against the same cold start: the first pass is now
0,1,2,3,4 in order. Playback resume (#234) is untouched — it never reaches the
re-check when its target is cached, confirmed by an Activity relaunch resuming
mid-playlist as before.
Tests: 6 new cases in PlaylistSelectionTest covering both sides of the rule, the
still-downloading item, the no-position-yet start, and the empty case.
Android 151/151, server 1298/1298.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Three faults in the web player, each found by driving the shipped code in a
browser against the real server rather than by reading it.
set_volume did nothing at all. The dashboard sends `{ level: 0..1 }`
(device-detail.js: slider/100) and the Android player reads exactly that; this
player read `payload.value` and divided it by 100. Nothing in the product sends
`value`, so every browser panel acked the command and ignored it — the quietest
possible failure. Correcting only the key would have been worse than leaving it
broken: `level: 0.5` would have become 0.5%, which is inaudible and looks fixed.
The fraction is now canonical, `value` is still read as a percentage for
anything written against the old handler, and the scale is chosen by WHICH KEY
arrived rather than by the size of the number — 1 is legal in both conventions,
so a magnitude guess is guaranteed to be wrong for somebody. Parsing moved into
volumeLevelFromCommand() so it can be asserted without a socket.
setMediaVolume() also wrote `el.muted = (v === 0)`, so any non-zero volume
un-muted whatever was playing. An item an operator had deliberately silenced
started making noise the moment anyone touched the slider — reproduced live:
item flagged muted, one set_volume, muted went false. Mute has four inputs and
a fixed order (lib/media-mute.js), it is resolved when the element is mounted,
and a level is not entitled to overrule it — least of all the autoplay rule,
where unmuting without a gesture costs the video rather than winning the audio.
Volume 0 is silence on its own.
And the service worker pruned its content cache to an EMPTY keep-set.
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and inside the `catch` when a published_snapshot fails
to parse — none of which mean "delete the media". Reproduced: three cached
assets, one empty payload, cache emptied. That is only survivable while the
uplink is up, which is precisely when the offline cache is worthless. A cache
kept too long costs disk the quota reclaims anyway.
Verified in Chrome against a live server: volume 0.42/0.8/0/0.25 land on the
element and survive an item change, a muted item stays muted through a volume
command, and three cached assets survive an empty push.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.
TIZEN
The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.
Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.
Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.
The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.
BRIGHTSIGN
FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.
The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.
offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.
display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.
roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.
dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.
Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.
TESTS
The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.
The three dead controls:
- The volume slider works on Android only. The dashboard sends set_volume as
{ level: 0..1 }; the web player reads payload.value and Tizen reads
payload.value ?? payload.volume, so on both the number is undefined and the
handler quietly declines. Three complete, working volume implementations
that cannot be driven. The fix is one line in each player and belongs to
those files; audio.volume is out of the web and brightsign baselines until
it lands, held there by a biconditional test that fails the moment a player
starts reading `level`.
- Every #161 Tier-2 command was refused for the entire fleet. lock_now,
power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
system.device_owner, which no player declares and no baseline grants, so
supports() was false everywhere -- including on the device-owner panels the
feature was built for. The dashboard still drew the buttons because it also
gates on device.tier === 2. Fixed here: those five now accept
system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
under `if (isOwner)` and nothing else, and which no non-Android player
declares. Android should declare system.device_owner and retire the
stand-in.
- enable_system_capture required the capability it creates. It raises the
MediaProjection consent dialog -- the way a panel GAINS capture -- and was
gated on remote.screenshot, so the only panel that needs it was the one
panel that could not be sent it. Now ungated. The dashboard still hides the
button behind the same check; that half is a frontend change.
The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:
- android loses display.power (v1.9.28 answers screen_on with a logged no-op,
so the ON half is dead on every fielded panel and one capability renders
both buttons) and system.reboot (owner-only; off-owner it paints an
accessibility power dialog over the signage). Scheduled reboots now skip
undeclared Android panels rather than logging a reboot that never happened,
which is the reason that gate exists.
- tizen gains display.power: v1.9.28 implements both halves with no signing
and no panel API, so withholding it hid a working control.
- brightsign loses audio.volume, display.power, system.reboot,
system.restart_player and offline.cache. All need a host bridge the unit is
not known to have, and restart_player without one is the page reload that
darkened a panel on 2026-07-28.
Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.
The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
#236: the wall canvas was secretly framebuffer space rather than the wall as
the audience sees it. Invisible while every panel is the normal way up, and
actively misleading the moment one isn't — two portrait-mounted panels standing
side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after
trial and error, and it meant a portrait wall could never reuse content as-is.
Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same
convention as the per-device orientation setting), the canvas means the physical
wall, and the player works out the mapping. The geometry lives in one place,
server/lib/wall-geometry.js, because four players have to agree on it to the
pixel across a seam.
Existing walls need no migration and do not move. Every wall in the field is
rotation 0, and that case takes the original expression verbatim on all three
players rather than the algebraically-equal centre-based one — the two differ in
the last float bit, and a float's worth of disagreement between two panels is a
hairline seam down a wall that was aligned yesterday. Pinned by the first test
in wall-geometry.test.js and by wall-payload.test.js.
While a display is in a wall its panel rotation replaces its own orientation:
both describe the same physical fact, so honouring both turned the content twice.
#235: a wall replaced its members' cards, so one dead panel of a four-panel wall
was invisible from the dashboard, and inspecting a single screen meant pulling it
out of the live wall and putting it back. The wall screen now lists its panels
with live online state, a per-panel screenshot request, and a link to each
device's page; the dashboard wall card carries per-member status chips that track
socket updates.
Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts
each panel's viewport maps onto exactly its own rect of wall space, for every
rotation, plus a mixed wall and the Tizen player's hand-ported copy executed
against the canonical rule. Full server suite green (1260).
Not verified here: the Android and Tizen renders on real hardware. Kotlin
compiles clean; the maths is shared/tested, the view plumbing is not.
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.
A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.
So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.
The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play
unless the operator looked up the runtime and typed it — per item, every time.
The content row already carries the probed duration; it now becomes the default.
The rule lives in one place (lib/item-duration.js) because the operator sees one
product, not six insert paths: playlist add, assign-to-display, group assign,
agency portal, content-only schedule, and the public API all share it. Only the
playlist route defaulted before, and it stored the raw probe (31.7) which the
Android player's optInt read silently truncated back to 31.
Explicit values always win. Content with no trustworthy duration (image, widget,
YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is
0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than
reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops
and black-screens the TV.
Dashboard: the add-item picker shows a clip's length, the assign-to-display modal
pre-fills the duration field from the selected clip (never overwriting a value the
operator typed), and onboarding stops hardcoding 10 on the first assignment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The preview shipped without the skip control #104 asked for, so checking a late item meant
watching every item before it in real time — the thing operators do most when ordering a
playlist with a client on the phone.
The preview is already the real player in device-free mode (an iframe of /player?preview=1),
so this drives that instance rather than growing a second playback implementation: the
dashboard posts next/prev to the one contentWindow, the player steps its own currentIndex and
re-renders through the same path a natural advance uses, and posts back index/total so the
modal can say "3 of 7".
Nothing here can reach a live screen. A real display is driven over its server socket and holds
no window handle this page could address; the message listener is installed only by the preview
boot path, previewNavigate refuses outside PREVIEW_MODE, and both ends pin the origin.
Stepping is schedule-aware in the direction of travel — falling forward past a dayparted item
would make "previous" walk forwards — and a multi-zone playlist reports itself as such, because
all zones play at once and a counter there would be a lie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
ghcr refused the 1.9.29 push with "denied: permission_denied: Error from
intermediary with HTTP status code 403", then accepted the identical build on a
manual re-run minutes later. Nothing about the token, the permissions or the
workflow changed in between; the registry simply said no once.
The timing is what makes it worth handling. The GitHub Release job has already
published by the time this runs, so a failure here leaves a tag that exists with
no image behind it — alpha and every self-hoster pulling :latest see a version
that is announced and unpullable, which reads as a broken release rather than a
hiccup at a registry. It also needs a human to notice and re-run, which is the
part that does not scale.
One retry, after a pause, and a second refusal still fails the release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Found deploying 1.9.29 to production. A worker's scope defaults to its own
directory, so /player/sw.js could only control /player/ and below; the fix was to
request a wider scope and permit it with Service-Worker-Allowed. That works right
up until something between the origin and the browser does not pass the header
on. Cloudflare served a CACHED response for that path across the deploy —
headers and all — and the registration failed outright.
A rejected registration is worse than a narrow one: the player runs with no
worker at all, on every URL, and nothing about it is visible from the server. The
origin was sending the header correctly the whole time; a cache-busted request
proved it. It self-heals when the edge entry expires, which is precisely the kind
of fix nobody should have to know about.
Served from /, the default scope is already the whole origin and no header has to
survive the trip — through Cloudflare, through whatever a self-hoster puts in
front of it, or through a corporate proxy we will never see. /player/sw.js keeps
serving for players still asking for it, and the header is still sent where it
does survive.
Verified in a real browser: all three of /player, /player/ and /player/index.html
are controlled from root scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A BrightSign could see things the page cannot ask for — the uptime, the wired IP,
the video mode in force, which volume it booted from, whether a staged package
applied — and it printed all of it to a serial console. On a panel on a wall that
is the same as reporting nothing.
The cost was concrete and recent. A single bad string literal stopped the host
script compiling; the only evidence anywhere was one line on a cable, and from
the server the display looked identical to one that had never started. Diagnosing
it needed someone physically present with a serial adapter. Every other player
reports its own failures.
Three hops, each thin: the host posts, the bridge carries, the player emits on
the channels it already uses (device:log, device:event, and the telemetry the
heartbeat has carried for releases).
The pre-widget phase is the part that matters and the part that was hardest to
reach — the storage probe, a pending package being applied, the video mode being
set, all happen before there is a page to talk to. Those lines accumulate in a
buffer and flush the moment the widget exists, so the boot story arrives even
though it happened before anyone could listen. BrightScript has no global store
here (no GetGlobalAA), so the buffer is threaded explicitly; losing the boot
entirely was the worse option.
Two things become incidents rather than console lines: the watchdog rebuilding a
wedged widget, which is the most important thing a player does unattended and
previously healed in silence — a panel rebuilding itself every two minutes looked
exactly like a healthy one — and a load-error, which now names the resource that
failed. Both use event types the server actually accepts; an invented one is
dropped silently and would have been just as invisible.
Host telemetry merges into the existing snapshot rather than opening a channel,
and the host's numbers win where they overlap: navigator.storage.estimate()
describes the widget's cache quota, not the disk, so a panel can report gigabytes
free while the volume holding them is full.
Two API traps caught in my own new code before it shipped, both the same shape as
the ones being fixed: Str() applied to a value already documented as a String
(it is for numbers, and would abort the event loop while reporting a diagnostic),
and Stri() handed a float from an inline division. The checker now pins the first.
Verified on the XT245: boots clean, plays, online. The bridge and player halves
are served BY the server, so they take effect on the next deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Both found while watching a real self-update run end to end on the XT245.
FileExists now uses roReadFile + type(), which is what BrightSign's own published
autozip.brs does (their CheckFile). MatchFiles is for LISTING a directory; as an
existence check it has already burned this codebase once, passing a full path as
both arguments so it could never return true for anything. Correcting it to a
directory plus a bare name did work — I misread a mid-cycle inspection as a
second failure and it was not — but roReadFile takes the full path every call
site naturally has, needs no reasoning about volume-root semantics, and is the
form the vendor ships. The narrower idiom is worth having here precisely because
nothing in CI can tell us when this is wrong.
CheckPackageUpdate now returns early when a package is already staged. Observed
on hardware: the periodic check fired in the gap between staging an archive and
the reboot that applies it, and pulled the whole thing down a second time.
Harmless on a desk; on a metered or marginal link it is exactly the waste the
rest of this release exists to remove.
The self-update chain is now proven on hardware, twice: check, download, sha256
and size verify, stage, reboot, staged unpack, move into place without touching
screentinker.json, mark done, reboot into it. The player reports 1.9.29-rc5 and
its autorun.brs carries the archive's timestamp rather than a hand-copied one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The host scripts were written against the wrong reference. BrightScript is
Roku's language, the two API references read almost identically, and nothing
here can run either — so a call to an object that does not exist looked exactly
like a call to one that does. Verified on an XT245 and against BrightSign's
published reference; every item below was confirmed, not guessed.
THE ONE THAT COST A BOOT. `body$ = "{""width"":"` is not an escaped quote —
BrightScript has no escape sequences, so that is three adjacent literals with no
operator, and the compiler rejects the WHOLE FILE:
ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)
Not a broken feature — no player at all, on a display showing nothing. Built
with Chr(34) now.
THE ONE IN THE FIELD. MatchFiles takes a DIRECTORY plus a pattern and returns
nothing when the pattern contains a separator; we passed a full path as both
arguments. FileExists() could never return true, for any file, on any player.
That is exactly what a consultant hit: "[st-autozip] no autorun.zip on any
volume" printed while `dir SD:` listed autorun.zip. It also silently disabled
the entire self-update path. (Related: `autorun.zip_invalid` on his card is not
an accusation — it is the rename BrightSign's own example performs AFTER a
successful unpack. Our STORED-only insistence fixed a problem that was never
there; deflate32 is supported.)
Roku objects that do not exist here, each of which disabled a feature quietly:
roFileSystem (~20 sites — the update path could never mark a package applied),
roMessageDigest (verification returned false unconditionally and burned an
attempt counter), PostFromStringWithRetry (a snapshot request raised "member
function not found" from inside the event loop and took the player down).
Replaced with MoveFile/DeleteFile, roHashGenerator, and an async POST on a
message port, which is the only documented way to read a POST body.
Unpack() returns Void, so `if not package.Unpack(...)` was a type error dressed
as an error check; success is now proven by looking for the extracted file. And
Unpack() DELETES everything already in its target — unpacking an update to the
volume root would have erased the player's provisioning and its whole content
pool as a side effect of a routine upgrade. It stages to a directory of its own
and moves files into place, deliberately never overwriting screentinker.json.
Also: SetMode() takes one argument (rotation belongs to SetScreenModes, which
REBOOTS, so it only fires on a real change); GetStorageStatus is unreliable with
"USBn:"; a load-error names its resource in `uri`, not `url`.
server/test/brightscript-api-surface.test.js is the cheap thing that would have
caught all of it: a deny-list of Roku APIs plus the argument shapes and literal
forms that compile and then do nothing. It cannot prove the scripts are right;
it stops these specific mistakes coming back. It has already earned its keep —
it caught a comment I had broken while writing this change.
Verified on hardware: the player loads clean from the NVMe, restores its cached
playlist and plays BEFORE the server connects, fetches media with the new
?rev= revision, and registers against alpha rc4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Verified on the XT245 after fitting an NVMe.
StorageRoot() knew only FLASH and SD. That unit has a dead card slot and boots
from internal flash, so the moment real storage was fitted and the deployment
moved onto it, every derived path — the offline page, the widget's local
storage, the self-update paths — resolved to "SD:", a slot with nothing in it.
It now probes in the order the OS itself searches for an autorun script, so the
answer matches the volume the player actually booted from.
storage_path was "/cache", which carries no BrightSign drive specifier and so
resolves outside the writable volumes. It is now an absolute path on the boot
volume, confirmed on hardware: after the move the player created SSD:/cache
where before it only ever touched FLASH:/cache.
Note for anyone chasing the same thing: this did NOT enable the service worker.
The widget still never requests sw.js, so the player's inability to cache
offline on BrightSign is not a storage-configuration problem. The capability is
declared honestly now (see the previous commit) rather than advertised and unmet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Found on alpha after deploying rc4, by comparing what a device advertised
against what it actually requested.
A real BrightSign XT245 has navigator.serviceWorker, passes an
`'serviceWorker' in navigator` check, and then never even fetches sw.js — its
widget runtime refuses the registration. It was declaring offline.cache to the
fleet while unable to cache a single byte, which is precisely the lie the
capability model exists to prevent. The claim is now made on a worker that is
actually IN CONTROL, and a refused registration sets a flag so the negative
sticks on a runtime where it will never succeed.
That failure previously went to console.warn, on a display nobody has a console
for, so a panel that could cache nothing looked identical to one that could. It
now reports app_error/sw_unavailable — as an allow-listed event type, since an
unknown one is dropped by the server and would have been just as invisible.
The cost is that the first load under-reports, before the worker claims the
page. That is the right direction to be wrong in, and it self-corrects: the next
register sends the true set.
Also corrects docs/player-parity.md, which claimed BrightSign simply inherits
the web player's service worker. The failing unit runs BSN's Supervisor rather
than our brightsign/autorun.brs, and Supervisor's widget has no storage_path —
the setting our own host script does configure and the precondition for a widget
having persistent storage. So this is likely a widget config issue rather than a
platform limit, but it is UNVERIFIED on hardware and the doc now says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Found by QA against a real browser, not by any test in the suite: the bug lived
entirely in the relationship between a URL and a header.
A service worker's default scope is its own directory, so /player/sw.js could
only ever control /player/ and below — which does not include /player itself.
The player is served at all three of /player, /player/ and /player/index.html,
and /player is the one that gets used: it is what the dashboard shows and what
gets typed into a panel. On that URL registration SUCCEEDED, logged "Service
Worker registered", and then controlled nothing. No shell cache, no content
cache, no offline playback, no error. Every web and BrightSign panel served at
/player has been running with its offline story switched off.
Registration now asks for scope '/' and the server sends Service-Worker-Allowed
to permit it. Both halves are required — without the header the registration
does not narrow, it fails outright.
Also: revision-keyed sweeping could not reclaim a replaced asset's predecessor.
A replace writes a NEW randomly-named file, so the superseded copy lives at a
different path entirely and nothing keyed on the asset path can find it; it
would sit there until the quota evicted it. The player now declares the complete
set of media it needs — the raw assignments, so multi-zone items are included
and a prune cannot delete something a zone is still playing — and the worker
drops everything else.
QA results this pass: web player 18/18 against a real browser (cold start with
no network renders a cached video at readyState 4); Android 12/12 on a device
including a replace round-trip that re-fetched 6MB and then dropped it for the
new bytes, and a cold start with the server stopped that played from disk;
Tizen 11/11 for the no-storage path, which must degrade to streaming and must
not claim a capability it cannot honour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two halves of the same problem. A screen has to keep playing when the link is
gone, and it must not keep playing the wrong thing once the link is back.
CACHING FOR OFFLINE, on the players that could not:
- Tizen cached nothing but the playlist, so a panel came back from a reboot
knowing exactly what to show and fetched every frame of it from a server that
was not there. tizen/js/media-cache.js caches the media itself to wgt-private
(the store Tizen documents as surviving reboots), resumable via Range and
If-Range, with the transfer async so a stalled chunk cannot freeze the player.
offline.cache moves from "absent" to a runtime claim: a build with no writable
private storage still says nothing.
- The web player's worker stored only what a single fetch() happened to
complete, which on a marginal link is nothing at all — a 200MB asset never
finishes in one go and every retry starts from zero. It now accumulates in
resumable chunks, driven by the player's playlist rather than by playback, so
the prefetch is not competing with the video that is currently on screen for
the same scarce bandwidth. BrightSign inherits this.
STILL UPDATING, which caching quietly breaks:
PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every
cache keys on that id, so before this the new bytes could not reach a panel that
already held the old ones — not until the next refresh, but never. Content now
carries a revision, stamped onto each item at send time like widget revs, and
every player keys its cache on it. The same send-time refresh fixes a second
bug: a replace writes a new randomly-named file and unlinks the old one, so the
filepath in a published snapshot pointed at a deleted file and web panels 404'd
on the item until somebody republished the playlist. The route now also pushes
to affected devices, which it never did.
Bytes are kept only where they can be built upon: no validator means no safe
resume, so the partial is discarded and the attempt backs off as the failure it
is rather than re-fetching the same prefix forever.
Server needed no new transfer support — res.sendFile already does Range,
If-Range and 416. The Tizen cache and the service worker are both driven in Node
against fakes, because neither can be exercised without hardware and "the chunks
assemble correctly" is not something to discover from a panel showing a corrupt
video.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A site on a marginal link (the report came from a one-bar 5G install) could
never fill its cache. Every attempt started at byte 0 and the .part was deleted
on any interruption, so an asset larger than one call's worth of transfer was
discarded and re-fetched forever — five minutes of progress thrown away, back
off, five more minutes, thrown away. With nothing cached, the player showed the
waiting state, which is what got reported as "the screens go black instead of
playing cached content". The offline playback path was never the problem; the
cache simply could not be filled.
An interrupted download now keeps its .part and the next attempt asks for the
rest with Range. Two ways that could corrupt the cache, both closed: If-Range
with a stored validator makes a changed asset come back as a full 200 (restart)
rather than a spliceable tail, and a partial longer than the asset gets a 416
and is discarded. Bytes are kept only when they can be built upon — with no
validator there is no safe resume, so the partial is dropped and the attempt
backs off as the failure it is, rather than re-fetching the same prefix forever.
DownloadCoordinator now distinguishes progress from failure: attempts chain
while bytes are landing (bounded, single-flight held throughout) and only a
no-progress attempt escalates the exponential backoff or acks "failed" — an
advancing download is not a failed one and should not be shown as such.
Server side is unchanged; res.sendFile already serves Range/If-Range, and
content-range-resume.test.js pins that since it is load-bearing and a future
middleware could silently remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.
Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.
The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.
Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two more corrections from the cross-player audit, both mine.
audio.volume removed: a fielded Tizen panel has NO set_volume handler — the
command falls through to "unknown command" and the dashboard slider does
nothing. One of the platform branches adds a handler, and those panels will
declare the capability for themselves once they run it; the baseline exists to
describe an un-updated display, so it must not borrow credit from a build that
has not shipped.
remote.screenshot and remote.stream added: both really are implemented in the
shipped player (captureAndSend, startStreaming). Omitting them would have hidden
working controls on every legacy Tizen display the moment gating went live —
the opposite failure, and the more damaging one.
That asymmetry is the thing to hold on to: over-claiming shows a dead button,
under-claiming removes a working one, and only reading the shipped code tells
you which you are doing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL