Ritual Studio · sweep 2 · deeper pass

Flow report & punch list

A second, deeper multi-agent pass — performance, concurrency, the full booking / membership / wallet lifecycles, finance reconciliation, notifications, CRM depth, and UI + mobile. Each flow graded for health; every bug independently re-verified. These are all new (the first sweep's items are tracked separately).

Environment dev.ritualstudiospa.comNew findings 40, verifiedFlows graded 14Cost $0 (local)
40
New findings
12
Critical
17
Should fix
11
Polish
3
Flows broken

Flow & performance report — how each area is doing

BROKEN
Booking lifecycle
The booking happy path is solid and fast (book, same-member overlap protection, slot-window/blackout/past-date validation, cancel/rebook, and occupancy accounting all behave correctly at ~120-180ms), but the state machine itself is broken: check-in has no state guard and resurrects cancelled/no-show bookings, cancelling an attended booking issues a refund, and transition flags are never cleared so records accumulate contradictory checkedIn/noShow/cancelled timestamps. Separately, guest-pass booking is fully broken and dangerous — any non-empty guests payload hangs the backend 60s and a few attempts took the whole booking subsystem offline (502) for several minutes.
Happy-path reads/writes: GET occupancy/location ~0.12-0.16s, POST /bookings ~0.18s, checkin/cancel ~0.2-0.4s. Guest booking: hangs to the 60s upstream timeout (504) then 502, and saturates workers causing collateral 502/timeouts on otherwis
BROKEN
Concurrency / races
The booking write path is not concurrency-safe on two axes: correctness (the per-member overlap guard is a non-atomic check-then-insert that races — 6/6 parallel duplicate bookings committed, and one member grabbed 11 of 12 seats — with the identical flaw confirmed in PTO), and availability (bursts of ~5-20 concurrent writes return 504 and then wedge the write path so every subsequent POST /bookings hangs ~50s -> 502 for 1-2 minutes while reads stay green). Positive controls: the hard capacity cap of 12 was never exceeded in committed rows, failed writes roll back cleanly with no phantom rows, and check-in and double-approve are idempotent — so state is not silently corrupted by the outage, but the guards and stability are.
Healthy single booking write ~0.4s and occupancy read ~40-140ms. Under concurrency it collapses: 20 parallel writes -> all 504; post-burst single writes hung 50.0/54.3/56.7s -> 502 (nginx upstream timeout) with reads still 200, recove
BROKEN
CRM directory
The CRM directory's find/browse/sort surface — the daily front-desk workflow — is fundamentally broken past the first 20 of 88 members: search and sort run client-side on page 1 only and there is no pagination, so 77% of members are neither searchable nor browsable in the UI. The backend is actually solid (correct totals, clean 88-id pagination, strict limit/page validation, injection-safe search, ~100–200ms responses) — the frontend just never calls it (no /contacts request fires on search/sort/scroll). Filters and tag CRUD that do hit the server (status/tier/tag) return correct, reconciling counts (status buckets 72+6+2+2+6=88).
API latency healthy at ~0.10–0.22s for list/filter/search; full 5-page walk clean. Reliability is the concern: intermittent nginx 502s (~1 in 6 requests, one ~11.5s stall before failing) hit both the API and the SPA and repeatedly forced re
SHAKY
Performance & availability
Warm steady-state performance is genuinely good (all ~15 key endpoints respond in ~0.12-0.36s, and 30 concurrent users/me stayed 200 at 0.16-0.36s), but the API is intermittently unreliable: under plain sequential polling the upstream periodically stalls, producing 60-second hangs that end in 504 and then clusters of 502 Bad Gateway before recovering. The numbers are fast when it's up; the problem is that it isn't reliably up.
Warm timings (admin token, 3 runs each, seconds): users/me 0.144/0.138/0.137; contacts 0.229/0.163/0.149; bookings/location/loc_primary?date=2026-07-26 0.129/0.124/0.150; reports/overview?range=today 0.136/0.118/0.126; reports/overview?rang
SHAKY
UI integrity
When the backend is healthy the UI is solid and fast — every management/member view renders correct content and API endpoints respond in ~100-290ms. But the backend intermittently flaps into 502/504 bursts (measured ~27% of root-doc loads in one window) that dump raw nginx error pages, and the SPA has no error boundary, so a failed load becomes a permanent 'Loading…' dead-end. Two concrete view defects remain: crm-pipeline renders blank, and documented nav-group view params silently fall back to the dashboard.
Healthy-state API timings good: users/me 128ms, reports/overview 138ms (today)/178ms (7d), bookings 158ms, contacts 201ms, staff/team 290ms; root doc 40/40=200 once recovered. Failure mode is availability (clustered 502/504), not latency.
SHAKY
Mobile / responsive
The member mobile experience is SOLID: all 7 member views (dashboard, book, schedule, membership, shop, profile, journal) render with zero horizontal body scroll at both 375 and 414, stack cleanly into cards, and the fixed bottom nav never covers content (main carries bottom padding; scroll-to-bottom test found 0 obscured controls). The owner side mostly adapts too (dashboard/analytics fine), but the Calendar view-mode toggle clips 'MONTH' off-screen at 375px with no scroll to reach it, and sub-24px text tap targets recur across member views — hence SHAKY rather than SOLID.
Member/owner views that loaded fully hydrated in ~3-4s over networkidle. The notable perf/stability issue was the DEV backend intermittently returning 502 Bad Gateway / 504 Gateway Time-out (seen on owner calendar, analytics, crm-members, s
SHAKY
Membership lifecycle
Once a member's billing subscription is in sync, the core transitions behave sensibly (downgrade = $0 scheduled to cycle end, upgrade = charge the delta now, same-plan blocked, minimum-term field present), but the lifecycle carries several real financial/correctness defects: a member with an active plan can be read as "no plan" and mis-quoted full price for a downgrade until an unrelated write heals the state; freeze bypasses the freezeAllowed=false policy and does not pause billing; and cancellation is invisible to the member, irreversible via any API/UI, and dated past the next bill so an extra month is charged. Upgrade pricing is a flat monthly delta with no proration for cycle position.
API is functional but flaky in this window: frequent intermittent 502 Bad Gateway from nginx on both reads and writes (had to add 3-5x retry loops); successful preview/summary/cancel calls themselves returned quickly (sub-second). Access to
SHAKY
Wallet & credits
Core credit accounting is fundamentally sound — bucket draw order, book/cancel decrement-and-restore, guest-pass seats, double-cancel idempotency, over-redeem/self-adjust guards, and the credit sub-ledger all reconcile exactly. But two real integrity defects leak value and break auditability: cancelling a CHECKED-IN booking refunds the consumed session (self-serve free-session/revenue leak), and loyalty reward redemptions write a wrong-signed +1 ledger row instead of the -1000 point spend so the points history never reconciles to the balance.
Endpoints are fast: login ~0.55-0.62s, users/me / bookings / cancel all sub-second. Reliability caveat: under a burst of sequential booking POSTs nginx intermittently returned 502 Bad Gateway, and at least one booking POST that returned 502
SHAKY
Finance reconciliation
Two of the four reconciliations I could run pass cleanly (revenueByCategory sums exactly to totalPaid, 17425==17425; reports/overview today bookings=3 exactly equals the 3 non-cancelled bookings from bookings/location, with 1 CANCELLED correctly excluded; today revenue=0 is legitimate since the only checked-out bookings are membership-covered and the last SUCCEEDED payment was 2026-07-24). But the finance report's failed-payments headline ($318) contradicts its own 5-row detail list ($865), and every CRM contact's lastVisitDays disagrees with its lastVisit timestamp (lapsed members shown as recently active). Member loyaltyPoints reconcile across surfaces (users/me 25105 == contacts 25105), but no loyalty/credit ledger endpoint exists (all probes 404) so wallet balances can't be reconciled against transaction history at all.
Healthy endpoints respond fast: reports/finance, reports/overview, bookings/location, billing/invoices all ~0.13-0.16s; billing/invoices?limit=200 was 0.16s (limit is effectively capped at 20 rows). However I hit intermittent nginx 502 Bad
SHAKY
Notifications & receipts
The notification substrate exists (message store + live SendGrid delivery + a manual admin receipt endpoint), but the transactional layer is largely absent: bookings produce no confirmation or reminder, purchases produce no receipt, and the manual receipt endpoint will happily 'send a receipt' for an unpaid invoice. comm-settings advertises email receipts and SMS reminders that are not actually wired to any event. Real emails also fire from the DEV environment with no sandbox.
Read endpoints are fast when idle (users/me ~0.13s), but the API repeatedly returned 502 Bad Gateway / 504 Gateway Time-out under even light concurrency or rapid sequential requests (multiple single-request calls, e.g. pricing/plans and che
SHAKY
Content & knowledge
Core content/knowledge flows function and the coarse RBAC is mostly right (members are 403 on /knowledge and /content/subscribers; front-desk is correctly blocked from editing the SOP library; blog HTML content is sanitized on render so no stored XSS). But there are several real authorization/data-hygiene defects: unpublished DRAFT blog posts leak to members, soft-deleted SOPs stay visible in the staff Employee Hub (DELETE only archives and the UI omits the status filter that the API supports), knowledge create skips type/status enum validation, and write-gating is inconsistent across the two content surfaces (SOP library gated, blog not).
When up, /knowledge and /content/blog GETs were fast (~130ms). However the dev backend was heavily unstable during this session: repeated nginx 502 Bad Gateway blips lasting up to ~2 minutes, causing both API and SPA loads to fail intermitt
SHAKY
Settings & connectors
Settings read paths and most authz are solid — settings/roles, integrations, notifications and comm-settings writes correctly gate out employee (403 'Manager or owner') and members (403), member comm-settings read is a reduced view, and the GoHighLevel apiKey is masked (pit-••••••••3f06). But the config WRITE surface is dangerous: PATCH /services/:id has no semantic validation and is exposed to the front-desk employee role, so one bad value (slotInterval=0) hangs and 502s the core booking availability endpoint; comm-settings time fields are also unvalidated. The area works in steady state but is one careless config write away from taking down booking.
Settings reads fast: locations/services/roles/integrations ~0.1-0.5s; occupancy 0.17-0.55s healthy. With slotInterval=0 the occupancy endpoint hung to a 25s timeout / 502 Bad Gateway (infinite slot-generation loop) and recovered only after
SHAKY
Error / empty states
Auth-failure, loading, and empty states are handled well (garbage/expired/no token cleanly redirect to Sign In; a 'Loading...' indicator shows during slow fetches; 'No upcoming bookings.' is a real empty state), but the app has no error-state UX for backend data failures: on 5xx it either paints fabricated placeholder data (999 sessions / 0 guest passes) with no warning, or intermittently white-screens with an empty body, and there is no 404/not-found state for unknown or unauthorized ?view= values (silent fallback to home). Overall usable on the happy path but the failure-mode UX is misleading or blank.
Document is fast and healthy at low concurrency: 15/15 sequential curls of /?view=dashboard returned 200 in ~0.10-0.61s (median ~0.12s). Under concurrent browser load the origin intermittently returns a raw nginx 502, indicating a capacity/
INCONCLUSIVE
Loyalty & rewards
no report

The bugs — 40 new, ranked

⚠ Fix these first — 12 critical

  1. Check-in has NO state guard — it resurrects CANCELLED and NO_SHOW bookings back to CONFIRMED
  2. Cancelling a checked-in (attended) booking succeeds and issues a refund
  3. Booking duplicate/overlap guard is non-atomic (TOCTOU race) — parallel bookings all commit, one member hoards seats
  4. Same non-atomic check-then-insert in PTO requests — parallel overlapping time-off requests all commit
  5. Unvalidated service slot config accepts garbage and can hang/crash the booking availability endpoint (DoS)
  6. Front-desk EMPLOYEE can modify core service (booking) config; only JWT role=ADMIN is checked, no manager/owner gate
  7. CRM member search is client-side only over the 20 loaded rows — 68 of 88 members are unfindable
  8. Members directory has no pagination — only the first 20 of 88 members can ever be browsed
  9. Scheduled membership cancellation is invisible to the member and cannot be reversed through any API or UI
  10. Intermittent upstream outage under trivial sequential load: 60s hangs (504) followed by 502 Bad Gateway clusters
  11. Cancelling a checked-in (attended) booking refunds the consumed session credit — self-serve free-session leak
  12. Loyalty reward redemption writes a ledger row with wrong sign and magnitude (+1 instead of -1000); points history does not reconcile to balance

Critical 12

HIGHbooking-lifecycle01

Check-in has NO state guard — it resurrects CANCELLED and NO_SHOW bookings back to CONFIRMED

Where
POST /api/v1/bookings/:id/checkin
What happens
The check-in endpoint performs no state validation. It accepts a booking in any state (CONFIRMED, NO_SHOW, or even CANCELLED) and flips it to status=CONFIRMED with a fresh checkedInAt, WITHOUT clearing the prior cancelledAt / cancellationMode / noShowAt. A member can cancel a booking (receiving a refund), and staff check-in then un-cancels it — so the member keeps the refund AND is recorded as having attended. CANCELLED is not treated as a terminal state.
Impact
A cancelled+refunded booking becomes an active confirmed attendance, corrupting occupancy/attendance data and letting members obtain a refund while still attending. Only /cancel and /no-show have partial guards; /checkin has none.
Fix
Reject check-in unless status is CONFIRMED (return 400 for CANCELLED/NO_SHOW). Enforce CANCELLED as terminal.
Evidence & reproduction
Booking cms27f9hs003gryr8tmnauivf (alice, 2026-08-26 07:00): cancelled via POST /cancel -> HTTP 200 status=CANCELLED, cancelledAt=2026-07-26T19:41:57.760Z, cancellationMode=refund. Then POST /checkin -> HTTP 200. Final record read back from GET /bookings/location/loc_primary?date=2026-08-26: status=CONFIRMED, checkedInAt=2026-07-26T19:43:01.624Z, cancelledAt=2026-07-26T19:41:57.760Z (still set!), cancellationMode=refund, noShowAt=null. Also reproduced for NO_SHOW->checkin: booking cms27epbg002qryr8y67x4lv4 no-showed (noShowAt set) then POST /checkin -> 200 status=CONFIRMED with noShowAt STILL set.
HIGHbooking-lifecycle02

Cancelling a checked-in (attended) booking succeeds and issues a refund

Where
POST /api/v1/bookings/:id/cancel
What happens
A booking that has already been checked in (member physically attended) can still be cancelled. The cancel returns 200, sets cancelledAt with cancellationMode='refund', and leaves the original checkedInAt in place — i.e. the system refunds a session the member already consumed.
Impact
Attended sessions get refunded/credited back, which is direct revenue leakage and produces contradictory records (attended + cancelled at once).
Fix
Block cancellation once checkedInAt is set, or at minimum suppress the refund (mode should be 'no-refund'/'forfeit') for attended bookings.
Evidence & reproduction
Booking cms276qs10080rysvmzszz1j5 (alice, 2026-08-17): POST /checkin -> 200 (checkedInAt=2026-07-26T19:35:31.075Z). Then POST /cancel -> HTTP 200. Read-back: status=CANCELLED, checkedInAt=2026-07-26T19:35:31.075Z, cancelledAt=2026-07-26T19:35:31.420Z, cancellationMode=refund. Reproduced again on cms27epbg (mode=refund after a check-in).
HIGHconcurrency03

Booking duplicate/overlap guard is non-atomic (TOCTOU race) — parallel bookings all commit, one member hoards seats

Where
POST /api/v1/bookings (svc_contrast, loc_primary)
What happens
The per-member 'already booked this slot' check reads current bookings, then inserts, without a lock or DB unique constraint. Fired concurrently, every request passes the check before any insert commits, so all of them succeed.
Impact
Corrupts capacity accounting and fairness: one member (or a retrying/duplicate client) silently grabs most or all of a slot's seats even though the product rule allows exactly one booking per member per overlapping time. The guard gives false confidence because it works fine sequentially.
Fix
Make check-then-insert atomic: add a DB unique index on (userId, serviceId, date, time) (and a range/exclusion constraint for overlaps), or take a per-(service,slot) row lock / advisory lock inside the same transaction as the insert. Do not rely on a pre-SELECT.
Evidence & reproduction
SEQUENTIAL control: 1st booking 201, 2nd/3rd -> 400 "This member already has a booking that overlaps this time". PARALLEL: 6 identical requests from member Alice for 2026-08-12 09:00 -> ALL six returned 201; location list confirmed 6 CONFIRMED rows / 6 seats reserved to u_alice in one 12-seat slot. Repeat with 14 parallel (2026-08-15 09:00) -> 11 committed for one member (guard caught only 3). So a single member can consume 6-11 of 12 seats, blocking real members. All test rows cleaned up (cancelled).
HIGHconcurrency04

Same non-atomic check-then-insert in PTO requests — parallel overlapping time-off requests all commit

Where
POST /api/v1/staff/pto
What happens
PTO creation checks for an overlapping existing request, then inserts, with no lock/constraint. Concurrent identical requests all pass the check and commit.
Impact
Duplicate/overlapping approved PTO can corrupt scheduling and any downstream balance math; demonstrates the whole write layer uses read-then-write without concurrency protection.
Fix
Same remedy as bookings: enforce overlap uniqueness at the DB level (exclusion constraint on staffId + date range) or lock the staff row within the insert transaction.
Evidence & reproduction
SEQUENTIAL control: 2nd identical request -> 400 "This time-off request overlaps an existing request." PARALLEL: 4 identical requests (staff_maya, 2027-04-10..11) -> ALL four returned 201, four overlapping PTO rows created. Cleaned up (deleted all 4). Confirms the TOCTOU is a systemic pattern across write endpoints, not a one-off in bookings.
HIGHconnector-settings05

Unvalidated service slot config accepts garbage and can hang/crash the booking availability endpoint (DoS)

Where
PATCH https://dev.ritualstudiospa.com/api/v1/services/svc_contrast ; impact on GET /api/v1/bookings/occupancy/loc_primary?date=
What happens
PATCH /services/:id performs almost no semantic validation on the slot config of the core booking service. Invalid values are accepted (200) and persisted: slotInterval=-5, slotInterval=0, slotDuration=0, slotDuration=-30, slotStart="99:99", slotStart>slotEnd (start 99:99/end 07:00), slotDays=["Funday","Xyz"], and unbounded maxCapacity=999999. (Only maxCapacity<1 is rejected.) With slotInterval=0 the slot-generation loop never terminates: GET /bookings/occupancy/loc_primary hung to a 25s client timeout and returned 502 Bad Gateway (nginx) for the whole endpoint, then recovered only after the config was restored. This is the primary/live Contrast Therapy service that feeds the booking flow, so one bad config write takes down availability for all users.
Impact
A single accidental or malicious config write silently corrupts booking-slot generation and can bring down the core availability/occupancy endpoint with a 502 — a denial of service on the primary revenue flow.
Fix
Add Zod validation on PATCH /services/:id: slotDuration/slotInterval > 0 with sane upper bounds, HH:MM regex on slotStart/slotEnd with slotStart<slotEnd, enum-restrict slotDays, cap maxCapacity. Also guard the slot-generation loop against zero/negative step to prevent infinite loops.
Evidence & reproduction
As owner: PATCH {"slotInterval":0} -> 200; then GET occupancy/loc_primary?date=2026-07-27 -> curl code 000 after 25.0s (hang); earlier combined-garbage state produced HTTP 502. Read-back confirmed persistence: maxCapacity=999999, slotDuration=0, slotInterval=-5, slotStart="99:99", slotEnd="07:00", slotDays=["Funday","Xyz"]. After restoring valid config, occupancy returned 200 in 0.17-0.55s. All values restored to original (slotDuration 90 / slotInterval 30 / 07:00-20:00 / 7 days / cap 12).
HIGHconnector-settings06

Front-desk EMPLOYEE can modify core service (booking) config; only JWT role=ADMIN is checked, no manager/owner gate

Where
PATCH https://dev.ritualstudiospa.com/api/v1/services/svc_contrast with employee (Priya) token
What happens
The employee token (Priya, front-desk) can PATCH the core service config and gets 200. This is inconsistent with the other settings write endpoints, which correctly reject the employee token with 403 'Manager or owner access is required.' (comm-settings, integrations, notifications all 403 for employee). services/:id apparently only checks JWT role=ADMIN (which the employee has) with no manager/owner check. Combined with Finding 1's lack of validation, a front-desk staffer can corrupt slot config or trigger the occupancy DoS (slotInterval=0).
Impact
A low-trust front-desk role can alter or take down the booking service — a config surface that should require manager/owner.
Fix
Apply the same manager/owner middleware used by comm-settings/integrations/notifications to the services write (and other config-mutating) routes; do not rely on JWT role=ADMIN alone since employees carry that role.
Evidence & reproduction
EMP PATCH /services/svc_contrast -> 200 (body returned with updated service). By contrast EMP PATCH /settings/comm-settings -> 403 'Manager or owner access is required'; EMP GET /settings/integrations -> 403; EMP GET /settings/notifications -> 403. MGR is allowed (200) on all; ALICE (member) is 403 everywhere. (To avoid re-crashing live booking I did not drive slotInterval=0 as the employee, but the 200 on the write endpoint plus Finding 1's missing validation establishes the path.)
HIGHcrm-depth07

CRM member search is client-side only over the 20 loaded rows — 68 of 88 members are unfindable

Where
crm-members view (Members > Directory) search box; frontend, backend GET /api/v1/contacts?search= is fine
What happens
The directory loads only page 1 (20 members, alphabetical A–Codex) and the 'Search members by name, email, phone, or tag...' box filters those 20 rows in the browser. It never calls the server. Any member alphabetically after ~'Codex' (68 of 88 = 77%) returns zero results.
Impact
Front-desk staff searching for the majority of members get 'no results' for people who are active members — the primary 'find a member' workflow is broken for 77% of the customer base.
Fix
Wire the search box to the server endpoint (GET /contacts?search=&page=) with debounce instead of filtering the local 20-row array.
Evidence & reproduction
UI: typing 'Alice' (in first 20) -> 1 row; typing 'Nina', 'Sarah', 'Marco' (real members, pages 3/4/2) -> 0 rows, and NO /api request fires for any term (newApiReqs=[]). Backend proves they exist: GET /contacts?search=Nina -> total=1 Nina Patel; search=Sarah -> Sarah Jenkins; search=Marco -> Marco Reyes. So the backend search works; the frontend simply never invokes it.
HIGHcrm-depth08

Members directory has no pagination — only the first 20 of 88 members can ever be browsed

Where
crm-members view (Members > Directory) list
What happens
The list renders exactly 20 rows (server default page 1) and there is no pager, 'load more', or infinite scroll. Scrolling to the bottom loads nothing further; the last visible row is always the 20th member ('Codex Signup').
Impact
68 members (F–Z) are unreachable by browsing. Combined with the client-side-only search bug above, those members are effectively invisible in the CRM UI.
Fix
Add pagination or infinite scroll that requests /contacts?page=N; the backend already supports it (page/limit validated, limit max 100).
Evidence & reproduction
API pagination is correct and reports total=88, totalPages=5 (walked all 5 pages: 88 unique ids, no dupes). But the UI: after fully scrolling, rowCount stays 20, last row = 'Codex Signup', page tail ends at Emma Liu/Codex Signup, and no /contacts?page=2 request is ever fired. Only toolbar controls present are A-Z / Advanced Filters / Manage Tags / New Member — no page controls.
HIGHmembership-lifecycle09

Scheduled membership cancellation is invisible to the member and cannot be reversed through any API or UI

Where
POST /api/v1/contacts/:id/cancel-membership; GET billing/summary; GET users/me; PATCH /api/v1/contacts/:id
What happens
cancel-membership schedules the cancellation (cancellationStatus=SCHEDULED, cancellationEffectiveAt set) but (a) the member's own billing/summary and users/me expose ZERO cancellation fields — the member still sees status ACTIVE / GOLD / Complete with no pending-cancel indicator; (b) there is no way to undo it: the "Keep Membership" button only closes the dialog, there is no resume/reactivate-membership endpoint, and PATCH /contacts/:id returns 200 but SILENTLY ignores cancellationStatus/status; (c) membership-change/preview still returns allowed=true during a scheduled cancellation with no warning, letting the member schedule a conflicting downgrade.
Impact
A member cannot see that their membership is scheduled to end, and neither member nor staff can reverse a mistaken cancellation without direct DB access — a serious retention/support hole.
Fix
Surface cancellationStatus/effectiveAt in billing/summary + users/me, add a resume/keep-membership endpoint that clears the schedule, and block/ warn on preview+apply while a cancellation is pending.
Evidence & reproduction
POST cancel-membership {reason:"too_busy"} -> 200 {cancellationStatus:"SCHEDULED",cancellationEffectiveAt:"2026-08-25T19:51:52Z"}. billing/summary after cancel: keys matching /cancel/ = [] (none); users/me cancellation keys = NONE, still membershipTier=GOLD activePlanCode=complete. PATCH /contacts/<alice> {cancellationStatus:null,...} -> 200 but record still SCHEDULED; PATCH {status:"ACTIVE",cancellationStatus:"NONE"} -> 200 still SCHEDULED; same PATCH successfully edits 'notes' (200, value changed), proving PATCH works but the cancellation fields are non-clearable. Re-apply same plan -> 400 "already on this plan"; freeze/unfreeze did not clear it.
HIGHperf10

Intermittent upstream outage under trivial sequential load: 60s hangs (504) followed by 502 Bad Gateway clusters

Where
All API endpoints behind nginx (observed on GET /api/v1/users/me; upstream Node behind nginx/1.24.0 Ubuntu)
What happens
During ordinary sequential polling of a single endpoint, the Node upstream periodically stalls. One request hangs until nginx's 60s proxy_read_timeout and returns 504 Gateway Time-out; the following requests then return 502 Bad Gateway for an extended window before the upstream recovers. Reproduced 4+ separate times in this session. This is upstream-level, so it affects every endpoint, not just users/me.
Impact
A real user clicking around will randomly get a 60-second spinner then a hard error, and a burst of failed requests, with no load applied (single sequential client). This is an availability defect, not just latency.
Fix
Investigate why the Node process stalls/crashes (event-loop block, unhandled rejection, OOM/restart). Add upstream health checks + multiple workers so nginx can fail over instead of returning 502, and lower proxy_read_timeout so a stalled request fails fast instead of hanging 60s.
Evidence & reproduction
Burst A: 40 sequential users/me -> #1=44.86s then 502, #24=60.11s then 504, #25=2.45s 502, #26-31=502, recovered at #32. Burst B (with header capture): #27 CODE=504 TIME=60.14s, #28 502 4.59s, #29-30 502. Burst C: #1 CODE=504 TIME=60.13s BODY='<html>...504 Gateway Time-out...nginx/1.24.0 (Ubuntu)', #2 502 3.15s, #3-5 502. After that 504 event the upstream stayed 502 across the next ~8 calls (contacts default/offset/limit all returned 502 HTML) before recovering on a later poll. Warm steady state by contrast is ~0.13s.
HIGHwallet-integrity11

Cancelling a checked-in (attended) booking refunds the consumed session credit — self-serve free-session leak

Where
POST /api/v1/bookings/:id/cancel (member's own token AND admin)
What happens
When a booking that has already been checked in (checkedInAt set, service rendered) is cancelled, the session credit is refunded back to the member's bucket and a 'Cancellation refund' credit-ledger entry is written. The booking moves to CANCELLED but retains its checkedInAt timestamp. A member can do this to their OWN booking after attending, reclaiming the session — i.e. unlimited free attended sessions.
Impact
A member books, gets checked in, uses the sauna/cold plunge, then cancels from their own app and gets the session back — repeatable indefinitely. Direct revenue leak plus corrupted usage/occupancy and no-show stats. Session is consumed physically but never accounted.
Fix
Treat a booking with checkedInAt set as consumed: refuse to refund the session bucket on cancel once checkedInAt is present (and ideally block/relabel cancel of a checked-in booking as a no-op/adjustment requiring manager override), rather than always reversing the funding lines.
Evidence & reproduction
Bob (drop_in bucket). BOOK 2026-09-21 15:30 -> 201, drop_in 1/1->0/1, credits 6->5. Admin CHECKIN -> 200, checkedInAt=2026-07-26T19:42:13.099Z, status=CONFIRMED, credits stay 5. Then CANCEL: status=CANCELLED, cancelledAt set, checkedInAt STILL=2026-07-26T19:42:13.099Z, refund funding line=[{type:drop_in}], drop_in restored 0/1->1/1, credits 5->6. /credits/history shows +1 ADJUSTMENT 'Cancellation refund: Contrast Therapy'. Reproduced 3x, including a MEMBER (bob's own token) cancelling his own checked-in booking -> 200 and credit refunded to 6.
HIGHwallet-integrity12

Loyalty reward redemption writes a ledger row with wrong sign and magnitude (+1 instead of -1000); points history does not reconcile to balance

Where
POST /api/v1/loyalty/redeem (row visible in GET /api/v1/credits/history)
What happens
Redeeming a SERVICE-type reward correctly deducts the point cost from the balance, but the LOYALTY_REDEEM history row records amount = the number of sessions granted (+1), not the negative point cost (-1000). The member's loyalty history therefore under-reports the spend and never sums to the actual balance.
Impact
Member-facing points history shows 'Redeemed +1' while 1000 points silently vanish from the balance — unauditable and confusing, and finance cannot reconcile outstanding point liability. The refund path already records point deltas correctly, proving the redeem path is the inconsistent one.
Fix
On reward redemption write the LOYALTY_REDEEM ledger amount = -(reward.pointsCost) (same convention as the refund-reversal rows), and keep any granted session credit as a separate PURCHASE/ADJUSTMENT credit row so the two currencies aren't merged into one signed number.
Evidence & reproduction
Bob redeemed rew_credit10 (name 'Guest Session Reward', cost 1000). API response: pointsDeducted=1000, loyaltyPoints 3540->2540 (delta -1000, correct). But the single new /credits/history row: type=LOYALTY_REDEEM, amount=+1, desc='Redeemed: Guest Session Reward'. Reproduced twice (4540->3540 and 3540->2540, each writing amount=+1). Bob loyalty-ledger sum EARN 3700 + REDEEM +1 = 3701 vs actual balance 2540 => does NOT reconcile. Contrast: Emma's refund-reversal LOYALTY_REDEEM rows correctly record amount=-600 each and her ledger reconciles exactly (EARN 7200 + REDEEM -1200 = 6000 = balance).

Should fix 17

MEDbooking-lifecycle13

Lifecycle timestamp flags are never cleared on transition — a single booking accumulates mutually-exclusive states (noShowAt + checkedInAt + cancelledAt)

Where
POST /bookings/:id/{no-show,checkin,cancel}
What happens
Each transition only sets its own timestamp and never clears the others, so a booking can simultaneously carry noShowAt, checkedInAt and cancelledAt. In particular a NO_SHOW that is later checked in keeps noShowAt set — corrupting no-show tracking (noShows90d), which drives member risk/identity flags.
Impact
Downstream counters (noShows90d, attendance, revenue/occupancy reports) read these flags; contradictory flags silently poison member risk scoring and reporting.
Fix
On each transition, clear the timestamps that no longer apply, or model a single explicit status field as the source of truth.
Evidence & reproduction
Booking cms27epbg002qryr8y67x4lv4: CONFIRMED -> no-show (noShowAt=2026-07-26T19:41:31.581Z) -> checkin -> status=CONFIRMED with checkedInAt=2026-07-26T19:41:31.913Z AND noShowAt still 2026-07-26T19:41:31.581Z -> cancel -> final: checkedInAt + cancelledAt(mode=refund) + noShowAt all populated at once. (Note: no-show AFTER checkin is correctly blocked with 400, so the guard is asymmetric.)
MEDconnector-settings14

comm-settings quiet-hours times are not validated (accepts "99:99", "25:77", "not-a-time")

Where
PATCH https://dev.ritualstudiospa.com/api/v1/settings/comm-settings
What happens
quietStart/quietEnd accept arbitrary strings and persist them (200). smsQuietHoursEnabled IS type-checked (rejects "yes" with 400 VALIDATION_ERROR), so validation exists on the endpoint but the time fields have no HH:MM format check.
Impact
Garbage quiet-hours values silently break SMS quiet-hour scheduling comparisons (members could receive SMS during quiet hours or the check could error), with no error surfaced to the admin who set it.
Fix
Add HH:MM regex validation to quietStart/quietEnd (and reject quietStart==quietEnd or otherwise document wrap-around semantics).
Evidence & reproduction
PATCH {"quietStart":"99:99","quietEnd":"07:00"} -> 200, read-back quietStart="99:99"; PATCH {"quietStart":"not-a-time","quietEnd":"25:77"} -> 200, persisted; PATCH {"smsQuietHoursEnabled":"yes"} -> 400 (boolean enforced). Restored to 21:00/07:00.
MEDcontent-knowledge15

Unpublished DRAFT blog posts are served to members via GET /content/blog (broken authorization / unpublished-content leak)

Where
GET https://dev.ritualstudiospa.com/api/v1/content/blog (Content Hub blog feed)
What happens
The blog feed applies no status filter for non-admin callers, so any MEMBER receives DRAFT (unpublished) posts with full body content, not just PUBLISHED ones. Work-in-progress or embargoed posts are exposed to end users before publication.
Impact
Members can read announcements, promos, or pricing before they are meant to go live, and internal draft notes leak.
Fix
For non-admin roles, filter the blog query to status=PUBLISHED server-side; only owner/manager should receive drafts.
Evidence & reproduction
As owner I created {"title":"QA_DRAFT_DELETEME","status":"DRAFT","content":"<p>unpublished secret</p>"} -> 201. Immediately GET /content/blog as member Alice (u_alice, JWT MEMBER) -> 200 returned 7 items including that DRAFT plus a second front-desk-created DRAFT ("EMP_POST_DELETEME"), each with status:"DRAFT" and full content ('unpublished secret'). With no drafts present the same call returns exactly the 5 PUBLISHED posts. Members are otherwise correctly blocked from /content/subscribers (403), so read gating exists elsewhere but not on draft filtering. Cleaned up (all test posts hard-deleted).
MEDcontent-knowledge16

DELETE on knowledge/SOP is a soft-delete that leaves 'deleted' items visible in the staff Employee Hub library

Where
DELETE /api/v1/knowledge/:id and GET /api/v1/knowledge (Employee Hub SOP/Policy/Training panels)
What happens
DELETE does not remove a knowledge item; it returns 200 with the item flipped to status=ARCHIVED. The default GET /knowledge listing (and the Employee Hub, which fetches GET /knowledge?type=SOP|POLICY|TRAINING) applies NO status filter, so archived/'deleted' SOPs keep rendering in the staff resource library alongside active ones. Retired procedures never disappear.
Impact
Staff can follow outdated/retired SOPs; 'deleting' a document does nothing visible, and archived junk accumulates permanently.
Fix
Have the Employee Hub / default library call pass status=ACTIVE (or default-exclude ARCHIVED server-side), and offer a separate 'Archived' tab.
Evidence & reproduction
DELETE /knowledge/<id> -> 200 body status:"ARCHIVED" (not removed). Subsequent GET /knowledge default listing = 24 items including 6 ARCHIVED (e.g. seed 'QA SOP 0724' plus my archived test items). ?status=ACTIVE correctly returns 18 (0 archived), proving the filter exists but the default/UI call omits it. Employee Hub UI (owner token, ?view=employee-hub) issued GET /knowledge?type=SOP / ?type=POLICY / ?type=TRAINING (no status param) and rendered the archived items (archived-title probe = true). Contrast: blog DELETE is a hard delete (204, gone), so the two content surfaces behave inconsistently.
MEDcrm-depth17

Sort (LTV / Tier / Rewards) reorders only the loaded 20 rows, producing incorrect global rankings

Where
crm-members view sort dropdown ('A-Z' button -> Tier & Status / LTV / Rewards); column header 'LTV' also
What happens
Choosing a sort re-sorts the 20 page-1 rows client-side (fires no server request), so a 'sort by LTV descending' silently ranks only the alphabetically-first 20 members, not all 88. The result looks like a full ranking but omits higher-value members.
Impact
A manager using 'sort by LTV' to find top customers gets a materially wrong answer (Alice shown as #2 when she is actually ~4th).
Fix
Implement server-side sort on /contacts (add validated sortBy/order) and have the UI request sorted+paged data instead of sorting the local page.
Evidence & reproduction
UI 'sort by LTV' top rows: Carlos $8,900, Alice $4,850, Bob $3,400... But the true 2nd/3rd highest-LTV members are Nina Patel $7,200 (page 3) and Marco Reyes $5,400 (page 2) — computed from all 88 via API — and neither appears anywhere in the 'sorted' list. Clicking sort options fired no /contacts request (reqs=[]). Backend also ignores sort params entirely: sort/sortBy/order/sortOrder/orderBy/sort=-ltv all return identical name-ascending order.
MEDcrm-depth18

Backend contacts search only matches name and email — phone, tag, notes, referral code, and address are ignored

Where
GET /api/v1/contacts?search=
What happens
The search box placeholder promises 'name, email, phone, or tag', but the server search only matches name and email. Phone, tags, notes, referral code, and billing address all return zero.
Impact
Front desk commonly looks a member up by phone number; this never works, even at the API level.
Fix
Extend the search predicate to include phone (normalized) and tags to match the advertised placeholder.
Evidence & reproduction
With proper URL-encoding: search='alice' -> 1; search='example.com' -> 81 (email matches). But search='253-555-0101' -> 0, search='2535550101' -> 0, search='0101' -> 0 (Alice's real phone), search='family' -> 0 (tag), search='Prefers early' -> 0 (notes), search='ALICE-RITUAL' -> 0 (referral code), search='University Place' -> 0 (address). Tag filtering only works via the separate ?tag= param. SQL-injection payloads are safely handled (returned 0, no error).
MEDerror-recovery19

Backend data outage silently shows fabricated placeholder data instead of an error state

Where
Member dashboard (?view=dashboard) + membership view; any data endpoint returning 5xx
What happens
When all data endpoints are forced to 500 (users/me kept OK), the member dashboard renders a fully-populated, healthy-looking screen with fabricated numbers and NO error/retry indicator anywhere. Real values are replaced by sentinel placeholders: 'SESSIONS REMAINING' shows 999 (real = 'Unlimited'), 'GUEST PASSES' shows 0 (real = 1), 'NEXT SESSION' shows 'No upcoming bookings' (real = a booked Contrast Therapy session). A member during an outage cannot tell the data is stale/wrong.
Impact
Presenting invented values (999 sessions) as real during a backend failure is misleading and erodes trust; a member could believe they have entitlements they don't, and staff have no signal the screen is showing stale/default data.
Fix
On data-fetch failure, render an explicit error state (e.g. 'Couldn't load your membership — retry') rather than substituting default/sentinel values; never let the 999 'Unlimited' sentinel leak to the UI.
Evidence & reproduction
Forced route fulfill 500 on **/api/v1/** (except auth + users/me). Full innerText captured: 'MEMBERSHIP STATUS / Complete / 999 / SESSIONS REMAINING / 0 / GUEST PASSES / NEXT SESSION / No upcoming bookings.' vs real dashboard 'Complete Unlimited SESSIONS REMAINING 1 GUEST PASSES ... Contrast Therapy Sat, Aug 1'. Reproduced 3x (dashboard, membership view, isolated repro). pageErr=0, no 'error/try again/retry' text present. Scripts: err-ux-inject.mjs, q-full.mjs; screenshots in errux/inject-alice-dashboard-data500.png.
MEDfinance-reconcile20

reports/finance headline totalFailed ($318) does not reconcile with the 5 failedInvoices it returns ($865)

Where
GET /api/v1/reports/finance (fields: totalFailed, failedCount, failedInvoices[])
What happens
On the same finance report, the 'Failed' KPI shows totalFailed=318 with failedCount=5, but the failedInvoices detail array it returns contains 5 rows whose amountDue sums to 865. The count reconciles (5==5) but the dollar total does not — the headline only sums a subset of the listed failures. 318 = 189+129 (exactly 2 of the 5 rows), so the headline aggregates ~2 of the 5 failed invoices while the count and the drill-down list all 5.
Impact
A finance owner reading the report sees $318 in failed payments but the underlying list totals $865 — a 2.7x understatement of failed/at-risk revenue. The row-level detail directly contradicts the KPI it sits under, so any dunning/recovery decision made off the headline is wrong. Note: revenueByCategory reconciled perfectly to totalPaid (17425==17425) and failedCount is correct, which makes this specific dollar-total bug stand out as a real aggregation error, not a definitional gap.
Fix
Compute totalFailed from the same query/window that produces failedInvoices (sum failedInvoices.amountDue) so headline, count, and detail all derive from one source. If totalFailed is intentionally scoped to a different window than the detail list, scope failedInvoices/failedCount identically so the three agree.
Evidence & reproduction
GET /reports/finance (fetched 3x, stable): {"totalFailed":318,"failedCount":5,"failedInvoices":[{"customer":"Sarah Jenkins","amountDue":189},{"customer":"Jake Morrison","amountDue":189},{"customer":"Bob Martin","amountDue":169},{"customer":"Amara James","amountDue":189},{"customer":"Bob Martin","amountDue":129}]}. Sum of amountDue = 865, not 318 (189+129=318).
MEDfinance-reconcile21

CRM 'days since last visit' (lastVisitDays) is stale/inconsistent with lastVisit timestamp for every contact

Where
GET /api/v1/contacts (fields lastVisit vs lastVisitDays), current date 2026-07-26
What happens
lastVisitDays does not match the lastVisit timestamp on the same record for any of the 20 contacts checked. Two patterns: (a) March visitors show tiny values — Alice Walker lastVisit=2026-03-30 (actually 118 days ago) reports lastVisitDays=1; Bob Martin lastVisit=2026-03-25 (123 days) reports 6; Carlos Mendes 2026-03-10 (138 days) reports 0; Emma Liu 2026-03-22 (126 days) reports 9. (b) July visitors are uniformly ~5 days too low — Avery lastVisit 2026-07-20 computes 6 but field=1; Blake 07-16 computes 10 field=5; Amara 07-05 computes 21 field=16; the offset is exactly 5 across all July rows, i.e. the field appears frozen at a snapshot date of 2026-07-21 rather than 'now'.
Impact
lastVisitDays drives churn/at-risk, win-back, and lifecycle segmentation. Members who genuinely haven't visited in 4 months (Alice 118d, Carlos 138d) appear as 0-6 days since visit, so lapsed members are invisible to retention workflows, while active members' recency is understated by a fixed 5 days. The KPI on the CRM surface contradicts the timestamp on the same record.
Fix
Compute lastVisitDays on read as floor((now - lastVisit)/1d) instead of storing/seeding a static value, or refresh the derived field on a daily job. Ensure the reference is the current date, not a build/seed snapshot.
Evidence & reproduction
From GET /contacts data[]: Alice Walker {lastVisit:'2026-03-30', lastVisitDays:1} (computed 118); Bob Martin {'2026-03-25', 6} (123); Carlos Mendes {'2026-03-10', 0} (138); Avery Simpson {'2026-07-20', 1} (6); Blake Evans {'2026-07-16', 5} (10) — 16/16 contacts with a lastVisit were MISMATCH; all July rows off by exactly 5 days.
MEDmembership-lifecycle22

Freeze/pause succeeds on plans that explicitly disallow freezing (freezeAllowed=false on every plan)

Where
POST /api/v1/contacts/:id/freeze (contact cmml74rxg0008ryidpvj3svfe / u_alice)
What happens
Freezing Alice returned HTTP 200 and set status=FROZEN even though her Complete plan has freezeAllowed=false — and in fact ALL plans in /pricing/plans have freezeAllowed=false, so no plan is supposed to be freezable, yet freeze always succeeds. The endpoint takes no freeze duration / end-date, and freezing did NOT push out the billing date (nextBillDate went 2026-08-23 -> 2026-08-22), so a "frozen" member keeps getting billed on schedule.
Impact
Freeze bypasses the plan-level freeze policy entirely, and because it does not pause/extend billing it also fails to deliver the normal benefit of a freeze — an inconsistent, unvalidated state.
Fix
Reject freeze with 400 when the target plan has freezeAllowed=false; when freeze IS allowed, require a duration and extend nextBillDate / pause billing accordingly.
Evidence & reproduction
pricing/plans: every plan has "freezeAllowed":false. POST /contacts/<alice>/freeze -> 200 with body "status":"FROZEN". billing/summary while frozen still returned nextBillDate 2026-08-22 (not extended). Unfreeze -> 200 status ACTIVE.
MEDmembership-lifecycle23

Cancellation effective date is now+30 days, landing AFTER the next billing date — member is re-billed a full month then cancelled days later with no refund

Where
POST /api/v1/contacts/:id/cancel-membership vs GET billing/summary nextBillDate
What happens
Cancellation was scheduled for exactly requestedAt+30 days (2026-07-26 -> 2026-08-25) rather than aligned to the end of the current paid term. Alice's computed next bill date is 2026-08-22, which falls BEFORE the cancellation effective date. So she gets charged for a new full month on 08-22 and is cancelled 3 days into it on 08-25, and preview text explicitly states "There is no mid-cycle refund."
Impact
Members who cancel shortly before their renewal are charged for a month they will barely use, with no refund — a likely chargeback/complaint driver.
Fix
Set cancellationEffectiveAt to the end of the current billing period (the nextBillDate), not a flat +30 days, so no extra renewal is charged.
Evidence & reproduction
cancel response cancellationEffectiveAt=2026-08-25; billing/summary nextBillDate=2026-08-22; preview message: "...take effect on August 22, 2026. There is no mid-cycle refund."
MEDnotifications24

Booking lifecycle (create / check-in / cancel) generates no confirmation, notification or reminder record

Where
POST /api/v1/bookings, /bookings/:id/checkin, /bookings/:id/cancel; GET /api/v1/messages; settings/comm-settings
What happens
A member booking a session receives no confirmation and no reminder through any channel; the same is true on check-in and cancellation. There is no queryable notification store at all (GET /api/v1/notifications = 404), and the messages table is untouched by booking events.
Impact
The app promises SMS reminders and email receipts in its own settings, but no booking confirmation, no reminder scheduling, and no cancellation notice is ever produced. Members get zero transactional communication for the core spa flow, and there is no reminderSentAt / scheduled-job mechanism for bookings anywhere in the API or client bundle (only UI toggles 'SMS Reminders' / 'Auto-reminder enabled').
Fix
On booking create/checkin/cancel, enqueue a confirmation/reminder honoring comm-settings; expose a notifications or scheduled-reminders resource so delivery is observable.
Evidence & reproduction
Created booking cms276mqu007urysv3cm455xm for u_alice (HTTP 201). Alice's GET /messages count was 24 before booking and stayed 24 after booking, after check-in (owner POST /checkin 200), and after cancel (owner POST /cancel 200). Newest message remained cms26fwed001mrysvd1sp0ltn dated 2026-07-26T19:14 (pre-test). Meanwhile settings/comm-settings advertises defaultSmsReminders:true and defaultEmailReceipts:true.
MEDnotifications25

email-receipt sends a 'receipt' for an UNPAID invoice (status PENDING, amountPaid 0)

Where
POST /api/v1/billing/invoices/:id/email-receipt and GET .../receipt
What happens
The receipt endpoint does not verify the invoice is paid. It happily emails a receipt for an invoice where nothing has been collected, and the generated receipt document itself shows status PENDING with amountPaid 0.
Impact
Emailing a 'receipt' for money that was never collected is misleading — a customer could reasonably believe they have paid. A receipt should represent a completed payment; here it is emitted for a $0-paid, still-due invoice.
Fix
Reject email-receipt (and receipt generation) unless status is PAID (or partially paid, clearly labeled); never label a PENDING/unpaid invoice as a receipt.
Evidence & reproduction
Invoice cms27obn40003rywxxq9uevpp was PENDING (amountPaid:0, amountDue:169). POST /billing/invoices/cms27obn40003rywxxq9uevpp/email-receipt (owner) => 200 {"sent":true,"invoiceId":"cms27obn40003rywxxq9uevpp","to":"bob@example.com"}. GET .../receipt => 200 with receiptNumber 'RIT-20260726-9UEVPP', status 'PENDING', amountPaid 0, netAmount 0.
MEDnotifications26

Admin message with channel=EMAIL fires a real SendGrid send in the DEV environment (no sandbox/suppression)

Where
POST /api/v1/messages (channel:EMAIL)
What happens
On the DEV host, composing a message with channel EMAIL immediately performs a real outbound send through the live SendGrid integration to the recipient's actual email address, returning a real provider message id. There is no dev/test suppression.
Impact
Any admin/staff activity (and QA/testing) on dev.ritualstudiospa.com dispatches real emails to real member inboxes with no sandboxing. Combined with the already-known 'employee/manager JWT = ADMIN' issue, front-desk-level accounts can dispatch real branded emails to members from a supposedly non-production environment.
Fix
Gate real SendGrid sends behind an environment flag; in DEV route to a sink/sandbox or suppress delivery while still recording the message; restrict outbound-email composition to appropriate roles.
Evidence & reproduction
POST /messages {subject:'qa-email-test', recipientId:'u_alice', channel:'EMAIL'} (owner) => created message with deliverySource:'EMAIL', deliveryStatus:'SENT', deliveryProvider:'SendGrid', externalMessageId:'9ZjcztWVQhyqzMmWgG9FTw', toEmail:'alice@example.com'. Manual receipt send similarly returned {sent:true,to:'bob@example.com'}.
MEDperf27

nginx returns raw HTML error pages (not the JSON error envelope) for API routes on 502/504

Where
nginx/1.24.0 error responses on /api/v1/* during upstream outage
What happens
When the upstream is down, the API returns Content-Type text/html with an nginx HTML body instead of the app's {"error":{"code":...}} JSON. SPA/JSON clients that JSON.parse the response will throw on '<html>...' exactly when the backend is failing, producing blank screens / unhandled errors rather than a graceful message.
Impact
Error handling in the client breaks at the worst moment (backend outage), turning a recoverable 'try again' state into a hard crash / white screen.
Fix
Configure nginx error_page for 502/504/503 to emit a JSON body with the standard error envelope and application/json content-type.
Evidence & reproduction
Captured bodies during outage: '<html><head><title>504 Gateway Time-out</title></head>...<center>nginx/1.24.0 (Ubuntu)</center>' and '<html><head><title>502 Bad Gateway</title></head>...'. 502 body size 166 bytes returned for GET /contacts, /contacts?offset=5, /contacts?limit=5.
MEDui-integrity28

Members → Pipeline view (?view=crm-pipeline) renders a completely blank content area

Where
?view=crm-pipeline (Members > PIPELINE sub-nav); owner_admin
What happens
Loading the documented crm-pipeline view returns doc=200 but renders only the left nav and the top search bar — the entire main content region is empty. Its sibling Members tabs render fully: DIRECTORY (crm-members, bodyLen 2807) and LOYALTY (crm-loyalty, bodyLen 954, real earning-rules/rewards content).
Impact
The sales/CRM Pipeline board is a dead feature area for the owner — clicking it or deep-linking to it shows a blank screen with no content and no error.
Fix
Fix the crm-pipeline route/component to render the pipeline board (or an empty-state placeholder if there is genuinely no pipeline data).
Evidence & reproduction
3/3 clean runs: `run1 doc=200 bodyLen=414 contentAfterSearchBar=""`, run2/run3 identical. Screenshot qa-view-crm-pipeline.png shows the sidebar with Members highlighted and a fully empty white content pane. Only h-heading present is 'RITUAL STUDIO OS' (the chrome); no view heading. No 4xx/5xx /api calls fired — the view simply renders nothing.
MEDwallet-integrity29

/credits/history conflates two different currencies (loyalty points vs session credits) in one unlabeled amount column

Where
GET /api/v1/credits/history
What happens
A single history feed interleaves LOYALTY_EARN/LOYALTY_REDEEM rows whose amount is loyalty points (e.g. 600, -600) with USAGE/ADJUSTMENT/PURCHASE rows whose amount is session credits (±1), under one 'amount' field with no unit/currency discriminator. Aggregating the feed is meaningless.
Impact
Any client/statement/export that totals this ledger (or shows a running balance) will display nonsense, and it is the root cause that let the redeem mis-recording (finding 2) go unnoticed.
Fix
Add an explicit currency/unit field (POINTS vs CREDITS) to each ledger row, or split into two endpoints (credits history vs loyalty history), so each stream reconciles independently to its balance.
Evidence & reproduction
Bob feed: by-type sums {ADJUSTMENT:9, USAGE:-17, PURCHASE:14, LOYALTY_EARN:3700, LOYALTY_REDEEM:1}; naive sum(amount)=3707 which matches neither credits (6) nor loyaltyPoints (2540). Only after splitting by currency does it reconcile: credit rows 9-17+14=6 = credits; loyalty rows are the ones broken per finding above. A LOYALTY_EARN of 600 sits directly beside a USAGE of -1 in the same list.

Polish 11

LOWcontent-knowledge30

Knowledge create accepts arbitrary non-enum type and status values (no enum validation)

Where
POST /api/v1/knowledge (owner/manager)
What happens
Required fields are validated (400 on missing type/category/title) but the enum values for `type` and `status` are not checked. Any string is persisted, producing knowledge items that don't match any category and, for status, escape both the ACTIVE and ARCHIVED filters.
Impact
Mis-typed items don't appear under any Employee Hub tab and a bogus status makes an item invisible to normal filtering while still counting in raw listings.
Fix
Validate type/status against their enums with a 400 on invalid values, as is already done for required fields.
Evidence & reproduction
POST /knowledge {"type":"BOGUS_TYPE","category":"x","title":"x","summary":"x","content":"x"} -> 201 with type:"BOGUS_TYPE". POST {...,"status":"BOGUS"} -> 201 with status:"BOGUS". Real enum types observed: SOP/POLICY/TRAINING/ONBOARDING/DOCUMENT_CATEGORY. (Malformed JSON here is correctly handled: 400 INVALID_JSON, unlike the systemic 500 on other write endpoints.)
LOWcontent-knowledge31

Inconsistent write-gating between content surfaces: front-desk (JWT ADMIN) is blocked from editing the SOP library but CAN create/publish company blog posts

Where
POST /api/v1/knowledge vs POST /api/v1/content/blog (employee Priya, front-desk)
What happens
Knowledge write enforces an app-level role check (front-desk -> 403 'Manager or HR access is required to change the resource library'), but the blog-post write path has no equivalent secondary check, so the same front-desk token can author and publish company blog content. Same JWT-role=ADMIN root cause as known issues, but the two content surfaces gate it differently.
Impact
A front-desk employee can push public-facing studio content unsupervised, while the less-public SOP library is correctly protected — the protection is applied inconsistently.
Fix
Apply the same Manager/HR (or per-permission) check on /content/blog and /content/subscribers write/read that already guards /knowledge writes.
Evidence & reproduction
As Priya (u_staff_priya, front-desk, JWT ADMIN): POST /knowledge {type:POLICY,...} -> 403 {"code":"FORBIDDEN","message":"Manager or HR access is required to change the resource library."}. POST /content/blog {"title":"EMP_POST_DELETEME","status":"DRAFT",...} -> 201 (created). Members are correctly 403 on both. Cleaned up.
LOWcontent-knowledge32

Front-desk employee can read full newsletter subscriber list including external (non-member) email addresses

Where
GET /api/v1/content/subscribers (employee Priya, front-desk)
What happens
The subscriber roster (marketing list) is readable by the front-desk ADMIN-JWT token. It contains external website signups (non-members) with names and emails, not just studio members. Members are correctly blocked.
Impact
Front-desk staff can export a marketing/PII list of outside prospects that they have no business reason to see.
Fix
Gate /content/subscribers behind Manager/HR or a marketing permission rather than raw JWT role.
Evidence & reproduction
GET /content/subscribers as Priya (front-desk) -> 200 with rows incl. ryan.ob@gmail.com (Ryan O'Brien), newsletter@techcorp.com (TechCorp HR), james@wilson.com (James Wilson), plus member emails; source:WEBSITE. As member Alice -> 403 FORBIDDEN. Same JWT-role=ADMIN root cause as known read-access issues, flagged here because the exposed data is an external marketing list.
LOWcrm-depth33

'offset' query param silently ignored; repeated multi-value params rejected with 400

Where
GET /api/v1/contacts
What happens
?offset=10 is accepted (200) but ignored — it returns page 1 unchanged (only page/limit are honored). Passing a query param twice (e.g. tag=family&tag=qa_temp_zz or status=ACTIVE&status=VIP) makes the value an array and fails validation with 400 'Expected string, received array'. Pagination validation itself is otherwise solid (limit>100, limit<1, page<1, non-numeric all 400).
Impact
Minor: an offset-based client would silently get wrong data; multi-select filters can't be expressed via repeated params.
Fix
Either support offset or reject it; and accept comma-separated or array values for tag/status if multi-select filtering is intended.
Evidence & reproduction
?offset=10 -> 200, first row still 'Alice Walker' (same as default). Duplicated tag param -> 400 VALIDATION_ERROR 'Expected string, received array'; duplicated status -> 400 same. limit=1000 -> 400 'less than or equal to 100'; page=0/-1 -> 400; limit=abc -> 400.
LOWerror-recovery34

No 404 / 'not found' / 'no access' state — unknown and unauthorized views silently fall back to home

Where
?view=<anything> for all roles
What happens
Navigating to a nonexistent view (e.g. ?view=garbage-nonexistent-zzz) or a view the role cannot access (member visiting ?view=calendar, analytics, settings, crm-members, sales, marketing, operations, content, etc.) silently renders the user's own home overview. For members the bogus URL is left unchanged in the address bar; for staff it is rewritten to ?view=employee-hub-home. There is never a 'page not found' or 'you don't have access' message.
Impact
Users get no feedback that a link is broken or restricted (looks like the app 'ate' the click); it also blurs the line between 'does not exist' and 'not permitted', which is confusing for deep-linked/bookmarked URLs.
Fix
Add a not-found/no-access view for unrecognized or unauthorized ?view= values, and normalize the URL when falling back.
Evidence & reproduction
Member (alice) across 23 known views: garbage view -> home (bodyLen 449, url stays ?view=garbage-nonexistent-zzz); calendar/analytics/settings/crm-*/sales/marketing/operations/content/journal all render member home shell (bodyLen ~440). Staff (priya) ?view=totally-fake-view-9999 -> rewritten to ?view=employee-hub-home. Scripts: err-ux-test.mjs, err-ux-test2.mjs (errux/member-views.json).
LOWmembership-lifecycle35

Inconsistent price for the Complete plan across catalog, contact, invoice, and preview

Where
GET /pricing/plans, GET /contacts/:id (planPrice), GET billing/invoices, POST membership-change/preview (nextBillingPrice)
What happens
The same Complete plan is priced $249 in /pricing/plans and in preview nextBillingPrice, but $239 in the contact record's planPrice, and a paid invoice line reads "Complete — Mar 2026" $199. The preview quotes Alice a $249 next-billing price even though her locked contact rate is $239.
Impact
The renewal amount shown to a member ($249) does not match their stored locked rate ($239), so the membership screen can misquote what they will actually be charged.
Fix
Have preview/nextBillingPrice read the member's locked contract price (planPrice) rather than the current catalog list price.
Evidence & reproduction
pricing/plans plan_complete price=249; contact.planPrice=239; invoice inv_02 amount=199 items "Complete — Mar 2026"; preview complete->complete nextBillingPrice=249.
LOWui-integrity36

Documented nav-group view params silently fall back to the Studio Overview dashboard instead of their screen

Where
?view=sales, marketing, operations, analytics, content, membership, my-schedule (owner_admin)
What happens
These documented view identifiers are nav-GROUP names, not real leaf views. Deep-linking to them renders the dashboard 'Studio Overview' (occupancy/bookings tiles) verbatim rather than the group's screen or a not-found. Real sidebar clicks instead route to distinct leaf ids that work: Sales→sales-cart ('Assisted Sale'), Marketing→crm-marketing, Operations→ops-tasks, Analytics→reports-ops, Content Hub→content-blog ('CONTENT HUB').
Impact
Bookmarks or shared deep-links using the documented view names land users on the dashboard with no indication they didn't reach the intended screen.
Fix
Redirect a group-name view param to the group's default leaf (e.g. analytics→reports-ops) or show a not-found, rather than silently rendering the dashboard.
Evidence & reproduction
For sales/marketing/content/analytics/membership/my-schedule the h1/h2 headings were identical to the dashboard: `heads=["RITUAL STUDIO OS","Studio Overview"]`; screenshot qa-view-analytics.png of ?view=analytics is pixel-for-pixel the Studio Overview dashboard with no nav item highlighted. Nav-click test showed the real destinations: `[Sales] url=/?view=sales-cart`, `[Analytics] url=/?view=reports-ops`, etc., all rendering their own content.
LOWui-integrity37

Employee portal fires an admin-only settings/roles request on every page load → 403 on every employee view

Where
Employee Hub portal (Priya, staff, JWT role=ADMIN) — every ?view=* load
What happens
Regardless of the requested view, the staff Employee Hub renders and issues GET /api/v1/settings/roles, which the backend rejects with 403 'Manager or owner access is required'. This produces a failed /api request and a console error on every single employee page load.
Impact
Every staff session generates repeated 403s — console/log noise, wasted requests, and a sign the client is requesting privileges the user provably lacks.
Fix
Gate the settings/roles fetch behind an actual manager/owner check on the client so the employee portal never calls it.
Evidence & reproduction
14/14 Priya views logged `APIFAIL: GET 403 /api/v1/settings/roles :: {"error":{"code":"FORBIDDEN","message":"Manager or owner access is required."}}` (fired twice per load) and a matching console 'Failed to load resource: 403 (Forbidden)'. The body always renders the same 'Employee Hub HOME' regardless of the view param.
LOWui-mobile38

Sub-24px text tap targets on member views (VIEW ALL / UPDATE / Complete)

Where
Member views dashboard, membership, journal, profile at 375 and 414. Buttons: 'VIEW ALL' (52x16), 'UPDATE' (46x16), 'Complete' (63x20).
What happens
Several text-only action buttons render only ~16-20px tall, below the WCAG 2.2 (2.5.8) 24px minimum target size and well under the ~44px comfortable-touch guideline. Measured heights: VIEW ALL 16px, UPDATE 16px, Complete 20px.
Impact
Small, closely-packed text links are easy to mis-tap on a phone; the header 'VIEW ALL' links sit next to other content and are only 16px tall.
Fix
Increase hit area to at least 24px (ideally 44px) via padding/min-height on these text buttons without changing visual font size.
Evidence & reproduction
mobile-audit/results.json tinyTargets: e.g. member-profile-iphoneX {'button.text-xs.font-black «UPDATE»' 46x16}, {'button.text-sm.font-bold «Complete»' 63x20}, member-dashboard {'button.text-xs.text-evergreen «VIEW ALL»' 52x16}. Present across dashboard/membership/journal/profile.
LOWui-mobile39

Backend 502/504 surfaces raw unstyled nginx error page on mobile (no in-app error state)

Where
Observed on owner loads of calendar, analytics, crm-members, sales during testing (dev backend intermittently returns 502 Bad Gateway / 504 Gateway Time-out).
What happens
When the API/backend returns 502/504, the app does not render an in-app error boundary; the browser shows nginx's raw '502 Bad Gateway nginx/1.24.0 (Ubuntu)' page. That page has NO viewport meta, so on a 375px device it lays out at 980px (innerWidth reported 981/scrollWidth 980) — i.e. a zoomed-out, tiny, non-responsive error screen. sales load 504'd and left the SPA stuck mid-navigation.
Impact
Intermittent, but when it hits a phone user they get an unbranded, zoomed-out nginx page instead of a retry/offline state; also indicates dev backend instability that intermittently blocks loading owner views.
Fix
Add a client error boundary / fetch-failure UI for 5xx responses so users get a styled, responsive 'something went wrong, retry' state; investigate the upstream 502/504s on the dev backend.
Evidence & reproduction
owner-probe.mjs run: crm-members httpStatus 502, bodyText '502 Bad Gateway nginx/1.24.0 (Ubuntu)', metaViewport '(none)', innerW 981/scrollW 980; sales -> 'Failed to load resource: 504 (Gateway Time-out)' with page.evaluate failing (execution context destroyed). Also console 502 captured in mobile-audit/results.json for calendar/analytics on first pass. Direct curl to /api/v1/auth/login returned HTML 502 twice during the session, then 200 on retry.
LOWwallet-integrity40

Cancel endpoint returns HTTP 200 for concurrent duplicate cancellations (not idempotent at the HTTP layer)

Where
POST /api/v1/bookings/:id/cancel
What happens
Firing 6 simultaneous cancels of the same booking returns two 200s and four 400s. Accounting stays correct (only one refund is applied / one ledger entry written), so there is no credit drift, but two requests both report a successful cancellation of the same booking.
Impact
A client that treats each 200 as a distinct successful action (e.g. sending two 'your booking was cancelled / credit returned' notifications, or a UI double-applying an optimistic +1) could be misled even though the server ledger is safe.
Fix
Guard the cancel transition with a conditional update (only the first request that flips CONFIRMED->CANCELLED returns 200; subsequent ones return 409/400 already-cancelled) so exactly one success is reported.
Evidence & reproduction
Promise.all of 6 concurrent cancels on one booking -> statuses [200,200,400,400,400,400]; credits refunded = exactly 1 (5->6), single 'Cancellation refund' ledger row. So no double-refund, but two callers each received a 200 success for the same state transition.