Skip to content

release: dev -> prod 2026-09-01 - #1310

Merged
teetangh merged 60 commits into
prodfrom
release/dev-to-prod-2026-09-01
Sep 1, 2026
Merged

teetangh merged 60 commits into
prodfrom
release/dev-to-prod-2026-09-01

Conversation

@teetangh

@teetangh teetangh commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Release of dev into prod for 2026-09-01. 60 commits (55 non-merge + 5 merges), 332 files changed, +28,901 / −4,411.

Merge base: e6d9e82a (prod tip, PR #1264 release of 2026-08-27).

Warning

Do not merge until the production schema is pushed. This range renames two Postgres enum values and two Recording columns. Merging first will take reads of Recording.storageType and the four *Plan.recordingStoragePolicy columns down with P2023. See "Schema changes in this range" below.

What is shipping

Stream / video (19 PRs — the #1270 join outage + the end-to-end hardening train)

Support, feedback and reviews (the #705 / support-hub sweep, ~25 commits)

  • Swiggy-style support & feedback hub across all dashboards; error-envelope migration for the threads and tickets routes; one shared appointment authz gate with opaque ids
  • Chat turn-loss: eight distinct causes behind a support turn that never appeared, plus the ghost-cursor self-heal, re-present dedupe and ticket→thread reply mirroring
  • Speakable FAM- ticket reference numbers, IT-Rules-2021 SLA clocks (ack ≤ 24h, disposal ≤ 15d) with a pause clock while the ball is in the user's court
  • Deflection measurement: SupportFlowOutcome records every terminal turn in both scopes so bot efficacy is answerable
  • Per-session consultant reviews with a weighted published score gated on N ≥ 5 distinct rated sessions, plus the consultant's right of reply
  • Session taxonomy reaching ops; attendee no-show separated from consultant no-show
  • ADR 25 + engineering log for the sweep

Onboarding (5 commits)

  • Draft payload gets a shape contract and a __v generation marker; free-text fields capped; the byte budget is now backed by a pg_column_size CHECK; the user is shown when a draft was dropped or is too big to save

Moderation

Auth / performance

Infra, CI and tooling

Schema changes in this range

Caution

A production db push (plus two one-off SQL files, in order) is required BEFORE this merges. scripts/ci/check-db-drift.ts will fail the branch until the renames are applied.

Destructive / ordering-sensitive — must run prisma/sql/one-off/2026-08-30-rename-recording-storage-vendor.sql FIRST

Postgres has no ALTER TYPE ... DROP VALUE, and db push sees a column rename as drop + add, so these cannot be left to the push:

Change From To
RecordingStorageType enum value SUPABASE PLATFORM
RecordingStoragePolicy enum value SUPABASE_PERMANENT PERMANENT
Recording column supabaseUrl storageUrl
Recording column supabasePath storagePath

If the push runs first, the live enums keep both labels and the Prisma client refuses every read of the five columns typed by them (P2023).

Also recorded as a one-off — prisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sql

Purely additive, safe in either order, already applied to the shared dev DB:

  • Consultation.chatFrozenAt TIMESTAMPTZ NULL
  • Subscription.chatFrozenAt TIMESTAMPTZ NULL
  • organizations.chatRetentionDays INTEGER NOT NULL DEFAULT 365 (note the @@mapped table name)

New models

  • SupportTicketCounter (support_ticket_counters) — year Int @id, nextSeq Int @default(1), updatedAt. Per-year allocator behind the ticket reference series.
  • SupportFlowOutcomeid, scope VarChar(32), flowKey VarChar(64), terminalNodeId VarChar(64)?, reason VarChar(64)?, outcome SupportFlowOutcomeKind, userId (Cascade), organizationId? (SetNull), createdAt Timestamptz; indexes [createdAt, outcome], [flowKey, outcome], [userId, createdAt], [organizationId, createdAt].

New enums

  • SupportFlowOutcomeKindRESOLVED, ESCALATED
  • AppointmentFeedbackRoleCONSULTEE, PROVIDER

New enum values on existing enums

  • SupportThreadCategory += DOCUMENTS
  • ModerationActionType += USER_REINSTATED

New columns

SupportTicket

  • referenceNumber String? @unique @db.VarChar(20)
  • lastMessageAt DateTime?
  • ackDueAt, acknowledgedAt, resolutionDueAt, resolvedAt, closedAt, firstAgentReplyAt, awaitingUserSince — all DateTime? @db.Timestamptz
  • pausedSeconds Int @default(0)
  • assignedToId gains a real FK to User (onDelete: SetNull); it was a bare String

AppointmentSupportThread

  • lastMessageAt DateTime?
  • messageSeq Int @default(0)

SupportMessage

  • seq Int @default(0)
  • authorUserId String? + authorUser User? (onDelete: SetNull)

AppointmentFeedback

  • raterRole AppointmentFeedbackRole?

Organization

  • chatRetentionDays Int @default(365) (also in the one-off above)

ConsultantProfile

  • publishedRating Float?
  • ratingUnitCount Int @default(0)
  • reviewCount Int @default(0)
  • ratingAggregatedAt DateTime? @db.Timestamptz

ConsultantReview

  • appointmentId String? + appointment Appointment? (onDelete: SetNull)
  • ratingUnitId String?
  • replyBody String? @db.Text, repliedAt DateTime? @db.Timestamptz, replyDeletedAt DateTime? @db.Timestamptz

Consultation, Subscription

  • chatFrozenAt DateTime? @db.Timestamptz (also in the one-off above)

ModerationReport

  • streamMessageId String?
  • streamChannelCid String?

Constraint and index changes

  • ConsultantReview unique replaced: @@unique([consultantProfileId, consulteeProfileId])@@unique([appointmentId, consulteeProfileId]). Legacy rows carry a NULL appointmentId, so the old per-pair rule is preserved for exactly that band by a new partial unique index in the sidecar.
  • SupportTicket index replaced: single-column @@index([organizationId]) dropped in favour of @@index([organizationId, status]) (Postgres reads the composite's leftmost prefix). Added @@index([acknowledgedAt, ackDueAt]) and @@index([resolvedAt, resolutionDueAt]) for breach sweeps.
  • AppointmentFeedback index replaced: @@index([organizationId, createdAt])@@index([organizationId, raterRole, createdAt]).
  • New indexes: AppointmentSupportThread [status, lastMessageAt]; SupportMessage [threadId, seq] and [authorUserId]; ConsultantProfile [publishedRating]; ConsultantReview [consultantProfileId, deletedAt, ratingUnitId] and [appointmentId]; ModerationReport [streamMessageId].
  • prisma/sql/check-constraints.sql gains two entries that must be re-applied after the push (sidecars are never created by db push):
    • consultant_review_legacy_pair_key — partial unique on (consultantProfileId, consulteeProfileId) WHERE appointmentId IS NULL
    • onboarding_draft_payload_sizeCHECK (pg_column_size(payload) <= 65536) on onboarding_drafts

Merge method

Every one of the last five dev→prod releases (#1264, #1259, #1248, #1239, #1228) landed as a merge commit — each merge SHA has two parents. This one should too: squashing would permanently diverge prod's history from dev's.

Pre-merge checklist

  • Apply prisma/sql/one-off/2026-08-30-rename-recording-storage-vendor.sql to production
  • Apply prisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sql to production
  • Run the production db push
  • Re-apply prisma/sql/check-constraints.sql
  • Confirm scripts/ci/check-db-drift.ts is green
  • Merge with a merge commit, not a squash

teetangh and others added 30 commits August 21, 2026 12:55
Two scopes, one channel-agnostic engine (lib/support/):

- Per-appointment threads: 10 stage-gated flowcharts (no-show attendee/
  provider variants, payment status, recording access, quality, technical,
  org-party dispute), machine-readable escalation reasons, reason->priority
  policy, org-attributed ticket hand-off into the existing ops queue.
- Platform intake: stateless flowchart (server-validated cursor) ending in
  self-serve answers or a SupportTicket via the shared createSupportTicket
  factory; session-scoped issue types rejected (422) on the platform route.

Surfaces: SupportHub (Sessions/Platform subtabs, status buckets,
last-activity sort) in consultee/consultant/org-workspace trees; inline
thread status card + Get help on appointment detail; back-office
Conversations inbox (threads.manage) with AGENT reply mirroring and CAS
status sync both ways; org triage page (metadata-only per ADR 20) with CSAT
aggregate card and the org-party dispute entry. ReportIssueDialog retired;
legacy no-show path unified into the flowchart thread.

Schema (additive-only under #705 freeze): SupportTicket.organizationId +
index, lastMessageAt on SupportTicket and AppointmentSupportThread.
Notifications carry NotificationScope (ADR 23). Security suite pins the org
metadata allowlist; ADR-20 addendum documents the boundary.
…dialog, org-session picker

- Both support sheets: proper padding (header/body/composer were
  edge-to-edge — the ui/sheet variant ships no p-*).
- SupportThreadSheet gains appointmentHref → 'Go to appointment' header
  button; wired from the hub (personal trees) and the consultee adapter.
  Deliberately withheld on org-scoped rows and org surfaces (ADR 20: no
  per-session detail page for org roles).
- Platform tab: explicit 'New request' dialog (CreateTicketDialog) with the
  platform-only issue taxonomy — the old form died with the requests page
  the hub replaced; server 422 remains the backstop.
- Role gap found in audit: personal scope pins organizationId:null, so an
  org LEARNER/EXPERT had NO per-session support entry point. The hub now
  merges orgMember-scoped sessions for ACTIVE memberships (new
  GET /api/user/org-memberships), tags them with the org name, and sorts
  the merged picker by slot date.
…d reply mirror

Three bugs from live testing of the thread sheet:

1. Inputs looked ignored / prompts repeated forever: threads created before
   the flow rewrite carry cursors (and rendered option ids) that no longer
   exist in the registry — every choice mismatched and re-presented. The
   service now restarts at the entry when the persisted cursor isn't in the
   current flow (self-healing), and clicking an intent chip always restarts
   its flow instead of resuming a stale cursor.

2. Double bubble on one press: a second POST could land in the refetch
   window after isPending cleared but before options swapped. Server side,
   a re-present (cursor didn't move) is no longer persisted when the tail
   already is that prompt — one press can never append two identical
   bubbles. Client side, controls stay disabled until the refetch
   round-trips (turnPending = isPending || isSuccess&&isFetching).

3. 'Other side's replies' invisible: staff replying from the TICKET queue
   never reached the thread (only the Conversations-inbox path mirrored).
   Public ticket responses now mirror into the linked thread as AGENT
   messages + lastMessageAt bump; internal notes stay ticket-only. The two
   reply paths (ticket→thread, thread→ticket) now keep one history.
…onal

- platform intake route: add the missing supportError import, hoist flowId
  above the try so the catch can tag Sentry context (was TS2304 x8), drop
  the now-unused Sentry import
- lib/api/support-http.ts: one envelope for the support surface — user-safe
  copy + machine code + developer detail, with the Sentry policy (capture
  the original exception with stack for 5xx; warnings for contract-drift
  4xx; 401/429 are expected noise and stay uncaptured)
- SupportHub: remove {profileId ? null : null} (Sonar S3923) and the dead
  profileId plumbing through SessionsTab; prop stays in the public hub
  signature for caller parity
Sonar's duplication gate failed on the twin authorize copies in the
support + feedback routes. They collapse into lib/api/appointment-access.ts:

- authorizeAppointment(appointmentId) — participation check + privileged +
  org-party branch, opt-in via a typed flag so routes with no org-party
  surface (detail, feedback) cannot silently accept the operator grant
  (ADR 20). Success carries organizationId (CSAT attribution) and the
  already-loaded detail, so the detail route no longer pays a second read.
- supportError gains parseRouteParams: schema-parse route params once,
  answer INVALID_ID envelope on failure — replaces the copy-pasted blocks.

Param schemas move from inline z.object duplicates to schemas/support.ts.
Ids are length-bounded opaque strings (uuid in prod, readable slugs in
seeded demo DBs like demo0813-appt-ba): the .uuid() format check 400'd
valid sessions before the DB lookup could speak — the lookup is the real
validator. Feedback/detail adopt the coded envelope; console.error in
supportError keeps causes visible to devs running without a Sentry DSN.
The rest of the support surface joins the coded envelope, so every client
sees {error, code, detail?} with user-safe copy and dashboards can group
by code:

- staff inbox list: query enums were raw 'as' casts — an unknown value
  crashed Prisma into a 500; now zod-validated → VALIDATION_FAILED 400
- staff thread GET/POST/PATCH: threadId validated via SupportThreadIdParams
  (was never validated); 404/400/409 carry codes; closed-thread CAS refusal
  is CONFLICT; the CAS WHERE-clause discipline itself is untouched
- org triage: orgId validated via OrgIdParams; the handler gains the
  try/catch it never had (Prisma errors were escaping unhandled); the ADR-20
  metadata allowlist and its pinned comments are byte-for-byte unchanged
- user tickets: 500s no longer leak error.message to clients and stop
  mis-tagging Sentry as subsystem:auth; validation returns flattened zod
  detail instead of raw issues; the 422 SESSION_SCOPED_ISSUE contract is
  preserved verbatim; stray 'lib/prisma' import aliased to @/lib/prisma
The envelope was server-only; every consumer still hand-rolled
b?.error reads and generic 'Failed to load' strings. Now the split the
envelope promises actually happens at the UI boundary:

- throwSupportError(res, context) replaces the ad-hoc reads across the
  thread sheet, platform intake, CSAT card, status card, ticket dialog,
  staff inbox, hub tabs, and the appointment detail page: raw payload +
  status go to console.error (devtools), the thrown message is code-mapped
  copy users can act on (toasts / EmptyState)
- useFeedbackSupport read errorData.message, a field the API never sent —
  every failure toasted the fallback. It now maps via describeSupportError
  (code copy → server error → fallback)

INVALID_ID now renders as 'This session's link looks broken — open the
session from your dashboard and try again.' instead of raw server text.
The turnSchema's XOR refinement (!!option !== !!message) rejected the one
shape startFlow actually sends first: {flowId} alone. Every platform
intake conversation died on its first click with 'Some details are missing
or invalid' — invisible to CI because the route had zero tests. Only the
both-set case is invalid; entry turns carry neither.
- support-http: envelope shape, message overrides, the full Sentry policy
  (original exception with stack at error/warning, causeless 4xx as
  captureMessage warnings, 401/429 silent), and parseRouteParams' INVALID_ID
  answer — including the fix this suite forced: handlers pass the params
  PROMISE, so the helper must await it (safeParse on a thenable 400'd every
  real request; only plain-object tests could have missed it)
- error-copy: code-mapped copy wins, server error is the fallback, dev
  detail never reaches users, non-JSON bodies never throw
- appointment-access: coded failures; participants/staff skip membership
  lookups; the org-party grant is opt-in at the type level and demands an
  ACTIVE operations.read membership (ADR 20)
- appointment-support-route: REGRESSION pins demo0813-appt-ba (the preview
  toast) and uuid ids at 200 through GET+POST; over-long ids 400 before
  Prisma; FORBIDDEN maps through the envelope
All 40 inline + 3 outside-diff review comments triaged against current
code and fixed:

Security & integrity
- CSAT writes are participant-only: staff read access no longer becomes
  write access (a privileged non-participant's rating polluted the org
  aggregate); feedback POST verifies canAccessAppointment explicitly
- org-party authz results drop  entirely (typed) — an operator's
  grant can never reach recordings/payment/participants (ADR 20)
- platform intake: a forged/spoofed explicit orgId is now a 403 instead of
  silently downgrading to a B2C ticket; org inference restricted to the
  ORG_OPERATOR_BILLING flow; ticket spam budget charged at escalation only,
  so navigating a flow never spends it
- feedback-summary suppresses averages below MIN_COHORT=3 — an n=1 average
  is one member's exact rating (ADR 20)
- support-http: the 401/429 uncaptured exemption applies on the cause path
  too; 5xx detail stays server-side (Sentry-only), client-fault detail still
  rides the envelope
- recording 48h window verified server-side in runSupportTurn: claiming
  'within' after endsAt+48h re-anchors onto the escalation terminal

Correctness
- platform sheet preserves message.metadata.options — PROMPT nodes rendered
  with NO option buttons before this (free text couldn't advance them)
- staff inbox: ?page=abc NaN'd into a Prisma 500 (now falls back); status
  buckets apply the non-status filters so counts reconcile with pagination
- thread PATCH mirrors atomically (), CLOSED guard unconditional
  on the ticket mirror, resolvedAt preserved when closing a RESOLVED thread
- queue replies bump SupportTicket.lastMessageAt inside one transaction with
  the response + CAS + thread mirror; reply draft moved to React state
  (duplicate sends / cross-thread draft bleed fixed); bounded transcript
  (newest 50, ascending shape preserved)
- notification deep-links follow resolve-href doctrine (org → org
  appointments surface, B2C bare /dashboard)
- context.ts picks the current/next slot (endsAt > now), not a stale past
  SCHEDULED row; user free-text bumps lastMessageAt even on re-present turns
- createSupportTicket isolates notifyStaff failures (a committed ticket must
  not 500 into a duplicate retry); replay dedup for terminal turns
- SupportHub cache keyed by capped org ids (not membership count); controlled
  sheets render no trigger (focus return); intents hidden while gated GET is
  in flight; error states with retry across hub/triage/inbox/platform sheet;
  SelectGroup/SelectLabel for category headings; absolute feedback/help hrefs

Tests: mock fidelity (real appointmentAuthzError via requireActual),
clearAllMocks for Sentry history, conditional expects removed, dead mocks
dropped, params-PROMISE regression pinned. Docs: docs/support/support-hub.md
(architecture, envelope contract, invariants, test map).

NOTE: schema adds @@index([status, lastMessageAt]) — requires

> familiarise_web@0.2.0 db:push
> npm run db:push:schema && npm run db:sidecars

> familiarise_web@0.2.0 db:push:schema
> prisma db push

Datasource "db": PostgreSQL database "postgres", schema "public" at "aws-0-ap-south-1.pooler.supabase.com:5432"

🚀  Your database is now in sync with your Prisma schema. Done in 2.59s

> familiarise_web@0.2.0 db:sidecars
> npm run db:triggers && npm run db:constraints

> familiarise_web@0.2.0 db:triggers
> npx tsx -r dotenv/config scripts/db/apply-ledger-triggers.ts

✅ Applied ledger balance trigger (3 statements) from /Users/kaustavghosh/Desktop/familiarise_web/prisma/sql/ledger-triggers.sql

> familiarise_web@0.2.0 db:constraints
> npx tsx -r dotenv/config scripts/db/apply-check-constraints.ts

✅ Applied CHECK constraints (67 statements) from /Users/kaustavghosh/Desktop/familiarise_web/prisma/sql/check-constraints.sql per environment after merge.
…clock

Three follow-ups from the re-review of ab842c0:

- findRecentOpenEscalation passed organizationId through '?? undefined',
  which OMITS the Prisma filter for B2C replays — a personal retry could
  dedupe against an organization ticket for the same issue type. The value
  now passes directly, so null filters on organizationId: null.
- Regression from this branch's own slot fix: filtering the context's slot
  read to endsAt > now left COMPLETED sessions with endsAt: null — exactly
  the stage where RECORDING_ACCESS lives, so the server-side 48h window
  verification could never fire. buildSupportContext now falls back to the
  most recently ENDED scheduled slot (the delivered session's end) when no
  active slot exists; stage/startsAt semantics unchanged.
- docs: fenced code block language (markdownlint MD040).

Pinned by __tests__/support/create-ticket-dedup.test.ts.
# Conflicts:
#	app/api/user/support-tickets/route.ts
…er told

A scoping review of this PR found that the per-appointment half of the support
system was disconnected from the queue it feeds.

1. THE SESSION TAXONOMY WAS WRITE-DEAD.
   `escalate()` wrote its ticket with a raw `tx.supportTicket.create` carrying
   no `issueType`, so every escalation from an appointment thread reached ops
   as an untyped row — the Issue type column rendered "-" and a no-show was
   indistinguishable from a billing question except by reading the prose. All
   twelve members of SESSION_SCOPED_ISSUE_TYPES were therefore unreachable in
   the entire product, which made the platform form's 422 refusing them a door
   guarding an empty room.

   `issueTypeForReason` now lives beside `priorityForReason` — the file that
   already owns exactly this kind of one-line-per-reason policy. The money
   reasons map to the same types the platform intake assigns, so "charged
   twice" means one thing in the queue whichever surface raised it. An
   unclassified reason returns null rather than a catch-all: `no_flow` is
   genuinely unclassified and should read that way rather than be filed as
   OTHER.

2. APPOINTMENT ESCALATIONS NEVER NOTIFIED STAFF.
   Bypassing `createSupportTicket` also bypassed `notifyStaff`, so the flow
   told the user "our team will review and follow up here" and the team was
   never told. `notifyStaff` is now exported as `notifySupportStaff` and called
   from `escalate` AFTER the transaction commits — the create has to stay
   in-tx to be atomic with the thread's state change, and notifying inside it
   would page ops about a ticket a rollback then erased.

3. "TECHNICAL ISSUES" WAS THE ONE REAL LEAK ONTO THE PLATFORM FORM.
   TECHNICAL_ISSUES is not in SESSION_SCOPED_ISSUE_TYPES, so the platform
   dropdown offered a bare "Technical issues" under Account & Access while a
   since-deleted constant in the same file classified it as a Session Issue. A
   user whose CALL dropped picked it and filed a company-wide ticket with no
   session attached. Relabelled "Site or app not working", which is what the
   platform flow's own copy already describes; in-session audio/video trouble
   is COMMUNICATION_ISSUE, raised from the appointment.

   Deleted alongside it: ISSUE_TYPE_CATEGORIES (the contradicting
   classification), CONTEXTUAL_ISSUE_TYPES, ALL_ISSUE_TYPES and
   getFilteredIssueTypes — all four had zero consumers outside their own file.

4. TWO ISSUE TYPES WERE BANNED WITH NOWHERE TO GO.
   DOCUMENT_ISSUE and TIMEZONE_CONFUSION are refused on the platform form with
   a message telling the user to open the appointment's Get help — where no
   matching option existed. Neither had a flow anywhere in either registry.
   Added a `DOCUMENTS` intent (materials missing / wrong file, ungated because
   pre-reads matter before and handouts after) and a timezone branch under
   TECHNICAL, where it belongs: it is a display problem and it only matters
   before the session starts. `SupportThreadCategory` gains DOCUMENTS — an
   additive enum value under the #705 freeze.

5. POST-CALL AUDIO/VIDEO PROBLEMS HAD NO HOME.
   TECHNICAL is gated `notCompleted`, and QUALITY_COMPLAINT offered no A/V
   option, so once a session ended "the call was broken" could only be filed as
   "Something else". Added that option, mapping to COMMUNICATION_ISSUE.

   Also dropped the "Your rating and feedback stay private" clause from the
   poor-quality terminal: this flow never collects a rating, and the CSAT card
   on the same page does. Promising privacy for a rating we do not take here
   is a claim about the wrong object.

The two lists that decide where an issue can be raised — the server's
SESSION_SCOPED_ISSUE_TYPES and the client's PLATFORM_ISSUE_TYPE_CATEGORIES —
are still hand-written, because grouping and order are editorial. They are now
pinned by a test asserting they are disjoint and together cover the enum, so
adding a member must place it on exactly one surface.

Not addressed here, deliberately: the CSAT/thread overlap (every
QUALITY_COMPLAINT terminal still escalates, so leaving an opinion files a
ticket) and `consultationId` on escalated tickets, which the SupportContext
does not currently carry. Both are follow-ups.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exactly two `maxLength` attributes existed anywhere in the onboarding
component tree (both on the 500-char verification notes). Every other
free-text field was unbounded, which makes a pasted resume the one
realistic way a user reaches the 64KB draft-payload cap — and crossing
that cap does not warn or fail, it just stops autosaving for the rest of
the run while the resume banner keeps promising saved progress.

Caps go on the fields with no natural length: work-experience,
education and achievement descriptions, the consultant expertise
summary (2000 each), education activities and mentoring style (500).
Sized against what was already capped — `bio` at 160 for a one-liner,
verification notes at 500 for a paragraph — so 2000 reads as "several
paragraphs".

Each cap is paired: a Zod `.max()` so the server rejects it, and a
`maxLength` on the input that writes it so the browser refuses the paste
in the first place rather than letting the user finish a step and then
lose it to validation. Where a counter already existed next to a capped
field the same treatment is applied here.

`mentoringStyle` is the odd one out: it is part of the draft payload via
`consultantScalarFields` but has no onboarding input at all — the only
place it is edited is the consultant settings tab, so that is where its
`maxLength` lands.

Part of the #onboarding-ux draft-layer guardrails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion marker

`OnboardingDraft.payload` stays a JSON blob — the audit is unambiguous
that normalizing it is the wrong trade — but the blob was accepted as
`z.record(z.string(), z.unknown())`, which is not a contract at all. Two
consequences, both real:

1. Anything could be stored, including `__proto__` and `constructor`.
   `page.tsx` already carries a consumer-side `Object.hasOwn` guard
   precisely because a stored `role: "__proto__"` white-screens the
   wizard — including the "Start over" button that would recover it.
   Defending at the consumer is right; defending ONLY at the consumer
   means every future reader has to remember.
2. Keys accumulated forever. A field retired two wizard versions ago
   kept riding along in a row nobody reads field-by-field, eating the
   byte budget and confusing anyone who inspects one.

`OnboardingDraftPayloadSchema` fixes the shape without pretending a
draft is valid data — it deliberately is not, that is what a draft IS.
It asserts three things and nothing more: the key set is the wizard's
key set (unknown keys stripped), values stay optional and untyped, and
anything the wizard treats as an array is an array. That last one is the
promise consumers actually depend on: `weeklySlots.map(...)` and
`subDomains.filter(...)` run during render, so a scalar there is fatal.

The key set is DERIVED from `OnboardingFormDataSchema`, not hand-listed
(55 keys, 13 array-guarded), so a field added to a role branch becomes
draftable the moment it exists rather than being silently dropped until
someone remembers this file. A key is array-guarded only when every
branch declaring it agrees, so no branch can lose a legal value.

The sanitizer drops the poisoned keys at every depth as well, because
the schema only sees the top level — and `out["__proto__"] = {…}` is not
an ordinary property write, it re-parents the object being built.

`payload.__v` is the second half. Without it, a wizard change means old
payloads get spread into new form state and the user resumes onto a form
that LOOKS filled while old keys mean something else now — discovered at
submit-time validation, or not at all. Discarding costs one run of
re-typing; merging costs trust in every answer on the screen. So a
mismatch is quarantined and the caller is told, which is what lets the
UI say the form changed instead of showing an empty wizard under a "we
saved your progress" banner. A quarantine also rewinds `currentStep`, or
the user lands on a blank step 4 with no way to see what is missing.

The marker lives in the payload, not a new column: it describes the
blob, so it has to travel with the blob. It is stamped by the writer
after sanitization and stripped from caller input, so a client cannot
forge a stale payload into looking current.

Finally, `OVER_BUDGET` now reports which top-level key dominates the
payload. "Your progress is too large" is unactionable on its own — the
user cannot see the blob — so the rejection has to name a field.

Part of the #onboarding-ux draft-layer guardrails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… big to save

Both draft failures were observable only to us. `OVER_BUDGET` emitted a
Sentry breadcrumb and returned; the wizard carried on with the resume
banner still saying "we saved your progress", which was by then false
for every subsequent save. A breadcrumb tells the team. It does not tell
the one person who can fix it by shortening a field.

Two non-blocking banners:

* Over budget — names the field that dominates the payload (from the
  reason-carrying prepare result) and says plainly that finishing and
  submitting still works. Only RESUMING later is at risk, and blocking
  submission over a convenience cache would be a much worse trade than
  the one it prevents. Clears itself as soon as a save succeeds.

* Quarantined — the stored draft existed but came from a different
  wizard generation, so it was discarded rather than half-merged. This
  is the one case where "nothing restored" is not the same as "nothing
  was ever saved", and staying silent reads as the wizard losing data.

`resolveRegistryRole` keeps its `Object.hasOwn` guard, with its comment
corrected: the new payload schema strips poisoned KEYS at the storage
boundary, but `role` is a legitimate key whose VALUE stays untyped by
design, so `role: "__proto__"` is still expressible and this guard is
still the thing that stops it.

Part of the #onboarding-ux draft-layer guardrails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…why the blob stays

The 64KB budget lived entirely in `utils/onboarding-draft.ts`. That is
the right place for the product rule — it is what lets the wizard warn
the user instead of throwing — but it is application code guarding a
column any future writer can reach directly. `pg_column_size(payload)
<= 65536` is the backstop. It measures the stored, post-TOAST width, so
a compressible paste still gets in: this is a bound on the row, not a
second copy of the rule. Rides the sidecar like every other CHECK; no
migration.

The schema doc block also gains the two arguments that actually decided
"keep the blob" and were missing from it:

* The real tables cannot be reused as draft storage. `Achievement` and
  `SlotOfAvailabilityWeekly` both require a `consultantProfileId`, and no
  consultant profile exists until `processOnboardingData` runs at the
  very end of the wizard. Drafting into them would mean creating a
  half-built profile hours before the user commits to being a
  consultant — or duplicating each table, which is a mirror, not reuse.

* A mirror would not buy stronger typing. Every mirrored column has to
  be nullable, because a draft is incomplete by definition, and every
  enum column is a liability for the same reason (a half-picked
  ScheduleType has no legal value). Ten tables of that is not typed
  storage; it is weak typing spread across ten tables, ten sets of FKs,
  and a migration for every form tweak.

And it records the cost honestly, because "keep the blob" is a trade and
not a free win: the database enforces nothing about the contents, so
every consumer has to defend itself — the structural schema, the `__v`
marker, the byte gate and this CHECK, and prototype-chain-safe lookups
at every read site. The flexibility is paid for at each of them.

Part of the #onboarding-ux draft-layer guardrails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run format:check` is advisory in CI (continue-on-error), which is
how six of these files drifted out of shape on dev in the first place.
The new `.max(...)` calls in schemas/user.ts pushed that file over too,
so rather than leave a newly non-compliant file behind, every file this
branch touches is normalized. No behaviour change — verified by tsc and
the full suite before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hree dead UI states

Triage of the 13 open review threads. Nine were real; two of those were
correctness, and one was a data-ownership bug.

**Ticket links could be forged onto someone else's subscription.**
`POST /api/user/support-tickets` kept the caller's own `consultationId` /
`subscriptionId` as fallbacks when an `appointmentId` was also supplied. The
appointment only ever resolves ONE arm, so the other survived — and the
validation block below skips its ownership checks whenever `appointmentId` is
present, on the stated grounds that it was "already validated above". It was
not. Sending your own consultation appointment together with a stranger's
subscriptionId attached their subscription to your ticket, unchecked. The
appointment is now the sole source of the link, or there is no link.

**Ticket and thread status could disagree.**
The staff PATCH updated the ticket, then the linked thread, as two separate
writes. A failure on the second returned 500 with the ticket already moved, so
the queue and the user's conversation disagreed and the caller retried against
a change that had in fact landed. Both writes are now one transaction, with the
notification sent after it commits.

**A successful empty intent list was treated as an error.**
`SupportThreadSheet` fell back to its static chip list whenever the server
returned no intents. But an empty list is a correct answer — it means every
intent was gated out for this stage and role — so the fallback re-offered
exactly what the server had just withheld: a no-show chip on an upcoming
session, a recording chip on one that never started. It now triggers only on a
genuine error. `ORG_ADMIN_DISPUTE` in that list also gains `orgOnly`, which it
always needed: a plain B2C consultee was being offered "Raise a concern" and
got an instant human escalation off a chip the route would 403 anyway.

**A turn arriving after the sheet was reset repopulated it.**
The platform intake holds its cursor client-side for one sitting. Closing the
sheet or switching flows cleared the transcript, but an in-flight turn's
`onSuccess` wrote to it unconditionally — dropping the user back into a
conversation they had abandoned, with a stale cursor. A monotonic sitting
marker now rides along as a client-only mutation variable and stale results are
discarded.

**The conversations list rendered failures as emptiness.**
It branched only on the loading flag, so a failed fetch looked identical to "no
conversations match this filter" and offered no way back. Now it shows the
coded message and a Retry, matching the detail pane beside it.

**Two dead ends removed.** The "leave feedback" terminal promised "the Feedback
tab on this page" — there is no `/feedback` route in the org-workspace tree, so
for operators it pointed at nothing. Reworded to describe the action rather
than a location. And the redundant single-column `@@index([organizationId])`
on SupportTicket is dropped: Postgres reads the leftmost prefix of
`@@index([organizationId, status])`, so it was pure write cost on every ticket
insert and update. It came from #1245 r2; removing it here rather than leaving
two indexes shadowing each other.

Judged not to need changes: the `lastMessageAt` NULL-ordering concern (all
three queries already order `nulls: "last"` with a `createdAt` tiebreak) and a
stale line in bugs/support/00-overview.md. Left open deliberately: the two
SonarCloud complexity extractions (the gate currently passes, and both touch
working code for a metric alone), two walker-branch tests, and the 48-hour
re-anchor scoping question, whose one-directional distrust reads as deliberate
and wants a decision rather than a guess.

Part of #1021

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review catch on the reason→issueType map added in 9c131e0. I had mapped
`attendee_no_show` to CONSULTANT_NO_SHOW so that "a policy escalation still
types correctly" — but that inverts the blame. `attendee_no_show` is the
PROVIDER reporting that the consultee did not turn up, so a consultant who
showed up and said so would appear in the ops queue as the no-show.

The enum has no member for an absent attendee, and the flow resolves that
branch in-flow anyway ("the fee stays with you"), so it only reaches this map
via a policy escalation. Unclassified is the honest answer there — which is the
rule the rest of the map already follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…drails

fix(onboarding): guardrails for the draft JSON layer — shape contract, generation marker, text caps, budget CHECK
**A resolved playback turn was being escalated as a missing recording.**
The 48-hour re-anchor fired on any resolved RECORDING_ACCESS turn once the
session was more than 48h old. But that flow has two resolved terminals:
`within` ("less than 48 hours"), which is a client-side claim about elapsed
time and is exactly what the re-anchor exists to distrust — and `fixed` ("yes,
it plays now") on the playback branch, which asserts nothing about time.
Recordings only exist after processing, so most playback conversations happen
well past 48h. A user who reported the problem was GONE had their confirmation
discarded, was told "our team will chase the processing", and generated a false
`recording_missing` ticket. The guard now keys on the resolved node.

It also picked its escalation target as "the first escalating terminal in
object order", which selected `beyond` only because it is declared before
`broken` — reordering the nodes would have silently started filing
`recording_broken` for a missing recording. Now looked up by name.

**Org attribution extracted and tested.** `resolveOrgAttribution` moves the
rule out of the route: a requested `orgId` outside the caller's ACTIVE
memberships is refused rather than silently downgraded to a B2C ticket (a
silent downgrade hides the attempt), and sole-membership inference stays
confined to the operator-billing flow — a B2C flow must never inherit an org
just because the person filing happens to belong to one, or a private
complaint lands in their employer's queue. Seven cases pin it.

**Walker branches covered.** `walkFlow` had two untested paths: an option with
`next: null` (ends the conversation with no bubble) and one pointing at a
missing node (a flow-authoring typo, which must fail toward a human rather
than strand the user on an unadvanceable cursor). Both scopes execute this
walker, so a regression in either was silent.

**`OrgThreadRow` extracted** from the triage list, carrying the ADR 20 rule in
its doc comment: metadata only, because a member's support conversation is
content the org may see happened but never read.

On the two SonarCloud complexity threads — the gate is currently PASSING, so
the stated rationale ("to clear the SonarCloud failure") did not hold. Both
extractions were done anyway, on merit: the attribution rule is
security-relevant and now independently testable, and the row component is
where the ADR 20 constraint belongs. The remaining length in the platform POST
is a linear chain of guard clauses with early returns, which is the shape it
should have.

Part of #1021

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deploy-preview context now trusts the preview origin, so authenticated
flows can be exercised there. Sign-in previously returned a bare 500 on
previews because the trusted-origins list only contained the production URL,
which made every authed surface untestable before merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the preview: the support sheet shows only BOT messages. Every
answer the user gives by pressing a chip is invisible, so the transcript reads
as a monologue — a run of bot questions with no trace of the replies that
produced them.

The cause is the same line in four places: a USER message was written only
`if (userMessage)`, i.e. only for free-typed text. Advancing by a chip sends
`chosenOptionId` and no `userMessage`, so nothing was recorded on the user's
side anywhere — not in the sheet, and not in the database.

The database half is the more serious one. This PR's stated purpose includes
giving staff the transcript of an escalated thread, because previously they had
none. But the stored transcript contained only bot messages, so a staff member
opening a no-show escalation saw the questions and could not tell which answers
led there. The ticket's machine-readable `reason` gives the outcome; it does not
give the path.

- `walkFlow` now returns `chosenLabel` — it already resolved the option object
  to advance, it simply never reported which one.
- The self-serve write and `escalate()` persist `userMessage ?? chosenLabel`,
  so a chip press becomes a real `SupportMessage` with sender USER.
- The recording re-anchor carries the label through its synthesised turn: the
  server overrides the outcome there, but the user did press "Less than 48
  hours" and the transcript should say so.
- `PlatformSupportSheet` renders the pressed chip as a USER bubble. That scope
  is stateless — nothing is persisted until escalation — so it needs the label
  client-side; it rides along as a mutation variable and is stripped before the
  request body.

`SupportThreadSheet` needed no change: it already renders USER messages
right-aligned in the primary colour. It had simply never been given one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A guided tour of the deploy preview found three problems that no test caught,
because each is a wiring mismatch rather than a logic error.

**The recent-sessions picker could never show anything.** It read
`json.data ?? json.appointments`, but `/api/appointments` returns
`listAppointmentsScoped`'s `{ items, total, page, perPage }`. Neither key has
ever existed on that response, so the picker was unconditionally empty and the
Sessions tab told users with real appointments that they had none — while the
API beside it was returning them correctly.

**The Platform tab rendered a failed load as an empty list.** Unlike the
Sessions tab, which distinguishes "Couldn't load — Retry" from genuine
emptiness, it branched only on `length === 0` and said "No platform requests
yet", inviting the user to file a request they may already have open. Now it
shows the coded error and a Retry.

**The intent-chip fallback was stage-blind.** When the gated fetch failed the
sheet fell back to a hardcoded chip list, which by construction knew nothing
about stage or role — so it offered "Cancel & refund" on a session that had
already happened and "The other party didn't show" on one that had not started,
which is exactly the order-state violation this flow exists to prevent. It also
failed silently: nothing looked wrong until a chip was pressed and the turn
errored. The fallback is deleted. A wrong chip is worse than no chip — the
server clamps the intent anyway, so it bought nothing and misled. On failure the
sheet now says so and offers a retry, and the `INTENTS` constant goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Support 5xxs were reaching the client while Sentry stayed empty. The capture
was firing; the event never left. On a serverless host the instance may freeze
the moment the response is returned, and the SDK transport is asynchronous, so
a queued event dies in memory unsent. `onRequestError` does not cover this path
either — these errors are caught and turned into an ordinary NextResponse, so
Next never sees a handler fail.

Schedules the flush with `after()` so it runs post-response but before the
platform is allowed to freeze. Never awaited inline; telemetry must not slow an
error response. `Sentry.flush` resolves false on timeout rather than throwing,
so that case is logged — an empty dashboard should be distinguishable from a
healthy one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thread query had no refetch of any kind and invalidated only after the
user's own turn, so a reply written from the ops queue sat in the database
until the user closed and reopened the sheet. The thread read as one-sided.

Bounded on three sides so an idle drawer costs nothing: only while the sheet is
open, never once the thread is RESOLVED or CLOSED, and never in a background
tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(support): Swiggy-style support & feedback hub — per-appointment threads, platform intake, enterprise triage
Additive only, and none of it is backfilled — a pre-MVP reset is coming, so
every new column is either nullable or carries a constant default.

**SupportMessage.seq, allocated from AppointmentSupportThread.messageSeq.**
`createdAt` alone cannot order this table. The user's turn and the bot's reply
are written inside ONE transaction and Postgres CURRENT_TIMESTAMP is
transaction START time, so the two rows can carry a byte-identical timestamp
and the sort falls to whatever the planner returns — the answer can render
above the question. Ordering by `[createdAt, id]` only makes that
deterministic, not correct: the id is a random uuid, so it would put the reply
first about half the time. `Int @default(autoincrement())` was rejected too —
a volatile default forces ADD COLUMN to rewrite the table under ACCESS
EXCLUSIVE and numbers legacy rows in physical heap order, silently scrambling
every existing transcript.

**SupportTicket.referenceNumber + SupportTicketCounter.** A uuid cannot be read
back over a phone line, so the two staff surfaces had each invented their own
truncation — one took the first eight characters, the other the last — and
users were shown no identifier at all. Year-scoped so the series leaks this
year's volume rather than the all-time count.

**SupportTicket SLA stamps.** Deadlines are stored at intake rather than
derived at read time, so a later policy change never retroactively re-dates an
open ticket's breach. `awaitingUserSince` + `pausedMs` stop the resolution
clock while the ball is in the user's court. No `slaState` column: the five
timestamps plus now() are complete, and a stored flag needs a cron to stay
honest and is wrong between runs.

**ConsultantReview.appointmentId + ratingUnitId, and the unique moves to
(appointmentId, consulteeProfileId).** `ratingUnitId` is what makes a group
event one data point. A sessionType enum could not do that job: a WEBINAR
shares one Appointment across every attendee but a CLASS mints one per
enrolment, so grouping by type would collapse every class a consultant ever ran
into a single point.

Legacy rows keep a NULL appointmentId, and Postgres treats a NULL key as
distinct, so the old one-review-per-pair rule would silently lapse for exactly
the rows that were never gated per session. `consultant_review_legacy_pair_key`
in the sidecar preserves it for that band; Prisma cannot express a partial
unique and this repo declines the preview flag.

**AppointmentFeedback.raterRole.** Nullable and never backfilled: rows written
before the column cannot be attributed, and asserting CONSULTEE over them would
re-admit the provider self-ratings this exists to exclude. Aggregates filter on
CONSULTEE, so unknown provenance fails closed.

**SupportFlowOutcome.** One row per terminal turn, in both scopes, carrying no
message bodies. Deflection was unanswerable even retrospectively because the
platform scope persisted nothing at all.

Also fixes the sidecar drift guard, which split the file on the staged-for-reset
banner. Active SQL sits below that banner too, so
`appointment_doc_thread_version_unique` and `onboarding_draft_payload_size`
were never asserted. What actually excludes a staged object is that it is
commented out, so that is what the guard now keys on — and its index-name regex
captured the literal "IF" from `CREATE UNIQUE INDEX IF NOT EXISTS`.

Part of #705

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb4vbRYxJH7wxc4tjhm6XJ
teetangh and others added 25 commits August 29, 2026 06:51
…HERE

Six more findings, all of which held up. The important one is a correction to my
own previous fix.

**Moving the read inside the transaction did not make the SLA milestones
atomic.** At READ COMMITTED two staff replying at the same instant still both
see a null `firstAgentReplyAt`, and the later write moves a timestamp that is
supposed to be the first one. A read-then-write is not a compare-and-swap
wherever you put it; the guard has to be a condition the database evaluates.
`applyStaffReply` now claims each milestone with `updateMany` guarded on the
field still being null, and banks an open wait with a CAS on the
`awaitingUserSince` it read, incrementing rather than reading-and-adding — so of
two simultaneous replies exactly one banks the interval. Both staff routes share
it, which also removes the duplicated patch-building.

**A CLOSED ticket could not follow its thread, and the thread moved anyway.**
The mirror's whole purpose is that the queue never disagrees with the
conversation, but the `updateMany` result was discarded, so resolving a thread
whose ticket was already closed split the two and still notified the user. It
now fails the transaction into the existing 409. Moving to CLOSED is exempt —
a closed ticket is already where the thread is going.

**The review card treated a failed eligibility check as "not eligible".** Both
rendered nothing, which is the same mistake the Platform tab made before it was
fixed: a load failure shown as an empty state. It now offers a retry, and only
stays silent when the request succeeded and the answer was genuinely null.

**A successful review could be submitted twice.** `isPending` went false before
the refetch landed, so `existing` was still null, the button still read "Post
review", and a second press took a 409 off the one-review-per-session unique.
The invalidation is awaited, which holds the button through it.

**Clearing your written review did nothing.** `UpdateReviewSchema` is
`.partial()`, so the `text.trim() || undefined` the card sent meant "leave it
alone". It now sends an explicit null, and the schema accepts one — null rather
than an empty string, so there is only one representation of "no text".

**The dry run compared two of the four aggregates the real run writes.** A
profile whose review count moved without crossing the publish threshold reported
as unchanged. It now compares all of them, which is the only reason a dry run
against a shared database is worth having.

Part of #705

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb4vbRYxJH7wxc4tjhm6XJ
…ew-sweep

fix(support): the eight causes behind a chat turn that never appeared, and the review/feedback sweep around it
… members, leaked capture (#1271)

* fix(stream): restore video joining — authorless call create, unsynced members, leaked capture

The join gate has thrown on every request since #1136. `getOrCreate()` was
called on a server-side client with no `created_by_id`, which Stream requires
under server-side auth even when the call already exists, so the route 500'd
after access had been granted and rendered "Could not join this meeting".
Because the throw preceded `updateCallMembers`, nobody was ever granted
membership: the whole Stream app holds zero `call_member` roles.

A second, independent blocker sat behind it. Stream refuses a call naming a
user it does not hold, and a token alone never creates one — only connectUser
does. Every chat path upserts before naming members; the video path never did,
so the 29% of consultants who had never signed in broke the mint outright.

Minting also opened the camera. `getOrCreate` applies the call type's
camera_default_on/mic_default_on, and the Call it ran on was function-local, so
capture outlived every reference to it and the recording light stayed on for
the life of the tab. Same shape on the meeting page, where a `cancelled` bail
or a throw dropped an instance whose devices `get()` had already started.

Teardown is now defensive: a handle that never reached get()/join() has no
device managers, and reading through them threw from inside the cleanup,
masking the original error.

Also: Home gated Join on `isApprovedStatus`, a strict equality that SCHEDULED
fails, so a scheduled subscription offered Join on the Appointments tab and hid
it on Home; and addUserToEventChannel let a consent refusal bubble unhandled
out of a server action, taking the whole appointment page down instead of chat.

The join-gate suite asserted the ORDER of the Stream calls but never their
arguments, so a bare getOrCreate() looked identical to a correct one. It now
asserts the author and the member sync.

Closes #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(stream): extract the consent guard out of addUserToEventChannel

Sonar S3776 — the inline try/catch pushed the function to cognitive complexity
18 against a budget of 15. Behaviour is unchanged; the guard is now a named
helper that returns false when consent is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): propagate the consent refusal, release capture on a cancelled effect

Review round 1 on #1271.

Turning the consent gate into a skip was only half the job. The channel-open
route discarded the result and answered 200, so a skipped user was handed a
channel id they are not a member of — worse than the 500 it replaced, because it
fails silently and only shows up later as an empty, un-postable thread. It now
answers 403 with the reason.

The meeting effect could not release an instance whose get()/join() was still
pending: cleanup set `cancelled` and returned, and the release only ran once the
SDK call settled. The closure is hoisted so both the cleanup and the `finally`
can call it. It deliberately does NOT clear the handle the way the review
suggested — applyDeviceConfig runs at the TAIL of get(), so a cleanup firing
mid-flight releases devices that are re-enabled a moment later, and a cleared
handle would leave the `finally` with nothing to release. That is the very leak
the suggestion set out to prevent. Guarding on `adopted` alone keeps both passes
armed; teardown is idempotent.

Also extracts tryAddToExistingChannel, taking addUserToEventChannel back under
the cognitive-complexity budget (Sonar S3776, 18 -> under 15 across both passes).

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…audited (#1273)

* fix(stream): staff get recording metadata, admin gets playback, both audited

`GET /api/stream/recordings/[recordingId]` opened with
`if (isPrivileged(session.user.role)) hasAccess = true`, and `isPrivileged`
is ADMIN *or* STAFF. Any staff member could therefore fetch a playback URL for
any recording on the platform — including a 1:1 consultation they had no
relationship to — and nothing was written anywhere to say they had. The same
blanket grant sat in the meeting recording-info route. The operator path was
strictly less accountable than the tenant path, where deleting a recording or
exporting a call log already produced an audit row.

The grant now resolves through BACKOFFICE_PERMISSIONS, which is the declared
single source of truth for who reaches which internal surface and previously
had no key for recordings at all. `recordings.read` (metadata) admits both
operators; `recordings.play` (any URL that renders the session) is ADMIN-only.
The operator branch is evaluated last, so a staff member who actually
delivered or bought the session passes an ownership path and keeps playback.

Every read granted by the operator branch writes a trail before the response
is built and before any URL is minted: a STREAM_RECORDING_ACCESSED row on
OrgAuditLog when the session belongs to a tenant, and a SystemEvent row always
so B2C recordings are not invisible. A staff response withholds every media
URL, not only `playbackUrl` — a thumbnail is a frame of the session and the
preview clip is a cut of it — and carries `access.level` so a consumer can
tell "not permitted" from "not ready yet".

Separately, `GET /api/organizations/[orgId]/stream/calls` returned
`Recording.recordingUrl` verbatim to any org MANAGER+. That is Stream's
pre-signed S3 link: fourteen days of validity, its own credentials, no session
required, so forwarding the JSON forwarded the video. It now uses an explicit
select allowlist naming no field that reaches the media, matching
`recordingMetadataSelect` in lib/api/scope/list-recordings.ts. It deliberately
gains no signed-URL arm: ADR 20 governs this route and already considered and
rejected letting an org role open session content behind an audit row.

Also here: POST /api/stream/recordings/sync gets a user-keyed limiter (one
POST fans out a listRecordings call per session the caller touches, and the
edge rule covering the path is IP-keyed at 60/min), and the three dead
"Send Email" / "Flag Account" / "Suspend User" dropdown items in
OperatorUsersPage are removed rather than wired — banning is ADMIN-only and
belongs to the moderation flow.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): fail closed on the operator audit write, and unbreak the doc links

Review round 1 on #1273. All four findings were legitimate.

The audit write was the substantive one, and it is the same gap the
implementation flagged as out of scope — found independently. `recordSystemEvent`
swallows its own insert failures by design, because its original callers sit on
the webhook critical path where an outage of that table must not cascade. But
for a B2C recording that row is the ONLY audit trail; there is no organization
to carry an `OrgAuditLog` entry. So a failed insert meant an operator read served
with no persisted record — the one outcome the function exists to prevent.
`strict` is additive, so every existing caller keeps the best-effort behaviour it
was written for.

The test reset return values but not call history, so an assertion could pass on
a call an earlier test had made. The response docs claimed every response carries
`access`; error responses, including the documented 410, do not.

The absolute `file:///Users/...` link CodeRabbit flagged was one of six across
docs/ — every one a broken link for anyone who is not me. Swept them all rather
than fix one and leave five, since it is the same defect and the fix is
mechanical.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(ops): make the Stream cron fleet run, and visible

Five scheduled workflows had never completed a single run, and nothing in the
repository said so. Their entrypoints reached lib/supabase.ts, which opens with
`import "server-only"` — a marker package whose main entry does nothing but
throw. Next resolves it to an empty module under the `react-server` export
condition; the bare Node process a workflow runs gets the throw, so the job died
during module evaluation before a line of its own code ran.

The Supabase clients and storage primitives now live in lib/supabase-storage-core.ts,
which carries no marker; lib/supabase.ts re-exports every one of them, so
application code is unchanged and keeps its client-import guard. Four workflows
additionally lacked NEXT_PUBLIC_SUPABASE_ANON_KEY, which that module also throws
without, and no workflow in the fleet referenced it at all.

A new jest guard, __tests__/maintenance/workflow-import-env.test.ts, re-derives
every scheduled workflow's import graph on each CI run and fails when one reaches
a module that cannot load in a bare Node process, or is missing the env its
imports read at module scope. It immediately found eight more: the payout,
payment-status, earnings, dispute and webhook-sweep crons all reach
lib/auth-server.ts, which calls React `cache()` at module scope — and react@18.3.1,
the version this repo pins, has no such export. Fixing that means changing the
money or auth subsystems, so those eight are registered and asserted rather than
fixed here.

Also:

- ensure-webhook-subscription.ts gains a `--check` mode that annotates each
  finding and exits 2 on drift, plus a daily + PR workflow that runs it. It used
  to return 0 whatever it found, which is why it was wired to nothing — and why
  #1134's six-of-ten subscription went unnoticed.
- New daily reconcile-orphaned-recordings job. A dropped `call.recording_ready`
  webhook was previously invisible until a consultant clicked Sync, and Stream
  deletes the file after 14 days, so the loss was permanent.
- All seven Stream jobs are now listed by /api/staff/system-jobs and runnable
  from /api/admin/system-jobs/run, calling the same cores the workflows call.
- stream-sync moves from its bespoke Redis lock to withCronLock, so it finally
  writes a SystemJobExecution row and appears in the operator surface.
- package.json `scripts:stream-sync` pointed at jobs/stream-sync.ts, which does
  not exist, through ts-node, which cannot resolve this repo's path aliases.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(ops): give the drift check a Redis stub, and stop repeating the job catalogue

Two CI failures on this PR, both self-inflicted.

The new drift workflow crashed at import for want of Upstash credentials:
`lib/stream-client` pulls in `lib/redis`, which throws at module scope. That is
precisely the import-time crash class this PR exists to fix, reintroduced by the
workflow that detects it. A read-only check takes no lock and stores nothing, so
it gets the in-memory stub rather than production Redis credentials it has no
use for.

Sonar's gate failed on new-code duplication at 3.5% against a 3% threshold. The
seven Stream catalogue entries were identical in shape, so they are built from
tuples now; and `entrypointOf` had been copied verbatim into both workflow
guards, which is how a parser that tracks tsx invocation styles drifts the
moment a third one appears. One copy in a fixture, two callers.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): stop handling two chat events nothing delivers

The drift guard added in this PR immediately found that no `chat` hook exists on
the Stream app, so `user.flagged` and `message.flagged` are handled by the
dispatcher and never arrive. The obvious response is to create the hook. That
would have been wrong.

The in-app report button already writes the ModerationReport itself, via
POST /api/report — the webhook was a second path to the same row, and both
increment `reportCount`, so one user report read as two. Automod is `disabled`
on both channel types with no blocklist, so there are no automated flags to
receive. Stream considers `message.flagged` obsolete under v2 moderation, which
uses `review_queue_item.*`. And the one path a hook would uniquely serve — a
moderator flagging from Stream's own dashboard — hits a handler that
FK-violates on any flagger with no User row, then retries forever.

So the events go, and `chat-moderation-handlers.ts` with them. Reporting is
unaffected: it never used this path. If Stream-side moderation is wanted later,
the v2 review queue is the thing to integrate, not a deprecated event.

The subscription test built its unplaceable-event scenario FROM the chat events;
it now inverts, using a chat-only hook against video events, so the guard stays
covered.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1272)

* fix(appointments): close the join gates on both sides of a booking

The join window itself was already shared and tested. Everything AROUND it
was not, and six separate defects had accumulated there.

Consultant Join had no booking-status gate. The appointments adapter asked
only "is a slot inside its window and is the row not in the cancelled
bucket?" — and that bucket is only the terminal-NEGATIVE statuses. Home
asked nothing at all. A consultant could therefore open the video room for a
booking still at APPROVED_PENDING_PAYMENT, where the slot is held but nobody
has paid, or one already COMPLETED, whose session was closed out. The trial
branch checked no status either. All of them now compose the same gate the
consultee adapter has always used: isConfirmedStatus AND an open window.

SessionTimeline offered JOIN on a session that had already ended. Its
joinable branch compared the clock against startsAt/endsAt and never looked
at meetingEndedAt, so a call the host closed early kept offering JOIN for the
rest of the booked hour and dropped whoever clicked it into a fresh, empty
room. Row status now routes through getSessionJoinState via a new
getSessionVMJoinState, which is the same predicate the join surfaces use.

SessionTimeline also rendered the word "JOIN" as inert text whenever the
adapter refused to supply a handler, with none of the muted styling the other
non-actionable states get. It looked like a live button and did nothing. The
label is now a state ("IN PROGRESS"), muted like its siblings, with a title
that says it cannot be joined from there.

Six surfaces each declared their own join window, landing on four different
values, so the same booking opened at four different times depending on which
page the user was looking at. The planner gave a host ten minutes while the
appointments list beside it gave the same host fifteen. Every caller now
imports CONSULTEE_JOIN_WINDOW_MS or CONSULTANT_JOIN_WINDOW_MS: hosts get 15
everywhere, learners 10. TrialsTab's hand-rolled comparison also read only
slotsOfAppointment[0], so a trial longer than half an hour stopped being
joinable thirty minutes in (#1061); it now goes through the session helpers.

useEventActions exported a handleJoinSession with zero callers. Deleting it
drops a static @/lib/meeting import, which was dragging the Stream video SDK
into every bundle that touched the hook (#248), along with the now-dead
isJoining state and appointment option.

The consultee adapter's join was a degraded private copy of
useLazyJoinMeeting: it read the video client synchronously, so a click landing
before the deferred connect finished was told "Not signed in" — destructive,
and untrue — while every sibling awaits the client and shows a retryable
warning. It also cleared joiningId only in catch, leaving a successful join
spinning. It now uses the shared hook.

Finally, the dev backdoor keyed off NEXT_PUBLIC_ENABLE_DEV_TOOLS in one place
and NODE_ENV in two others, and on consultant Home the dev arm REPLACED the
gate rather than adding to it (which also mislabelled every genuine Join as
"Join (Dev)" on a dev build). One flag now, and every dev arm is a separate,
distinctly labelled affordance that appears only where the real Join does not.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(appointments): extract the inert-status title into a lookup

Sonar S3358. The nested ternary was building a tooltip for the two row states
that can look actionable without being one; a keyed lookup says the same thing
and leaves room for the next state.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(stream): page queryChannels at Stream's real 30-row cap

Stream returns at most 30 channels per `queryChannels` call regardless of
the `limit` passed — verified against the live app, where `limit: 100`
answered with exactly 30. Both reconciliation call sites in
`event-channel.action.ts` assumed otherwise.

`syncUserEventChannels` paged with `do … while (page.length === 100)`, so
the first page looked short, the loop exited after one request, and the
reconcile pass only ever examined a user's first 30 memberships. A DM whose
booking no longer justified it but that sat at position 41 was never read,
never classified stale and never revoked — the DM revocation leak. The same
loop advanced `offset` by the requested 100 rather than the 30 returned, so
a second iteration would have skipped 70 channels. `getUserEventChannels`
asked for 100 and did not page at all.

Both now share `queryChannelsPaged` in `lib/stream/batch.ts`, which pages at
the real cap, advances by the rows actually returned, and reports
`truncated` at Stream's 1000 offset ceiling instead of silently handing back
a prefix. The reconcile walk also pins `created_at` ascending: offset paging
needs a stable order, and Stream's default `last_message_at` sort moves
underneath a multi-page walk. `scripts/stream/purge-memberless-dms.ts`
already knew all this; the constant lives in one place now.

Separately, `channel.create()` carries its roster in the request body and
accepts at most 100 members — the ceiling `upsertUsersToStream` already
respected. Both create paths handed it the whole array, so a 150-seat
webinar's first attendee to open chat got a rejected create and no chat.
They now create with `createMemberChunk(...)` and follow with
`addRemainingMembers(...)`, host and joiner ordered first so the person who
triggered the create is never the one stranded in a follow-up request.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): delete getUserEventChannels, an ungated server action nobody calls

Found while paging its `queryChannels` call. It is a `"use server"` export that
takes an arbitrary `userId` and returns that user's channel list — ids, names,
member counts — with no session check at all, in the same module where
`syncUserEventChannels` carries a full `getSession(true)` self-or-privileged
gate. Every server-action export is remotely invocable, so this is readable from
any signed-in browser for any user id. Same shape as F-MED-6 in the 2026-08-23
review.

It has zero production callers, so the fix is deletion rather than a gate: no
capability is lost and there is nothing left to get wrong later.

Its two paging tests went with it. They exercised the 30-cap through this
function; `__tests__/stream/batch.test.ts` now covers the same behaviour against
`queryChannelsPaged` directly, and the end-to-end leak case still runs through
`syncUserEventChannels`, which is the path that actually matters.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
)

* fix(stream): make the server the sole creator and owner of a call

The Stream call for a booking was created in the browser. `getOrCreate`
ran on the signed-in user's own video client from ten dashboard
surfaces, which meant Stream recorded whoever pressed Join first as the
call's `created_by` — the consultee for roughly half of all sessions —
and every field of `custom` was authored by a browser, including the
`consultantUserId` that `useSessionInfo()` derives `isHost` from and that
gates "End for everyone". Entitlement was checked afterwards, in
`createDbMeetingSession`, so a refused caller still left a real billable
room behind. And `getOrCreate` applies the call type's device settings,
so minting a room opened the camera on the dashboard (#1271 had to
release it).

Creation moves to `provisionAppointmentMeeting`. It resolves the anchor,
short-circuits on an existing session, runs every refusal, checks
entitlement BEFORE the Stream write, and then creates the call with the
server client naming the appointment's host as author and reading the
whole `custom` payload from the rows the gate already read. The room id
is unchanged (`slot-<anchorSlotId>`). `lib/meeting.ts` keeps its name and
its place in the flow but no longer imports the SDK or builds a `Call`,
so the media-release workaround goes with it.

Members are named `call_member` at creation instead of `host`/`user`.
The live `default` type has no `host` role key at all, so those
consultants held nothing, and `user` loses `join-call` the moment the
grants script runs — that pair would have locked out both sides.
`scripts/stream/backfill-call-member-role.ts` repairs existing calls, and
`ensure-call-type-grants.ts` now refuses to --apply until it has seen a
member actually holding the role. Its old guard only checked that the
grant existed on the role, which is true by construction.

`resolveMeetingAccess` refused three statuses while every dashboard's
Join affordance is an allowlist of {APPROVED, SCHEDULED, IN_PROGRESS}, so
`APPROVED_PENDING_PAYMENT`, its trial twin `AWAITING_PAYMENT`, PENDING
and a DRAFT webinar were all hidden by the UI and admitted by the server.
It now shares `isConfirmedStatus`. Completed-like statuses are handed to
the existing time gate rather than refused outright, because the trial
completion sweep has no buffer and would otherwise eject a live session.

Ending a call moves to `POST /api/meetings/[meetingId]/end`, which
requires the hosting side. `end-call` is still granted to `call_member`
and revoking it stays a later, post-deploy step.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(stream): one authorization preamble for both meeting routes

Sonar's gate failed on new-code duplication at 3.2% against a 3% threshold, and
the offender was worth fixing on its own terms: the new end route repeated the
join route's guard verbatim — authenticated, not suspended, meeting id present,
Stream configured, access resolved — 50 lines at 34% density.

Two copies of an authorization preamble is how the two drift, and the drift
would be silent: a guard added to one and not the other looks like nothing at
all in a diff. `guardMeetingRoute` now owns those five questions, and a third
meeting route gets them for free.

It returns a discriminated union rather than throwing. A refusal here is an
ordinary outcome with a status code attached, and making each caller handle it
explicitly keeps that visible at the call site instead of hiding it in a catch.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* style(appointments): satisfy eqeqeq in the deliberate-end predicate

`== null` was deliberate — null OR undefined — but eqeqeq is a blocking rule
here and SonarCloud fails the PR on it. Truthiness reads the same and treats an
empty reason as no reason, which is the same conservative branch.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): an empty room is not a finished session

The gate fix earlier in this PR stopped a 30-second dropout from locking people
out of their own booking. It missed the other half of the same write.

`handleSessionEnded` marked the SLOT `COMPLETED` unconditionally. Stream fires
that event `inactivity_timeout_seconds` after the last participant leaves — 30
seconds on the live call type — so one party stepping out at 09:56 of a
10:00-11:00 booking completed the slot before the session had started. That made
it review-eligible via HELD_SLOT and handed it to auto-complete-appointments, so
the booking closed itself for a consultation nobody had attended yet.

The session row still records that Stream's session ended, because it did.
`endedReason` distinguishes it from a host closing the room, which is what
`isDeliberateEnd` reads. The slot now completes only once its booked time is
actually over.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(appointments): make endedReason impossible to forget

Review round 1 on #1277. The most important finding was one I had missed
entirely: `isDeliberateEnd` treats an ABSENT `endedReason` as deliberate, which
is the safe reading for a historical row written before the column existed — but
it is the wrong reading for a query that simply did not select it. Eleven
projections selected `endedAt` alone, so on every one of those surfaces a
timed-out session still read as deliberately ended and still locked people out
of their own booking. The fix was half-applied and I could not see it.

Rather than patch eleven call sites and hope, `endedReason` is now REQUIRED on
the slot shape. A projection that omits it does not compile. That immediately
found six more sites than the review named — the Prisma validator shapes in
types/appointment.ts, the appointment-detail query, two view-model types, the
consultee event processor, and the SessionVM path behind SessionTimeline, which
had no way to carry a reason at all and now does.

Also corrected: the doc comment on `callHasLiveParticipants` claimed it fails
open. It fails closed, and that is right — the probe only ever ADDS permission,
since it runs solely where the caller was already about to be refused. Failing
open would be a new grant issued on the strength of an outage.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): close the booking-state leak, and the remaining review findings

Review round 1 on #1277, continued.

**A stranger could read another user's booking state.** `getMeetingCreationRefusal`
reads the persisted slot and its parent booking status for any slotId it is
handed, and the resulting string goes back to the caller as data — so running it
before the entitlement check let anyone who guessed a slot id learn "This session
is not confirmed yet." or "This session was cancelled or moved." Entitlement now
runs first, and a stranger gets one answer that tells them nothing. Pinned by a
test that seeds a real tentative slot the caller is not on.

**A refusal we could not evaluate was read as a pass.** That helper caught every
error and answered `null`, so a slot read that threw let the mint proceed;
`createDbMeetingSession` then re-ran the same check, and a second read that
succeeded threw — leaving exactly the orphaned billable Stream room the ordering
exists to prevent. It now refuses, with a message vague enough not to leak.

**The end button could send twice.** `endCall` is invoked from inside a
`setProgress` updater, and React may run an updater more than once; both runs
close over the same `isEnding === false` and sail past a state guard. A ref
closes it. The request is also bounded now — `fetch` has no default timeout, and
a stalled network left the host on a disabled spinner, still broadcasting, with
teardown and navigation in `finally` never reached.

**The live-participant probe now goes through the circuit breaker**, like every
other server-side Stream call in this cohort. Without it, during an incident it
ran on the request thread for every refused join and waited out the SDK's
30-second default, and its failures never fed the breaker that exists to stop
precisely that.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): the lockout guard must not be satisfied by a partial result

Review round 2 on #1277. Three findings the thread-resolution pass correctly
refused to close, and one lint warning I introduced.

**The pre-flight guard had the same shape as the bug it guards against.**
`anyOpenCallMemberHolds` returned true on the FIRST member holding
`call_member`, so a mixed roster passed — and the apply path then stripped
`join-call` from `user` and `guest`, locking out every member who lacked the
role. That is a partial outage, waved through by a check written to prevent
exactly it. It now requires every member of every open call, reports how many
are uncovered and in which calls, and the tests encode the new semantics: a
mixed roster is refused, full coverage applies. Memberless calls stay valid —
they have nobody to lock out.

**`session_timeout` had zero test coverage anywhere**, which is remarkable given
it is the case the whole predicate exists for. Worse, a comment I wrote claimed
it was "tested separately, in meeting-join-gate", and that was simply false.
Three cases added: a timeout does not end a live session, a reconciler's guess
does not either, and a row with no reason stays terminal for historical safety.

**The end-route test could not fail.** The `video.call` mock discarded its
arguments, so the route could have ended the id from the URL instead of the one
the MeetingSession points at — the exact thing the test is named for — and it
would still have passed. The arguments are the assertion now.

Also: `isEnding` left in the `endCall` dependency array after the ref-guard
rewrite removed the last read of it.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): build the session memo on first call, not at import

Closes #1275.

`lib/auth-server.ts` called React's `cache()` at module scope. `react@18.3.1` —
the version package.json pins and `npm ci` installs — has no `cache` export at
all; it resolves inside the app only because Next aliases `react` to its own
vendored React 19 in the RSC layer. The module's own docstring recorded that
dependency and predicted it would "silently degrade to no memoization" if the
alias ever stopped applying.

The reality was worse. Outside the RSC layer the call THREW, and it threw during
module evaluation, so every process that imported this file transitively died
before running a line of its own code:

  $ npx tsx -e "import('./jobs/payments/reconcile-payment-status.ts')"
  IMPORT FAILS: (0 , import_react.cache) is not a function

Eight scheduled workflows import it that way. Seven are the payments and payouts
reconciliation layer — payouts, payment status, earnings sync, lost disputes,
orphaned confirmations. The eighth is sweep-stuck-webhook-events, which is the
durability backstop the Stream webhook route explicitly delegates to: it acks
fast and processes in after(), and the sweeper is what re-drives anything lost
to a frozen instance. None of the eight had ever completed a run.

Deferring the call fixes the import. Tolerating an absent `cache` fixes the job:
a one-shot cron process serves a single request, so the unmemoized reader is the
correct behaviour there rather than a degraded one. Inside a render nothing
changes — the memo is built by the first guard's call and shared by the rest.

Verified by running the jobs, not merely importing them:

  reconcile-payment-status         Success: true
  reconcile-orphaned-confirmations Done. scanned=0 confirmed=0 stillBlocked=0
  sweep-stuck-webhook-events       Still failing: 0

`KNOWN_UNRUNNABLE` in the import-graph guard is now empty, and `lib/auth-server`
is out of `EXTRA_UNLOADABLE`. Both registries are kept rather than deleted: the
defect class they catch — a module that imports fine but throws on a runtime
export — does not announce itself in an import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): stop billing an MAU for every search result

#1280 Bucket 2. Stream bills chat by monthly active users, and an MAU is any
user who has opened a WebSocket. The search route upserted every RESULT — people
who had taken no action at all, whose only involvement was that somebody typed
their name.

Nothing is lost by removing it. Every path that actually needs a user to exist
on Stream upserts them itself, immediately before naming them, because Stream
refuses an operation referencing a user it does not hold — the chat channel
creates have always done this, and the video mint has since #1271.

The larger lever is still open: both dashboard layouts mount the Stream provider
around the ENTIRE dashboard, so every user who opens their dashboard connects
whether or not they ever use chat or video. Moving that to the routes that need
it is #1280 Bucket 2 and wants a measurement first — the ratio of chat-active
users to logins decides whether it is worth anything.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(moderation): stop reporting enforcement that never happened

A ban whose Stream write failed was reported to the moderator as a clean
success. The database said banned, the sessions were gone, the appointments
were cancelled and refunded — and the target's existing chat token kept
working for up to an hour, because the client type omitted the `stream` and
`errors` fields the route has always sent and the success handler toasted
unconditionally. `ModerationAction.sideEffects` recorded the failure and
nothing read it.

Six changes, all of the same shape — say what actually happened:

- The queue returns the latest action's side-effects, the target's ban state,
  and a `capabilities.canModerateUsers` flag. A failed Stream step now toasts
  what is still true about the account ("its existing chat token still works"),
  moves the filter to the report it just resolved, and the report renders the
  whole summary. Ban and Suspend are hidden from moderators who would get a 403.
- CONTENT_REMOVED on a chat message removed nothing: it was routed to the
  review soft-delete, which no-ops on the null `reviewId` every MESSAGE report
  has. `ModerationReport` gains `streamMessageId`/`streamChannelCid`, the
  action deletes the message on Stream, and the queue offers the action at all.
- The excerpt captured at report time is rendered, so a ban is no longer
  decided from a reason string.
- Message reports aggregate per message instead of per author, so the twelfth
  complaint no longer shows the first message anyone objected to.
- A ban can be lifted: POST .../[reportId]/unban clears the ban columns,
  records USER_REINSTATED, and calls restoreStreamAccess, which had zero
  callers — reversing a ban by hand left an account permanently mute on Stream.
- A failed Stream write is retried by a sweep that reuses the
  sweep-stuck-webhook-events shape, with the sideEffects JSON as its queue. It
  skips a ban that has since been lifted and gives up terminally after six
  attempts or 72 hours. Its scheduling workflow is not here (.github/workflows
  is owned by a sibling change), so it runs on demand for now.

Schema: two nullable columns and one enum value; no backfill.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(moderation): schedule the enforcement retry so it actually runs

The retry sweep shipped with a CRON_SECRET route and a catalogue entry reading
"On demand" — self-healing that needed a human to remember. That is the wrong
shape for this failure specifically: a ban whose Stream revocation failed is
invisible from the product, so nobody would think to press the button. The only
signal is the harasser carrying on posting.

`.github/workflows/` was off-limits while #1274 was in flight; it has merged, so
the entrypoint and workflow land here. Every-30-minutes on a minute no other job
claims — enforcement already reported as done should not wait an hour to become
true.

The env block carries the Supabase variables because the moderation import graph
reaches `lib/supabase`, which throws at module scope without them. That is the
same defect that had five workflows never running; #1274's import-env guard now
covers this one too, and it passes.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(moderation): harden the new workflow, and clear the Sonar findings

The quality gate failed on `new_security_rating: 3`, and the cause was the
workflow this PR added: it copied the sweep template rather than the hardened
form seven other workflows already use. `npm ci --ignore-scripts`,
`npx --no-install --ignore-scripts prisma generate`, and `--ignore-scripts` on
the tsx invocation. A cron runner has no business executing arbitrary package
lifecycle hooks, and Prisma is generated explicitly anyway.

Also: `node:fs` over `fs`; `OPEN_REPORT_STATUSES` as a `Set`; readonly props on
`EnforcementSummary`; and the banned/suspended nested ternary extracted into
`describeEnforcementState`, because "banned" and "suspended until X" are
different states rather than two branches of one.

Sonar's `structuredClone` suggestion (S7784) is DECLINED in both places, with the
reasoning recorded in the code. Those calls are a serialization, not a deep
clone: `SideEffectSummary` has five optional fields, the JSON round-trip drops
the undefined ones, and Prisma's InputJsonValue rejects `undefined` in an object.
structuredClone would preserve them and break the write.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(moderation): verify a reported message with Stream before trusting its identity

Review round 1 on #1279. Seven findings; one was a vulnerability I introduced.

**A reporter could get an arbitrary message deleted.** The route took
`streamMessageId` and `streamChannelCid` from the browser and stored them, and
`CONTENT_REMOVED` later forwards the stored id to Stream's server-side delete.
So an authenticated caller could report user X while supplying a message written
by someone else, and a staff moderator acting in good faith became the
instrument for deleting it. The caller-supplied cid meant the staff deep-link
pointed wherever the reporter chose, too.

Both now come from Stream's own answer: the message is resolved with server
credentials and its author must be the reported user, or the report is stored
with no message identity at all. That degrades to the pre-#1270 behaviour — a
report a human reads — rather than refusing the report, because a reporter
should not be blocked by Stream being briefly unavailable. Three tests pin it,
including the wrong-author case and the Stream-down case.

**The failed-enforcement summary could vanish.** That row IS the outbox: the
retry sweep selects on `sideEffects.stream === "failed"`, so a swallowed write
did not merely lose a status field, it lost the queue entry — and a ban that
never reached Stream was then never retried and never surfaced. It retries once
and escalates at `fatal` if both attempts fail.

**The sweep forwarded a message id without its channel**, so a re-drive could not
address the message it was retrying. The test that should have caught it asserted
only the id.

Also: the sweep reported `success: true` while recording errors, which is what
the workflow's exit code reads; `persist-credentials: false` on checkout, since
no git write happens in a job that runs third-party package code; the Jobs page
still described this cleanup as on-demand after it was scheduled; and the action
spinner replaced the icon on every button rather than the clicked one.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* docs(moderation): the sweep IS scheduled — say so in all three places

Review round 2 on #1279. The finding was a consolidated one naming three sites,
and the first pass fixed only the anchor. The other two still claimed, inside
this very PR, that the scheduling workflow belonged to a sibling change and that
the sweep ran on demand — while `.github/workflows/retry-moderation-enforcement.yml`
sits in the same diff carrying `8-59/30 * * * *`. Documentation that contradicts
the code in its own commit is worse than none.

The ADR now records WHY it is scheduled rather than merely that it is: a ban
whose Stream revocation failed is invisible from the product, because the
database says banned and the moderator has already been told it worked. Nobody
would think to press the button, so the only signal would be the harasser
continuing to post.

Also drops `streamChannelCid` from the request destructure. It is still accepted
by the schema and deliberately ignored — the stored cid comes from Stream's own
answer, because a caller-supplied one pointed the staff deep-link wherever the
reporter chose. Left unread it was an unused binding, which SonarCloud fails on.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): HMAC the bytes Stream signed, not the bytes it sent

Not moderation. Folded into this PR deliberately rather than opening another,
since it is small and self-contained.

Stream computes its webhook HMAC over the UNCOMPRESSED payload and then
optionally gzips the body on the wire. `enable_hook_payload_compression`
defaults to TRUE for apps created after 2026-05-07, with a 256-byte threshold
that every recording and session event clears. This route did
`crypto.createHmac(...).update(await req.text())` — the bytes that arrived.

Today those are the same thing, because the live app has the setting unset. I
verified that against Stream rather than assuming it. So the route has been
right by accident, and the accident is not worth keeping: the moment a gzipped
body arrives the signature cannot match, the route answers 401, and Stream
treats a 401 as FINAL. It is not in the retryable set, so the event is dropped
and never redelivered — silently, for every delivery. No attendance rows, no
recordings, no session ends. That is precisely the #1134 shape: 0 WebhookEvent
rows for provider='stream', 0 MeetingAttendance, and 1,663 sessions that never
ended.

`readSignedBody` reads the raw bytes and gunzips them when they start 0x1f 0x8b.
Detecting the magic number rather than trusting `Content-Encoding` is
deliberate: a platform layer may decompress the body and leave the header on, or
pass it through and strip it, and this repo's own history with Netlify request
handling says not to assume which. The bytes cannot lie about what they are.

Six tests, including one asserting the OLD behaviour would have failed the
gzipped case — a fix to a signature path nobody exercises is worth very little.

This also makes pinning the Stream-side setting unnecessary rather than urgent.
Fixing the code so the configuration cannot hurt us is the better half of that
choice; the config can still be pinned, but nothing now depends on it.

Part of #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore(stream): bump the video and chat SDKs to current

The declared ranges were stale by up to eleven minors, so the lockfile alone
decided which video stack shipped. This brings both into line and picks up a
second, independent cause of the hung join that started #1270 — client 1.50.0
fixes a call.join() hang on a silent WS handshake stall, which #1271's
server-side fix could never have reached.

Also lands three fixes for a network blip tearing down a healthy SFU socket,
which is the client-side mechanism behind the empty-room session-ended bug
#1277 fixed the consequences of, two camera-release bugs below
lib/stream/media-teardown.ts, and the concurrent-join guard.

No source changes: the diff is package.json and package-lock.json.

The two named risks were measured rather than assumed. #1078 warned that
1.39.0 pins ES2022 while we compile es2017, but the emitted bundles contain no
ES2022 syntax at all — the highest feature is ES2021 `??=`, supported since
2020 — so transpilePackages is deliberately not extended, which would only add
weight against #1158's non-raisable 250MB server-function cap. The packed
delta there is +470KB, and video-filters-web still fetches its 26MB of
MediaPipe assets from a runtime URL rather than a filesystem require, so
nothing traces them.

stream-chat 9.52.0 also brings verifyAndParseWebhook, which the webhook
compression fix needs later.

Held deliberately: stream-chat v10 (rc.8, breaking renames as recently as
rc.7), stream-chat-react v14 (a design-system refresh against heavy custom
chat UI, zero correctness gain), and node-sdk 0.8.x (drops Node < 22.12 for no
benefit).

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* chore(stream): pin the Stream stack to exact versions

Carets let a lockfile regeneration move the video stack silently, which is how
the declared range drifted eleven minors behind what was installed. Two things
here make that worse than ordinary dependency drift.

The server function sits near 245MB against a non-raisable 250MB Netlify cap,
and the failure presents only at the deploy stage with every other check green
(#1158). And 1.39.0 raised the supported-browser floor, which no type check
would surface. Neither is something a silent minor bump should be able to
introduce.

Pinning the top of the chain is enough: @stream-io/video-react-sdk pins
video-client, video-react-bindings and video-filters-web exactly in its own
dependencies, so one pin fixes the whole video stack. Dependabot remains the
upgrade path, which is the point — the bump becomes a reviewable PR with a
deploy preview rather than an accident of reinstall.

Resolved versions are unchanged; this is a declaration change only.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1284)

* fix(recordings): move storage access out of the transfer pipeline

The transfer service is scheduled for deletion — once Stream writes recordings
straight into our bucket via recording_external_storage there is no download
step left to keep. But three things inside it have to outlive it, and one of
them is load-bearing for revenue.

getBestRecordingUrl is what mints playback for a PAID replay. It lives in the
file being deleted, so removing the pipeline without moving it first would take
the recordings marketplace down alongside it, silently — the player would
simply stop resolving a URL.

Alongside it, publish, purchase and the marketplace listing query each carried
their own copy of `status === "AVAILABLE" && storageType === "SUPABASE"`. That
spells a vendor into a business rule that is really about custody: has this
recording outlived Stream's fourteen-day clock, or not. Three copies of a rule
drift three ways, and the rule is about to change under all of them. It is one
predicate now, in two forms — a check for loaded rows and a where-fragment for
queries that cannot load one first, built fresh per call for the same reason
dmEligibleStatusFilter is.

No behaviour changes here. The predicate is the same pair of values it was.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(recordings): take the vendor name out of the storage model

The schema said Supabase in three places that are really about custody, not
about a vendor: RecordingStorageType.SUPABASE, RecordingStoragePolicy
.SUPABASE_PERMANENT, and the supabaseUrl/supabasePath columns. The question
each of them actually answers is "have these bytes outlived Stream's fourteen
day clock", and the answer is about to stop involving Supabase at all — Stream
can write straight into whichever bucket we point it at, and which bucket that
is has been left deliberately open.

So: SUPABASE → PLATFORM, SUPABASE_PERMANENT → PERMANENT, supabaseUrl/
supabasePath → storageUrl/storagePath.

Doing it now rather than later is the cheap moment. Checked against the live
database first: no row anywhere uses either enum value. All 191 recordings are
STREAM_S3 and all 971 plan rows across the four plan models are STREAM_ONLY, so
this is a pure schema change with nothing to migrate.

Five files that mention supabaseUrl are deliberately untouched — there it is
the client URL from NEXT_PUBLIC_SUPABASE_URL, not the column, and renaming it
would have broken the storage client.

Needs a db push before deploy: two enum values and two column names.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(recordings): record the DDL the rename needed

`prisma db push` cannot express this change. Postgres has no
`ALTER TYPE ... DROP VALUE`, so a push would have added PLATFORM and PERMANENT
while leaving SUPABASE and SUPABASE_PERMANENT behind in the live types, and the
Prisma client refuses to read a column whose enum carries a label it does not
know — P2023, on every read of Recording.storageType and of the four
*Plan.recordingStoragePolicy columns. That is the failure that left
reconcile-disputes red for weeks after 183d0e7. Push also treats a column
rename as a drop plus an add.

Unlike that earlier case this is a rename rather than a drop, so it does not
need the create-replacement-type-and-swap-columns dance: `ALTER TYPE ... RENAME
VALUE` has existed since Postgres 10 and rewrites the label in place. No new
type, no column swap, no defaults to rebuild, a much lighter lock.

Applied to pzmbxqdgibfkhjwzeprf as migration rename_recording_storage_vendor.
Verified after: both enums read STREAM_S3/PLATFORM and STREAM_ONLY/PERMANENT
with the old labels gone rather than accumulated, both columns renamed, all 191
recordings intact, and check-db-drift green at 118/118 enums.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…pwire, make the DB guards real (#1285)

* fix(stream): close the live billing tripwire and make the DB guards real

Two operational fixes that need no deploy, and one guard that has never run.

## The call type

`audio.noise_cancellation.mode` was `auto-on`, which means registering the Krisp
instance and switching on per-participant-minute billing are the same act — the
day `@stream-io/audio-filters-web` ships, every call on the platform starts
charging for it. Now `available`, so enabling it becomes a deliberate decision.

`frame_recording` and `ingress` were both live and entirely unused. Stream ships
permissive call-type defaults, so "we have not built it" and "it cannot be
started" are different statements; both are billable and both were startable by
anything holding the capability.

`session.inactivity_timeout_seconds` was 30. That is how long Stream waits after
the LAST participant leaves before ending the session, and it is what let one
party stepping out mid-appointment end a booking — locking both sides out and,
in one direction, auto-refunding in full against a consultant who was three
minutes late. Now 900. #1277 fixed the consequences in code; this removes the
event from the hot path. They fail independently, which is why both.

The script probes before it writes. `updateCallType` is undocumented on whether
it merges or replaces, its chat twin `channel.update()` is a full replace, and
`user`/`guest`/`call_member` all currently hold `join-call` — so a replace would
have been a total video outage. The probe settled it: top-level settings merge,
grants are untouched. Sub-objects are validated whole, so each is rebuilt from
the live read rather than written from a literal.

## The guards

`check-db-sidecars` and `check-db-drift` both printed "DATABASE_URL unset —
skipping" on every CI run. CI writes the secret to `.env` as a file and only
Next loads that implicitly; every other database-touching script here imports
`dotenv/config` and these two did not. So the guard built to catch enum drift
has never executed once — which is why #1284's drift reached the build stage and
surfaced as an opaque prerender error instead of a four-line diagnosis.

Turning it on exposed a bug in it. `parseSidecars` regexed raw SQL, so the block
of PROPOSED constraints commented out in check-constraints.sql counted as
required objects and the guard demanded three nobody had agreed to create. The
bug and the reason nobody saw it were the same bug. Comment stripping is
quote-aware: a `--` inside a literal is data, and the trigger bodies live in
dollar-quoted blocks that must survive intact.

That leaves one genuine drift, now repaired: the 2026-08-29 per-session-review
migrations dropped `consultant_review_legacy_pair_key` and never recreated it,
so pre-appointmentId rows silently lost their one-review-per-pair guarantee.
Applied as restore_consultant_review_legacy_pair_index after verifying zero
duplicate legacy pairs.

Both guards now run and pass under CI conditions: 46 sidecar objects present,
118/118 enums.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): disable individual and raw recording too

The first pass caught frame_recording and missed these two. My read script only
printed three capabilities, so I hardened what I could see rather than what
#1160 actually listed — the new Stream MCP's full call-type read surfaced both
still sitting at `available`.

Same class as the others: billable, entirely unused, and startable by anything
holding the capability rather than merely unbuilt.

Applied and verified: all six now read as intended, grants unchanged.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): resolve the call type from the shared constant

The script read `process.env.STREAM_CALL_TYPE`, which resolved correctly only
because that variable is unset. The app does not use an env var: `call-cid.ts`
exports `STREAM_CALL_TYPE = "default"` and `ensure-call-type-grants.ts` imports
it from there.

Left as it was, setting that variable would have pointed the settings script at
one call type while the grants script hardened another — two tools disagreeing
about which type they are protecting, which is the exact divergence class this
subsystem keeps repeating.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): refuse webhooks for call types this app does not use

