` held across read-modify-write.
-10. **TopUpGiftCard could top up an EXPIRED card** (reviving forfeited balance). **Fixed:** expiry gate added to admin top-up (till already had it).
-11. **UserPaymentModal infinite fetch loop** — `$effect` re-triggered on store `loading` toggle → 983+ API calls. **Fixed:** one-shot guard + idempotent store.
-12. **Money displayed 100× too small** — `formatCurrency(totalPaid / 100)` where totalPaid already pounds. **Fixed:** `formatCurrency(totalPaid)`.
-13. **postChargeRecheck stranded money silently** — no refund row, no notification. **Fixed:** flood-capped critical notification raised.
-14. **`payment.completed` orphan detection could fail a legit completed charge** (delayed webhook). **Fixed in two passes:** (a) `SweepKeyedReplayAge` age gate; (b) **B1-EVIDENCE GATE** (round 3): failed-mark now requires the sweep's actual B1 markers (`b1_attempts > 0` or a `duplicate charge — sweep replay` refund row); no evidence → origin left pending for the sweep to reconcile. New test locks the no-evidence path.
-
-### MEDIUM
-15. **Online card SAVE unusable in enforced env** — gate matched only mock's fictional `cnon:sca-`. **Fixed:** genuineness derived from Square acceptance of any token-like source.
-16. **Payment 2FA gate dead UI + fallback machinery** — frontend sent codes backend never read; `insertTwoFAFallbackAudit`/`reissueTwoFACodeAfterFailedCharge`/`enforceSCAFallbackConsent` unreachable. **Fixed:** dead machinery deleted, `verification_code` removed from charge structs, TwoFactorCodeInput removed from payment flows (account flows intact).
-17. **Privacy policy false statements + Art 13 gaps** — "never store card expiry" (false: last_4/exp_month/exp_year stored), missing processors (Cloudflare/R2/CardDAV/Google Fonts/CARTO), no international transfers. **Fixed:** reworded + added sections.
-18. **No right-to-cancel at gift-card purchase + self-purchase forfeits 14-day right without disclosure.** **Fixed:** disclosure + explicit acknowledgement checkbox + links to gift-card-terms.
-19. **Terms page omitted Distance Contracts & Right to Cancel section.** **Fixed:** ported from obsidian §5.
-20. **Business cancellation full-refund policy vs notice-tier code.** **Fixed:** policies rewritten to the accurate "full refund or free reschedule; refunds under standard tiers unless waived".
-21. **Terms §2.2 contradicted privacy policy on deletion balance.** **Fixed:** rewritten to "balance retained on anonymised record".
-22. **Idle-account warnings promised but commented out.** **Fixed:** docs corrected to "not yet implemented".
-23. **Till responses hand-rolled JSON** (~50 `http.Error` + 6 `json.NewEncoder`) instead of `mw.RespondJSON/RespondError`. **Fixed:** migrated.
-24. **Dev/mock new-card charges broken** — `verify_mock_` overwrote the cnon nonce; mock token ≠ backend contract. **Fixed:** mock mints `cnon:sca-...`; `tokenizeWithVerification` returns `verificationToken: null` for new cards (real-SDK parity) so `save_card` works in dev.
-25. **Gift-card buy (account page) kept the old overwrite pattern + sent dead consent fields.** **Fixed:** explicit precedence + `scaFallbackConsentFields` removed.
-26. **`GetBookingRemainingBalancePence` counted `on_the_house`/discounts as paid.** **Fixed:** excluded.
-27. **refund_failed notification dedup missing `acknowledged_at IS NULL`.** **Fixed.**
-28. **Verification codes stored plaintext + brute-force budget keyed per code value.** **Fixed:** HMAC-peppered hashing at rest (CHAR(64)), per-user budget, `[VERIFY]` log relay in dev / fail-closed in prod.
-
-### MEDIUM-HIGH (adversarial round 3)
-29. **£250 online-tip cap bypassable via the overflow-tip carve** — `confirm_overflow_tip=true` + £10,000 minted uncapped tip rows. **Fixed:** gate rejects `req.Amount − realRemaining > maxOnlineTipPence` (£250) in BOTH carve paths + `buildSplitRecords` belt-and-braces returns an error (signature `([]PaymentRecord, error)`). Runtime-verified: £10k → 400; £250 → succeeds with £215 tip carve.
-30. **Completion campaign auto-apply over-redemption race** — read-then-write + non-conditional increments. **Fixed:** all 4 increments atomic reserve-first (`... AND times_redeemed < max_redemptions RETURNING id`), skip-on-ErrNoRows; per-user milestone + anniversary get `ON CONFLICT DO NOTHING`; schema backstops added (`chk_times_redeemed` CHECK + partial unique index `uq_booking_discounts_user_milestone_campaign`). Deterministic race tests added.
-
-### LOW / MINOR / NITPICK
-31. **4 gift-card admin handlers lacked in-handler `isAdminRequest` backstop.** **Fixed** (CreateGiftCard/TopUpGiftCard/TransferGiftCard/ClaimExpiredBalance).
-32. **Tip lock key inconsistent** (`crussell:tip:` vs `crussell:payment:`). **Fixed:** aligned.
-33. **Float→int64 overflow in gift-card cap checks.** **Fixed:** NaN/Inf/oversize guard before pence conversion.
-34. **Mock SCA error-shape parity unpinned + prod 2FA branches never compiled** (dead under `test,dev`). **Fixed:** `run-prod-tag-tests.sh` + handler-level verification_required tests + mock parity tests.
-35. **Test-gap fixes:** ApplyScheduledDefaultHours test; webhook tests hardcoded-2025 dates → clock-relative; ValidateCardInfo table tests; listCards 20-page guard test; penceLess/roundingEpsilon boundaries; main.go startup-check tests; policy.ts↔refund_policy.go cross-check tests; vitest svelte-component project (happy-dom) + SCA dialog test.
-36. **Mobile parity (26 findings):** touch targets ≥44px on UserBookingModal/PaymentModal/TipPayment/BookingFlow/OverflowTipConfirm/TillPurchases; dialog close 44px; `active:` pressed feedback; TimeSlotPicker 50dvh+44px; `.no-scrollbar` utility; receipt document.write fields escaped; policyPopover mobile anchor; CSP meta added to app.html.
-37. **Docs parity (17 findings):** refresh-token grace 60s→20s; session 30d→90d; notification reasons 16→17; test counts recomputed; deposit advance 24h→36h; gift_card_expiry default 12→24; patch-test 24-48h→24h; phantom `no_deposit` reason removed; line-ref drift → function-name refs; 5-min header mislabel; route tables + cancellation/gift-card-terms; cash-refund claim verified; flood-cap enumeration; idle-account warnings; M1 VAT undercount; Testing Architecture package counts; `.env` stale `TWO_FACTOR_FALLBACK=true` removed; verification-code delivery channel documented.
-38. **Policy minors:** cancel-confirm dialog overstatement; deposit 20–50% phrasing; obsidian User/Admin Manual inconsistencies (24h/36h, forfeit vs full-refund); gift-card expiry reminders; last-updated date format; footer links; deletion balance in obsidian.
-39. **stale comment/struct-field cleanup:** `twofa.go:197` clock sentinel; sweep-cap coordination comment; `releaseBookingPaymentLock` note; `ConsentVersion/ConsentAccepted` dead struct fields removed from charge structs; stale references to deleted 2FA machinery.
-
----
-
-## ADVERSARIAL PASSES — CLEARED (no fix needed)
-- Refund capacity/over-refund/pence rounding — **CLEARED** (exact pence math, per-payment + per-booking caps, idempotency replay returns stored result).
-- Refund webhook replay double-apply — **CLEARED** (status-guarded UPDATEs, event_id dedup).
-- Webhook signature verification — **CLEARED** (HMAC-SHA256 URL+body, constant-time compare).
-- Sweep refund of a just-succeeded payment / cancel of a just-completed checkout — **CLEARED** (age gates + replay-window discriminator).
-- Sweep notification flood caps — **CLEARED** at every insert site.
-- Gift-card transfer TOCTOU / clawback / expiry boundary / per-admin cap — **CLEARED**.
-- Loyalty stamps — **CLEARED** (atomic daily-cap + once-per-booking UPDATE).
-- Route guards — **CLEARED** (admin group + in-handler backstops; all 4 missing backstops now added).
-- SCA/2FA boundary — **VERIFIED-OK**: SCA-only for charges (token required in enforced env), homegrown 2FA only for account/admin actions, fail-closed default, no header-based gate bypass.
-- `snapshot` encryption plaintext fallback — **KNOWN/ACCEPTED** (documented money-safety-over-PII tradeoff; CRITICAL startup warning).
-- App-clock vs DB-clock money gates — **THEORETICAL/LOW**, no exploit path (server clock not client-controlled).
-
-## REMAINING KNOWLEDGE (intentional, documented)
-- `SNAPSHOT_ENC_KEY` unset → plaintext snapshot fallback (startup CRITICAL; ops must set it in prod).
-- **2FA/verification-code delivery**: intended channel is email/SMS (P6), not yet wired. Stdout-log delivery (`[2FA]`/`[VERIFY]`) is a **local DEV ONLY feature** — dev/test builds only. Production builds have no delivery channel; code issuance FAILS CLOSED (503) until email/SMS ships. The `TWO_FACTOR_ALLOW_LOG_DELIVERY` production opt-in was **removed entirely**.
-- TRUST_PROXY_HEADERS=false default means rate limiting sees the proxy IP behind nginx (startup warning; ops decision).
-- LocalStorage tokens + no hard CSP on the SvelteKit shell beyond the added meta (residual XSS-exposure risk; documented).
-
-**Session verdict:** review FAILED at start (2 critical wire-contract breaks + 30+ issues) → after 4 fix/review loops: **all issues fixed, all tests green, all keystone journeys runtime-verified.**
diff --git a/.sisyphus/plans/staged-default-hours-change.md b/.sisyphus/plans/staged-default-hours-change.md
deleted file mode 100644
index 4825f5e..0000000
--- a/.sisyphus/plans/staged-default-hours-change.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# Staged Default Hours Change with Conflict Resolution
-
-## Overview
-
-When an admin edits default working hours, instead of applying changes immediately:
-1. Run conflict resolution (same pattern as holiday hours/time blockers)
-2. Stage the change with a future effective date (admin picks, default +2 weeks)
-3. Current hours continue to apply until the switch-over date
-4. At midnight on the effective date, the change auto-applies and triggers a notification
-5. Contact page shows "These opening hours will change from [date]"
-6. Available hours use current hours before the date, new hours after
-
----
-
-## What Changes
-
-### New DB Table: `default_hours_scheduled_changes`
-
-```sql
-CREATE TABLE default_hours_scheduled_changes (
- id SERIAL PRIMARY KEY,
- effective_date DATE NOT NULL, -- London midnight date to switch over
- created_by CHAR(12) NOT NULL REFERENCES users(id),
- created_at TIMESTAMPTZ DEFAULT NOW(),
- applied_at TIMESTAMPTZ, -- NULL until cron applies it
- cancelled_at TIMESTAMPTZ, -- NULL unless admin cancels
- hours JSONB NOT NULL -- [{weekday, startTime, endTime, isOpen}]
-);
-```
-
-Only ONE pending change is allowed at a time. If a pending change exists and the admin tries to create another, they must cancel the existing one first.
-
-### New `admin_notification_reason` enum value
-
-```sql
-ALTER TYPE admin_notification_reason ADD VALUE 'default_hours_changed';
-```
-
-Used by the cron job when it applies the change — creates a single notification for admin review.
-
----
-
-## Files to Create/Modify
-
-### Backend
-
-| # | File | Action |
-|---|---|---|
-| 1 | `init-scripts/init-script.sql` | Add new table and enum value |
-| 2 | `backend/handlers/scheduling/default-hours.go` | Add `ScheduleDefaultHoursChange`, `GetScheduledDefaultHoursChange`, `CancelScheduledDefaultHoursChange` handlers |
-| 3 | `backend/handlers/scheduling/default-hours.go` | Modify `GetDefaultHours` — return both current and pending future hours |
-| 4 | `backend/handlers/scheduling/default-hours.go` | Modify `computeAvailableHours` / `GetWorkingHours` — inject future hours for dates >= effective_date |
-| 5 | `backend/handlers/scheduling/scheduled-cleanup.go` | Add `ApplyScheduledDefaultHours` cron handler |
-| 6 | `backend/internal/jobs/cleanup.go` | Register `apply-default-hours` cron job (daily at 00:05) |
-| 7 | `backend/main.go` | Register new routes |
-
-### Frontend
-
-| # | File | Action |
-|---|---|---|
-| 8 | `frontend/src/lib/components/admin/WeeklySchedule.svelte` | Add conflict resolution UI + date picker + staged save flow |
-| 9 | `frontend/src/lib/components/layout/BusinessHours.svelte` | Show upcoming hours change with date |
-| 10 | `frontend/src/routes/admin/+page.svelte` | Wire modal props if needed |
-
----
-
-## Detail: Backend Design
-
-### Handler: `ScheduleDefaultHoursChange` (POST)
-
-`PUT /api/admin/default-hours` → replaced with a staging flow:
-
-```
-POST /api/admin/default-hours/schedule
-Content-Type: application/json
-
-{
- "hours": [{"weekday": 0, "startTime": "10:00", "endTime": "18:00", "isOpen": true}, ...],
- "effective_date": "2026-08-17" // optional, defaults to +14 days from today London
-}
-```
-
-**Flow:**
-1. Parse + validate input
-2. Check for existing pending change — return 409 if one exists
-3. Run conflict detection between current bookings and the proposed hours
-4. If conflicts exist → return 409 with `{error: "conflicts exist", bookings: [...]}` (same format as holiday hours)
-5. If no conflicts → store the pending change, return 201 with `{effective_date: "2026-08-17"}`
-
-### Handler: `GetScheduledDefaultHoursChange` (GET)
-
-```
-GET /api/admin/default-hours/scheduled
-```
-
-Returns the pending change or 404:
-```json
-{
- "effective_date": "2026-08-17",
- "hours": [...],
- "created_at": "...",
- "created_by": "..."
-}
-```
-
-### Handler: `CancelScheduledDefaultHoursChange` (DELETE)
-
-```
-DELETE /api/admin/default-hours/scheduled
-```
-
-Sets `cancelled_at` on the pending change. Returns 200.
-
-### Handler: `ApplyScheduledDefaultHours` (cron)
-
-Runs at "5 0 * * *" (00:05 daily — 5 minutes after midnight to avoid midnight race conditions).
-
-**Flow:**
-1. Query `default_hours_scheduled_changes` where `effective_date <= CURRENT_DATE` (London time) AND `applied_at IS NULL` AND `cancelled_at IS NULL`
-2. For each due change:
- a. BEGIN transaction
- b. DELETE all existing `working_hours` rows
- c. INSERT new rows from the change's hours JSON
- d. INSERT an `admin_notifications` with reason `default_hours_changed` (details: "Default hours changed from [old summary] to [new summary]")
- e. SET `applied_at = NOW()`
- f. COMMIT
-
-### Modified: `GetDefaultHours`
-
-Current: returns `SELECT weekday, start_time, end_time, is_open FROM working_hours ORDER BY weekday`
-
-New: if a pending change exists with `effective_date > today`, include an extra field:
-```json
-[
- {"weekday": 0, "startTime": "00:00", "endTime": "00:00", "isOpen": false, ...},
- ...
-]
-```
-Response changes to include optional `scheduled_change` field:
-```json
-{
- "current": [...],
- "scheduled_change": {
- "effective_date": "2026-08-17",
- "hours": [...]
- }
-}
-```
-
-This is backwards-compatible for existing consumers — they read the array from `current`.
-
-### Modified: `computeAvailableHours` / `GetWorkingHours`
-
-The hours resolution currently goes: proposed > exceptional > default > closed.
-
-For the staged hours feature, I need to inject the **scheduled future default hours** for dates >= effective_date:
-
-Priority order: proposed > exceptional > scheduled_future_default > default > closed
-
-In `computeAvailableHours` (the per-day loop), after looking up the `defaultMap` entry:
-
-```go
-// Before falling through to default/closed, check if there's a staged future change
-if scheduledChangeHours != nil && d.In(londonLocation).Format("2006-01-02") >= scheduledEffectiveDate {
- if fh, ok := scheduledChangeHours[weekday]; ok {
- baseStart = fh.StartTime
- baseEnd = fh.EndTime
- isOpen = fh.IsOpen
- day.Source = "scheduled_change"
- }
-} else if def, ok := defaultMap[weekday]; ok {
- // existing default logic...
-}
-```
-
-This means:
-- Today → current working hours apply
-- Between today and effective_date → current working hours apply (no change)
-- On/after effective_date → new scheduled hours apply
-- Exceptional hours always override (higher priority)
-
-For `GetWorkingHours`, same logic applies — it needs to return the correct hours for each date in the range.
-
----
-
-## Detail: Frontend Design
-
-### WeeklySchedule.svelte — "Edit Schedule" Modal
-
-The modal gets a new top section (same pattern as HolidayHours conflict resolution):
-
-**Step 1: Admin opens modal, edits hours**
-Same time picker interface as today. No change to the editing UX.
-
-**Step 2: Conflict resolution (new)**
-- A date picker for "Apply from" (defaults to +14 days from today in London)
-- A `checkConflictingBookings()` function that:
- - Takes the proposed hours + effective date range (from effective_date to effective_date + 90 days or so)
- - Calls `POST /api/admin/default-hours/schedule` with a dry-run flag or a dedicated conflict endpoint
- - Shows amber warning with conflicting bookings + "View Booking" / "View Client" buttons
-- Guard: submit button disabled while conflicts exist
-
-**Step 3: Submit**
-- Button text: `Schedule Change for [date]`
-- On success toast: `Default hours will change at 23:59 on 17/08/2026`
-- The modal closes, admin sees a "Pending change" indicator on the WeeklySchedule card
-
-**Step 4: Pending change indicator**
-- After a change is scheduled, the WeeklySchedule card shows:
- - An amber banner: "Default hours are scheduled to change on 17/08/2026"
- - A "Cancel" button that calls `DELETE /api/admin/default-hours/scheduled`
-- The `fetchDefaultHours` response now includes `scheduled_change` — display it
-
-### BusinessHours.svelte — Contact Page
-
-Add a new section below the current "Upcoming Holiday Hours" section:
-
-```svelte
-{#if scheduledChange}
-
-
- Opening hours will change from {formattedDate}
-
- {#each scheduledChange.hours as h}
-
- {dayNames[h.weekday]}
-
- {#if h.isOpen}
- {formatTime(h.startTime)} – {formatTime(h.endTime)}
- {:else}
- Closed
- {/if}
-
-
- {/each}
-{/if}
-```
-
-To load this, the `fetchData` function needs an additional API call:
-```
-GET /api/admin/default-hours/scheduled (public, or a new public endpoint)
-```
-
-Since this is shown on the public contact page, the endpoint should be under the public read-only group (OptionalAuth) — similar to how `GET /scheduling/working-hours` is public.
-
-### admin/+page.svelte
-
-If `WeeklySchedule` needs `openUserModal` / `openBookingModal` for conflict resolution, wire the same props. Currently `WeeklySchedule` does not accept these props, but the conflict resolution flow needs "View Client" and "View Booking" buttons.
-
----
-
-## Conflict Resolution vs Holiday Hours — Reusing the Pattern
-
-The conflict detection for default hours changes reuses the EXACT same pattern as the holiday hours conflict handler, but with a date range instead of week starts:
-
-```
-POST /api/admin/default-hours/conflicting-bookings
-{
- "proposed_hours": [{"weekday": 0, "startTime": "10:00", "endTime": "18:00", "isOpen": true}, ...],
- "start_date": "2026-08-17",
- "end_date": "2026-11-17" // default: +90 days from effective date
-}
-```
-
-This reuses the same `parseTimeToMinutes` comparison logic and the `ActiveBookingStatuses` filter. The response is the same `OverlappingBookingsResponse` format.
-
-**Note:** The availability-for-reschedule logic is simpler than holiday hours because:
-- Default hours changes apply PERMANENTLY (not per-week like holiday hours)
-- The "what hours apply for rescheduling" is just: current hours until effective_date, future hours after
-- No per-week mapping needed
-
----
-
-## Risks
-
-| Risk | Mitigation |
-|---|---|
-| Admin sets effective_date in the past | Validate: must be >= tomorrow (London date + 1) |
-| Cron miss at midnight doesn't apply change | Cron runs 00:05 to avoid midnight race. Query uses `<= CURRENT_DATE` so it catches any missed days |
-| Two admins try to schedule simultaneously | Unique constraint on `(applied_at IS NULL AND cancelled_at IS NULL)` — use a partial unique index |
-| Notification is an admin-only todo placeholder | Add `default_hours_changed` to the enum, create one notification. Future work: user notification |
-| Scheduling a change far in the future (6+ months) | Conflicts only checked against existing bookings in the window. Long-range changes may need re-checking when new bookings are made — acceptable for v1 |