You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Stream production-readiness: everything found, bucketed by launch stage
Umbrella for the findings from the 2026-08-30 deep audit — four parallel agents across dependency failure, recording-transfer integrity, client concurrency, and external Stream research — plus the edge cases raised directly in review.
How to read this. Work the buckets in order. Bucket 1 is what must be true before real money changes hands. Bucket 4 is what only matters once you have thousands of concurrent sessions, and building it earlier is waste. Each item states the failure, the evidence, and an honest size.
Every claim marked [verified] was reproduced against the live system or the installed dependency during the audit. Claims marked [code] were read but not executed. Claims marked [hypothesis] are reasoned and explicitly not confirmed.
These either move money incorrectly, lose customer data permanently, or lock a paying user out of what they bought.
1.1 — Eight money-reconciliation crons have never run · P0 · already filed as #1275
react@18.3.1 has no cache export, and lib/auth-server.ts:31 calls cache() at module scope. It resolves in-app only because Next aliases react to its vendored React 19 in the RSC layer. A bare npx tsx jobs/... process gets real React 18 and throws at import. [verified]
$ npx tsx -e "import('./jobs/payments/reconcile-payment-status.ts')"
IMPORT FAILS: (0 , import_react.cache) is not a function
That last one matters more than its name suggests. It is the durability backstop for the entire Stream webhook design — the webhook route acks fast and processes in after(), and its own comment argues, correctly, that safety comes from the WebhookEvent row plus this sweeper. The sweeper has never executed. So any webhook lost to a frozen Netlify instance is lost permanently.
Fix: make the memo lazy and tolerate cache being absent, where memoization is meaningless anyway (one call per process). Roughly ten lines in one file, and it unblocks all eight.
This is the highest value-per-line change available anywhere in this document. Most of Bucket 2 becomes hypothetical once it lands.
1.2 — The recording size cap is smaller than a normal recording · P0
lib/stream/recording-transfer-service.ts:27 — MAX_TRANSFER_SIZE = 500MB, and the bucket is provisioned to match. [code]
A 60-minute 720p composite runs roughly 450–900 MB, centre of mass ~700 MB. So a one-hour webinar is more likely than not rejected pre-flight, retried every six hours for fourteen days, paged to Sentry exactly once at hour ~18, and then silently expired when Stream deletes the source.
A consultant sells permanent access to a recording that no longer exists.
Fix: raise the constant and reprovision the bucket — ensureBucketExists only applies options at creation, so an existing bucket keeps its old limit and the code does not notice. Check your Supabase plan's per-object ceiling first: free tier caps at 50 MB, which would make this impossible rather than merely wrong.
1.3 — A transfer is marked successful without checking the bytes · P0
Nothing verifies the upload. No size comparison, no checksum, no ETag, no HEAD. [verified — zero matches for checksum|sha256|md5|etag in the file] The success gate is the absence of an error object, and the fileSize written to the database is Stream's Content-Length header — an assertion about the source, never a measurement of what landed.
Concretely reachable: if Stream responds 200 with no Content-Length, the size gate short-circuits on the falsy value, an empty body uploads cleanly, and a 0-byte MP4 becomes the permanent record of a paid session. Supabase signs URLs without checking existence, so the player looks fine until someone presses play.
Fix: one storage.list() on the path after upload, compare size to source, and only then write AVAILABLE. Roughly ten lines and one round trip. Also reject when the source sent no Content-Length rather than skipping the gate.
1.4 — Two concurrent transfers can permanently strand a recording · P1
The status write is a plain update, not a compare-and-swap, and recordTransferFailure writes status: READY unconditionally. [code] If transfer A succeeds and transfer B then fails, B stamps READY while leaving storageType: SUPABASE and a valid supabasePath.
Nothing exits that state: the URL resolver serves the dying Stream URL, every cron filters on storageType: STREAM_S3 and skips it, the manual route refuses with "already transferred", and the retention sweep eventually deletes the good object. Three writers can overlap — the six-hourly cron, the user's Transfer button, and the ready-time webhook kick — and the cron lock covers only cron-versus-cron, failing open when Redis is down.
Fix:updateMany with a status guard on both writes. Free, no new infrastructure, and it is the pattern the booking subsystem already mandates.
1.5 — Every retry writes a new random path; orphans are never collected · P1
storagePath embeds crypto.randomUUID(), so upsert: true never targets the same key twice. [code] A transfer that uploads and then dies before the database write leaves a full-size object nothing tracks and nothing deletes — a cost leak and a DPDP retention violation, since personal video data survives past the retention window in a bucket no one sweeps.
The codebase already reasoned this out correctly for preview assets and chose deterministic naming for exactly this reason. The main path does what that comment warns against.
Fix: deterministic path keyed on the recording id. One line, and it makes orphans structurally impossible.
1.6 — An empty room ends a session that is still running · P0 · fixed in #1277, verify after merge
Stream fires call.session_endedinactivity_timeout_seconds after the last participant leaves — 30 seconds on the live call type. [verified against the live call type] Every gate read endedAt alone, and handleSessionEnded marked the slot COMPLETED unconditionally.
So one party stepping out at 09:56 of a 10:00–11:00 booking ended it: both sides locked out with "This session has ended", the slot marked complete before the session began, review eligibility granted, and auto-complete closing the booking. In one direction nobody is refunded for a session they were locked out of; in the other the no-show detector auto-refunds in full against a consultant who was three minutes late.
Both halves are fixed in #1277 — isDeliberateEnd for the gates, and slot completion gated on the booked window. Verify in production after merge; this was the single most expensive finding in the audit.
1.7 — A dropped webhook causes a wrong auto-refund · P1
detect-consultant-no-shows decides on row existence alone: consultee has an attendance row, consultant does not. [code] Those two rows come from two separate webhook deliveries, potentially to different Netlify instances, each processed in its own after().
Lose only the consultant's and the predicate is satisfied exactly: the booking is CAS-cancelled and fully refunded for a session the consultant attended. It is idempotent, so it will not double-refund — but it is wrong, and reversing it means manually re-charging a customer. The sweeper that would repair the lost row is 1.1.
Fix: land 1.1, then require positive evidence of absence rather than a missing row. Consider staging automated refunds for human approval at current volume — it costs almost nothing and it is the right posture for automated money movement.
1.8 — Nobody joins, and the consultee is charged in full, silently, forever · P1
There is no consultee-no-show detector and no both-absent detector. [code]findNoShowCandidates requires a MeetingSession to exist at all, so if the room was never created it is not even a candidate. auto-complete-appointments then marks the booking COMPLETED.
This is the failure mode a first angry customer will find.
Fix: detect it and open a support ticket with both attendance records attached — do not auto-decide. Your own marketing copy commits to this: "We deliberately do not automate that decision, because a genuine connectivity failure and a no-show look identical to a script." Honour it; it is also the cheaper option.
Bucket 2 — Launching soon. Fix before hundreds of users.
2.1 — Redis and Stream share one circuit breaker · P0-by-severity, P2-by-urgency
lib/redis.ts declares a single module-level breaker object, and every Stream call routes through it. [code] Consequences run both ways: five Stream failures open it, and booking-lock acquisition uses the same breaker, so a video-vendor outage stops checkout. In the other direction a Redis outage reports as "Video is temporarily unavailable", and /api/health attributes it to Stream — pointing ops at the wrong vendor. Failure counts interleave, so three Stream errors plus two Redis errors trip it.
Note the breaker is close to inert today: state is per-instance in serverless and dies with the instance. That is why the coupling matters and the tuning does not.
Fix: give Stream its own breaker instance. Roughly twenty lines.
2.2 — Nothing distinguishes a billing refusal from an outage · P1
Only 404 and 429 are classified. [verified — no 402/403 handling anywhere under lib/stream*, actions/stream/, app/api/stream/] So a MAU cap, a declined card, or a suspended account falls into the generic branch: Sentry error, breaker trips, 30-second reset, half-open probe, trips again — flapping forever rather than surfacing as "we owe Stream money".
Fix: an isStreamBillingError helper treated like 429 (does not trip the breaker) but escalated to a distinct alert, because unlike 429 it does not self-resolve. Ten lines, and it prevents the most likely real outage at a pre-revenue company from presenting as an unreadable flap.
2.3 — The Stream URL is never refreshed after a failed download · P1
streamUrlExpiresAt is a local guess — wall-clock plus fourteen days at webhook receipt. The URL's real X-Amz-Expires is never parsed. [code] When a transfer 403s on a stale URL, nothing re-lists from Stream, even though Stream can mint a fresh link and may still hold the bytes. The machinery to do it already exists.
Fix: on any non-2xx, re-list, refresh the URL, retry once. Plus parse the real expiry so the deadline is a fact rather than an assumption you cannot see is wrong.
2.4 — Transfer alerting is blind to the most common failure, and goes quiet when the data dies · P1
[code] Mid-flight kills reset via the stale sweep, which does not increment transferAttempts — so they never reach the alert threshold. The alert is stamped once and never repeated. The "at-risk" gauge filters on not yet expired, so it returns to zero at exactly the moment the recording is lost. And the loss event itself emits a log line, not an alert.
Fix: alert when a permanent-policy recording reaches EXPIRED — a broken product promise should page. A STREAM_ONLY one expiring is normal and should stay a log line; the code currently cannot tell them apart.
2.5 — Navigating away mid-rejoin orphans a joined call with a live camera · P1
In the rejoin path, cancelled is checked afterjoin() and restoreDevices() but before the instance is stored. [code] Navigate away during the spinner and a joined Call with live tracks is stored nowhere. The page's cleanup tears down the old call from state, not the orphan. The capture light stays on.
lib/stream/media-teardown.ts exists specifically to prevent this and its docblock says so; this is the path that slips past it.
Fix: store the instance before join(), or leave it in a finally when cancelled.
2.6 — Two chat features are dead code · P1
client.on("*.**", handler) never fires. [verified in the installed SDK] — the wildcard key is the literal string "all", registered by the single-argument form. So the entire live channel-list updater and the debug stats are inert. useChatUnreadCount uses the correct form, which is why the badge updates while the list it points at does not.
Fix: one line each.
2.7 — Join has no in-flight guard · P1
The shared join hook has no ref, no dedup, no abort. [code] A double-click fires two concurrent mints. Idempotent call ids and a P2002 catch bound the damage — except that the ?? slot anchor fallback is explicitly documented as unsafe under exactly this race, which is how two people end up in different rooms.
ExitMeetingButton gets this right with a leavingRef. Fix: copy it. Four lines, one file, fixes every surface.
If call.end() fails, the loop continues before stamping endedReason: "maintenance" — but channels are frozen for all active sessions regardless, and the unfreeze selects only rows stamped maintenance within a six-hour window. [code] An appointment whose sessions all failed to end is frozen and never unfrozen. Any maintenance window longer than six hours has the same effect.
This is precisely the outcome the file's own docstring warns about: "permanently unwritable by every user AND every admin, with no visible cause."
2.9 — The MAU meter is driven by dashboard visits, not chat use · P1
Both dashboard layouts mount the Stream provider around the entire dashboard, so every user who opens their dashboard is upserted and connected whether or not they ever open chat or join a call. [code] The search route upserts every result — users who took no action. The sync cache is instance-local and resets on cold start, so the upsert re-fires.
Nothing caps growth. This is the meter Stream bills on.
Fix now: drop the upsert from the search route — one line, no capability lost, since the channel-create path upserts anyway. Fix when it costs money: move the provider onto the routes that need it, which the org dashboard already does.
Bucket 3 — Live with paying customers.
Webhook subscription drift now has a daily check (fix(ops): make the Stream cron fleet run, and visible #1274), but call.session_started is subscribed and deliberately unhandled, so actual session duration is never measured. Duration is computed from the scheduled start and only written to a log line. Overrun and underrun are invisible to analytics and to no-show logic. Handle the event and persist real bounds.
The ready-time transfer kick probably never completes. It runs inside a nested after() on a function with a ~26s ceiling, against transfers that need 25–60s. [hypothesis, high confidence] Measure it — count stale-reset warnings per day — before deciding. If confirmed, delete it; the six-hourly cron has fourteen days of margin and this is one of the three concurrent writers in 1.4.
The retry ladder ends in a dead end — four attempts, then a static error screen with no retry button, even though the retry event bus already exists and is unused from that screen.
Client and server disagree about the rejoin grace by 30 minutes. The server allows re-entry past the scheduled end; the dashboard button dies exactly at it. During an overrun that is the moment people need it. Export one shared constant.
The join gate is evaluated once, at page load. Membership is granted persistently and never revoked, so parking in the lobby past the window and then joining works. Low impact until you meter by the minute.
Attendance is keyed per user, not per device session, so two tabs produce one row and a lastLeftAt that lies while the user is still present. Inert today — nothing reads those fields — but it becomes a money bug the moment anything bills by duration. Add a schema warning now; do not build session-level attendance.
resetStreamConnection is never called, so an org switch keeps mounting the SDK context with a disconnected client.
"Access Denied" is shown for ordinary time-based refusals like "Join opens 10 minutes before the start time." The discriminant already exists and is discarded.
Bucket 4 — Scaling. Do not build this yet.
Recorded so the reasoning is not lost, and so nobody builds it early.
Resumable/TUS uploads. Transfers run on a datacentre runner with fourteen days of margin and ~56 free retries. TUS buys nothing here and costs a stateful upload session to manage.
Checksums on stored objects. After day 14 there is exactly one copy. A hash that proves the copy is bad, with no good copy to restore from, is a more precise way to learn you have lost the data. Size comparison catches every reachable failure.
A job queue or dead-letter for transfers. Attempt counter plus alert plus six-hourly retry is an adequate work queue at tens per day.
Cross-tab coordination — BroadcastChannel, SharedWorker, leader election. The observable cost of two tabs is echo, which the user fixes by closing one. A week of work and a new class of bugs to solve a self-correcting annoyance.
Per-device attendance tables. Solving a data-quality problem in fields no consumer reads.
Tuning circuit-breaker thresholds. State is per-instance and dies with the instance; at low traffic one instance rarely accumulates enough failures to trip. Fix the coupling (2.1), leave the numbers.
Re-litigating PG_POOL_MAX or pool sizing. Two investigations have already established the real cost is a platform-level cold-boot stall that memory, vCPU and lazy init all failed to move. The only proven mitigation is not invoking the function.
A duration-weighted "did this session really happen" model. A policy engine for a product with no policy yet, and every threshold picked now will be picked wrong.
A consultee-no-show auto-refund engine. Your marketing copy commits to not automating this.
Stream teams multi-tenancy. The setting exists and reads false; the ADR's "not available on our tier" is imprecise and worth one email to Stream support. It is a documented one-way door — do not flip it casually. Revisit if multi-org isolation becomes contractual.
The recordings product question
You asked whether the plan — Stream keeps two weeks, longer retention as a paid add-on transferred to Supabase by cron — is right. The model is sound. The implementation is not ready to sell against it.
The pricing logic is good: Stream's fourteen days is free, so charging for permanence prices the thing that actually costs you money. Keep it.
But today, selling that add-on means promising permanence backed by a pipeline that will reject a normal-length webinar outright (1.2), can record a zero-byte file as a success (1.3), and can strand a transferred recording where nothing will ever serve it (1.4). Do not enable the paid tier until Bucket 1's recording items are closed. They are four small, local edits in one file.
One strategic note worth a decision separately: Stream supports external storage — writing recordings directly to your own bucket, removing the download-reupload hop, the fourteen-day race, the size cap and the transfer cron entirely. That was considered and declined in favour of a cron backstop. The backstop has not been running. Worth revisiting before investing further in the transfer path.
Suggested order
1.1 — ten lines, unblocks eight crons including the webhook backstop. Everything else gets more reliable for free.
1.2 → 1.5 — four small edits in recording-transfer-service.ts, all local, all low-risk. Closes every recording data-loss path.
Stream production-readiness: everything found, bucketed by launch stage
Umbrella for the findings from the 2026-08-30 deep audit — four parallel agents across dependency failure, recording-transfer integrity, client concurrency, and external Stream research — plus the edge cases raised directly in review.
How to read this. Work the buckets in order. Bucket 1 is what must be true before real money changes hands. Bucket 4 is what only matters once you have thousands of concurrent sessions, and building it earlier is waste. Each item states the failure, the evidence, and an honest size.
Every claim marked [verified] was reproduced against the live system or the installed dependency during the audit. Claims marked [code] were read but not executed. Claims marked [hypothesis] are reasoned and explicitly not confirmed.
Related: #1270 (the fix train), #1275 (money-cron P0), #1278 (trial auto-complete).
Bucket 1 — Pre-MVP. Fix before anyone pays.
These either move money incorrectly, lose customer data permanently, or lock a paying user out of what they bought.
1.1 — Eight money-reconciliation crons have never run · P0 · already filed as #1275
react@18.3.1has nocacheexport, andlib/auth-server.ts:31callscache()at module scope. It resolves in-app only because Next aliasesreactto its vendored React 19 in the RSC layer. A barenpx tsx jobs/...process gets real React 18 and throws at import. [verified]Dead:
process-payouts,handle-stuck-payouts,reconcile-payout-status,reconcile-payment-status,sync-payment-earnings,handle-lost-disputes,reconcile-orphaned-confirmations,sweep-stuck-webhook-events.That last one matters more than its name suggests. It is the durability backstop for the entire Stream webhook design — the webhook route acks fast and processes in
after(), and its own comment argues, correctly, that safety comes from theWebhookEventrow plus this sweeper. The sweeper has never executed. So any webhook lost to a frozen Netlify instance is lost permanently.Fix: make the memo lazy and tolerate
cachebeing absent, where memoization is meaningless anyway (one call per process). Roughly ten lines in one file, and it unblocks all eight.This is the highest value-per-line change available anywhere in this document. Most of Bucket 2 becomes hypothetical once it lands.
1.2 — The recording size cap is smaller than a normal recording · P0
lib/stream/recording-transfer-service.ts:27—MAX_TRANSFER_SIZE = 500MB, and the bucket is provisioned to match. [code]A 60-minute 720p composite runs roughly 450–900 MB, centre of mass ~700 MB. So a one-hour webinar is more likely than not rejected pre-flight, retried every six hours for fourteen days, paged to Sentry exactly once at hour ~18, and then silently expired when Stream deletes the source.
A consultant sells permanent access to a recording that no longer exists.
Fix: raise the constant and reprovision the bucket —
ensureBucketExistsonly applies options at creation, so an existing bucket keeps its old limit and the code does not notice. Check your Supabase plan's per-object ceiling first: free tier caps at 50 MB, which would make this impossible rather than merely wrong.1.3 — A transfer is marked successful without checking the bytes · P0
Nothing verifies the upload. No size comparison, no checksum, no ETag, no HEAD. [verified — zero matches for
checksum|sha256|md5|etagin the file] The success gate is the absence of an error object, and thefileSizewritten to the database is Stream'sContent-Lengthheader — an assertion about the source, never a measurement of what landed.Concretely reachable: if Stream responds 200 with no
Content-Length, the size gate short-circuits on the falsy value, an empty body uploads cleanly, and a 0-byte MP4 becomes the permanent record of a paid session. Supabase signs URLs without checking existence, so the player looks fine until someone presses play.Fix: one
storage.list()on the path after upload, compare size to source, and only then writeAVAILABLE. Roughly ten lines and one round trip. Also reject when the source sent noContent-Lengthrather than skipping the gate.1.4 — Two concurrent transfers can permanently strand a recording · P1
The status write is a plain
update, not a compare-and-swap, andrecordTransferFailurewritesstatus: READYunconditionally. [code] If transfer A succeeds and transfer B then fails, B stampsREADYwhile leavingstorageType: SUPABASEand a validsupabasePath.Nothing exits that state: the URL resolver serves the dying Stream URL, every cron filters on
storageType: STREAM_S3and skips it, the manual route refuses with "already transferred", and the retention sweep eventually deletes the good object. Three writers can overlap — the six-hourly cron, the user's Transfer button, and the ready-time webhook kick — and the cron lock covers only cron-versus-cron, failing open when Redis is down.Fix:
updateManywith a status guard on both writes. Free, no new infrastructure, and it is the pattern the booking subsystem already mandates.1.5 — Every retry writes a new random path; orphans are never collected · P1
storagePathembedscrypto.randomUUID(), soupsert: truenever targets the same key twice. [code] A transfer that uploads and then dies before the database write leaves a full-size object nothing tracks and nothing deletes — a cost leak and a DPDP retention violation, since personal video data survives past the retention window in a bucket no one sweeps.The codebase already reasoned this out correctly for preview assets and chose deterministic naming for exactly this reason. The main path does what that comment warns against.
Fix: deterministic path keyed on the recording id. One line, and it makes orphans structurally impossible.
1.6 — An empty room ends a session that is still running · P0 · fixed in #1277, verify after merge
Stream fires
call.session_endedinactivity_timeout_secondsafter the last participant leaves — 30 seconds on the live call type. [verified against the live call type] Every gate readendedAtalone, andhandleSessionEndedmarked the slotCOMPLETEDunconditionally.So one party stepping out at 09:56 of a 10:00–11:00 booking ended it: both sides locked out with "This session has ended", the slot marked complete before the session began, review eligibility granted, and auto-complete closing the booking. In one direction nobody is refunded for a session they were locked out of; in the other the no-show detector auto-refunds in full against a consultant who was three minutes late.
Both halves are fixed in #1277 —
isDeliberateEndfor the gates, and slot completion gated on the booked window. Verify in production after merge; this was the single most expensive finding in the audit.1.7 — A dropped webhook causes a wrong auto-refund · P1
detect-consultant-no-showsdecides on row existence alone: consultee has an attendance row, consultant does not. [code] Those two rows come from two separate webhook deliveries, potentially to different Netlify instances, each processed in its ownafter().Lose only the consultant's and the predicate is satisfied exactly: the booking is CAS-cancelled and fully refunded for a session the consultant attended. It is idempotent, so it will not double-refund — but it is wrong, and reversing it means manually re-charging a customer. The sweeper that would repair the lost row is 1.1.
Fix: land 1.1, then require positive evidence of absence rather than a missing row. Consider staging automated refunds for human approval at current volume — it costs almost nothing and it is the right posture for automated money movement.
1.8 — Nobody joins, and the consultee is charged in full, silently, forever · P1
There is no consultee-no-show detector and no both-absent detector. [code]
findNoShowCandidatesrequires aMeetingSessionto exist at all, so if the room was never created it is not even a candidate.auto-complete-appointmentsthen marks the bookingCOMPLETED.This is the failure mode a first angry customer will find.
Fix: detect it and open a support ticket with both attendance records attached — do not auto-decide. Your own marketing copy commits to this: "We deliberately do not automate that decision, because a genuine connectivity failure and a no-show look identical to a script." Honour it; it is also the cheaper option.
Bucket 2 — Launching soon. Fix before hundreds of users.
2.1 — Redis and Stream share one circuit breaker · P0-by-severity, P2-by-urgency
lib/redis.tsdeclares a single module-level breaker object, and every Stream call routes through it. [code] Consequences run both ways: five Stream failures open it, and booking-lock acquisition uses the same breaker, so a video-vendor outage stops checkout. In the other direction a Redis outage reports as "Video is temporarily unavailable", and/api/healthattributes it to Stream — pointing ops at the wrong vendor. Failure counts interleave, so three Stream errors plus two Redis errors trip it.Note the breaker is close to inert today: state is per-instance in serverless and dies with the instance. That is why the coupling matters and the tuning does not.
Fix: give Stream its own breaker instance. Roughly twenty lines.
2.2 — Nothing distinguishes a billing refusal from an outage · P1
Only 404 and 429 are classified. [verified — no 402/403 handling anywhere under
lib/stream*,actions/stream/,app/api/stream/] So a MAU cap, a declined card, or a suspended account falls into the generic branch: Sentry error, breaker trips, 30-second reset, half-open probe, trips again — flapping forever rather than surfacing as "we owe Stream money".Fix: an
isStreamBillingErrorhelper treated like 429 (does not trip the breaker) but escalated to a distinct alert, because unlike 429 it does not self-resolve. Ten lines, and it prevents the most likely real outage at a pre-revenue company from presenting as an unreadable flap.2.3 — The Stream URL is never refreshed after a failed download · P1
streamUrlExpiresAtis a local guess — wall-clock plus fourteen days at webhook receipt. The URL's realX-Amz-Expiresis never parsed. [code] When a transfer 403s on a stale URL, nothing re-lists from Stream, even though Stream can mint a fresh link and may still hold the bytes. The machinery to do it already exists.Fix: on any non-2xx, re-list, refresh the URL, retry once. Plus parse the real expiry so the deadline is a fact rather than an assumption you cannot see is wrong.
2.4 — Transfer alerting is blind to the most common failure, and goes quiet when the data dies · P1
[code] Mid-flight kills reset via the stale sweep, which does not increment
transferAttempts— so they never reach the alert threshold. The alert is stamped once and never repeated. The "at-risk" gauge filters on not yet expired, so it returns to zero at exactly the moment the recording is lost. And the loss event itself emits a log line, not an alert.Fix: alert when a permanent-policy recording reaches
EXPIRED— a broken product promise should page. ASTREAM_ONLYone expiring is normal and should stay a log line; the code currently cannot tell them apart.2.5 — Navigating away mid-rejoin orphans a joined call with a live camera · P1
In the rejoin path,
cancelledis checked afterjoin()andrestoreDevices()but before the instance is stored. [code] Navigate away during the spinner and a joined Call with live tracks is stored nowhere. The page's cleanup tears down the old call from state, not the orphan. The capture light stays on.lib/stream/media-teardown.tsexists specifically to prevent this and its docblock says so; this is the path that slips past it.Fix: store the instance before
join(), or leave it in afinallywhen cancelled.2.6 — Two chat features are dead code · P1
client.on("*.**", handler)never fires. [verified in the installed SDK] — the wildcard key is the literal string"all", registered by the single-argument form. So the entire live channel-list updater and the debug stats are inert.useChatUnreadCountuses the correct form, which is why the badge updates while the list it points at does not.Fix: one line each.
2.7 — Join has no in-flight guard · P1
The shared join hook has no ref, no dedup, no abort. [code] A double-click fires two concurrent mints. Idempotent call ids and a
P2002catch bound the damage — except that the?? slotanchor fallback is explicitly documented as unsafe under exactly this race, which is how two people end up in different rooms.ExitMeetingButtongets this right with aleavingRef. Fix: copy it. Four lines, one file, fixes every surface.2.8 — Partial maintenance drain freezes chat permanently · P1
If
call.end()fails, the loopcontinues before stampingendedReason: "maintenance"— but channels are frozen for all active sessions regardless, and the unfreeze selects only rows stampedmaintenancewithin a six-hour window. [code] An appointment whose sessions all failed to end is frozen and never unfrozen. Any maintenance window longer than six hours has the same effect.This is precisely the outcome the file's own docstring warns about: "permanently unwritable by every user AND every admin, with no visible cause."
2.9 — The MAU meter is driven by dashboard visits, not chat use · P1
Both dashboard layouts mount the Stream provider around the entire dashboard, so every user who opens their dashboard is upserted and connected whether or not they ever open chat or join a call. [code] The search route upserts every result — users who took no action. The sync cache is instance-local and resets on cold start, so the upsert re-fires.
Nothing caps growth. This is the meter Stream bills on.
Fix now: drop the upsert from the search route — one line, no capability lost, since the channel-create path upserts anyway. Fix when it costs money: move the provider onto the routes that need it, which the org dashboard already does.
Bucket 3 — Live with paying customers.
call.session_startedis subscribed and deliberately unhandled, so actual session duration is never measured. Duration is computed from the scheduled start and only written to a log line. Overrun and underrun are invisible to analytics and to no-show logic. Handle the event and persist real bounds.after()on a function with a ~26s ceiling, against transfers that need 25–60s. [hypothesis, high confidence] Measure it — count stale-reset warnings per day — before deciding. If confirmed, delete it; the six-hourly cron has fourteen days of margin and this is one of the three concurrent writers in 1.4.lastLeftAtthat lies while the user is still present. Inert today — nothing reads those fields — but it becomes a money bug the moment anything bills by duration. Add a schema warning now; do not build session-level attendance.resetStreamConnectionis never called, so an org switch keeps mounting the SDK context with a disconnected client.Bucket 4 — Scaling. Do not build this yet.
Recorded so the reasoning is not lost, and so nobody builds it early.
BroadcastChannel,SharedWorker, leader election. The observable cost of two tabs is echo, which the user fixes by closing one. A week of work and a new class of bugs to solve a self-correcting annoyance.PG_POOL_MAXor pool sizing. Two investigations have already established the real cost is a platform-level cold-boot stall that memory, vCPU and lazy init all failed to move. The only proven mitigation is not invoking the function.teamsmulti-tenancy. The setting exists and readsfalse; the ADR's "not available on our tier" is imprecise and worth one email to Stream support. It is a documented one-way door — do not flip it casually. Revisit if multi-org isolation becomes contractual.The recordings product question
You asked whether the plan — Stream keeps two weeks, longer retention as a paid add-on transferred to Supabase by cron — is right. The model is sound. The implementation is not ready to sell against it.
The pricing logic is good: Stream's fourteen days is free, so charging for permanence prices the thing that actually costs you money. Keep it.
But today, selling that add-on means promising permanence backed by a pipeline that will reject a normal-length webinar outright (1.2), can record a zero-byte file as a success (1.3), and can strand a transferred recording where nothing will ever serve it (1.4). Do not enable the paid tier until Bucket 1's recording items are closed. They are four small, local edits in one file.
One strategic note worth a decision separately: Stream supports external storage — writing recordings directly to your own bucket, removing the download-reupload hop, the fourteen-day race, the size cap and the transfer cron entirely. That was considered and declined in favour of a cron backstop. The backstop has not been running. Worth revisiting before investing further in the transfer path.
Suggested order
recording-transfer-service.ts, all local, all low-risk. Closes every recording data-loss path.