Any signed-in user could bind a recording of their own choosing to somebody
else's appointment.

Every handler resolves its row with `call_cid.split(":")[1]`, discarding the
type half — eight sites across recording-handlers and session-handlers. The app
only ever uses `default`, but the Stream app also carries the built-in
`livestream`, `audio_room` and `development` types, and the plain `user` role
holds `create-call` on all three. On `development` it also holds
`start-recording`, `start-transcription` and `start-broadcasting` outright.
Video tokens here are app-wide — `generateUserToken` with no `call_cids` claim,
deliberately, since the call-scoped wrapper was removed as unused — so every
signed-in user already holds a token that works on all of them.

A user knows their own anchor slot ids. Calling `getOrCreate` on
`development:slot-<id>`, recording anything, and letting Stream fire a genuine
`call.recording_ready` produced a cid whose id half collided with a real
MeetingSession. The handler created a Recording row against someone else's
appointment, inheriting its title and organizationId, and surfaced it to that
consultant and their org library. Signature verification cannot help: the event
really is from Stream. The same collision reached the session handlers, where
injected participant events feed attendance, which feeds no-show detection,
which issues refunds.

Confidentiality was never at risk — call type is part of call identity, so this
never reached an existing consultation. It is injection, not disclosure.

The check goes at the dispatch choke point rather than the eight call sites, so
a type added later cannot reintroduce it by forgetting one. `callTypeFromCid`
already existed in call-cid.ts for exactly this and had zero callers anywhere in
the repo.

