Change evidence report

Sauna CRM Launch Map: reviewer evidence pack

PR 1800 implements eleven launch map items (#1785 to #1795) plus supporting schema and CI work across the API, public booking widgets, CRM, docs, shared format package, Kotlin mobile client, simulation harness and repository workflow: 239 files, 68,225 insertions, 1,701 deletions over 44 commits, with four migrations. Three generated Drizzle snapshots account for most of the final line-count increase. This pack pairs each captured user-visible claim with the same surface at the merge-base, records the non-visual evidence boundary, and names each independent review finding with its disposition.

Range
c9cabba1 (merge-base with main)…4f72870f2
Source state
feat/launch-map at 4f72870f2, pushed and merged as 15e410cd4
Generated

Reviewer brief

Outcome and scope

The product claims reproduce, final remediation is approved, and exact-head CI has passed

The captured user-visible claims reproduce against the merge-base, and corrected captions state only what their pixels show. Four independent domain reviews and six focused follow-up code reviews found defects in membership windows, un-reversal, widget navigation, first paint, disclosure, tenant scoping, rolling-deploy compatibility and accessible names. Every material finding was fixed; both final reviewers approved exact head 4f72870f2. Targeted local validation passes, and GitHub CI plus Compile and test both succeeded on that exact head. This evidence pack remains separate from reviewer approval.

What this pack is and is not

This is an evidence pack for a reviewer, not an approval and not a readiness sign-off. Every image is reproducible from a pushed tree, though the branch was rebased after capture. References below use reachable patch-equivalent SHAs: the PR-claim pairs compare merge-base c9cabba1 with b3271dbde; two Remediation pairs use fd2c35283 as the submitted side, 52344d47b for the plan-card after-shot and 7143f9db6 for the placeholder after-shot. The base-only placeholder contrast shot uses c9cabba1. Patch IDs confirm each reachable SHA is identical to the pre-rebase commit that produced its image. The declared range also contains two inherited mainline fixes: #1814 corrects CI failure propagation and #1817 preserves the last Super Admin under concurrent writes. They are included in the scope counts.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
#1785 custom_days is a first-class recurrence in the CRM and the expanderMatched captures of the Recurrence select, plus the head-only weekday picker it revealsCovered
#1793 session type gains event in the schema, CRM and public booking surfaceMatched captures of the Add session Type selectCovered
#1786 private-booking approval separates from ordinary booking approvalCode and integration-test evidence only. Settings and per-session controls exist, but were not captured in this passGap
#1787 visit packs can carry an expiry window and disclose it before purchaseAs-submitted and remediated captures of the plan card, plus focused API coverage of entitlement and reversal pathsCovered
#1788 fixed_term is a plan type with a required validity windowMatched captures of the Plan Type select, plus the head-only Access Duration fieldCovered
#1789 booking cutoff uses session start rather than session endCode and integration-test evidence only. This server rule has no distinct settled visual stateGap
#1790 makes daily view the default, and #1792 lets a studio's saved default-view policy override itMatched first-paint captures from an embed with no view attribute, backed by the saved appearance policyCovered
#1791 widget theme defaults to light rather than following the host pageCode reading only. Every widget default changed from auto to light; no dedicated dark-host capture was takenGap
#1792 Design Settings, the saved default-view policy and a rebalanced session palette reach the widgetsMatched captures of Design Settings, its live preview, the default-view result and the reduced colour-picker paletteCovered
#1793 the public booking surface exposes seats, a Book control and session typeMatched captures of day rows, week tiles and the mobile weekCovered
#1794 the sign-in panel accepts a customer password without losing the email linkMatched captures of the signed-out account widgetCovered
#1795 attendance names the waiver state the customer is actually inMatched captures of the attendance row chip, plus a head-only capture of the popover it opensCovered
The Book chip is legible on every card colourTwo-way capture at the defect commit and at head, with contrast measured from the live DOM. The merge-base has no Book control to compareCovered
The migration chain applies cleanly and remains intactIndependent schema review of the journal, snapshot lineage and lock behaviour, plus both capture databases migrated from emptyCovered

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Scope

Table

What the two capture stacks were

Each revision ran its own API, CRM, widget harness and database, so a shot can only render the revision its port belongs to. Both databases were created empty and migrated from the revision under test, then seeded by the same QA reset command.

ComponentBase (c9cabba1)Head at capture (b3271dbde)
APIport 3311port 3312
CRMport 5491port 5492
Widget harnessport 5391port 5392
Databasepr1800_basepr1800_head
Migrations appliedup to 01780179, 0180 and 0181 added

Schedules, #1785

Screenshot

Recurrence gains a specific-days option

Add schedule entry, Recurrence select opened. Same seed, same viewport, 1280 by 950 CSS pixels.

Five options, ending at Monthly. A schedule that runs Monday, Wednesday and Friday had to be modelled as three entries.Before
Recurrence dropdown at base listing None one-time, Daily, Weekly, Biweekly and Monthly
Specific days of the week is added as a sixth option.After
Recurrence dropdown at head listing the same five options plus Specific days of the week

Schedules, #1785

Screenshot

Choosing specific days reveals a weekday picker with a weekday preset

Head only: the control does not exist at base, so its pair is the select above. Weekdays Mon to Fri is applied, leaving Saturday and Sunday unselected.

Repeats on exposes seven day toggles and a Weekdays (Mon to Fri) preset, and the Private booking toggle sits below it.After
Add schedule entry panel at head showing Repeats on with Mon through Fri selected, Sat and Sun unselected, and a Weekdays preset button

Sessions, additional schema value

Screenshot

Event joins Recurring and One-off as a session type

Add session dialog, Type select opened, 1280 by 950 CSS pixels.

Two types only.Before
Add session dialog at base with the Type dropdown listing Recurring and One-off
Event is added as a third type.After
Add session dialog at head with the Type dropdown listing Recurring, One-off and Event

Memberships, #1788

Screenshot

Fixed Term joins the plan types

Add Membership Plan dialog, Plan Type select opened, 1280 by 950 CSS pixels.

Monthly and Yearly only, both renewing.Before
Add Membership Plan dialog at base with the Plan Type dropdown listing Monthly and Yearly
Fixed Term is added, for a one-time charge that buys a window rather than a renewal.After
Add Membership Plan dialog at head with the Plan Type dropdown listing Monthly, Yearly and Fixed Term

Memberships, #1788

Screenshot

A fixed-term plan requires its window up front

Head only: selecting Fixed Term re-titles the dialog to Add Fixed-Term Offer and reveals a required Access Duration (days) field, so a fixed-term offer cannot be saved without the window that decides when access ends.

Access Duration (days) appears as a required field once Fixed Term is chosen, with the rule stated underneath.After
Add Fixed-Term Offer dialog at head with Plan Type set to Fixed Term and a required Access Duration in days field revealed

Settings, #1792

Screenshot

Design Settings gains widget appearance controls and a truthful preview

Settings, Design, full settings column at 1280 CSS pixels wide. The base capture is shorter because the section below Logo URL does not exist there.

Primary colour, logo and a preview of the brand row. Nothing here decides how an embedded widget looks.Before
Design Settings at base showing Primary Color, Logo URL, a brand preview and Save
A Widget Design section adds Widget theme and Schedule opens on, and the preview now renders the widget's own surface in the chosen theme.After
Design Settings at head adding Widget theme set to Light, Schedule opens on set to One day at a time, and a widget preview showing Today's sessions and a Book control

Public booking, #1790

Screenshot

The widget's first paint honours the studio's saved default view

book.html at 1280 by 900 CSS pixels, no view attribute on the embed, so the studio's saved answer decides. With Schedule opens on set to One day at a time, the widget opens on Day.

Opens on the weekly grid regardless of any studio preference.Before
Booking calendar at base opening on the Aug 17 to Aug 23 weekly grid with Week selected
Opens on Day because that is the saved answer. The single seeded session on this day has already started, so it reads Closed; the seat count and Book control are shown on a bookable day in the next figure.After
Booking calendar at head opening on Day with the studio's saved default view

Public booking, #1793

Screenshot

Day rows gain seats, a Book control and a session type icon

book.html, Day view, 1280 by 1100 CSS pixels, same two seeded sessions on both revisions.

A row states the time, name, duration, discipline, instructor and facility. It does not say how many seats are left, and it offers nothing to press.Before
Two coloured session rows at base with no seat count and no booking control
Each row adds a remaining-seat count, a Book control and a type icon next to the name.After
The same two rows at head, each showing 20 left or 15 left, a Book chip and a recurrence or event icon

Public booking, #1793

Screenshot

The same affordances reach the week grid

book.html, Week view, 1280 by 1000 CSS pixels. The tile is the narrowest place these controls have to fit.

A tile carries name, time, duration and discipline, and nothing actionable.Before
Weekly grid at base with plain session tiles
Each tile adds a Book chip and a seat count, and the label truncates rather than wrapping.After
Weekly grid at head where each tile carries a Book chip and a 20 left or 15 left count

Public booking, #1793

Screenshot

The mobile week keeps the booking control

book.html, Week view at 414 by 900 CSS pixels, the narrow layout where the grid becomes day-grouped cards. Each shot is clipped to the widget host, which is 57 CSS pixels shorter at base, so the base card is cut through its detail line; nothing below the cut carries a seat count or a control.

Day-grouped cards with no seat count and no control.Before
Mobile weekly view at base with a plain session card
The card adds the seat count on the title row and the Book control beside the detail line below it, leaving that detail line intact.After
Mobile weekly view at head with 20 left and a Book chip inside the session card

Public booking, contrast

Screenshot

The Book chip is legible on every card colour

The chip was introduced filled with the brand primary, which put a blue chip on a blue card. The fix inverts against each card: its readable foreground becomes the chip fill and the card hue becomes the label. Colours below were sampled from the live DOM, not estimated from the image.

Defect commit 7063fe657^ (30cffbabf): chip fill rgb(37, 99, 235) with a white label, on a card that is itself rgb(37, 99, 235). Measured contrast of chip against card 1.00 to 1.Before
Two session rows where the Book chip is brand blue, invisible against the blue card and clashing on the red card
Head: the chip inverts against the card, taking the card's own readable foreground as its fill and the card's hue as its label. On the two dark cards here that is white: measured 5.17 to 1 on the blue card and 4.83 to 1 on the red card. On a light card the same rule fills the chip black, as the amber tile in the week grid above shows, measured 9.78 to 1.After
The same two rows at head with white Book chips carrying blue and red labels

Customer account, #1794

Screenshot

The sign-in panel accepts a password without losing the email link

account.html at 900 by 900 CSS pixels, signed out.

Email only. A customer who has set a password has nowhere to type it.Before
Member login at base with an email field and an Email me a sign-in link button
An optional password field is added, and the copy states both routes: enter your password, or we will email you a link.After
Member login at head with an email field, an optional password field labelled if you've set one, and the email link button

Attendance, #1795

Screenshot

The waiver chip names the state the customer is actually in

Attendance, a seeded customer with an outstanding waiver. Cropped to the row, 670 CSS pixels wide, captured at 2x.

A count, and a separate Waiver dropdown. A count does not say whether the studio holds a signature at all.Before
Attendance row at base with a chip reading 1 unsigned waiver and a Waiver dropdown button
The chip names the state: No Waiver on File, or Waiver Update Required when a signature exists but the text has moved on. AttendancePage.tsx:224 to 228 picks between them.After
Attendance row at head with a chip reading No Waiver on File

Attendance, #1795

Screenshot

The chip opens a popover naming each waiver and its own state

Full CRM at 1280 by 900 CSS pixels. Presented head-only on purpose: at base the equivalent control is the closed Waiver dropdown visible in the pair above, and the capture could not open it, so pairing the two images would imply a comparison that was not made.

The chip is the trigger. The popover repeats the named state, says check-in is blocked until the waiver below is signed, lists Participation waiver as Never signed, and offers Sign Waiver in Person, Send Waiver and Dismiss.After
Attendance popover at head titled No Waiver on File, listing Participation waiver as never signed with Sign Waiver in Person, Send Waiver and Dismiss actions

Settings, palette

Screenshot

The primary colour picker offers a smaller, more distinguishable palette

Settings, Design, primary colour control activated, 1280 by 1000 CSS pixels. These presets reflect the rebalanced shared session palette, ten hues reduced to eight, plus the default brand primary. Explicitly stored session colours do not move. Both revisions keep the hex field and current value #2563EB, so the visible difference is the preset set itself.

Twelve presets in two rows of six, including several hues that read almost identically at swatch size.Before
Primary colour picker at base showing twelve preset swatches in two rows of six above a hex field reading 2563EB
Nine presets in three rows of three. The count and layout change is visible here; whether each remaining hue is distinguishable is a claim this image cannot settle on its own.After
Primary colour picker at head showing nine preset swatches in three rows of three above the same hex field

Remediation, #1787

Screenshot

A pack's credit expiry is disclosed before purchase

membership.html at 1100 CSS pixels wide, with validity_days set to 90 on the one publicly listed pack in the head database. The seed ships every listed pack with a null window, and the column does not exist at base, so this pair is fd2c35283 against 52344d47b rather than base against head. Only the widget bundle differs between the two shots.

As submitted: the card reads one-time over 20 visits. The API returns the window, and the customer is never told the credits lapse.Before
Plan card as submitted showing 20-Visit Value Pack, 159.99 dollars and one-time, with no expiry statement
Remediated: Credits expire 90 days after purchase, placed under the cadence it qualifies rather than in the ticked benefit list.After
Plan card remediated, adding the line Credits expire 90 days after purchase under one-time

Remediation, #1790 default-view gate

Screenshot

A widget waiting on the appearance policy reserves a box

The gate withholds paint until the studio's appearance answer settles, which affects an embed that pins no view, the shape the CRM's snippet generates. Captured with /api/config held open so the gate cannot resolve, inside its bounded wait. This pair is fd2c35283 against 7143f9db6. A 320 pixel band is clipped at a fixed offset because the unremediated element has no box to clip to.

As submitted: the element collapses to nothing and the page below it moves up. This is a public page that painted immediately at base.Before
The widget region as submitted, empty, with the site footer pulled up into the space
Remediated: a neutral aria-busy plate reserves a 240px floor, so the region has a box from first paint instead of collapsing to nothing. The real surface is usually taller, so some shift remains; what is gone is the collapse to zero.After
The widget region remediated, showing a rounded neutral placeholder box roughly a third the height of the eventual calendar

Remediation, #1790 default-view gate

Screenshot

For contrast, the same embed at base never waits

The same held request against the base bundle, which has no appearance gate. Included so a reviewer can see what the gate changed rather than only what the placeholder fixed.

Base paints the calendar immediately, with the fallback brand name, because it has nothing to wait for.Before
The same embed at base painting a full weekly calendar with the fallback studio name

Review

Table

Independent reviews, and where each finding stands

Four independent domain reviews covered API logic, widgets, security and schema. Six focused follow-up code reviews then audited successive remediation heads, including the final accessible-name correction. Severity is the reviewer's own; rows below name the reachable failure and the commit that closes it.

ReviewVerdictFindingStatus
API logicrequest-changesAn unrelated membership edit re-derived endDate and retroactively back-dated a grandfathered visit packFixed by the author in b3271dbde
API logicrequest-changesThree membership-plan reads lacked brandId scoping and could supply another tenant's plan nameFixed in fd2c35283 and a0a031647
API logicrequest-changesUn-reversal rebuilt access deadlines from a plan edited after saleRuntime snapshot preservation landed in d624c57e1
API provenance follow-uprequest-changesA timestamp heuristic could rebuild a genuine snapshot from an edited plan, and a proposed data repair could miss edited or voided legacy rows or clear a genuine post-0179 deadlineBoth approaches were rejected and replaced by explicit provenance in f0474243b
API inactive-state follow-uprequest-changesMarker-bearing paused or expired access rows bypassed membership_not_restored during failed-refund recoveryFixed in 945cc5755; the final scoped review returned approve with no findings
API logicrequest-changesA plan change anchored the stored entitlement start on UTC today rather than the studio's dayFixed in c9cec4c21
API logicminorA planId-only update audit omitted the five fields the server also derivedFixed in c9cec4c21 with separate changedFields and derivedFields
Widgetsrequest-changesJump to next available skipped the current day and could miss a bookable session under another categoryFixed in bd049a9bf
Widgetsmajor follow-upA same-day category jump disabled the next studio-midnight rolloverFixed in 3c12ca6c0
Widgetsrequest-changesA visit pack's credit window was sold without pre-purchase disclosureFixed in 52344d47b; non-positive imported values aligned with the API in 3c12ca6c0
Widgetsrequest-changesThe appearance gate collapsed CRM-generated embeds to zero until config settledFixed in 7143f9db6 with a 240px reserved floor
Securityapprove-with-nitsNo auth bypass, tenant-isolation gap or credential disclosure found; passwordHash was absent from the projection regression guardGuard extended in 09c7da16c
Securityaccepted informational riskProof-bearing magic links can land on any path of an allowlisted host, and password rotation preserves the calendar-feed keyDisclosed under Risks; the security reviewer did not treat either as a merge blocker
Schemano blockerClasses written during a rolling deploy could miss the private-approval carry-overFixed by the author in e7eb5f8ff with a legacy-write bridge
Schemano blocker0180's claimed replay idempotency could overwrite independently configured private-approval valuesClosed in e7eb5f8ff by making the migration explicitly one-time and adding the legacy-write bridge
Cross-surfacerequest-changesStaff-created and widget-sold memberships did not enforce every new entitlement windowFixed by the author in 45a99f283 and fbc8b1cdd
Widgetsminor follow-upExplicit names on compact View controls omitted type or metadata and could collide across locationsFixed in 4f72870f2 across BookingCalendar, Schedule and shared Book controls
Widgetsmajor final reviewSchedule's new daily default rendered a live empty-state jump before the initial request resolvedFixed in 4f72870f2 with a loading gate and deferred-request regression test
Widgetsminor final reviewOne-day fixed-term offers rendered '1 days' in both customer-facing duration stringsFixed in 4f72870f2 with singular-copy coverage
Final exact-head reviewapproveThe API reversal state machine, migrations, membership windows and final widget remediation were re-read at the pushed headTwo final reviewers approved 4f72870f2; the UTC-day and rolling-deploy convention risks remain disclosed below

Review

Table

The membership-window fix was reviewed as a sequence, not one commit

The blocker fix introduced a coherent snapshot model, then concurrent review exercised the requests around its three consecutive guards. The final design preserves the caller-keyed refusal, re-issues both ends on a real plan change, uses the brand-local day, refuses only caller-named inversions and records every derived field in the audit row.

DefectReachable requestClosed by
The missing-window 400 fired when the body omitted endDate, locking an already-null row out of status changesPUT {"status":"active"} on a row whose endDate is already nullfd2c35283, keying the refusal on body.endDate === null
Changing to a 14-day plan moved only endDate, stretching a 2025-06-01 start into 462 days of coveragePUT {"planId": fixed_term} from the CRM dropdowna0a031647, writing both ends from one anchor
The same derivation put endDate before startDate on a pre-sold rowPlan change on a row whose startDate is in the futurea0a031647, preserving the future start and adding a caller-date inversion guard
The new anchor used UTC today and denied west-of-UTC members the rest of the studio's current dayPlan change after 17:00 America/Los_Angelesc9cec4c21, using todayInZone(brand.timezone)

Schema

Table

Migration chain, measured from the head journal and snapshots

Verified by independent schema review, generated Drizzle metadata and focused lineage tests. The two capture databases prove the chain through 0181; 0182 is a generated schema migration whose snapshot extends the linear chain.

PropertyMeasured
Migrations added0179 launch-map schema, 0180 private-approval bridge and backfill, 0181 magic-link column, 0182 reversal end-date provenance
Journal entries182, every tag has a matching .sql file and every idx equals its filename number
Snapshot lineageLinear through 0182: 0179 follows 0178, 0181 follows 0179 and 0182 follows 0181
Absent 0180 snapshotCorrect for the hand-written trigger and data migration, matching existing repository precedent
Tables and enums93 and 55, unchanged from the merge-base: no new tables, three values added to existing enums
Columns addedNine, three more than the PR body's count of six. The final provenance column was added during remediation
Lock profileNo CREATE INDEX. Schema defaults are constant where present; 0182 adds one nullable timestamp without a table rewrite

Confidence

Validation

Pass

GitHub CI on the final head

CI run 32679220164 and Mobile run 32679220213, whose Compile and test job is its only job, both succeeded on exact head 4f72870f2. CI passed lint, typecheck, docs, unit, database integration, migration-chain, simulation, image-build and exact-tree attestation steps

Pass

Widget follow-up suites

BookingCalendar, Schedule and PlanCard on exact head 4f72870f2: 111 pass, 0 fail, 401 expect() calls across 3 files. Coverage includes initial daily loading, same-day jump, studio-midnight rollover, type and location accessible names, singular fixed-term copy and non-positive expiry disclosure

Pass

Membership route suites

memberships and membership-end-date: 43 pass, 0 fail, 114 expect() calls across 2 files, including brand-local anchoring and audit derivedFields

Pass

Refund reversal and provenance migration

Real Postgres reversal e2e: 25 pass, 0 fail, 142 expect() calls, covering finite and null access snapshots, billing cadence, pre-marker refusal, timestamp-invalidated refusal and later paused or expired states. Migration lineage: 8 pass, 0 fail

Pass

Screenshot provenance

Every pair captured from isolated stacks in one run, animations and caret disabled, fonts and viewport pinned per shot. Rebased references were verified patch-identical with git patch-id

Pass

Full local CI checks and exact-head focused checks

At f0474243b, scripts/run-all.sh actions:lint security:audit lint typecheck docs:build ran 5 steps with 0 failures. The later API remediation at 945cc5755 separately passed API typecheck, Biome over 1,906 files and 25 real-Postgres reversal tests. Exact head 4f72870f2 passes widget package typecheck, Biome and 111 focused widget tests

Pass

Schema convergence assertion

Not run locally with the two-fresh-database harness. Exact-head CI's Validate migration chain step succeeded, and the focused 0182 migration and lineage tests passed locally

Gap

Non-visual launch-map rules

#1786 private approval and #1789 booking cutoff have server behavior beyond what a settled screenshot can prove. The report labels their captures as gaps and relies on integration tests for those contracts

Limits

Known issues, risks, and gaps

Residual deployment and evidence boundaries a reviewer should weigh

Ambiguous pre-deploy access reversals require operator reconciliation The old reversal destroyed the original access deadline, so the new runtime does not guess. 0182 adds a timestamp marker written only when new code proves it preserved the snapshot. A failed refund for a pre-marker cancellation, or for a row edited after reversal, leaves that membership cancelled and opens a membership_not_restored operator exception with corrective instructions. This preserves data integrity at the cost of a manual decision for genuinely old in-flight failures.

Proof-bearing magic links can land on any path of an allowlisted host POST /api/widget/auth/request constrains redirectUrl by scheme and allowlisted host, not by path, and marks the resulting token self-requested. A script that reads the fragment on that page could obtain both a session and the 30-minute first-password authority added here. The security review accepted this as informational rather than a merge blocker.

Password rotation does not rotate the calendar-feed key Password set, change and removal revoke sessions, magic links and device requests, but preserve calendarFeedKey, matching the existing sign-out-everywhere design. This remains an accepted security boundary.

Purchase-time duration windows use the UTC day New visit-pack and fixed-term duration windows inherit the purchase paths' existing UTC start date, while a later staff plan change anchors its re-issued window on the studio day. In studios west of UTC, an evening purchase can therefore start and end one calendar day later than a staff re-issue. The final reviewer classified this as informational because the window remains internally consistent and the pre-existing stored start-date boundary is unchanged.

The private-approval rolling-deploy bridge relies on a writer convention Every current writer that updates auto_confirm_bookings marks the transaction before doing so, which preserves the independent private-approval value during rollout. A future writer that adds the column to its SET list without the marker could overwrite that private axis, and CI does not enforce the convention.

What the captures cannot prove A screenshot shows one settled state. Focus order, the bounded wait's duration, the recurrence expander's output, midnight rollover and the server halves of #1786, #1787 and #1789 are established by code and behavioral tests rather than pixels.

The branch was rebased repeatedly during evidence gathering The author continued fixing and rebasing while review ran. Image provenance therefore names reachable patch-equivalent commits instead of orphaned pre-rebase SHAs. Final code counts, validation and CI status are refreshed against the exact merge head before delivery.