Skip to content

fix: seventeen devices from the poka-yoke audit (#705–#721) - #722

Merged
rainmanjam merged 8 commits into
mainfrom
fix/poka-yoke-audit-devices
Sep 5, 2026
Merged

fix: seventeen devices from the poka-yoke audit (#705–#721)#722
rainmanjam merged 8 commits into
mainfrom
fix/poka-yoke-audit-devices

Conversation

@rainmanjam

@rainmanjam rainmanjam commented Sep 4, 2026

Copy link
Copy Markdown
Owner

All seventeen findings from the poka-yoke audit, each with the device its
rung called for.

Closes #705, #706, #707, #708, #709, #710, #711, #712, #713, #714, #715, #716,
#717, #718, #719, #720, #721.

Every guard here has a test that has watched it fail — a planted mutation,
or a fixture that reaches the refusal arm. That is the audit's own standard: a
guard nobody has watched fail is a guard nobody should trust.

#706 — a password change left its tokens' sockets streaming

Revocation had two halves: delete the row, and mark the id in an in-process
set the /ws tick reads. handleRevokeAPIToken did both.
handleChangePassword, which calls DeleteAllAPITokens, marked nothing.

A socket opened with an admin token kept receiving admin-shaped events until it
or the process died — while the response correctly reported zero surviving
tokens. The revoke handler had predicted it word for word:

a second deletion path that did not do this would be a silent hole

The set is gone. The tick asks the store (db.APITokenExists) the way the
session half beside it already asks for users.token_epoch, failing closed on a
read error for the reason stated there. That is Control rather than Warning:
there is no second half left to forget, so resetadmin (#718) and whatever is
added next are honoured without being told to be. One indexed read per socket
per ping period — what the epoch check already costs.

Its test asserted the old premise and closed with "if something else now
writes to it, this test's premise is stale"
. Something did. It now pins the
stronger property — a deletion made anywhere ends the socket — and keeps the
half that still matters: this check closes sockets early and is not an
authorisation source.

#710requireScope passed a request with no principal

Its siblings requireSession and requireCSRF both fail closed on the same
condition. Every group carrying it today also carries requireAuth, so this
changes no live behaviour — it removes the affordance of mounting them apart or
in the wrong order. The p.token == nil arm is a different, deliberate case and
stays: a session principal is not a scoped token.

#714stopAux took a slot and a name that had to correspond

Two silent mistakes were available: e.stopAux(&e.preview, "recorder") compiles
and reads correctly while releasing the wrong port and leaking the preview's;
and the switch had no default, so a fourth consumer would skip its release
and leave reconcile believing the child was up.

One auxSlot value per consumer — no second argument to mismatch, no switch to
fall through. It also brings these three into line with every other subscriber
in the package, which stores its name at subscribe time rather than recomputing
a literal at teardown. The test table now names the production slots instead
of carrying its own copy of the same correspondence.

#720kill() gained the reaped-check its two siblings carry

killGroup issues a raw syscall.Kill(-pid, SIGKILL), naming a process group by
number and bypassing Go's ErrProcessDone. On a reaped pid that can signal a
group this supervisor never started. The safety argument was correct — and was a
comment spanning three functions. Now it is a guard.

#716 — and the regression it caused

Recorded rather than quietly fixed, because it is the general lesson.

Making the policy switch fail closed dropped every passthrough event — Log,
Status, Levels, Stats, Loudness — because wsPassthrough is iota, the zero
value, and had no case of its own: it had always reached the sender through the
default arm I was replacing. The opening burst on a fresh install delivered
nothing, and TestTheWebSocketOpeningBurstSurvivesZeroSources caught it.

That hazard is now a comment at the case: the arm being replaced has to be
enumerated first, and a zero-valued constant is the member most likely to be
sitting in it unnoticed.
wsPassthrough is explicit, and default still fails
closed for a constant added later — which was the point, since passing an
unknown policy through sends a read-scoped socket the admin shape.

#707 and #708 — the engine leaked a relay port per aux child, and Release had no owner

The pool is 500 ports shared across every engine, and Manager.Sync stops
an engine on every source delete while the daemon keeps running — so a port an
engine fails to return is gone for the life of the process. Nothing reports it
until Allocate starts failing, and then it fails everywhere at once and
reads as an unrelated fault.

Four kinds were not returned. Destinations, loudness, clips, captions, feeds,
silence, backup and playlist went through a teardown that released; the
recorder, preview, meters and renditions went through a bare stop helper that
only called Stop. Measured before the fix: three ports plus one per
rendition leaked per shutdown
, and the hub kept their subscriptions.

Engine.allocPort/releasePort now keep a per-engine ledger, so a release
names a port this engine holds or is refused and logged (#708). Release
took a bare int and did delete(a.held, p), so releasing twice silently
un-held a port the pool may have since handed to a different engine — two
engines on one UDP port, which is what the allocator's bind probe exists to
prevent. The probe only masks it while the first owner's child is bound; during
restart backoff, or between Allocate and Start, it is open. StopWithin
carries a post-condition on heldPortCount.

The reachable double-release path: stopBackup deliberately does not clear
d.backupPort, so the struct still names a released port and a later teardown
releases it again.

#709 — a mutation whose reconcile failed answered with a plain success

Sixteen handlers called s.reconcile() and two spellings lived side by side
in one package
: three turned the error into a 500, twelve logged it at Warn
and returned success. Nothing in the signature said which was right, so a
handler written by copying its nearest neighbour got whichever that neighbour
was.

The silent spelling is worse than it looks. Engine.Reconcile returns early on
a reconcileOutputs error, so preview, clips, captions and loudness are skipped
with it — and reconcile is event-driven with no ticker, so the failure is
never retried. Stored state and the running FFmpeg diverge until the next
successful mutation or a restart, while the response said 200 and the UI raised
a green toast.

The worst case was invisible: handleDeleteDestination returned
{"status":"deleted"}, the row left the list, and the FFmpeg child kept
publishing to a destination the console no longer draws.

Server.reconcileNow is now the only caller of s.reconcile(). writeMutation
folds its sentence into the response through the marshalled form rather than
a field on each of a dozen response types — adding a field per type is the same
per-site decision being removed. expert.go shipped Applied: true as a
literal beside a comment claiming "the command shown back is the one the
reconcile above just started"
, false on exactly the swallowing branch.

The UI half is in the transport, not at the call sites: api.ts's
request() raises the amber toast on reconcileFailed, so all fifteen routes
and every route added later are covered without being told.

#711 — a relay subscriber name collision silently replaced one consumer

h.subs[name] = &subscriber{...} was a bare map assignment. The replaced
consumer keeps running, keeps a correct command line, keeps a green card — and
receives nothing. The hub logged "relay subscriber added" either way, so the
log positively confirmed the wrong thing
.

Three devices already existed to avoid the collision and all three were rung
zero: a naming convention, a lock, and a comment. It had bitten twice.

Subscribe refuses an occupied name. Thirteen production call sites change
signature; each already had a release-and-bail path for the allocator refusing a
port. Unsubscribe was the mirror — delete on an absent key is a no-op and
the line said "removed" regardless.

A test case asserted the hazard: "re-subscribing the same name replaces rather
than duplicates."
Replacing is the failure.

#715 — an unreadable hook secret degraded to an unsigned POST

Restore a backup taken on another machine, or rotate secret.key, and every
webhook went out unsigned. scanHook loaded the row with an empty Secret
and said nothing; attempt skips the signature header on an empty secret.

At the far end that is indistinguishable from a forgery. Hook.SecretUnreadable
is the device Destination.KeyUnreadable already is. Three places refuse it,
and attempt — the one place the signature is decided, reached by both delivery
and the operator's Test button — is what makes an unsigned delivery unreachable
rather than merely filtered.

#717 — the census covered one of about twenty-five spawn sites

enrol/discharge were unexported inside internal/supervisor, so one spawner
could use it — and internal/ffmpeg could not have, because supervisor imports
ffmpeg. Its own comment framed it as "WHAT HAVE WE ACTUALLY SPAWNED?", true
only for supervisor children, while the shutdown report said nothing at all on a
clean stop.

internal/childcensus is a leaf package every spawner can reach. Media
transcodes and the transcription and live-caption workers enrol.
TestEverySpawnSiteIsAccountedFor walks the tree and fails the build for a
package that spawns without enrolling and without a stated reason: seven silent
omissions became seven stated decisions.

#719 — the UI reported a failed read as an empty result

lib/readState.ts exists to make .catch(() => setX([])) unwriteable.
DestinationDialog.tsx never imported it and had five. Two drove the
operator to act on a claim the server never made: "No shared encodes yet.
Create one on the Renditions page first."
and a Connect account button for
an account already linked, which discards the half-filled dialog.

What enforced the rule was a list of three filenames — training, not a device,
and it did not know this file existed. readState.shape.test.ts walks every
file and matches on the shape, and found nine more the audit never named.
One was a real bug: the Dashboard's metadata composer hides itself on null but
the catch stored [], so a failed read rendered the composer with zero
platforms.

#705fsperm's ACL builder stored SIDs as unpinned integers

TrusteeValueFromSID converts a *windows.SID to a uintptr, and x/sys says
it in as many words: "The caller must pin sid using a runtime.Pinner for the
lifetime of the TrusteeValue."
Both SIDs are Go-allocated and collectable, and
the EXPLICIT_ACCESS outlives the call.

The pinner is taken in restrict rather than in grantFull — the version that
looks correct and is not, because a pinner there unpins on return, before
ACLFromEntries ever reads the trustee. Consequence was a wrong DACL, not heap
corruption, on the files holding every stream key.

#712destWritesAFile failed open on a kind this build does not know

db.DestKind and ffmpeg.DestKind are declared independently and joined by a
conversion that compiles for any string, so a fifth kind reaches the default.
Returning false there was wrong for the reason the function's own comment
gives: without the confinement an audio file target is written relative to the
process working directory, outside the directory every other file destination is
held to. An unknown kind is now treated as a file writer and confined, and
RTMP/SRT are named rather than left to the default so the exhaustiveness is
visible to a linter.

#718resetadmin could not say what still had access

It printed "every existing session has been signed out" and stopped. True and
incomplete: the epoch ends sessions, while API tokens are resolved by hash
alone and survive. This is the command an operator reaches for when they cannot
sign in — the compromise case — and it was the one path that could not say what
still reached the install.

It now always lists the surviving tokens and how to end them, mirroring the
disclosure the HTTP handler already performs, and --revoke-api-tokens does the
ending. Opt-in rather than implied, for the reason that handler argues: routine
rotation is the common case and destroying every integration's credential is the
wrong default for it.

Its two failure arms matter most, because they fire exactly when the command
cannot answer the question — and an operator not told that silence means
unknown reads it as nothing. Both are tested, reached by breaking the token
table in two ways that survive db.Open.

#721isSetup and setupSlot are two switches over one closed type set

Folded into a single condition in cacheSetup, which made "ordinary media" and
"setup with no slot" indistinguishable. A test now asserts they agree on every
handled type. The runtime log the issue also proposed was not taken:
stream has no logger, threading one in for this is not worth it, and a
build-time comparison catches the disagreement when it is written rather than
when it hangs somebody's ffprobe.

#713 — the code disproved the finding as filed

Recorded rather than quietly fixed. The issue says Rumble, Trovo and Vimeo are
"silently exempt from loudness checking" and should get policy rows. Reading
internal/db's own constants says otherwise: Rumble "exists for CHAT and for
nothing else"
, Vimeo "for SIGN-IN", and Trovo can fetch a stream key but not
the ingest URL beside it — so all three are pasted by hand and behave as custom
destinations do. They take the custom row deliberately.

The hazard it identified is still real, and it is the other half of its own
sentence: nothing distinguished "custom by choice" from "custom because the
table forgot you". So the device is a test that forces the decision to be
stated — an opt-out list carrying the reason per platform, with a second
test refusing a placeholder reason and refusing to let the list grow past four.
Three silent omissions become three stated decisions, and a fourth fails.

Verification

Mutation-tested, each red before the fix: the socket no longer asking the
store; passthrough falling back to the closed default; the aux ports not being
released; the rendition port not being released; a port not held being released
anyway; a type dropped from either side of the setup switch pair; a platform
removed from the routing decision list.

Coverage for the guards themselves, since a guard nobody has watched fail is
a guard nobody should trust: APITokenExists on a live, deleted, zero and
absent id; tokenRevoked's fail-closed arm, reached by closing the store
underneath it; requireScope with no principal in the context; eventView's
explicit passthrough case and both fail-closed arms — the unclassified type,
and a policy constant with no case, reached by planting one in the table;
destWritesAFile on a kind this build does not know; kill() on a reaped pid,
tested against the guard directly because the supervisor clears p.exited
during teardown and waiting for a real reap races the field the guard reads.

internal/api, internal/engine, internal/supervisor, internal/db and
cmd/polyemesis all green.

https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL

Closes #706. Closes #710. Closes #714. Closes #716. Refs #720.

Five of the sixteen findings in the 2026-09-04 audit, chosen as the ones whose
device reaches CONTROL for a bounded cost. The rest are filed with their
proposed rung and are not in this change.

#706 -- A PASSWORD CHANGE LEFT ITS TOKENS' SOCKETS STREAMING. Revocation had two
halves: delete the row, and mark the id in an in-process set the /ws tick reads.
handleRevokeAPIToken did both; handleChangePassword, which calls
DeleteAllAPITokens, marked nothing. A socket opened with an admin token kept
receiving admin-shaped events until it or the process died, while the response
correctly reported zero surviving tokens.

The revoke handler had predicted it exactly: "a second deletion path that did
not do this would be a silent hole." handleChangePassword was that path.

The set is now GONE. The tick asks the store (db.APITokenExists), the way the
session half beside it already asks for users.token_epoch, and fails closed on a
read error for the reason stated there. That is Control rather than Warning:
there is no second half left to forget, so resetadmin (#718) and whatever is
added next are honoured without being told to be. Cost is one indexed read per
socket per ping period, which is what the epoch check already costs.

Its test asserted the old premise -- that a deletion bypassing the handler is
invisible -- and closed with "if something else now writes to it, this test's
premise is stale". Something did. It now pins the stronger property, that a
deletion made anywhere ends the socket, and keeps the half that still matters:
this check closes sockets early and is not an authorisation source.

#710 -- requireScope passed a request with NO PRINCIPAL. Its siblings
requireSession and requireCSRF both fail closed on the same condition. Every
group carrying it today also carries requireAuth, so this changes no live
behaviour; it removes the affordance of mounting them apart or in the wrong
order. The p.token == nil arm is a different case and stays: a session principal
is not a scoped token.

#714 -- stopAux took (slot, name) and recovered the rest from a switch. Two
silent mistakes were available: e.stopAux(&e.preview, "recorder") compiles and
reads correctly while releasing the wrong port and leaking the preview's, and
the switch had no default, so a fourth consumer would skip its release and leave
reconcile believing the child was up. One auxSlot value per consumer means there
is no second argument to mismatch and no switch to fall through. It also brings
these three into line with every other subscriber in the package, which stores
its name at subscribe time rather than recomputing a literal at teardown. The
test table now names the PRODUCTION slots instead of carrying its own copy of
the same correspondence.

#720 -- kill() gained the reaped-check its two siblings carry. killGroup issues
a raw syscall.Kill(-pid, SIGKILL), which names a process group by number and
bypasses Go's ErrProcessDone; on a reaped pid that can signal a group this
supervisor never started. The safety argument was correct and was a comment
spanning three functions. Now it is a guard.

#716 -- AND THE REGRESSION IT CAUSED, recorded rather than quietly fixed.
Making the policy switch fail closed dropped every passthrough event -- Log,
Status, Levels, Stats, Loudness -- because wsPassthrough is `iota`, the zero
value, and had no case of its own: it had always reached the sender through the
default arm being replaced. The opening burst on a fresh install delivered
nothing, and TestTheWebSocketOpeningBurstSurvivesZeroSources caught it.

That is the general hazard of tightening a default, and it is now a comment at
the case: the arm being replaced has to be enumerated first, and a zero-valued
constant is the member most likely to be sitting in it unnoticed. wsPassthrough
is explicit now, and default still fails closed for a constant added later --
which was the point, since passing an unknown policy through sends a read-scoped
socket the admin shape.

Two mutations, each red: the socket no longer asking the store, and passthrough
falling back to the closed default. internal/api, internal/engine,
internal/supervisor and internal/db all green.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
Copilot AI lite review requested due to automatic review settings September 4, 2026 23:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

A couple of newly introduced/modified documentation comments are now misleading (e.g., token existence and WS revocation tick behavior) and should be corrected to avoid future maintenance mistakes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR implements four targeted fixes from the poka‑yoke audit (plus the noted #716 regression follow-up), tightening correctness in WebSocket revocation/policy handling, auth middleware ordering safety, engine aux teardown safety, and supervisor signalling safety.

Changes:

  • WebSocket hardening: replace the process-local revoked set with a store-backed APITokenExists check, and make unknown WS policy values fail closed (with an explicit wsPassthrough case).
  • Auth & supervisor safety: make requireScope fail closed when there is no principal, and add a reaped-check guard to Process.kill() before issuing syscall.Kill(-pid, SIGKILL).
  • Engine aux teardown safety: replace (slot, name) teardown with a single auxSlot descriptor to prevent mismatched teardown/unsubscribe/release, and update lifecycle tests accordingly.
File summaries
File Description
internal/supervisor/supervisor.go Adds a reaped-check guard to kill() to avoid signalling unrelated process groups.
internal/engine/engine.go Refactors aux teardown to use auxSlot and eliminates name/slot mismatch hazards.
internal/engine/lifecycle_test.go Updates aux teardown tests to use production auxSlot values and validate isolation.
internal/db/tokens.go Adds APITokenExists store query to support WS revocation via the DB.
internal/api/ws.go Switches WS revocation check to tokenRevoked (store-backed).
internal/api/ws_revoke_test.go Updates revocation tests to assert “deletion anywhere ends the socket” property.
internal/api/ws_policy.go Makes passthrough explicit and defaults to fail-closed for unknown policies.
internal/api/token_handlers.go Removes the revoked-set write (set no longer exists).
internal/api/api.go Removes revoked set, adds tokenRevoked store-backed logic, and makes requireScope fail closed on missing principal.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/api/ws.go
// as a reason not to return would keep the socket that this branch
// exists to end.
if s.isRevoked(tokenID) {
if s.tokenRevoked(tokenID) {
Comment thread internal/db/tokens.go
Comment on lines 160 to +161
// DeleteAPIToken revokes a token.
// APITokenExists reports whether a token row is still there.
Closes #705. Closes #712. Closes #713. Closes #718. Closes #721.

#705 -- fsperm's ACL builder stored SIDs as unpinned integers.
TrusteeValueFromSID converts a *windows.SID to a uintptr and x/sys says in as
many words: "The caller must pin sid using a runtime.Pinner for the lifetime of
the TrusteeValue." Both SIDs are Go-allocated and collectable, and the
EXPLICIT_ACCESS outlives the call. The pinner is taken in restrict rather than
in grantFull -- the version that looks correct and is not, because a pinner
there unpins on return, before ACLFromEntries ever reads the trustee. Consequence
was a wrong DACL, not heap corruption, on the files holding every stream key.

#712 -- destWritesAFile now fails CLOSED on a kind this build does not know.
db.DestKind and ffmpeg.DestKind are declared independently and joined by a cast
that compiles for any string, so a fifth kind reaches the default. Returning
false there was wrong for the reason the function's own comment gives: without
the confinement an audio file target is written relative to the process working
directory. An unknown kind is now treated as a file writer and confined, and
RTMP/SRT are named rather than left to the default so the exhaustiveness is
visible.

#718 -- resetadmin printed "every existing session has been signed out" and
stopped. True and incomplete: the epoch ends SESSIONS, while API tokens are
resolved by hash alone and survive. This is the command an operator reaches for
when they cannot sign in, which is the compromise case, and it was the one path
that could not say what still had access. It now always lists the surviving
tokens and how to end them, mirroring the disclosure the HTTP handler already
performs, and --revoke-api-tokens does the ending. Opt-in rather than implied,
for the reason that handler argues: routine rotation is the common case and
destroying every integration's credential is the wrong default for it.

#721 -- isSetup and setupSlot are two switches over one closed type set, folded
into a single condition in cacheSetup that made "ordinary media" and "setup with
no slot" indistinguishable. A test now asserts they agree on every handled type.
The runtime log the issue also proposed was NOT taken: `stream` has no logger,
threading one in for this is not worth it, and a build-time comparison catches
the disagreement when it is written rather than when it hangs somebody's
ffprobe. Two mutations, each red -- dropping a type from either switch.

#713 -- AND THE CODE DISPROVED THE FINDING AS FILED, which is recorded rather
than quietly fixed. The issue says Rumble, Trovo and Vimeo are "silently exempt
from loudness checking" and should get policy rows. Reading internal/db's
constants says otherwise: Rumble "exists for CHAT and for nothing else", Vimeo
"for SIGN-IN", and Trovo can fetch a stream key but not the ingest URL beside
it, so all three are pasted by hand and behave as custom destinations do. They
take the custom row deliberately.

The hazard the issue identified is still real, and it is the other half of its
own sentence: nothing distinguishes "custom by choice" from "custom because the
table forgot you". So the device is a test that forces the decision to be
STATED -- an opt-out list carrying the reason per platform, with a second test
refusing a placeholder reason and refusing to let the list grow past four. Three
silent omissions become three stated decisions, and a fourth fails. Two
mutations red.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…le release

#707 and #708, plus coverage for the devices the previous commit installed.

THE PORT LEDGER. The relay pool is 500 ports shared across every engine, and
Manager.Sync stops an engine on every source delete while the daemon keeps
running -- so a port an engine fails to return is gone for the life of the
process. Nothing reports it until Allocate starts failing, and then it fails
everywhere at once and reads as an unrelated fault.

Four kinds were not returned. Destinations, loudness, clips, captions, feeds,
silence, backup and playlist went through a teardown that released; the
recorder, preview, meters and renditions went through a bare `stop` helper that
only called Stop. Measured before the fix: three ports plus one per rendition
leaked per shutdown, and the hub kept their subscriptions.

Engine.allocPort/releasePort now keep the ledger, so a release names a port THIS
engine holds or is refused and logged (#708). Release took a bare int and did
delete(a.held, p), so releasing twice silently un-held a port the pool may have
since handed to a different engine -- two engines on one UDP port, which is what
the allocator's bind probe exists to prevent. The probe only masks it while the
first owner's child is bound; during restart backoff it is open. StopWithin
carries a post-condition on heldPortCount.

Each of the three leaks was mutation-tested red before the fix went in.

COVERAGE FOR THE DEVICES. Eight guards from the previous commit had no test that
had watched them fail, which is the one thing this audit says not to ship:

  - APITokenExists on a live, deleted, zero and absent id (#706)
  - tokenRevoked's fail-closed arm, reached by closing the store underneath it
  - requireScope with no principal in the context (#710)
  - eventView's explicit wsPassthrough case, and BOTH fail-closed arms -- the
    unclassified type, and a policy constant with no case, reached by planting
    one in the table (#716)
  - destWritesAFile on a kind this build does not know (#712)
  - kill() on a reaped pid, against the guard directly: the supervisor clears
    p.exited during teardown, so waiting for a real reap races the field the
    guard reads (#720)
  - resetadmin's disclosure when it cannot revoke and cannot read the token
    list, reached by breaking the table in two ways that survive db.Open (#718)

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
@rainmanjam rainmanjam changed the title fix: four devices from the poka-yoke audit (#706 #710 #714 #716 #720) fix: twelve devices from the poka-yoke audit (#705–#708, #710, #712–#714, #716, #718, #720, #721) Sep 4, 2026
… to an unsigned POST

Closes #715.

Restore a backup taken on another machine, or rotate secret.key, and every
webhook silently went out UNSIGNED. db.scanHook loaded the row with an empty
Secret when box.Open failed and said nothing; dispatch.attempt skips the
signature header on an empty secret, so the delivery went anyway.

At the far end that is indistinguishable from a forgery. The signing secret was
the only thing that made the delivery trustworthy, and its absence is invisible
to the receiver: an endpoint that verifies rejects everything, and one that does
not now accepts anything anybody sends it. internal/db/platforms.go handled the
identical situation the other way and failed loud, so the two halves of the same
decision disagreed.

Hook.SecretUnreadable is the same device Destination.KeyUnreadable already is,
for the same reason and with the same shape: set by the scanner and never by a
column, because it is a fact about this process's key file rather than about the
row. Restore the key file and it goes away by itself, with no repair step and
nothing to un-set. The row still loads intact -- failing the read would take
every other hook down with it and turn a lost key file into a hooks page that
will not load.

Three places refuse it, and the order matters:

  - Validate, so reload never starts a worker and there is no queue for a
    delivery to sit in;
  - attempt, the one place the signature is decided and the only path BOTH
    delivery and the operator's Test button reach -- this is what makes an
    unsigned delivery unreachable rather than merely filtered;
  - reload logs at ERROR for this refusal and stays quiet for the others, since
    every other one is something the operator typed and was already told about
    at save time, while this one is a machine fact they can fix and would
    otherwise learn from a hook that stopped firing.

Three mutations, each red: scanHook not marking the row, Validate accepting it,
and attempt posting anyway.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…lently replacing the consumer

Closes #711.

Hub.SubscribeAddr was a bare map assignment:

    h.subs[name] = &subscriber{...}

Registering two consumers under one name REPLACED the first. It kept running,
kept a correct command line, kept a green card on the monitoring page -- and
received nothing. Nothing about the process, its target URL or its status
revealed it, and the hub logged "relay subscriber added" either way, so the log
positively confirmed the wrong thing.

Three devices already existed to avoid the collision and all three were rung
zero: a naming convention (destinations.go's role suffix), a lock (engine.go's
reconcileMu) and a comment (setup.go's note about the RTMP key variant). It had
bitten twice. The sink itself accepted the collision without a word.

Subscribe and SubscribeAddr now return (string, error) and refuse an occupied
name with ErrSubscriberExists, logging at ERROR with BOTH addresses -- the whole
difficulty of the old failure was that everything downstream looked healthy, so
the one place it can be seen is the refusal itself.

Thirteen production call sites change signature. Each one already had a
release-and-bail path for the allocator refusing a port, and the subscription
refusal takes the same path: release the port, and in the two places that record
a subscription name before starting the child (the selector feed and the
preview), clear it BEFORE the teardown can unsubscribe a name this consumer
never took -- which would cut off whoever actually holds it.

Unsubscribe is the mirror and was silent in the same way: delete() on an absent
key is a no-op and the line said "removed" regardless, so a teardown naming the
wrong subscriber read as successful cleanup. Not an error return -- every caller
is a teardown path that would either ignore it or abandon the rest of its
cleanup -- but it now says so at ERROR and removes nothing quietly.

A test case asserted the hazard: "re-subscribing the same name replaces rather
than duplicates". Replacing IS the failure. It now pins the refusal, that the
FIRST consumer keeps the name, and that the name is free again after
Unsubscribe. The new test asserts against the socket rather than the
bookkeeping, because the failure was a consumer going quiet while looking
healthy.

Test fixtures across relay, engine and rtmpserver take a mustSubscribe helper
rather than dropping the error: a fixture that silently fails to register would
be asserting fan-out against a hub with no consumer on it. playout's fakeHub
refuses collisions too, since a fake that accepts what production refuses hides
the bug.

Two mutations, each red: the bare map assignment restored, and Unsubscribe
reporting a removal it did not make.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…port says what it covered

Closes #717.

The census was added inside internal/supervisor with unexported enrol/discharge,
so exactly ONE of the roughly twenty spawn sites in this repository could use it
-- and internal/ffmpeg could not have used it even if it wanted to, because
supervisor imports ffmpeg and the import would have been a cycle.

Its own comment framed it as "WHAT HAVE WE ACTUALLY SPAWNED?" and the shutdown
report claimed it "would have said it on the first occurrence" of #631. Both
were true only for supervisor children. A transcode or a whisper child
surviving shutdown produced exactly the silence #631 produced -- while the
shutdown log actively reported that nothing was wrong. A detection device that
under-reports is worse than none, because its green is read as an all-clear.

Three parts:

  - internal/childcensus, a leaf package importing nothing from this module, so
    every spawner can reach it. Enrol and Discharge are exported.

  - The spawners that can outlive a call now enrol: media.Exec (every transcode
    and derivative encode), the live-caption whisper and audio tap, and the
    transcription extract and whisper workers. Discharge is paired at the Wait,
    which is the only moment the pid is genuinely gone.

  - TestEverySpawnSiteIsAccountedFor walks every non-test .go file in the tree
    and requires each package calling exec.Command to be either a package that
    enrols, or one listed WITH A REASON saying what bounds its child's life.
    Seven silent omissions become seven stated decisions and the twenty-first
    spawner fails the build. The excuses are themselves checked: a reason under
    40 characters, or one containing TODO, fails. An entry for a package that no
    longer spawns anything fails too, so the list cannot rot into a standing
    excuse nobody re-earns.

The walker skips every dot-directory, not just .git: .claude/worktrees holds
checkouts of this same repository, and walking them reported every spawn site
three times under a path nobody can act on.

reportSurvivingChildren now says so on a clean shutdown, and names its scope.
Its test asserted the opposite -- "a line here on every clean stop would teach
operators to skim past the one that matters" -- and that reasoning was sound
when the scope was narrow enough to make silence ambiguous. It now pins the new
property and records why it changed.

Control is not available: nothing in Go stops a package calling exec.Command.
This is Warning, raised when the code is written rather than when a child is
found on a host.

Also: coverage for the #711 refusal paths, which are the ones that had none.
Every aux consumer, the destination and its backup, the silence tier and a
playout variant, each driven into a taken name and each asserted on both halves
-- nothing started, and the port came back. Closing the collision hazard must
not open the leak hazard #707 was about.

Three mutations, each red: a spawner that stops enrolling, an excuse reduced to
a placeholder, and the clean shutdown going back to saying nothing.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…plain success

Closes #709.

Sixteen handlers called s.reconcile() and TWO SPELLINGS lived side by side in
one package: three turned the error into a 500, twelve logged it at Warn and
returned success. Nothing in the signature said which was right, so a handler
written by copying its nearest neighbour got whichever that neighbour was.

The silent spelling is worse than it looks. Engine.Reconcile returns early on a
reconcileOutputs error, so preview, clips, captions and loudness are skipped
with it; Manager.Reconcile returns firstErr. And reconcile is EVENT-DRIVEN WITH
NO TICKER -- engine.go says so in as many words -- so the failure is never
retried. Stored state and the running FFmpeg diverge until the next successful
mutation or a restart, while the response said 200 and the UI raised a green
toast.

The worst case was invisible: handleDeleteDestination returned {"status":
"deleted"}, the row left the list, and the FFmpeg child kept publishing to a
destination the console no longer draws.

ONE SPELLING. Server.reconcileNow is the only caller of s.reconcile() and
returns the sentence to hand the operator, empty on success. The three loud
sites turn a non-empty result into their 500; the rest attach it. What is gone
is the per-site choice about whether the failure is reported at all.

writeMutation folds the sentence into the response THROUGH THE MARSHALLED FORM
rather than a field on each of a dozen response types -- adding a field per type
is exactly the per-site decision being removed. It appends to `warnings`, which
two handlers already build and the SPA already renders, rather than inventing a
second array meaning the same thing, and sets reconcileFailed so the UI branches
on a flag rather than on an English sentence. writeMutationNoContent handles the
204 routes: a 204 has no body, so a failed reconcile becomes a 200 that can say
something rather than an empty success.

expert.go was the sharpest instance and is fixed as such. Applied shipped as a
hardcoded `true`, and a comment claimed "the command shown back is the one the
reconcile above just started" -- false on exactly the branch that swallowed the
error. Applied is now `rw == ""` and Warning carries the reason, which
MonitoringPage already renders.

scheduler.Result gains ReconcileErr, set on every schedule that FIRED in a sweep
whose reconcile failed. On every one, because the reconcile is a single call for
the batch: which of them took effect is not knowable, and saying so on each is
honest where attributing it to one would not be.

THE UI HALF IS IN THE TRANSPORT, not at the call sites. api.ts's request()
raises the amber toast when it sees reconcileFailed, so all fifteen routes and
every route added later are covered without being told. It reads the FLAG rather
than the array, so a warning the server attached for another reason -- a
DestinationDialog platform note -- is not doubled.

TestReconcileHasOneSpellingAndItsResultCannotBeDropped enforces both rules at
build time: s.reconcile() must have exactly one caller, and a bare
`s.reconcileNow(...)` statement fails, because Go will happily let you discard
the returned string and discarding it is precisely the mistake.

Warning rather than Control, deliberately: refusing the write would be wrong.
The row genuinely was saved, and a 500 invites a retry that re-POSTs a
destination. The honest ceiling is that the response states what did not happen.

Six mutations, each red: a handler dropping the warning, a second s.reconcile()
spelling reappearing, writeMutation dropping it, an existing warnings array
being overwritten, the transport not reading the flag, and the transport reading
the array instead of the flag.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…s the operator to act

Closes #719.

lib/readState.ts exists to make `.catch(() => setX([]))` unwriteable, and its
header calls itself "a CONTROL rather than a warning" because mayClaim is a type
guard. DestinationDialog.tsx never imported it and had FIVE of them.

Two of the five drove the operator to act on a claim the server never made:

  - a failed listRenditions() rendered "No shared encodes yet. Create one on the
    Renditions page first." and disabled the shared-encode radio, sending them
    to build a second real encode they already have;
  - a failed listAccounts() replaced the account picker with "Connect {platform}
    account" -- a re-authorisation for an account that is very likely already
    linked, and following it navigates away and discards the half-filled dialog.

All five reads now carry a ReadState. The arrays are derived with rowsOf, so
every consumer that only wants rows is untouched; mayClaim guards the two places
that say something, and readFailed gives each a sentence that distinguishes "we
could not ask" from "there are none".

WHAT ENFORCED THE RULE WAS A LIST OF FILENAMES. readState.test.ts asserted that
specific source strings were absent from SettingsPage.tsx and AutomationPage.tsx
-- three files, hand-maintained. That is training, not a device, and it did not
know DestinationDialog.tsx existed.

readState.shape.test.ts walks every .ts/.tsx under ui/src and matches on the
SHAPE instead, so the file added tomorrow is covered without being listed. It
found nine more instances the audit never named. Each is now a STATED decision
in an excuse map keyed by file AND setter -- a file-level key would have covered
DestinationDialog's other three by accident, which is exactly how the previous
enforcement missed it. Most turn out to be `null` used as a genuine third state
with the consumer already branching on it; those carry the reason, and the
reasons are themselves checked for length and placeholders, and for still
matching a read that exists.

One was a real bug. Dashboard's metadata composer holds `MetaTarget[] | null`
and `if (targets === null) return null` hides it entirely -- but the catch
stored [], so a failed read rendered the composer with ZERO platforms. That is a
positive claim that this broadcast has nowhere to push metadata. It is null now.

The guard bans okRead([]) inside a catch as well as a bare [], because adopting
ReadState and then storing a successful-looking empty read is the same mistake
wearing the fix, and the type no longer objects to it. Found by mutation: the
first version of the guard passed that spelling.

Comments are blanked before matching, with newlines preserved so reported line
numbers still point at the file: a guard that fires on prose describing the
hazard is a guard people delete rather than obey. It also carries positive and
negative controls over the pattern itself, because the regex IS the device and
one that quietly stopped matching would leave every assertion passing over
nothing.

Warning rather than Control: nothing in TypeScript stops a component holding a
bare array and assigning [] in a catch. The earliest a device can speak is when
the code is written.

Four mutations, each red: a dialog read storing [] again, the same read storing
okRead([]), an excuse reduced to a placeholder, and an excuse outliving the read
it excused.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
@rainmanjam rainmanjam changed the title fix: twelve devices from the poka-yoke audit (#705–#708, #710, #712–#714, #716, #718, #720, #721) fix: seventeen devices from the poka-yoke audit (#705–#721) Sep 5, 2026
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@rainmanjam
rainmanjam merged commit d353ba4 into main Sep 5, 2026
36 checks passed
@rainmanjam
rainmanjam deleted the fix/poka-yoke-audit-devices branch September 5, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fsperm: the Windows ACL builder stores SIDs as unpinned integers, against x/sys's documented contract

2 participants