Refused events are marked processed so the sweeper does not re-drive them for
its full 168-hour window.

The test fails without the guard — verified by disabling it, which turns four of
the seven red. An earlier draft called processStreamEvent with three of its six
arguments and went green on the refusal assertions anyway, because dispatch bailed
long before the guard ran; `baseEvent.call_cid` is passed separately from the
event body and is the thing under test.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): address the review round on the ops hardening

Four items from CodeRabbit and the Sonar MCP, triaged against the current code.

**Block comments were removed rather than replaced with whitespace.** Postgres
treats a comment as whitespace, so it can legally be the only separator between
two tokens: `CREATE/* note */INDEX "x"` became `CREATEINDEX "x"` and the parser
stopped seeing an object that IS declared — the guard passing because it had
stopped looking. That is the dangerous direction for a drift check, and it was a
bug in code added hours earlier in this same PR. Block comments also NEST in
Postgres, unlike C, so scanning to the first `*/` left the tail of an outer
comment behind as apparent SQL; both are fixed and pinned by six tests covering
the commented-out proposal, the token-separator case, nesting, a `--` inside a
string literal, a dollar-quoted trigger body, and an unterminated comment.

Testing it meant importing the module, which ran `main()` and opened a database
connection that raced the runner's teardown. The entry point is guarded now, so
the file is importable and still behaves identically when invoked as a script.

**The call-type check moved into `isOwnCallType`.** It was inline, which pushed
`processStreamEvent` one point over the complexity threshold; as a named
predicate at the boundary it also reads as what it is. The redundant `undefined`
argument to `markWebhookEventProcessed` is gone — its second parameter is
optional — and the guard test's assertion moved with it, which is how the change
was caught.

**Two small ones in the settings script:** the nested ternary in the canonical
sort became a named `byCodeUnit` comparator, and the pre-image filename now
replaces `:` in the ISO timestamp, which is an illegal filename character on
Windows and would have thrown before the probe ever ran.

Not done: `main()` in the settings script and `stripSqlComments` still exceed
Sonar's cognitive-complexity threshold. Both are linear — a sequence of steps and
a lexer state machine — and #1134 already declined SonarCloud-motivated
refactors on this basis. The quality gate passes; these are open issues, not gate
failures, and CodeRabbit's claim that the gate "currently fails" was checked
against the Sonar MCP and is wrong.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* style(stream): prettier on the new SQL comment test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(stream): split the SQL comment scanner, stop mutating in an expression

Two problems I introduced with the previous commit, both surfaced by reading the
Sonar analysis afterwards rather than assuming the fixes were clean.

Adding nested-block-comment handling inline took `stripSqlComments` from 27 to
34 cognitive complexity — I made the thing I was fixing harder to read. It is
now a dispatcher over three named scanners (`scanSingleQuoted`,
`scanDollarQuoted`, `scanBlockComment`), which is what the function was always
doing implicitly. Behaviour is unchanged and the six tests still pin it.

Replacing the nested ternary with a named comparator introduced a new S4043: the
sort ran in-place inside an expression. `Object.entries` returns a fresh array so
nothing observable was wrong, but `toSorted` says what is meant.

Verified after: guard still reports 46 sidecar objects under CI conditions, the
settings script is still idempotent, tsc and eslint clean.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1286)

* fix(recordings): let the reconciler see partially delivered sessions

The candidate filter was `recordings: { none: {} }`, so the job could only ever
notice a session that received NOTHING.

Stream fires `call.recording_ready` once per FILE, and splits any session over
two hours into separate files — a three-hour class is three deliveries. Lose the
second and third and the session holds one Recording row, fails `none: {}`, and
is never examined again. The missing segments are gone when Stream deletes them
at day fourteen, and nothing ever reports it: the session has recordings, so it
looks healthy from every angle we had.

This is not a hypothetical shape here. The webhook endpoint rejected every
delivery for months because it required a secret Stream does not issue, and
exactly one Stream event has arrived since that was fixed. Partial delivery is
the expected case for recovery, not an edge case.

Correcting my own earlier framing: this is NOT a bug in handleRecordingReady.
One event per file is right, and `syncSessionRecordings` already iterates every
file `listRecordings` returns and already skips any `streamRecordingId` that has
a row. The recovery machinery was complete; only the candidate query was blind.

So the second pass reuses it verbatim. It runs after the orphan pass and spends
what that pass left of MAX_SESSIONS_PER_RUN, so a session with nothing is always
preferred over one that merely might be short — without that ordering, older
complete sessions would crowd out genuinely orphaned new ones. Re-examining a
complete session costs one Stream read and writes nothing.

Counted and alerted separately from `recovered`. A row created by this pass means
a session we already believed complete was missing a file — a delivery lost from
the middle of a set, which is a different and more alarming signal than one that
never arrived at all.

Bounded as before: the same fourteen-day window, the same 200-session cap.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(recordings): share one recovery pass between both queries

The second pass duplicated the first almost exactly — same sync call, same
error handling, same counting shape — and took the enclosing function to
cognitive complexity 20. Two near-identical loops is also the shape Sonar's
duplication gate objects to.

`runRecoveryPass` now owns the loop, and the two passes differ only in which
sessions they select and how the result is counted, which is the only thing that
was ever actually different between them.

Tests unchanged and still fail without the second pass.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(recordings): address the review round on the orphan reconciler

Both CodeRabbit findings were real, verified against the code before fixing.

**A recovered orphan was feeding itself into the partial pass.** The partial
query ran AFTER the orphan pass, so a session the orphan pass had just repaired
then matched `recordings: { some: {} }` and was re-examined in the same run —
consuming the partial budget, inflating `partialScanned`, and displacing a
session that was genuinely short all along. The candidate set is now snapshotted
before the orphan pass, so the two passes see disjoint sets.

**A swallowed failure was being read as an answer.**
`getCallRecordingsFromStream` returned `[]` on any transport error, so an
unreachable Stream was indistinguishable from "this call has no recordings" —
the session was counted as checked-and-empty and the run stayed green. That is
precisely the mistake this job exists to catch. It returns `null` for "could not
ask" now, and `syncSessionRecordings` returns a `SyncOutcome` saying whether the
sync actually completed and why not.

The outcome is additive: the two other call sites ignore the return value, so
their behaviour is unchanged. `syncSessionRecordings` still swallows errors so
one bad session cannot abort a batch — it just reports them now, which is what
the reconciler needs to tell "Stream says nothing" from "Stream did not answer".

My existing failure test only ever exercised the throw path, which production
almost never takes. Added one that models the swallowed outcome and asserts the
session is NOT counted as a session Stream had nothing for.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ging for sessions nobody attended (#1287)

* wip(no-show): corroborate against Stream before auto-refunding

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(appointments): corroborate a no-show against Stream before refunding, and stop charging for sessions nobody attended

Two halves of #1280's money pair. Neither existed; both move real money.

## The refund that fires on a lost webhook

`evaluateConsultantNoShow` decides on the ABSENCE of a MeetingAttendance row:
consultee has one, consultant does not, therefore the consultant no-showed,
therefore cancel and refund in full.

Those two rows come from two separate `call.session_participant_joined`
deliveries, potentially to different serverless instances. Lose only the
consultant's and the predicate is satisfied exactly — a full refund against a
consultant who was in the call. It is idempotent so it will not double-refund,
but reversing it means re-charging a customer by hand.

That this has never fired is luck rather than design: the webhook endpoint
rejected every delivery for months, so there are no attendance rows at all and
the detector has never had a candidate. It goes live the moment attendance
starts working.

Stream holds the same fact independently. `report.participants.unique` is a
count of distinct participants in the session and owes nothing to our webhook
pipeline — two, in a 1:1, means both parties were there whatever our rows say.
So the refund is refused when Stream contradicts it, with an alert, because a
contradiction means a delivery was lost and this is the only place that surfaces.

Refused on missing evidence too. "We could not check" is not "it definitely
happened", and the asymmetry is stark: refusing wrongly costs a support ticket,
refunding wrongly costs a manual re-charge.

## The session nobody attended

There was no both-absent detector, so a consultation where neither party turned
up is charged in full, silently, and auto-complete then closes it as COMPLETED.

It raises a ticket and does not decide. The product copy commits to exactly
this — "a genuine connectivity failure and a no-show look identical to a
script" — and two absent parties is precisely what a script cannot attribute.
The ticket carries both parties, the Stream call ids, and why it was not
refunded automatically.

Uses TECHNICAL_ISSUES rather than CONSULTANT_NO_SHOW: the latter presumes the
fault this deliberately refuses to assign. There is no neutral enum value, and
inventing one is a schema change this does not need.

Idempotent — `createSupportTicket` documents that callers own dedup, and this
runs on a cron.

## Tests

Ten, and they are real: disabling the corroboration turns four of them red.
They cover both parties seen, no report, no Stream call, a corroborated single
participant, a multi-slot booking where only the second session shows both, the
ticket being raised and its ownership, cron idempotency, and the two cases that
must stay silent — Stream saw someone, and Stream cannot speak at all.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(appointments): update the existing no-show suite for corroboration

CI caught what my own tests could not: `__tests__/booking/no-show-refund-front-door.test.ts`
exercises the whole detector, and every refund case in it started refusing.

Both reasons were correct behaviour, which is the point of the change:

Its fixture's `meetingSession` carries no `streamCallId`, so there is nothing to
ask Stream about and the detector refuses rather than refunding on an
un-corroborated inference. The fixture predates the requirement; it has a call
id now.

And the suite did not mock `getCallPresenceEvidence`, so the real one ran, found
no Stream, and returned "no evidence" — again a refusal. It now models a no-show
Stream AGREES with: exactly one participant in the call.

Also switched my two new imports in the detector from `@/` to relative paths,
matching every other lib import in that file. The alias resolves to a different
module instance under jest, so `jest.mock` on the relative path did not bind and
the mock silently had no effect — the failure looked identical to the fixture
problem and masked it.

Full suite green: 328 suites, 3612 tests.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* refactor(appointments): decompose the both-absent check

Sonar flagged two things in the previous commit, both mine.

The `SupportIssueType` import was a second `@prisma/client` statement beside the
existing one; merged.

`detectBothAbsent` sat at cognitive complexity 23, and the two things making it
dense were both decisions with names: what Stream says about the sessions, and
whether a ticket already exists. `streamPresenceAcross` and
`bothAbsentTicketExists` now carry those, which also makes the corroboration
result readable as `{ sawSomeone, evidenceMissing }` rather than two loop
variables.

Behaviour unchanged: 78 tests green, and disabling the corroboration still turns
the guard tests red.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(appointments): address the review round on the money pair

Three findings, all real, all verified against the code first.

**Any prior technical-issues ticket suppressed the escalation.** The dedup
matched on `consultationId` + `issueType` alone, so a ticket the USER filed
during the failed call already satisfied it — and the both-absent escalation was
then never raised for that consultation. It lost the automated escalation in
precisely the case where someone had complained. Matches on a title prefix this
job owns now, so an unrelated ticket cannot stand in for it.

**Both passes were doing the same expensive work twice.** `detectBothAbsent`
re-ran `findNoShowCandidates` — the same heavy nested query the caller had just
run — and a candidate the main loop refused on Stream evidence keeps its
APPROVED status, so it came back and had its Stream report fetched a second
time. Sequential calls, inside a held cron lock. It takes the candidate list now,
and one per-run cache serves both passes.

**A test proved the wrong half.** The saw-someone branch asserted that no ticket
is raised, but not that the alert fires — and the alert IS the feature there,
being the only place a lost `call.session_participant_joined` delivery becomes
visible at all. It asserts the Sentry warning now.

Two cases added: one pinning the narrowed idempotency key, one proving a call id
is looked up at most once per run.

80 tests green, and disabling the corroboration still turns the guard tests red.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(stream): make the call types this app never uses unusable

#1285 closed the exploit route at the webhook boundary. This closes the other
half — the ability to mint the call in the first place.

We use exactly one call type. The Stream app also carries three built-ins, and
Stream ships them permissive: on `development` the plain `user` role held
thirty-six permissions including create-call, start-recording,
start-transcription and start-broadcasting, and on all three `guest` and
`anonymous` held join-call. Video tokens here are app-wide — generateUserToken
with no call_cids claim, deliberately — so every signed-in user already held one
that worked on all of them.

Deleting them would be simpler and is not possible: all three are Stream
built-ins.

Strips reach (create-call, join-call, join-backstage, join-ended-call and their
-any-team variants) and the billable starters (recording, frame recording,
transcription, closed captions, broadcasting) from every role an end user can
hold. Platform staff keep theirs: staff are Stream `admin` via mapRoleToStream,
and an operator needs to be able to inspect or end a call on any type.

The billable ones go even though reach is already gone, so re-granting reach
later cannot silently re-arm the meter.

Applied and verified. Blast radius was zero — no call has ever existed on any of
the three. `default` re-read afterwards and confirmed untouched: `user` keeps
create-call and join-call, `call_member` keeps join-call. That was the risk worth
checking, since a grants map is catastrophic to get wrong and invisible when you
do; the script reads back after every write and refuses if anything it stripped
survived.

Tests pin the one mistake that would take the platform down — the strip list
containing the type the app actually uses — plus the -any-team variants, which
`call_member` held on `development` and which stripping only the plain grant
would have left behind.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

* fix(stream): address the review round on the call-type hardening

Three findings, all valid.

**A dry run was not a dry run.** `mkdirSync(BACKUP_DIR)` ran before the `--apply`
check, so merely inspecting created `.stream-backups`. A dry run has to leave the
tree exactly as it found it or the distinction stops meaning anything. It happens
inside the apply path now, and the fix is checked: the directory is removed, a
dry run is run, and it stays absent.

`main` was at cognitive complexity 20 against a limit of 15; the per-type work is
`hardenOne` now, which also makes the loop read as what it is. `STRIP` is a Set —
it was a list scanned with `.includes()` once per permission per role per type.

Behaviour is unchanged: the three types still report `already hardened`, and 432
Stream tests stay green.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmzXXn3P2o9Ef3fzWjPkqs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…st door (#1301)

* fix(stream): revoke end-call from every participant, and shut the guest door

Two doors that #1134 and #1280 found open and could not close at the time.

`end-call` on `call_member`. The join route assigns that role to EVERY
participant, and Stream's roles do not separate host from participant here —
host-ness is `custom.consultantUserId`, which Stream knows nothing about. So any
attendee could end a paid consultation for both sides from devtools;
`EndCallButton`'s `isHost` only decides what renders, not what Stream permits.

It could not be revoked while the client needed the grant. #1270 built the
replacement — `POST /api/meetings/[meetingId]/end` resolves access server-side
and ends the call with the server client, and the button posts to it behind an
`endingRef` guard. So the grant can go, gated on the same
`--join-route-is-deployed` flag as the join-call move, because both routes ship
in one deploy and a host on an old bundle would otherwise lose End Call
silently.

The rollback hands `end-call` back to `call_member` and to nobody else. That
rollback exists for exactly one situation — the end route is not actually
serving traffic — and in that situation restoring join alone would fix the
lockout and strand every host inside a room they cannot close.

`guest_user_creation_disabled`, read live as `false`. Guest sessions are
mintable from the browser with nothing but NEXT_PUBLIC_STREAM_API_KEY, and
`guest` holds `join-call` on the `default` type. #1134 decided against guest
access and nothing in the tree creates a guest, so this is an unused capability
with the door left open. The grants script strips the grant; the new
`ensure-app-settings.ts` stops the accounts existing. Two independent locks,
because the grants script has been wrong about which roles exist before.

`enable_hook_payload_compression` is pinned off in the same request rather than
its own — the blast radius is per-request, not per-field. It is WRITE-ONLY: on
`UpdateAppRequest`, absent from `AppResponseFields`. So it cannot be read back,
idempotency-checked or verified, and the script reports it as sent rather than
as confirmed. The route's own gzip handling is the real guarantee.

The dangerous part is neither value. `updateApp` takes a partial and nothing
documents whether the fields it is not given survive, while the chat twin
`channel.update()` is a full replace. This app's `event_hooks` is one hook
carrying the nine video event types the entire webhook pipeline depends on, and
losing it would present exactly like the 2026-08-13 outage in which that
pipeline had never processed a single event. So the script writes a pre-image to
disk, sends a no-op probe, re-reads, and only proceeds if nothing moved.

Both new guards are pinned by tests proved red with the guard disabled and green
with it restored. Verified by dry-run against the live app: `call_member`
`end-call` true -> false, `guest_user_creation_disabled` false -> true.

Part of #1280
Part of #1144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): review round — a probe that can silently pass, and an unverified rollback

Three CodeRabbit findings on #1301, all legit, all verified against the code
before being taken.

**The no-op probe could test nothing and report clean.** `updateApp` is sent
`{ moderation_enabled: before.moderation_enabled }`. `AppResponseFields` types
that as a required boolean and the live app returns `true`, but a type is a
contract rather than a runtime guarantee, and this subsystem has been burned by
trusting the declared shape before. If the field were ever absent the body
serialises to `{}` — Stream changes nothing, the fingerprint matches, and the
probe reports CLEAN having proved nothing about merge-versus-replace, then
licenses the real write against the document that holds `event_hooks`. A probe
that can silently pass is worse than no probe, so an absent field is now a
refusal.

**The key-ORDER test was vacuous.** It reassigned `file_upload_config`, which
has ONE key, so no reordering occurred: the fingerprints matched under plain
`JSON.stringify` and the case passed with `canonical`'s sorting deleted. It now
reverses the eight-key event hook, and deleting the sort turns it red.

**The rollback had no post-write verification at all.** Both grant assertions
are gated on `!opts.restore`, so `--restore-user-join` fell through to the
settings comparison, found nothing moved, and returned 0 — reporting success
without ever asking whether the restoration landed. That is backwards: the
rollback is the emergency path, reached when the revocation has already locked
people out, and "it worked" is the one thing an operator cannot afford to be
told wrongly. Asserted only when the run actually intended to restore the grant,
so rolling back a call type that never had it does not fail on a no-op.

Each new guard proved by disabling it, confirming red, and restoring. 24 grants
tests and 9 app-settings tests green; tsc and eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(stream): one drift comparison for the three config scripts, not three

SonarCloud failed #1301's gate on new-code duplication (3.7% against a 3% cap),
pointing at 28 lines shared between `ensure-app-settings.ts` and
`ensure-call-type-settings.ts`. Chasing it found a third copy in
`ensure-call-type-grants.ts`, written differently again — an inline `.sort()`
where the other two use `.toSorted()`.

The duplication is the symptom; three implementations of one safety check is
the problem. All three scripts write a partial to Stream, none of the endpoints
document whether the omitted fields survive, and the chat twin
`channel.update()` is a full replace — so each snapshots, writes, re-reads and
compares. That comparison is what decides whether an operator is told their
`event_hooks` was just destroyed, and keeping three copies of it is three
chances for one to drift into a false negative on the run where it matters.

`lib/stream/config-fingerprint.ts` now owns `canonical`, `byCodeUnit` and
`diffFingerprints`. Code-unit ordering, never `localeCompare` — same rule as the
channel ids, and for a sharper reason here: an ICU-dependent comparison would
make an identical config report as drift on one machine and not another, which
mid-incident reads as "Stream wiped settings it never touched".

No behaviour change. 445 tests across 34 suites green, tsc and eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…t can never work (#1302)

* fix(stream): unpick the shared circuit breaker, and stop retrying what can never work

The Bucket-2 residue from #1280 plus the review findings #1146 caught after the
PRs that owned them had already merged.

## Stream and Redis had one circuit breaker between them

`lib/redis.ts` declared a single module-level object and every Stream call
routed through it. The coupling ran both ways and both were wrong: five Stream
failures opened the breaker that `utils/appointmentlock.ts` acquires booking
locks through, so a VIDEO VENDOR OUTAGE STOPPED CHECKOUT; and a Redis outage
surfaced to users as "Video is temporarily unavailable" while `/api/health`
blamed Stream, pointing whoever was on call at the wrong vendor mid-incident.
Counts interleaved too, so three Stream errors plus two Redis errors tripped a
breaker neither service had earned.

Now a factory, one instance each. The thresholds are deliberately untouched —
#1280 Bucket 4 is right that per-instance state on short-lived serverless rarely
accumulates five failures. The coupling is the correctness bug; the numbers are
not.

## A billing refusal is not an outage

Only 404 and 429 were classified, so a MAU cap, a declined card or a suspended
account fell into the generic branch: Sentry error, breaker trips, 30s reset,
half-open probe, trips again, forever. Nothing in that loop says "we owe Stream
money", which is the only actionable fact. `isStreamBillingError` (402/403, API
codes 99 and 2) is treated like 429 for the breaker — retrying cannot fix it —
but escalated to its own alert, because unlike 429 it does not self-resolve.

## The health probe had no deadline of its own

`getAppSettings()` takes no arguments, so there is no per-request timeout to
narrow, and the breaker's only timing knob governs OPEN→HALF_OPEN rather than
the operation. The breaker therefore protected the SECOND probe of an outage and
not the first: in the opening minutes `/api/health` could hang for the client's
full 30s on the endpoint whose whole job is to report that outage quickly. Now
2s, raced INSIDE the breaker so the timeout counts as a failure — a caller-side
race would return early and never trip the thing that makes later probes cheap.

## Eight `.parse()` calls that punished a permanent failure like a transient one

A ZodError landed in the handler catch, was stamped as an ordinary error, and
the sweeper re-drove the row every ten minutes for its 168-hour window —
roughly a thousand attempts at a payload that cannot become valid, burning the
sweeper's budget and hiding a real contract break behind noise that ages out on
its own.

One `safeParse` at a choke point now, against a schema/handler table whose
`satisfies Record<HandledEventType, …>` is the same exhaustiveness proof the
`never` default branch was. A mismatch is stamped `permanent:` — a prefix the
sweeper's selector skips, alongside the `gave up:` marker it already had, both
now derived from one shared constant rather than a string literal in each place
with a comment asking the next editor to keep them in sync. One Sentry alert
naming the offending fields, instead of a thousand silent retries.

**Not adopted: `verifyAndParseWebhook`.** It returns only the parsed `Event`,
not the uncompressed bytes, and our dedup key is `sha256` of exactly those bytes
because they are the only material Stream signs. Adopting it would force the key
to be re-derived by re-serialising, and `JSON.stringify` is not byte-stable, so
two retries of one delivery could hash differently and dispatch twice. The SDK's
`verifySignature` is adopted instead — same algorithm, constant-time, maintained
against the cross-SDK contract, and it drops a hand-rolled comparison that never
validated its input was hex.

## Join had no in-flight guard on any surface

The first `await` is `waitForGlobalVideoClient()`, which waits out a retry
ladder on a cold provider, and the per-row "joining" flag that looks like a
guard is React state — written asynchronously, so the second click reads the
stale value. Two concurrent runs mint the call twice, and
`useLazyJoinMeeting`'s `?? slotsOfAppointment?.[0]` fallback reads an UNSORTED
array, so they can resolve two different anchors and put the two sides of one
booking in two different rooms. That is #1061.

`createInFlightGuard` copies `ExitMeetingButton`'s ref pattern rather than
inventing a second mechanism, keyed so one row's Join does not block another's.
Split from its React binding because this repo has no renderer in its dev
dependencies, and a guard against a double-click is exactly the thing that must
not go untested.

## The maintenance drain could skip the entire maintenance posture

`drain-sessions.ts` had NO test coverage at all, which is why all three of these
survived. Stream grants `use-frozen-channel` to no role, so a channel left
frozen is unwritable by every user AND every admin, with no error and no visible
cause.

- The per-session transaction was unguarded inside a plain `for`, so one
  rejection propagated out of `drainActiveSessions` and cost two things: every
  later session kept running on Stream while the platform went OFFLINE, and —
  because the freeze happens after the loop — no channel was frozen at all.
- The unfreeze re-derived its set from a heuristic that was approximate three
  ways: a window longer than six hours matched nothing, an uncapped 200-row take
  left an arbitrary remainder, and a session whose `call.end()` failed was
  frozen but never stamped, so it could not be found — and `call.end()` failing
  means a Stream outage, which is when maintenance runs. Now a Redis ledger of
  what was CONFIRMED frozen, retiring only what came back unfrozen so a refusal
  is retried on the next OFF rather than left forever. The heuristic stays as a
  fallback: approximate beats never.
- `runPostRecovery()` was awaited bare, ahead of the unfreeze. Every step inside
  it is individually wrapped today, but the call site took no responsibility, so
  one future unguarded `await` in there would make an OFF transition skip the
  unfreeze and brick group chat with no signal.

Note the breaker fallbacks were REMOVED from the ledger writes: a fallback
returns its value and swallows the rejection, so the `catch` meant to record the
failure never ran and a Redis outage left no ledger and no error.

## Docs

`SKILL.md` taught three things the code had since decided against: scoping video
tokens per call (the wrapper was written and removed — `stream-client.ts:117`),
dedup on `X-Webhook-ID` (spoofable; the route rejects it at `:183-201`), and
`noise_cancellation: auto-on` (it is `available` since #1285). Also drops a
reference to `chat-moderation-handlers.ts`, which no longer exists.

## Verification

Full suite: 332 suites, 3640 tests, green. Cold `tsc` clean, eslint clean on
every changed file. Every new guard proved by disabling it, confirming red, and
restoring — including the exhaustiveness table, which fails `tsc` in two places
when a row is removed.

Part of #1280
Closes #1146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): narrow the billing classification to what Stream actually documents

CodeRabbit round on #1302. Four findings, all legit; one was already fixed, one
is Major.

## `isStreamBillingError` matched three things it should not have

Checked against Stream's published error table
(<https://getstream.io/chat/docs/node/api_errors_response/>) rather than
reasoned about:

| code | HTTP | meaning            |
|------|------|--------------------|
|   99 |  403 | App suspended      |
|    2 |  401 | Access Key invalid |
|   17 |  403 | Insufficient perms |
|   70 |  403 | No channel access  |

The first revision matched `402 || 403 || code 99 || code 2`. Three quarters of
that was wrong in a way that mattered:

- **code 2 is authentication, not billing.** A rotated-away or mistyped API key
  would have been excluded from the breaker and reported as "we owe Stream
  money" — the most misleading diagnosis available for a misconfiguration,
  because it sends whoever is on call to the billing page instead of the env
  vars.
- **a bare 403 is not billing either.** Codes 17 and 70 share it, so an ordinary
  permission refusal would have been laundered into a billing alert.
- **Stream documents no 402 at all**, so including it implied knowledge of a
  contract that does not exist.

Now code 99 and nothing else. Narrow and cited beats broad and guessed —
anything this does not catch still reaches the generic branch, which pages.

## The main claim of that change was untested

The breaker mock ignored its third argument, so `shouldTrip` — the predicate
deciding whether a failure counts toward opening the breaker — was invisible to
every test. The headline behaviour, that a suspended-app error must NOT trip
Stream's breaker, had no assertion behind it. The mock now records the verdict.

Note the mock had to be restored per-test as the RECORDING one: an existing case
swaps the delegate to capture `shouldTrip`, and that assignment persisted, so
restoring a bare pass-through in `beforeEach` left every later case blind again.

## A partially-written ledger was treated as complete

The sharper of the two Major findings, and correct. The frozen-channel ledger is
written incrementally per batch, so "non-empty" and "complete" are different
claims. If batch A records, batch B freezes successfully, and B's `sadd` fails,
the ledger holds only A — and the unfreeze, seeing a non-empty ledger, would
reverse A, skip the derived fallback entirely, and leave every channel in B
frozen after the OFF transition. Frozen means unwritable by every user and every
admin, with no error text and no visible cause.

A failed ledger write now records a marker, and the unfreeze UNIONS ledger with
derived whenever that marker is set or unreadable. Union rather than either-or,
because the asymmetry decides it: unfreezing something already unfrozen is an
idempotent no-op costing one rate-limited call, while missing one is permanent.
The clean path is unchanged and still skips the extra query.

An earlier draft of this also kept a module-level `ledgerComplete` boolean as a
belt-and-braces in-process signal. It was removed rather than kept: it is
redundant with the marker and the existing fail-to-derived catch, and module
state outlives a single call, so a warm instance carried one drain's failure
into an unrelated later unfreeze — which the test suite caught by leaking the
same way.

## Already fixed

`run`'s cognitive complexity of 21 was decomposed into `isRefusing`,
`recordSuccess` and `recordFailure` before this review landed. Also aliased the
repeated `"ledger" | "derived" | "none"` union (`typescript:S4323`).

13 drain tests (was 8) and 30 client tests (was 26), each new guard proved by
disabling it and confirming red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(stream): cover the MockRedis set operations the ledger relies on

`USE_MOCK_REDIS=true` is how local dev and parts of CI run, so a ledger that
silently misbehaves against the mock would make the maintenance drain look
correct everywhere except production. Six cases: incremental accumulation across
batches, no duplicate on a retried batch, partial removal leaving refused
channels for the next run, absent key reads empty, key dropped when the last
member is retired, and a non-set value degrading to empty rather than throwing
inside a drain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ook again (#1303)

* feat(stream): freeze a dormant pair's DM, and unfreeze it when they book again

The last piece of #1270's scope. Nothing has ever ended a direct-message
channel: `syncUserEventChannels` reconciles membership but no stage freezes or
deletes a DM, so channel count and membership grow without bound on a product
billed per monthly active user, and there is no retention answer for a
compliance review.

## Dormancy is a property of the PAIR

DM ids are keyed on the pair (`dm-<a>-<b>`), never on an appointment, and
`DM_ELIGIBLE_STATUSES` deliberately includes `COMPLETED` so a finished booking
keeps the conversation open. A per-appointment trigger would therefore freeze a
live relationship the moment one of its bookings completed. So the job groups by
CHANNEL — the id is a function of the pair AND the funding context, so one pair
legitimately holds a personal `dm-` channel and a separate `dmo-` per org — and
measures against the latest slot across every booking that channel covers.

Ninety days, not the event stage's seven. An event ends on a schedule and its
chat has a natural tail; a consulting relationship does not, and a fortnight
between sessions is ordinary. Deletion follows at the org's `chatRetentionDays`.

## The unfreeze is the part that makes it safe

An event never resumes, so a frozen event channel stays frozen correctly. A pair
does resume. Without a reversal, the first thing a returning consultee would
find is a channel they cannot post in — and because Stream grants
`use-frozen-channel` to no role, with no error text explaining why. Freezing
without unfreezing would be a worse bug than never freezing.

So an active-but-stamped pair is unfrozen, FIRST and outside the per-run budget:
a frozen channel belonging to an active pair is a live user-facing fault, while
a dormant pair staying unfrozen one more day is not.

## Schema

`Consultation.chatFrozenAt` and `Subscription.chatFrozenAt` mirror the existing
columns on `Webinar` and `Class`, but are read as MAX() across the pair and
cleared across all of them — stamping one row would let a second booking report
the channel as unfrozen while it was not.

`Organization.chatRetentionDays` defaults to 365, split from
`streamRecordingRetentionDays` (90) which chat used to borrow. The two are not
the same question: a recording is a stored asset with a storage bill, a chat
channel is the written record of a professional consultation. Deliberately NOT
clamped to a floor — PR #1266 establishes Rule 8(3) as a narrow
preservation-for-State-access duty that does not commence until 13 May 2027, so
a hard minimum would over-state what binds us.

Migration applied to the shared dev database and verified; purely additive, and
recorded at prisma/sql/one-off/2026-09-01-add-dm-chat-freeze-ledger-and-chat-retention.sql.
`check-db-drift` green, 118/118.

## The freeze says so before it lands

`lib/stream/system-message.ts` is the first `sendMessage` wrapper in the repo —
nothing anywhere sent a Stream message from the server before. A frozen channel
refuses every send with no error a user sees: they type, nothing happens. So the
freeze posts a system notice first, and the order is load-bearing, because a
message sent after the freeze would itself be refused.

## Also

Two chat features were dead code. `client.on("*.**", handler)` registers under
the LITERAL string key — verified against the installed SDK, which records
`'*.**'` and `'all'` as separate keys and fires only the latter — so the entire
live channel-list updater in `ChatSidebar` and every counter in `DebugDialog`
have never fired once. The single-argument form is the "every event" listener,
which is why `useChatUnreadCount`'s badge updated while the list it points at
did not.

351 legacy underscore channels (`webinar_`, `class_`) are declared rather than
resolved by accident. They are deliberately NOT added to
`MANAGED_CHANNEL_PREFIXES`, which the plan for this work called for: that list
makes the reconciler REMOVE a user from any channel carrying the prefix that is
absent from the expected set, and the expected set emits `webinar-<id>`, never
`webinar_<id>` — so all 351 would be classified stale and their members removed
on the owner's next dashboard load. That is #1134 P0-7 exactly.

Ran `purge-memberless-dms.ts`: 30 phantom channels deleted, all with zero
messages, the 6 message-bearing ones preserved by the script's default. 135 → 105
channels, re-run confirms 0 candidates remain, pre-image on disk.

## Verification

331 suites / 3636 tests green, cold tsc clean, eslint clean. The DM stage goes
from no coverage to 12 tests, each proved by disabling the behaviour it pins:
removing the unfreeze branch reds 2, sending the notice after the freeze reds 1,
measuring dormancy on the oldest booking instead of the newest reds 3.

Part of #1280
Closes #1270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): never hard-delete a DM from a page that may be incomplete

CodeRabbit round on #1303. Seven findings, all legit, one of them serious.

## The critical one: truncation could destroy a live pair's history

Both booking queries cap at `MAX_DM_PAIRS_PER_RUN` and order by `requestedAt`,
which is NOT the key dormancy is measured on — that comes from the latest slot
`endsAt` across the pair. The two are independent, and a long-running booking is
requested once and then generates sessions for years, so it sorts old and is the
first thing a full page drops.

If a pair keeps one low-activity booking on the page and loses its active one,
`lastActivityAt` is computed from the stale row, the pair classifies as past
retention, and `hard_delete: true` destroys the chat history of a live
consulting relationship. Unrecoverable, and silent — nothing recorded that rows
had been dropped.

The delete stage is now withheld entirely when either query fills its page, the
run is marked unsuccessful, and the held-back count is reported. Freezing still
runs, deliberately: an over-eager freeze is undone by the next run's unfreeze
branch, and withholding it too would be over-correction.

## And an org above 365 days got no deletion at all

The scan window came from `MAX_RETENTION_DAYS` (365) plus margin, while
`chatRetentionDays` accepts any value. An org on 500 days had every booking
dropped by the bound before it could be classified — silently, and in the
direction of keeping personal data forever. The window is now derived from the
largest value actually configured across all organizations.

## SonarCloud gate

`runDmStage` was cognitive complexity 20 and `applyDmFrozen` 16, against a limit
of 15. Extracted `classifyDmPairs` (pure, now directly testable), `announceFreeze`
and `writeDmLedger`. Behaviour-preserving.

## The rest

`deletedDms` counted requests, not deletions — `deleteChannels` is idempotent
and returns a task id, so a pair past retention is re-sent every run until it
ages out of the scan window. Renamed `dmDeleteRequests`, which is what the
number is. Bounded by the lookback rather than unbounded, so the metric was the
defect, not the work.

`sendSystemMessage` spread `custom` LAST, so a caller key named `type` could
override `type: "system"` — the field the docstring calls load-bearing, because
a regular message touches unread counts. Fixed ordering; no caller does this
today.

The channel-identity test proved nothing: it re-derived both ids from
`getDmChannelId` and compared them to each other, which passes even if the job
addressed the wrong channel or the wrong Stream type. The mock now captures
`channel()` arguments and the test asserts the org channel was frozen and the
personal one was not — the failure `lib/stream-channel-ids.ts` records as having
gone unnoticed for months.

Left as-is with a reason: `loadOrgChatRetention` still reads every organization
rather than only those with a booking on the page. It has to — the scan window
is derived from the largest configured retention, and narrowing it to the orgs
already loaded would make the window depend on the page, which is the same
incomplete-input-drives-a-destructive-decision shape as the finding above.

15 DM tests now (was 12), each new guard proved red with it disabled. 331 suites
/ 3639 tests green, tsc and eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…er ran (#1304)

* fix(stream): tag the org at source, and find out why the backfill never ran

Measured against the live Stream app on 2026-08-30: ZERO of 886 channels carry
`organization_id`, in any form, and `dmo-` is 0. The org Messages tab and the
`/api/organizations/[orgId]/stream/channels` compliance route both filter on
that field, so both return empty for every organization — a compliance export
that reports "no channels" rather than failing.

#746 §1 records per-org channel tagging as done. It is not, and there are three
separate reasons, each of which had to be found by running things rather than
reading them.

## 1. The lazy create path never carried the field

`createChannel` tags on the eager path, but `addUserToEventChannel` — the
create-on-miss that actually mints most channels — built its `create()` payload
without it. Now resolved through the existing `bookingOrgId`, which had to be
widened: it knew about consultation and subscription plans but not
`webinarPlan`/`classPlan`, so the event path had nothing to ask. One resolver,
not two — a second implementation for events is how the tag came to disagree
between the creator, approval and the reconciler in the first place.

Spread in only when set. A literal `organization_id: null` is a SET on Stream's
side, and the reconciler's `$exists` filters treat present-but-null differently
from absent.

## 2. The backfill script has never been able to start

`scripts/stream/backfill-channel-org.ts` is the only script in that directory
without `import "dotenv/config"`. `tsx` does not read `.env` on its own, so
`lib/redis.ts` — pulled in transitively through `lib/stream-client` — hit its
module-scope env check with `UPSTASH_REDIS_REST_URL` undefined and threw before
a single line of the script executed. Written, reviewed, merged, and never once
runnable. Same shape as the CI database guards in #1285, which printed
"DATABASE_URL unset — skipping" on every run for the same reason.

## 3. And once it started, every channel errored

`channel.query()` maps to Stream's GetOrCreateChannel endpoint, which under
server-side auth refuses without an author:

  StreamChat error code 4: GetOrCreateChannel failed with error: "either
  data.created_by or data.created_by_id must be provided when using server
  side auth."

A read-shaped call that can create is not a read — the same trap #1270 hit on
the video side. Replaced with `queryChannels` filtered by id, which cannot
create: a missing channel comes back as an empty page, which is the right answer
for a backfill. Minting an empty channel as a side effect of inspecting it would
be worse than not tagging it.

While there: dry run is now the DEFAULT and `--apply` writes. It was the other
way round, so the bare command wrote to production Stream — the opposite of
every sibling script in that directory, which is a trap for an operator who has
learned that these are safe to run and read first. `--dry-run` still works.

## 4. Logout left the connection store reporting "connected"

`resetStreamConnection` had exactly one reference in the repo: its own
definition. The obvious reading is dead code to delete, and the plan for this
work said to delete it. It is the opposite — a correct helper nobody wired up.

`disconnectStreamClients()` nulls the global client refs but never touched the
store that `useStreamConnection` reads through `useSyncExternalStore`. So after
sign-out the store still reported `{ chatConnected: true, videoConnected: true,
clients: <the objects just torn down> }`, and the next user's session started
against the previous user's state until the provider republished. Now called
from the teardown. `connection-store` is `import type` only for the SDK, so
`disconnect.ts` stays SDK-free.

## Not done: the org switch

The plan called for `resetStreamConnection` on an org switch too. It is not
needed and would be a no-op with a cost: `OrganizationSwitcher` is pure
navigation, `StreamProvider` is keyed on the session `userId` which does not
change across orgs, and org scoping is route-derived filtering in `ChatSidebar`.
Tearing the connection down and rebuilding it on every org switch would add a
reconnect to a navigation that does not need one.

## Docs

Corrected #746 §1's claim (in a comment on the issue) and added a dated
correction to `docs/stream/13-recording-webhooks.md`, which describes the
recording transfer pipeline as healthy and running when it has never executed
once — 191 recordings, all `STREAM_S3`, `transferAttempts = 0`. Follows the
addendum pattern already used in `architecture-review-2026-08-23.md` rather than
deleting the section, because it still describes the design accurately.

## Verification

653 tests across 49 suites green, cold tsc clean, eslint clean on every changed
file. The resolver's new arms are pinned by 4 tests, proved red by removing
them. The backfill was run for real against the live app: it now completes with
0 errors where every channel errored before.

Part of #1280
Part of #746

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): make the org resolver answer the same way for every booking kind

Self-review of this PR, since it is the one with no bot review — CodeRabbit has
been rate-limited on it since it opened.

`bookingOrgId` resolves plan-first-then-appointment, but the consultation and
subscription arms of `getEventData` were only passing the PLAN. A personal plan
booked under an organization carries its org on the APPOINTMENT, and the
DM-eligibility path (`pairBookingContexts`) does read it — so the same pair
would be treated as org-scoped by the eligibility gate and produce an untagged
channel from the tagger.

That is the same class of defect this PR exists to remove: a resolver that
answers differently depending on which caller asks. Leaving two arms
half-resolved while fixing the other two would have written the bug back in a
quieter place.

Not a live defect. `app/api/stream/channels/open/route.ts:69` restricts
`eventType` to `["webinar", "class"]`, so those two arms are reachable only from
tests today — verified rather than assumed. Fixed anyway, because whether the
trapdoor is currently reachable is not the same question as whether it is a
trapdoor.

Two tests, proved by deleting the appointment fallback from `bookingOrgId` —
two go red.

331 suites / 3,629 tests green, tsc clean, eslint clean on every changed file.

Part of #1280
Part of #746

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): a teardown should only clear the session it started

CodeRabbit's first look at #1304 — it had been rate-limited since the PR opened,
and got through only after pausing automatic review and asking for a full one.
Three findings, two Major, both real.

## The teardown could clear a session it did not start

`disconnectStreamClients` awaits `disconnectUser()` on each client, and those
awaits take real time. The provider retries connecting on failure, so a retry
landing mid-teardown publishes new clients and new store state — and the code
after the awaits then nulled a client nobody asked it to touch and reset a
snapshot describing a live session. The app ends up connected on Stream's side
and disconnected in its own state.

The ref-nulling half of that predates this PR; adding `resetStreamConnection`
widened it to the store, which is what made it visible. Both halves are fixed
together: the clients being torn down are captured at entry, and nothing is
cleared if a newer connection replaced them. A supersession is logged, because
it means a sign-out and a reconnect interleaved and the reconnect won — not
broken, but not what the user asked for either.

## The org fallback was order-dependent

`find()` returns whichever tagged appointment the relation happened to yield
first, and Prisma relations come back unordered. With two different orgs present
the answer changed between reads — and since the DM channel id is a function of
the org, one relationship would mint `dmo-<digest(A)>` on one path and
`dmo-<digest(B)>` on another. Two channels, split history. That is the same
shape as the `[0]`-versus-`find` bug this helper was written to fix; `find`
closed the null-versus-org half and left this one.

Fixed in `bookingOrgId` rather than by adding `orderBy` to each query, because
there are several callers and the ones that forgot would keep the bug. Code-unit
ordering, never `localeCompare`, for the same reason as the channel ids.

A booking is funded once, so in a well-formed dataset there is one distinct org
and any choice is the same choice. This only decides what happens when that
invariant is violated — and a stable wrong answer is recoverable where an
unstable one is not.

## Docs

`Last Updated` still read 2025-01-22 on a file amended tonight, and the footer
had ended up above the correction rather than below it. Both fixed.

The `2026-09-01` date on the correction itself is kept: it is 05:20 on
2026-09-01 in Asia/Kolkata, which is the authoring timezone. It only looks
future-dated read as UTC.

Each fix proved by reverting it and confirming the test goes red. 331 suites /
3,631 tests green, tsc and eslint clean.

Part of #1280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…econd dead role check (#1305)

* feat(stream): bound the call, unbrick the appointments page, kill a second dead role check

Four independent items, none depending on another.

## A server-side duration cap, as a billing backstop

`limits.max_duration_seconds` reads `null` on the live call type and is not
metered. #1144 listed it as the highest-value unbuilt item on the grounds that
the SFU would end calls "at the slot boundary". #1160 corrected that, and the
correction is the entire design: **the timer counts from the moment the first
participant joins, not from `starts_at`.** Set to the booked length, a
consultant arriving fifteen minutes early to check their camera would have
Stream hard-terminate the session before its booked end, ejecting both parties
mid-sentence. `lib/meeting.ts` declined to send the field for exactly that
reason, and on that point it was right rather than over-cautious.

So it is set generously — booked run + `CONSULTANT_JOIN_WINDOW_MS` + a 30-minute
grace matching the server's own `REJOIN_GRACE_MS`, so the cap cannot expire
while the join gate still honours a reconnection. Floored at two hours (bad
input must fail long, never short) and ceilinged at twelve (a corrupt booking
must not disable the bound entirely).

It is NOT slot enforcement and must not be mistaken for it. What it buys is that
Stream stamps `ended_at` whether or not our webhook pipeline works — #1134 found
1,417 sessions with no `endedAt` against a pipeline that had never processed one
event, and this is the only control in the stack that degrades through that
because it does not run on our infrastructure — and that a forgotten tab stops
billing participant minutes forever.

Extracted to `lib/meetings/duration-cap.ts` rather than left inside the
`"use server"` action: it is a pure policy calculation, and putting it in its own
module means it can be tested without dragging Prisma and the Stream client into
the test process.

### The existing pin had to be sharpened, not weakened

`session-room-identity.test.ts` asserted `settings_override` absent entirely.
Its stated invariant is that we never let Stream REFUSE a join — backstage and
`join_ahead_time_seconds`, deferred in #1070 — and a duration cap ends a session
already running rather than gating entry, so it does not touch that. But the
blanket assertion was the blunt form of the pin, so the fix whitelists the exact
nested shape instead. That is strictly stronger: `backstage` smuggled one level
down inside `settings_override` would have satisfied the old check by the object
simply not existing, and now fails on the key set. Verified both ways.

## A withdrawn consent no longer takes the appointments page down (#1269)

`resolveSessionCallProfile` called `upsertUsersToStream` unguarded, and that
throws `ConsentRequiredError` when STREAM_DATA_PROCESSING is absent. Failing
closed is correct for Stream; doing it from a render path is not — the throw
propagated out of a React Server Component and the consultee lost access to
their own bookings. Withdrawal is a right DPDP explicitly grants, and the first
person to exercise it should not be locked out of the list of things they paid
for.

Now returns `null`, an outcome this function already reaches three other ways
and every caller handles. The refusal still bites where it should: the JOIN path
upserts separately and is left to throw, because "you cannot join a video call
without consenting to the video processor" is the right answer to a join and the
wrong answer to a page load.

## The second dead role check, which the first fix missed

`ChannelInfoAndManageDialog` had `isEventOwner` fixed with a comment explaining
that `client.user.role` is the STREAM role and `mapRoleToStream` collapses every
non-staff account to `"user"`. Two hundred lines below, `canTruncateChannel`
still did the same comparison. `isPrivileged` was therefore permanently false
and only a channel's creator could ever clear a group channel — staff and admins
could not, despite the branch claiming they could. #1144 asked for a grep of
this pattern; this is what it found. Now reads the app role from the session,
the same source and pattern `ChatSidebar` already uses.

## Sonar

`buildSlug`'s `.replace(/^-+|-+$/g, "")` is `typescript:S8786`, and the only
Stream-owned contributor to the failing `new_reliability_rating` on `dev`.
Rewritten as split-and-join, which needs no trim at all. Honestly: V8 optimises
the old pattern and a 40,000-dash input still returns in under a millisecond, so
this is a gate fix and a simplification rather than a demonstrated denial of
service. Output verified byte-identical across leading/trailing punctuation,
unicode, empty input, all-separator input, and titles past the slice boundary.

## Not included

`FAMILIARISE_WEB-10` ("Unauthorized: sign in to request a Stream token", 6
events across 3 production releases over 21 days) is left alone. It is a session
expiring mid-visit, and suppressing it means changing how a server action's
throw reaches Sentry — too speculative to bundle at the end of a train. Filed
rather than guessed at.

Closes #1269
Part of #1280
Part of #1144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): keep the publish-route diff to the slug, not prettier churn

Running `prettier --write` over the whole file reformatted six unrelated blocks
inside the POST and DELETE handlers — line wrapping only, no behaviour change,
but it moved every line in `POST`. SonarCloud attributes new code by blame, so a
cognitive-complexity finding that has been sitting on `dev` since long before
this branch was re-attributed to this PR.

Reverted to `origin/dev` and re-applied only `buildSlug`. The complexity finding
goes back to being `dev`'s, where it belongs and where someone can decide about
it on its own merits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stream): no cap beats a guessed one, and a consultant is not a moderator

CodeRabbit round on #1305. Three findings, all legit, two of them Major and both
defects in code from the first pass.

## A guessed duration cap could terminate a four-hour webinar two hours in

The worst of the three, and it reintroduced the exact failure the feature exists
to prevent.

`resolveSessionCallProfile` returns `null` when it cannot resolve the run — a DB
blip, or the consent refusal this same PR added. The cap then fell back to
`DEFAULT_MEETING_DURATION_MS` (60 min) and floored the result at two hours, and
that value went to Stream. Since the timer counts from FIRST JOIN, a four-hour
webinar with an unresolved profile would have been ended by the SFU two hours
before its booked end, mid-session, for everyone.

The floor was written to stop a *mis-derived* run producing too short a cap. It
cannot do that job for a run never derived at all: 60 minutes is not a
conservative estimate of an unknown booking, it is a guess that is wrong in the
dangerous direction for every booking longer than it.

So an unresolved profile now returns `null` and the caller omits
`max_duration_seconds` entirely — which is exactly the behaviour before this
backstop existed, because the call type carries no limit of its own. Losing a
billing backstop on a session we could not describe beats ending a real one
early.

## The truncate button would have shown to consultants who cannot truncate

`handleClearChat` calls `channel.truncate()` from the CLIENT, so Stream's
permission system decides, not ours:

  ADMIN / STAFF    → mapRoleToStream gives Stream `admin`          → allowed
  channel creator  → channel-scoped `channel_moderator` at create  → allowed
  other consultant → plain Stream `user`, no moderator grant       → REFUSED

Putting the app role `CONSULTANT` in the privileged set showed the button to
every consultant and handed the non-owners a Stream authorization failure. That
trades a dead button for one that visibly errors, which is worse than the bug it
replaced. A consultant now qualifies only by owning the channel.

Relative to `dev` this is still a strict improvement: `isPrivileged` was
permanently false there, so ADMIN and STAFF — who genuinely can truncate — were
also locked out.

## Round the cap up, not down

`Math.floor` loses up to a second whenever either `Date` carries milliseconds.
Immaterial in size, wrong in direction for a number whose whole design is that
it errs long.

Each fix proved by reverting it and confirming red, including the caller-side
omission: making the caller send a fallback `7200` reds the profile-failure case
in `session-room-identity`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(chat): ownership required both ids, and `undefined === undefined` is true

CodeRabbit round 2 on #1305. One Minor finding, and it was reachable.

`creatorId === client?.userID` reads as an identity test and is not one when
both sides are absent. Both are reachable independently: `created_by` is missing
on a channel whose creator metadata did not come back from the query, and
`client.userID` is missing for the moment before the client connects. Together
they granted ownership to whoever happened to be looking.

The report named the Clear Chat branch. The same comparison is four lines from
the top of the same file gating `isEventOwner`, which controls the remove-member
button — the one that mutates. Both are fixed, and the predicate moved to
`channelUtils.viewerOwnsChannel` because writing it twice is how it came to be
wrong twice.

Empty strings are excluded too, which the report did not ask for: `"" === ""`
is the same trap one layer down, and Stream returns an empty string rather than
omitting a field often enough to matter.

Five tests, proved by reverting the predicate to the bare comparison — two go
red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ied about why (#1306)

Two blockers on the same operation, both found by actually running
`ensure-call-type-grants.ts --apply` rather than reading it.

## The pre-flight could never complete

`--apply` refuses unless it can prove every member of every open call already
holds `call_member` — the write makes that role the only thing admitting anyone,
so an uncovered member is a lockout. The proof needs a full traversal, and the
traversal always threw:

  QueryCalls failed with error: "cannot specify sort and next/prev at the same
  time"

`anyOpenCallMemberHolds` fails closed, correctly, so the refusal looked like a
transient Stream problem. It was not. The whole call-type hardening rollout —
#1297 and #1301 — has been unrunnable.

Measured against the live app rather than reasoned about:

  limit=25  -> 25 calls, next present
  limit=50  -> 50 calls, next present
  limit=100 -> 84 calls, NO next
  limit=250 -> "limit must be 100 or less"

  any request carrying `next` -> the sort/next error, unconditionally

That last point is the crux. Dropping `limit`, dropping the filter, and passing
`sort: []` or `sort: undefined` all fail identically — the server sees a default
sort on every request the SDK builds, so the cursor is unusable from here. The
old page size of 25 therefore guaranteed a second page, and the second page
always threw.

At 100 the current 84 fit in one page with no cursor. Past 100 there is no
supported way to continue, so the traversal raises
`OpenCallScanTruncatedError` rather than returning a prefix — a caller must not
conclude "every member holds the role" from an arbitrary hundred of them.

The pre-flight now reaches a verdict against the live app:

  Scanned 84 open call(s). 7 member(s) across 4 call(s) do NOT hold
  `call_member`. Backfill the role first, then re-run.

Which is the guard working as designed, and actionable.

## The flag was named after half of what it asserts

`--join-route-is-deployed` gates a write that depends on TWO routes. Verified on
prod today: the join route IS deployed, the END route is NOT, and prod's
`EndCallButton` still calls `call.endCall()` client-side. So an operator
asserting something entirely true about the join route would have stripped
`end-call` and taken End Call away from every host.

The refusal message names both routes, but the message is what you read AFTER
being refused; the flag is what you type from memory. Renamed
`--routes-are-deployed`, with the old spelling kept as a working alias so any
runbook, shell history or pasted command still functions — being refused for
typing the previously-documented flag would be its own small outage.

Both guards proved by reverting them and confirming the tests go red. 335 suites
/ 3,676 tests green, tsc and eslint clean.

Part of #1280
Part of #1134

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The config has never been applied. CodeRabbit reports the parsing error only in
a collapsed <details> block on each PR, so it went unnoticed: every review since
#1223 (2026-08-23, 168 commits) silently ran on DEFAULT settings — no assertive
profile, no payments path instructions, no security analyzers.

Two independent defects, both from #1223:

1. `tone_instructions` was 259 characters against a schema cap of 250. One
   over-long field fails the WHOLE file, not just that key. Rewritten to 238,
   with the cap noted in a comment so the next edit counts first.

2. `tools` sat at the root. The schema allows it only under `reviews`, and
   rejects unknown top-level keys — so even with a valid tone string the file
   would still have failed. All 14 analyzer names check out against the schema;
   they were just nested one level too high.

Validated against CodeRabbit's published schema.v2.json: `.coderabbit.yaml valid`.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#1307)

* fix(db): share one sidecar SQL splitter, clearing both S5852 criticals

The three `db:sidecars` scripts each carried a byte-identical statement
splitter, including `/^(--.*\n?)*$/`. SonarCloud flags that regex as
`typescript:S5852` on two of them, and they are the only CRITICAL security
findings on `dev` — the reason `new_security_rating` sits at 4.

The flag is a heuristic and here it is a false alarm: `.` never crosses a
newline, so each iteration of the group is line-bounded and there is no
ambiguous partition for the engine to explore. Measured over six adversarial
shapes (20k near-miss lines, a 300k-char single line, alternating blanks) the
old regex never exceeded 1ms. There was no live denial of service.

Replaced anyway, because clearing the gate is worth more than the regex and
these files recreate the money-path invariants on a fresh database. Extracted
to `sql-chunks.ts` so the next fix lands once instead of three times.

Equivalence is pinned rather than asserted: the test keeps the old regex as an
oracle and checks agreement on every chunk of all three real SQL files, plus
the adversarial inputs. Both mutants (`every`->`some`, dropped `trimEnd`) turn
it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): make the exported predicate exactly match the regex it replaced

#1307 review — `trimEnd()` disagreed with the oracle in two places: `""`
returned false where the old regex's `*` permitted zero iterations and returned
true, and a trailing whitespace-only line was trimmed away rather than
rejected.

Neither is reachable through `splitSqlStatements`, which trims and drops empty
chunks first. But `isOnlyComments` is exported, and the docblock claimed exact
equivalence — so the claim was wrong even though the pipeline was not. Pop the
single empty element a trailing newline produces, which is precisely what the
old `\n?` allowed, and reject everything else.

Seven degenerate inputs added to the oracle comparison; three of them fail
against the previous implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…1308)

#1302 review. `source` was doing two jobs: reporting how the unfreeze set was
determined, and gating ledger retirement. On the union path it reads "derived"
because the set is only best-effort complete — but the set still CONTAINS the
ledger entries, so `result.source === "ledger"` skipped `retireFrozenChannels`
and `FROZEN_CHANNELS` never shrank.

Left alone that is not just untidy. The stale set is re-unfrozen on every later
OFF transition, spending the 300/min UpdateChannelPartial budget on it, and —
since #1303 landed — reopening DM channels the dormancy sweep froze on purpose.
Maintenance would silently undo another subsystem's decision.

Split the two: `usedLedger` tracks participation, `source` keeps reporting
provenance. `srem` on an id the set never held is a no-op, so passing the
derived ids through with the ledger ids costs nothing.

Also corrects two `withStreamCircuitBreaker` fixtures that simulated Redis's
breaker message. They passed only because the guard matches the substring
"circuit breaker is OPEN", so they were asserting against a string this path
cannot emit.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 5d7813e
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a968de85aa8840008382964
😎 Deploy Preview https://deploy-preview-1310--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 32 (🔴 down 6 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 90 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e8e0bd30-e55a-4686-854a-b77e791633c0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

Parsing errors (1)
Validation error: Too big: expected string to have <=250 characters at "tone_instructions"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

@teetangh
teetangh merged commit 1442213 into prod Sep 1, 2026
22 checks passed
@teetangh
teetangh deleted the release/dev-to-prod-2026-09-01 branch September 1, 2026 08:50
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.

1 participant