Skip to content

Known Issues (QRS-###)

Ids are permanent · next free id: QRS-304

Every item on this page carries a unique QRS-###. Never renumber, never reuse, never reassign — ids are referenced from commits, ADRs, READMEs and code comments, so a renumber silently invalidates every one of those references. Numbering follows document order at the time of assignment (2026-07-26), which means ids are not chronological and are not a priority ranking. To add an item: take the next free id above, append your row, and bump that number in this banner.

An id is an identity, not a status. Confirmation state is carried by the section a row sits in (Candidate findings — … sections are observations not yet reproduced) and by the status marker inside the row — not by withholding an id.

Why these were unnumbered until 2026-07-26

This page originally staged findings as QRS-TBD, deferring ids to "the real tracker" — from when a separate system was expected to exist. That system never materialised: this file is the tracker (per CLAUDE.md). The placeholder was left in place long after it stopped being correct, so ~170 confirmed items — including shipped features and two remediated security incidents — had no id. CLAUDE.md's own rule to "reference the id in commits" was therefore impossible to follow, and cross-linking between the tracker, ADRs and commits was broken for the entire period. Ids assigned in bulk on 2026-07-26; nothing was re-scoped or re-worded in that pass.

🔴 Security incidents

IDCatSummary
QRS-001securityAnonymous PII exposure on public.profiles — live on Prod, remediated 2026-07-25. What: GRANT ALL ON TABLE public.profiles TO anon combined with CREATE POLICY "Public can view profiles by slug" … FOR SELECT USING (is_deleted = false AND slug IS NOT NULL) with no TO clause. An unqualified policy defaults to PUBLIC (every role, incl. anon), and RLS filters rows but never columns — so any holder of the publishable anon key (shipped inside every client build, not a secret) could run select email, mobile_number, gstin, pin_code from profiles where slug is not null against qr-setu-prod and exfiltrate every merchant's contact details and tax identifier. Under India's DPDP Act this is a personal-data breach, not merely a hardening gap. Detected: 2026-07-25, during an architectural review of the authentication plan — not by any automated control, which is itself a finding. Introduced: pre-dates the standards program; carried into the baseline squash (20260710134136) verbatim from production, so it existed on Prod for an unknown period. Blast radius (measured): of 45 SELECT policies lacking TO, 29 are saved by an auth.uid() predicate that is null for anon; 16 tables are genuinely anon-readable and 15 of those are intentionally public (published menus, bio/setu pages, templates, business domains, subscription tiers) — profiles was the only leak. Fix: migration 20260725173747_restrict_anon_profile_columns.sqlREVOKE ALL … FROM anon, then column-level GRANT SELECT (…) on a reviewed public allow-list (RLS cannot restrict columns, but column privileges can, and they reject a bad query before RLS is consulted); authenticated narrowed from ALL to SELECT, INSERT, UPDATE; the policy recreated with an explicit TO anon, authenticated. Chosen over dropping the policy because it closes the exposure with zero frontend breakage — verified that the only anonymous reader in the tree (legacy/…/useStatusBanner.js, select('id, business_hours')) touches only allow-listed columns. Prevention (two layers, because review demonstrably failed): static — tools/check-sql-grants.js (npm run check:sql, new sql-grants job in backend-ci) rejects any new GRANT ALL … TO anon or policy without TO, self-tested to prove it actually fires; runtime — supabase/tests/database/profiles_anon_exposure_test.sql asserts privileges via has_column_privilege() (a row-level test would pass while the table was wide open). Standard recorded in ADR-0014. ✅ REMEDIATED on both projects 2026-07-25 — Dev applied+verified, Prod applied+verified the same day (the runbook's same-day rule for security migrations). Measured impact on Prod (the breach scope): 10 of 11 profiles were exposed (is_deleted = false AND slug IS NOT NULL), all 10 carrying an email address and a mobile number; 0 carried a GSTIN. anon held 28 column privileges on the table including email, mobile_number and role — and not only SELECT but INSERT / UPDATE / REFERENCES (writes were refused by RLS, reads were not). After: 19 columns, all SELECT, allow-list only; 0 PII columns reachable; 0 policies applying to PUBLIC; 5 policies each naming an explicit audience; data untouched (11 profiles / 10 public cards). Verified by running the breach query itself as the anon role against Prod → ERROR: permission denied for table profiles, while the legitimate public-card and business_hours reads still return all 10 rows. ⚠️ Still open: (1) disclosure assessment — whether the exposure was actually exercised is a Postgres/PostgREST log question on Prod and the DPDP notification obligation follows from the answer; this is the last outstanding item on the incident itself (Prod's greenfield/no-traffic status, confirmed 2026-07-26, lowers its urgency but does not close it — do it before logs age out); (4) product decision — whether unpublished cards (is_published = false) should be publicly visible at all, since the row predicate keys only on slug IS NOT NULL (carried forward unchanged into the get_public_profile_by_slug RPC below, deliberately — see that row). Items (2) and (3) from the original list are done — see the next row.
QRS-002securityLeast-privilege closed schema-wide, plus the root cause and the get_public_profile_by_slug RPC (2026-07-26). Follow-up to the profiles incident above, scoped this time to every table. Measured on Dev before writing the fix: all 57 tables held GRANT ALL (all 7 privileges — SELECT/INSERT/UPDATE/DELETE/TRUNCATE/REFERENCES/TRIGGER) for both anon and authenticated; 39 tables carried a policy with no TO clause. RLS was enabled on all 57 (3 partitioned + 54 regular), which is what kept most of this from being exploitable — and is exactly one policy edit away from not being. Root cause, found by checking pg_default_acl rather than assuming one: ALTER DEFAULT PRIVILEGES ... IN SCHEMA public granted everything to anon/authenticated on every new table — so narrowing the 57 existing tables alone would not have stopped the 58th from reopening the same hole. Two things were genuinely exploitable, both closed: (1) digital_menu_item_favorites — policy "Anyone can manage favorites" FOR ALL USING (true) with no TO clause, combined with the blanket grant, let any anonymous caller read, insert, update and DELETE every row (worse in kind than the profiles leak, which was read-only); the table keys on a client-supplied visitor_id rather than auth.uid(), so it cannot be re-secured without a real ownership model — closed with no replacement policy (RLS now denies all non-service_role access) pending that model, which is R2 Digital Menu re-home work (ADR-0009), not this migration's. (2) vw_public_page_ops_cron_job_status — a view with no RLS to fall back on, granted ALL to anon, exposing internal cron/ops state; grant revoked. Fix: migration 20260726093952_least_privilege_anon_all_tables.sql — closes the default-privilege root cause; blanket-revokes then narrowly re-grants anon SELECT on the 15 tables that generally back public pages (Setu/bio pages, published menus, templates, tiers, domains, active forms — checked for the profiles failure mode and confirmed to carry no PII column, so table-level SELECT is correct here, not column-level); preserves the one legitimate anonymous write (digital_menu_qr_scan_analytics INSERT, for QR scan counting) while revoking the SELECT that would let a scanner read back another merchant's analytics; restates the 16 policies anon can now reach with an explicit TO clause. Deliberately deferred, not fixed here: 89 policies schema-wide still carry no TO clause — measured, and confirmed by hand that none is USING (true)/WITH CHECK (true) (the shape that actually caused both incidents), so none currently admits a row to anon; tracked as its own follow-up rather than rewritten wholesale inside a security migration, which would trade a real risk of typo-induced breakage for no additional protection today. get_public_profile_by_slug (migration 20260726095831) ships alongside it — the SECURITY DEFINER RPC the incident fix always intended as the end state, projecting an explicit column allow-list (never SELECT */row_to_json) so the security boundary is legible in a diff, not hidden in a GRANT catalog. Its row predicate is copied verbatim from the existing policy (is_deleted = false AND slug IS NOT NULL, no is_published check) — deliberately not resolving the open "should unpublished cards be public" question inside an RPC migration. It does not yet replace the column grant; that is gated on legacy/.../useStatusBanner.js (and any other direct anon reader) switching over first. Verified: 59/59 pgTAP assertions (anon_least_privilege_test.sql + get_public_profile_by_slug_test.sql, alongside the existing profiles_anon_exposure_test.sql — which this migration's first draft broke, by blanket-revoking profiles's column grant along with everything else, and which the test suite caught before promotion); direct attack proof against the local stack via PostgREST as anon — favorites read/delete, the ops view, and a closed table's INSERT all return permission denied, while templates SELECT, scan-analytics INSERT, and the new RPC all succeed, and profiles.email is still denied. check:sql passes (3 migrations scanned). ✅ REMEDIATED on both projects 2026-07-26 — Dev applied+verified, Prod applied+verified the same day (Prod confirmed greenfield/no live traffic; same-day promotion taken per the runbook's security-migration exception, same as the 2026-07-25 fix). Verified on Prod directly, not inferred from Dev: table-level privilege catalog matches exactly (16 rows — the 15 SELECT-only + 1 INSERT-only), zero anon entries in the postgres-owned default ACL, digital_menu_item_favorites has zero policies, the ops view has zero anon privileges, the profiles column grant is intact at 19 columns with the 4 PII columns still unreachable, and the RPC is executable and resolves real merchant data (hotel-krushna). Live attack proof as the anon role against Prod: reading all favorites, deleting all favorites, reading the ops view, and re-running the original profiles breach query (select email, mobile_number, gstin ... where slug is not null) all return permission denied — while legitimate reads (templates, the new RPC) still succeed. Data untouched: 11 profiles / 10 public cards, identical to the 2026-07-25 measurement.
QRS-003infraProd migration history is unreconciled — blocks every db push to Prod (found 2026-07-25 while promoting the security fix). supabase_migrations.schema_migrations on qr-setu-prod holds 5 versions with no local file: 20260119073613, 20260120060657, 20260122102143, 20260128102737, 20260129145252. These are not the 3 files in _archive_pre_baseline/ (different timestamps entirely) — they are CLI-tracked migrations from Jan 2026 whose SQL never existed in this repo. db push refuses with "Remote migration versions not found in local migrations directory". Why it surfaced only now: the 2026-07-10 baseline was db dump-ed from Prod, never pushed to it, so the security migration 20260725173747 is the first-ever db push against Prod and the first to hit the guard. Dev is unaffected (bootstrapped clean; the fix applied and verified there). Resolution: supabase migration repair --status reverted <the 5 versions> --db-url <prod> — correct rather than a hack, because (a) the files do not exist and never will, (b) 20260710134136_baseline_schema_from_prod.sql is a dump of Prod's live schema and therefore already contains whatever those migrations built, and (c) with no files carrying those versions there is nothing that could ever re-apply. After repair, local history becomes the single source of truth (baseline + everything after). ✅ RESOLVED 2026-07-25 — repair executed against Prod, then db push applied 20260725173747 cleanly. Prod's history is now baseline + everything after, so subsequent promotions work through the normal CLI path. Recorded in PROMOTION_RUNBOOK.md.
QRS-179riskA pgAdmin server registration named "Dev" points at PROD (observed 2026-07-26, USER-OWNED). Seen in a screenshot: connection labelled Dev with host aws-1-ap-southeast-2.pooler.supabase.com (Sydney) and username postgres.ygmqxyrbnemhwkiyobocboth identify qr-setu-prod. Real Dev is a different project entirely (dyhjofjjuazhyqcvlrkx, Mumbai, aws-1-ap-south-1.pooler.supabase.com). Anyone opening "Dev" to run a quick DROP, an UPDATE without a WHERE, or a schema experiment is doing it on production. Prod's greenfield/no-traffic status limits today's blast radius; that is temporary and is not a control. Recommended: rename to PROD ⚠, register the real Dev separately, and set distinct background colours per server (pgAdmin → General → Background/Foreground; red for Prod) — colour is the cue that actually registers at 11pm, a name is read straight past. Also worth checking whether a second entry named "Prod" targets the same project, which would mean two names for production and none for Dev. Left to the user deliberately (local pgAdmin config, not repo state). Cross-ref QRS-003 — Prod is already the environment where promotion mistakes surface first.
QRS-180debtThe tracker had no ids at all until 2026-07-26 — ~170 confirmed items were QRS-TBD (fixed). The page was authored as a staging area that deferred ids to "the real tracker", a separate system that never materialised — CLAUDE.md designates this file as the tracker. The placeholder outlived its rationale by months, so shipped features, resolved ADR decisions and two remediated security incidents all carried no identifier. Consequences while it lasted: CLAUDE.md's own rule to "reference the id in commits" was impossible to follow, so no commit in the repo cites a tracker row; ADRs and screen-reviews could only link to the tracker page as a whole, and 11 downstream docs carried dangling "tracked as QRS-TBD" pointers that named no row. Fixed by assigning QRS-001..QRS-173 in document order (nothing re-scoped or re-worded in that pass), adding a next free id allocator banner with the never-renumber rule, mapping all 11 stale cross-references to their actual rows, and rewriting dev-tracker/index.md to stop describing the staging-area model. Ids are identity, not status — candidate findings keep their id when promoted. Raised by the user, not by any control.

Frontend rebuild — foundation decisions (Track B)

IDCatSummary
QRS-004projectTier-first monorepo layout adopted (apps/{app}/src/tiers/{tier}/features/{name}), amends ADR-0012 §A1. Full skeleton + README-everywhere scaffolded for apps/mobile (user active, admin placeholder) and apps/web (landing/public/admin; no user — merchant web = mobile RNW export). tools/check-readmes.js extended to gate the tree.
QRS-005projectMerchant = full R1 installable SW-PWA (Android + iOS) + native Android + unrestricted RNW web — upgrades ADR-0011 from "iOS PWA". Screens built RNW-web-safe now; the PWA shell (manifest + Workbox SW + install UX + web deploy + Playwright web E2E) is a committed R1 follow-on PR.
QRS-006projectDS token set is the SSOT — full light+dark semantic palette + all families (color/radius/space/type/motion/elevation/z/sizing) ported into @qrsetu/tokens from the DS handoff; radius reconciled to DS px scale (reversible).
QRS-007projectWelcomeStory onboarding shipped (first merchant screen) — 6-scene auto-advancing tri-lingual (en/mr/hi) story; global theme+locale (Zustand); analytics funnel seam (ADR-0010); RNW-web-safe. Verified on-device (light+dark), 11 unit tests, Maestro smoke green. Branded app icon/splash added (rasterized from App_icon_svg.svg).
QRS-008projectOnboarding polish from on-device review — (1) new BrandSplash screen: held ~1.9s branded intro (native splash hides too fast + can't show attribution) with centered mark + By / Digious Platforms Pvt. Ltd.; (2) final-scene ring now pops in clockwise (RadialHub staggered Entrance), matching the prototype's obPop stagger; (3) fixed Hindi brand tagline "हर Business की, Digital पहचान!" under the tricolor (AppText script="deva"); (4) layout rebalanced — more CTA bottom breathing room, bilingual secondary suppressed on the final scene to avoid text overload. Tagline + attribution are fixed brand constants (not localized). Type-check + 11 tests green.
QRS-009projectApp-size budget + proactive size-callout rule — arm64 APK ~30–45 MB / Play AAB ~20–30 MB baseline (native runtime, not screens). New standard in CLAUDE.md "App-size budget": surface size delta + alternative before adding any weighty lib/asset/font. See [[app-size-and-versioning-policy]].
QRS-010projectVersioning policy — CI must own the bump. app.json is SSOT (version→versionName semver; android.versionCode monotonic int), synced by expo prebuild; local builds do NOT auto-increment. Action: add eas.json autoIncrement or a CI versionCode=build-number step on the release branch (no eas.json today).
QRS-011infraWindows local release build OOMs. 16 GB RAM box: building all 4 ABIs concurrently OR ThinLTO (reanimated) with default ninja -j exhausts the commit limit → clang … failed due to signal / paging file too small. Mitigation (documented in mobile README): build -PreactNativeArchitectures=arm64-v8a --max-workers=2 + free the emulator/daemons first. Durable fix TBD: larger Windows page file (needs admin) and/or CI-only release builds.
QRS-012infraProcess breach — TEMP/TMP never relocated off C: (root-caused + fixed 2026-07-24). The D:-relocation covered GRADLE_USER_HOME/npm/Playwright/ANDROID_HOME/repo but omitted Windows TEMP/TMP, which stayed at C:\Users\bnlah\AppData\Local\Temp since setup — so every build's Metro/Hermes/aapt2/clang scratch (multi-GB) wrote to C:, filling it (93%, 10 GB free) and starving the C:-only, system-managed page file → commit limit pinned ~33.5 GB → release-bundle node/V8 OOM (0xC0000409/0x80000003). Not a revert — a gap in the original relocation vs. the guide's "keep caches off C:" intent. Fix implemented: setx TEMP/TMP D:\DevCache\temp; added METRO_MAX_WORKERS knob to metro.config.js; documented in windows-build-environment.md (env table + "Memory / commit limit" section + troubleshooting rows) + qrsetu-dev-relocate.ps1 must set them. Also fixed a diagnostic hazard: piping gradlew to tail masked the failing exit code as success. Permanent structural fix (2026-07-24, round 2): the setx fix is necessary but not sufficient — a shell (or agent) opened before it still inherits TEMP/TMP=C:. Added a guarded build script apps/mobile/scripts/build-android.mjs (npm run build:android) that owns the env instead of trusting the shell — forces TEMP/TMP/GRADLE_USER_HOME onto a non-system drive regardless of what was inherited, refuses to build otherwise, caps METRO_MAX_WORKERS/CMAKE_BUILD_PARALLEL_LEVEL, auto-cleans obsolete APKs + >24 h scratch before/after, and verifies the APK is fresh. This is now the only sanctioned local build path (guide "Guarded build script" section). expo-build-properties evaluated + rejected (can't set ABIs; no OOM benefit). Durable TBD (needs admin+reboot): move/add the page file to D: (35 GB free) so commit isn't hostage to C: free space; or EAS/CI builds. See [[mobile-dev-loop-and-machine-setup]], [[app-size-and-versioning-policy]].
QRS-013bugCI lint gate was a green no-op (FIXED). Root lint was npm run lint --workspaces --if-present, but 0/12 workspaces defined a lint script → it lint nothing, yet ci.yml ran it and passed — a false-positive gate manufacturing confidence. @qrsetu/eslint-config was an empty skeleton; apps/mobile had eslint installed but never invoked; the real guardrails were stranded in legacy/eslint.config.mjs (not a workspace). Discovered when a device-feedback change claimed "no lint script, relies on type-check".
QRS-014projectLint + format standardized — mandatory, whole-tree, warnings=errors (DONE). Decided (with product): centralized root eslint . --max-warnings=0 (coverage by default — silent-skip impossible) over per-workspace fan-out; rules as composable layers in @qrsetu/eslint-config (base/reactWeb/reactNative/node/guardrails + prettierCompat); Prettier as a separate format:check gate; husky v9 + lint-staged pre-commit (staged-only); ci.yml now runs real lint + format:check as required checks. Active guardrails: no hard-coded colors (apps/**), tier boundaries (mobile user⇎admin), package purity (packages/tooling ⇏ app @/). Fixed the 34 real violations it surfaced (hoisted render-created components, hook-in-callback in BuildCard, documented illustration-fill color exceptions). Standard in CLAUDE.md "Lint & format".
QRS-015debt✅ fixed 2026-07-29
QRS-016projectCross-platform feature parity is now a non-negotiable standard (codified in CLAUDE.md). Any feature applicable to all supported surfaces of the universal merchant app — Android native · iOS PWA · Web PWA (desktop + mobile) — must ship on all of them in the same release; parity is a release requirement, not a follow-up. Per-platform exceptions must be documented + justified + reviewed + approved before implementation (a QRS-### + ADR link if architectural), never discovered post-release. Every feature carries a parity verification checklist in its Definition of Done, and every README/impl-note records its parity status. Strengthens ADR-0011.
QRS-017debtWelcomeStory parity verification incomplete. Per the new parity standard: Android native ✅ (on-device); Web PWA and iOS PWA verification are still pending (headless/RNW-web-safe only so far). No divergence seams (pure presentation), no approved exception. Action: verify via expo export -p web on desktop + mobile browsers and as an installed iOS PWA before the onboarding feature is parity-complete for release.
QRS-018debtGuardrail follow-ups. (a) The no-hard-coded-color rule is hex-only — rgb()/rgba()/hsl() literals (e.g. rgba(255,255,255,0.92) in scene art) slip through; widen the selector when a themeable case appears. (b) Activate the deferred guardrails WITH their target code: no supabase.from() in app code (packages/data), no inline EF name strings (service layer + EDGE_FN), full schemas→domain→data DAG. (c) Add eslint-plugin-sonarjs + eslint-plugin-security (still planned).
QRS-019projectSentry error/crash reporting integrated across all surfaces (DONE — closes the former top deferred observability risk). Decided (with product): free-plan only, integrate before the dashboard slice so every screen is instrumented from birth. Architecture: @qrsetu/observability is now a pure-leaf contract + pluggable per-surface sink (mirrors @qrsetu/analytics) — the Sentry SDK never enters the shared graph. Mobile @sentry/react-native (apps/mobile/src/lib/observability.ts + root _layout.tsx Sentry.wrap, @sentry/react-native/expo plugin) covers Android native + iOS PWA + Web PWA (RNW) in one wiring; EF @sentry/deno in _shared/observability.ts hooked into err() (5xx only, fire-and-forget). No-op until a DSN is set (EXPO_PUBLIC_SENTRY_DSN/SENTRY_DSN); PII scrubbed (sendDefaultPii:false + scrubPii); free-plan safe (tracing off, no Replay). App-size (approved): +~1–2 MB arm64 native / ~50–90 KB JS on the ~30–45 MB baseline — native crash/ANR/OOM capture. 11 new tests (35 suites/149 green). Full doc: Sentry integration; standard in CLAUDE.md "Observability".
QRS-020projectMerchant app shell + Settings screen shipped (P1). Bottom-tab shell (app/(user)/(tabs)/_layout.tsx: Home · Create · Profile · Settings — minimal IA now; Home is the lean first-run placeholder, Create/Profile are thin coming-soon states until their phases). Settings (tiers/user/features/settings, first real tab): General (currency/language/appearance/show_ads — all auto-save, optimistic via TanStack Query over the stubbed SettingsService; language also drives the live locale store, appearance the theme store), Security (change email/password via AccountService, Zod-validated at the boundary), Danger Zone (delete account, confirm-guarded via new @/ui ConfirmSheet). New primitives: ConfirmSheet + Button danger tone (tested). i18n nav.* + settings.* added in en/mr/hi. 24 new tests (40 suites/173 green); type-check/lint/format/readmes clean; web export renders all routes. Follows profile-settings-onboarding-spec.md Part 3.
QRS-021projectLean first-run dashboard home shipped (P3). The Home tab (tiers/user/features/dashboard, route /dashboard) over the stubbed DashboardService: greeting · Setu Card summary (branded status + public URL; View/Share arrive with the Setu Card feature, shown "coming soon") · progressive profile-completion nudge (ProgressBar, shown while < 100%, routes to /profile) · entitlement-gated tool launchers (QR/Website/Feedback; only available tiles route — ADR-0006/0007, never hardcoded). New @/ui ProgressBar. i18n home.* (en/mr/hi). No digital-menu stats (R2). 14 new tests (48 suites/206 green); type-check/lint/format/readmes clean; web export renders /dashboard.
QRS-022projectProfile screen shipped (P2). Tabbed profile editor (tiers/user/features/profile) over the stubbed ProfileService + getBusinessDomains. Header (avatar + identity + tier); business audience → Basic / Business / Hours / Social, individual → Basic / Details(location-only) / Social (audience derived from brand_name/business_domain_id, mirroring the onboarding fork). Single-draft edit committed by Save changes (validated with profileUpdateSchema; render-phase draft seeding keyed by id so cache write-backs never clobber edits). Business Hours tab = per-weekday open/close/closed + planned-holidays add/remove; Social tab = 8 platform inputs. New @/ui Avatar (initials fallback + edit affordance). Exact public.profiles column names, no invented fields (ADR-0001). i18n profile.* (en/mr/hi). 19 new tests (44 suites/192 green); type-check/lint/format/readmes clean; web export renders /profile. Follows profile-settings-onboarding-spec.md Part 2.
QRS-023debtProfile real-wiring + native-module follow-ups. (a) Runtime parity: Web (RNW) verified ✅; Android-native + iOS-PWA pending (next device build). (b) Avatar image pickerexpo-image-picker is a native module deferred by the app-size policy; the change action currently exercises uploadAvatar with a stub payload. Decide + size-callout before adding. (c) Time/date fields are plain HH:MM / YYYY-MM-DD text (no native pickers yet). (d) Backend wiring (P5): swap the ProfileService stub for get_profile RPC + manage-profile EF — including the allow-list fix (it silently drops slug/gstin/hours/social today) so profile writes actually persist.
QRS-024debtSettings parity + real-wiring follow-ups. (a) Runtime parity: Web (RNW) verified ✅; Android-native + iOS-PWA pending (next device build). (b) Backend wiring (P5 delta PR): swap the SettingsService/AccountService stubs for manage-settings/manage-account EFs — incl. the manage-settings language allow-list widening and confirming default_currency/show_ads are written only here (not manage-profile). (c) Post-delete sign-out + route wires with real auth. (d) Create/Profile tabs are placeholders → real screens in P2/P3.
QRS-025debtSentry parity + source-map + web-app enablement follow-ups. (a) Runtime parity verification pending — Web (RNW) export bundles clean ✅; Android-native + iOS-PWA runtime capture verification lands with the next device build (no approved exception; feature-flagged off until DSN set). (b) DOM web-app sink (@sentry/react) bootstraps when apps/web is scaffolded (adapter contract already defined). (c) Source-map upload — wire the Metro-serializer wrapper + Hermes source-map upload + SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT CI secrets with the first release build. (d) Promote SENTRY_DSN to both Supabase projects (Dev→Prod) per the promotion runbook when provisioned.
QRS-026bugFirst-run gating was missing entirely — the welcome story replayed on every launch and Sign In ran the full onboarding wizard (Round-2 review, 2026-07-25). Root cause: app/index.tsx unconditionally redirected to /onboarding/welcome, and the AuthStep sign-up/log-in toggle was cosmetic — both paths called onNext() into the wizard. There was no session state of any kind. Fixed by adding the seam that was absent: a typed AuthService in @qrsetu/data (OTP send/verify moved off OnboardingService, signOut moved off AccountService — identity ≠ setup ≠ account-security), returning AuthSession { email, onboardingCompleted } so the server owns the "needs setup?" verdict, never the tapped tab; a persisted sessionStore (hasSeenWelcome · accountType · email · onboardingCompleted · stub-era knownAccounts); a pure resolveEntryRoute four-way gate (console · resume setup · story · sign-in) held behind BrandSplash until rehydration; a new auth feature with one shared AuthScreen rendered by both /sign-in and the wizard's first step; stepsFor(type, authed) dropping the auth step on resume; and an "Already registered? Sign in" link on the fork (a returning merchant on a new device correctly sees the story and must be able to bypass it). Logout keeps hasSeenWelcome by design → lands on /sign-in. 29 new tests (59 suites/257 green); lint/format/type-check/readmes clean; /sign-in exports and serves on the RNW web preview.
QRS-027bugexpo export -p web crashed with ReferenceError: window is not defined — found while re-exporting after the session work. The static export prerenders on Node, where AsyncStorage's localStorage backing has no window; any store write in that pass throws. sessionStore is the first store that writes during rehydration (flagging itself hydrated), so it exposed a latent trap all three stores shared. Fixed with a shared SSR-safe stores/storage.ts adapter (real AsyncStorage wherever window exists — RN and browsers both — inert in-memory only during prerender), now used by theme/locale/session.
QRS-028bugProfile "Save changes" silently discarded cleared fields while reporting success. buildPatch omits empty values, so emptying a previously-saved field produced a patch without that key — the save then no-op'd on it and still showed the success toast. Surfaced by a new test written for the Round-2 "buttons enabled on empty forms" item. Fixed by gating the CTA on dirty and schema-valid and identity-complete and not-blanked. Remaining debt: genuinely clearing a profile field still isn't expressible — it needs a null-capable patch contract on ProfileService.
QRS-029bugForm CTAs were enabled for input that could only be rejected (Round-2 review). Settings › Security gated on !email / !next — i.e. "has characters", not "is valid". Fixed: both now gate on emailSchema / passwordSchema passing. Audited the rest: the onboarding steps were already correctly gated; Profile Save was not (row above).

Transitional debt (from CLAUDE.md, known)

IDCatSummary
QRS-030debt~56 files still call supabase.from() in src/ — migrate feature-by-feature to RPC/EF.
QRS-031debtTwo caching layers coexist (TanStack Query + src/lib/cacheUtils.js) — converge on TanStack Query, retire cacheUtils.js.
QRS-032debtTwo "shared" locationssrc/shared/ vs legacy top-level src/{components,contexts,...}. Prefer src/shared/.
QRS-033debtEdge Functions historically in two camps; some violated the @supabase/supabase-js version pin — unify onto the _shared kit + pin 2.30.0.
QRS-034riskpg_cron job not on Dev — hardcodes Prod's EF URL; needs a project-URL-aware rewrite.
QRS-035riskprofile-pictures bucket not on Dev — environment-specific, capture as migration.
QRS-036riskObservability is the top deferred risk — structured logs only today; error-tracking + metrics + alerting/on-call deferred. Don't let it drift.

Candidate findings — surfaced building the portal

QRS-037 · reminders hook violates the data-access rule

File: src/tiers/user/features/reminders/hooks/useReminders.js

Multiple [ENFORCED]-rule violations in one file:

  1. supabase.functions.invoke called from a hookinvoke belongs in services/, not hooks.
  2. Inline EF-name strings 'get-dashboard-data', 'manage-reminders' — must come from an EDGE_FN map (UPPER_SNAKE_CASE).
  3. Neither function exists under supabase/functions/ — the deployed set is 10 functions (see EF index); these two are absent. Calls will fail unless the functions exist only in a Supabase project and were never committed. Confirm before touching.
  4. A read done via invoke (get-dashboard-data, POST) — a pure read should be a get_* RPC through useQuery, not an Edge Function.
  5. Raw useState/useEffect data fetching instead of TanStack Query.

Remediation: move writes to remindersService.ts (EF via EDGE_FN map), convert the read to a get_reminders_summary RPC via useQuery, and reconcile the two missing function names (create the EFs, or repoint to what actually exists). Log a real QRS-### first.

QRS-038 · Digital Menu data-access retrofit (largest from() cluster)

Feature: digital-menu (both tiers). See the feature status page.

Functionally mature (74 tests / 14 files pass) but predates the data-access rule:

  1. ~52 supabase.from() calls, zero RPC/EF in the dashboard half — the bulk of the platform-wide from() debt. Migrate reads → get_* RPCs (TanStack Query), writes → an Edge Function behind an EDGE_FN map.
  2. Hardcoded production URLs in the public menu (useCategories.js, usePublicMenuItems.js, PublicMenuPage.jsx) — raw fetch() to api.qrsetu.com / www.qrsetu.com fallback chain instead of supabase.functions.invoke('get-public-menu'). Violates the "no hardcoded prod URL" client rule.
  3. Public status banner reads base tables (useStatusBanner.js.from('profiles'|'digital_menus'|'digital_menu_settings')) from the unauthenticated tier — route through get-public-menu / a public RPC.
  4. Services + hooks still .js — convert to .ts (types/schemas already exist).

QRS-039 · confirm the reminders feature backend exists

The reminders and reminder_categories tables exist in the baseline schema, but no manage-reminders / get-dashboard-data Edge Function is committed. Determine whether the backend was ever built, lives only in Prod, or the feature is dead code.

Candidate findings — Claude Designs prototype review (2026-07-19)

Surfaced reviewing the Claude Designs prototype project against documentation/open-questions-from-claude-designs/templating.md. Full findings, evidence, and (where applicable) copy-paste Claude Designs prompts live in the portal's Screen Reviews section. These are architecture-gated: they need a decision during core architecture planning, not a design-only fix, so they are logged here rather than sent to Claude Designs as a prompt.

IDCatSummary
QRS-040riskTemplates block library has no ad-slot concept; the ad system's servicecard.footer slot is defined but not consumed anywhereAdManager.dc.html lets an admin target a servicecard.footer placement, but ServiceCard.dc.html (confirmed read directly) never calls getPromo/QRPlatform. An orphaned slot: supply-side exists, demand-side doesn't. See admin-panel review and ADR-0004.
QRS-041riskSelf-serve external advertiser portal is UI scaffolding only, no real auth/billing designAdManager.dc.html's "Self-serve advertiser portal" section exists, but its own code marks the invite action (demo). A self-serve ad marketplace implies a third external-facing product surface (advertiser auth, ad-spend billing) with no architecture behind it yet. Needs explicit product sign-off before deepening, same as the dashboard-ad-serving question below. See ADR-0004.
QRS-042riskAdvertiser-uploaded creative content has no sanitization/validation modelAdManager.dc.html's Creative library and 6-step campaign builder (Creative/Placement/Targeting/Budget/Schedule/Review) show zero sanitization/format-validation terms on a full-file grep, the same gap class as the Custom HTML finding below but from a less-trusted external party (an approval gate exists — "ops team reviews and approves" — but the creative assets themselves aren't confirmed validated). See ADR-0004.
QRS-043debtNo Organization-above-Workspace entity — Subscriptions' Custom Deals tab has per-seat pricing and org/workspace scoping but no seat/department/multi-location data model for franchise, hospital, school, or family/group plans. See Subscriptions review and ADR-0001.
QRS-044riskIn-house billing engine designed instead of a payment-provider integration — ✅ decided 2026-07-20 (ADR-0002 accepted): Razorpay is the PSP/system-of-record for money; Supabase holds entitlement state, synced by a Type-B billing-webhook EF (HMAC-SHA256 raw-body + x-razorpay-event-id idempotency). Subscriptions' proration/dunning/refund UI becomes display-of-PSP-state. Implementation pending.
QRS-045debtClaude Designs' actual artifact structure diverges from design-to-code-workflow.md — no tokens.json (CSS files instead), no /components gallery, device-split folders instead of .mobile.dc.html/.desktop.dc.html pairs, SCREENS.md instead of a machine-readable manifest.json with a status field, no React Native artifacts. This is the root cause of the mobile/desktop merchant-console divergence below, and blocks reliable automated revalidation.
QRS-046riskMobile and desktop merchant consoles are two divergent designs, not one responsive design — desktop derives nav from manifest.computeNav(workspace) with a real multi-workspace switcher; mobile hardcodes a single workspace and a fixed 4-tab bar with no call to the manifest at all.
QRS-047riskAdmin screens (Templates, Subscriptions, AdManager) have effectively zero keyboard/ARIA support — confirmed by direct grep across three 74-155 KB screens; AdManager additionally has 4 native <select> elements, violating the project's own dsSelect-only rule.
QRS-048riskAd-serving inside a paying merchant's own dashboard needs explicit product sign-off — confirmed hardcoded "Sponsored" banner in mobile-console/Home.dc.html. A trust/strategy decision, not a design detail.
QRS-049riskClient trusts self-writable user_metadata.role for admin gatingsrc/contexts/SupabaseAuthContext.jsx resolves the role as DB → user_metadata.roleapp_metadata.role'user'. user_metadata is settable by the end user via supabase.auth.updateUser, so a non-admin can make the client render/mount the entire admin tier (information disclosure + broken UX). Bounded — every admin EF calls DB-only requireAdmin, so actions still 403 — but the client/server disagree on who is an admin. Fix: trust only DB/app_metadata, never user_metadata. See ADR-0006.
QRS-050debtRoute guards read profiles via from() directlySupabaseAuthContext.jsx (role) and ProtectedRoute.jsx (onboarding_completed) both call supabase.from('profiles'), violating the [ENFORCED] no-from()-in-src/ rule and costing two round-trips. Replace with a single get_my_auth_context RPC. See ADR-0006.
QRS-051debtAuthorization is a single flat profiles.role; the prototype's tenant ROLE_RANK is unbackeduser/admin/super_admin (platform axis) is real; CONTRACTS.md's viewer<staff<accountant<manager<owner (tenant axis) has no workspace_members table. Formalize the platform axis + capabilities-as-code now; sequence the tenant axis with ADR-0001's Workspace phase. See ADR-0006.
QRS-052debtNo affiliate/referral/growth-rewards program capability exists — the real schema has zero affiliate/referral/commission tables; the prototype has only one hardcoded peer-referral promo row in Subscriptions.dc.html ("₹200 credit each side") and an unbuilt loyalty nav-module stub in manifest.js with no screen in SCREENS.md (and that stub is gated plan:'pro', which is wrong for a free-to-paid referral upgrade lever). The original Nov-2025 vision docs explicitly wanted a broader affiliate/agency/creator ecosystem that was never carried forward. See ADR-0005 (revised to cover viral/gamified growth mechanics, not just cash commission) and the Affiliate Program spec. Update 2026-07-19: Claude Designs built all three surfaces; reviewed against spec + ADR — core compliant (manifest gate corrected: invite ungated/appliesTo:'both', loyalty kept plan:'pro'; cash/non-cash cleanly separated; dsSelect-only, 0 native selects). The three design-fixable nits from the first review (tier/hero hex, an em-dash, missing Tier-1 pending state) were all fixed and revalidated 🟢 the same day. Design work is complete; remaining scope for this row is the real backend (schema/EFs/RPCs per ADR-0005), still pending. See affiliates review.
QRS-053debtDesign system has no metallic/tier color tokens — ✅ resolved 2026-07-19. colors.css now defines --tier-bronze/silver/gold (+ -soft) and --partner-hero-from/to in both light and dark themes (--tier-gold maps to --accent); all affiliate screens (mobile + admin) reference them via var(--token, #hex), desktop modules.jsx carries 0 raw flagged hex. Surfaced + closed via the affiliates review.

QRS-054 · Custom HTML / dynamic widget blocks have no sanitization or sandboxing model — ✅ fully resolved 2026-07-19

File: prototype/admin-panel/Templates.dc.html (BLOCKLIB, type:'custom' and the former type:'dynamic').

A full-file grep for sanitiz, sandbox, iframe, dompurify, xss, csp returned zero matches. Fixed across two revalidated passes 2026-07-19. type:'dynamic' was replaced with named, schema-driven widget types (widget-calculator, widget-booking, widget-map, widget-leadform, widget-payment all defined in a real WIDGETSCHEMA). Custom HTML now discloses its sanitization policy in the UI itself ("Renders in a restricted, sanitized context. Scripts, event-handler attributes, iframe, object/embed, and external stylesheets are stripped before render.") and gates behind an independent pending_security_review → approved / rejected state machine, separate from the template's own draft → review → published lifecycle, exactly as asked.

The fix's own two regressions (6 hardcoded hex colors, 2 new em-dash prose violations) were caught in the first revalidation, a follow-up prompt was sent, and a second revalidation confirmed both are now fixed: the hex colors all sit behind var(--token, #hex) fallbacks and the two sentences were rewritten without em dashes.

See the full write-up and both revalidation passes in Templates review. This finding is closed; Findings 2-4 on the same screen (AI-generation stub disclosure, no Claude-Designs import path, ad-slot concept) remain open.

Candidate findings — Profile / Settings / Onboarding sweep (2026-07-19)

Surfaced during the RBAC/identity codebase review (ADR-0006 / ADR-0001), reading the real profiles table, manage-profile / manage-settings Edge Functions, and the onboarding + profile + settings feature code. The first four are silent data-loss bugs: the UI collects a value and reports success, but the Edge Function's server-side allow-list drops it, so it is never persisted. Confirm each against a live write before promoting.

IDCatSummary
QRS-055bugOnboarding slug is silently dropped — Step 5 collects a unique URL slug (validated via validate-user-input), but manage-profile's buildProfileUpsertRow (helpers.ts:47-65) allow-list does not include slug, so the chosen public URL is never saved. profiles.slug exists.
QRS-056bugOnboarding onboarding_completed not set by the profile upsert — the client sends onboarding_completed:true on submit, but it is not in buildProfileUpsertRow's allow-list, so this write doesn't flip it. Completion appears to rely on another mechanism/celebration flow — confirm the flag actually persists, or a user could be looped back into onboarding.
QRS-057bugProfile gstin edits are silently dropped — the Business Details tab edits GSTIN (validated), but gstin is not in buildProfileUpsertRow, so it is never written. profiles.gstin (with a 15-char CHECK) exists.
QRS-058bugSettings language changes are silently rejected — General Settings offers en/mr/hi, but manage-settings's ALLOWED_SETTINGS_FIELDS (helpers.ts:1) is only ['default_currency','show_ads'], so language lands in rejectedFields and never persists, though profiles.language (CHECK en/mr/hi) exists.
QRS-059debtshow_ads is allow-listed + returned by manage-settings GET but has no UI toggle — a settable field with no control; decide whether users may toggle ads (interacts with ADR-0004) or remove it from the settings surface.
QRS-060debtProfile/Settings hooks bypass the data-access ruleuseBusinessHours, useSocialMediaLinks, usePlannedHolidaysOOO, and OnboardingGuard read/write profiles via direct supabase.from('profiles') (jsonb columns business_hours, social_media_links, planned_holidays_ooo, and onboarding_completed). Route through manage-profile / a get_my_auth_context RPC per the [ENFORCED] rule (part of the ~56-file from() debt, but concentrated here).
QRS-061debtOnboarding has no business-vs-individual fork — every user must supply full_name and brand_name and an industry (business_domain_id), contradicting the locked dual-audience decision that individuals use generic features with no business profile. Reconcile per ADR-0001's D3 naming decision. See the Profile/Settings/Onboarding brief.
QRS-062debtprofiles.permissions jsonb and is_demo_account are unwired — both exist in the baseline but are read nowhere; ADR-0006 (D1/D2) adopts them as the capability store and the ops/demo mechanism respectively. Tracked so they aren't "discovered" again as dead columns.

Candidate findings — Freemium entitlements / subscription gating (2026-07-20)

Surfaced assessing whether every user-facing feature can be enabled/disabled per subscription tier via the admin Subscription Plans (the freemium mandate). Full analysis + the chosen model in ADR-0007. The schema is present (even over-built); the enforcement layer is absent and the intended bridge is broken.

IDCatSummary
QRS-063bugThe three entitlement RPCs are brokenget_user_subscription, user_has_feature_access, user_has_exceeded_limit all query a nonexistent subscriptions table filtered on s.status='active'; the real table is user_subscriptions with is_active (no status). user_has_exceeded_limit also selects jsonb into INT and ->> on an int. They fail at runtime and nothing calls them. These are the intended schema→app bridge. Fix per ADR-0007 (collapse into one get_my_entitlements).
QRS-064bugTier enum diverges across three tablesprofiles.subscription_tier allows …/essential; user_subscriptions.tier does not; user_feature_permissions.override_tier adds unlimited. One canonical ladder needed (expand-contract).
QRS-065riskNo feature-entitlement enforcement in src/ — subscription tier is read only for a cosmetic badge + upgrade CTA; there is no useEntitlements/featureGate/hasFeature layer, navigation is a static USER_NAVIGATION constant, and ProtectedRoute is auth-only. The freemium mandate is unenforced today.
QRS-066debtOnly functional gate is hardcoded + tier-blinduseMenuManagerEligibility hardcodes business_domain_id === 1 and ignores subscription tier and the domain_features table built for this. Replace with the ADR-0007 entitlement resolver.
QRS-067debtFour disconnected feature vocabularies — admin ent:{cards,qr,…}, console manifest.js module-id plan:'pro', DB platform_features.feature_code, and domain_features. None map to each other, so an admin plan change does not affect runtime. ADR-0007 makes platform_features.feature_code canonical.
QRS-068debtTwo overlapping tier-gating models in the schemasubscription_tiers.features/limits (jsonb) vs domain_features.tier_requirement (normalized). Unreconciled. ADR-0007 (revised 2026-07-20 → Option D) makes a normalized (domain × tier × feature) entitlement matrix authoritative and promotes domain_features (per-domain-per-tier limits + applicability); subscription_tiers jsonb keeps only domain-independent global flags.
QRS-069projectDomain-scoped entitlement command center required — product wants full business-level control: per-(business_domain × tier × feature) limits/quotas (e.g. real-estate free = 3 listings/5 photos → Pro 10 → Business unlimited; electrician a different set), all admin-editable with no code change, and adding a new domain/plan must be data entry. Drives ADR-0007 Option D + a re-brief of the admin Subscriptions.dc.html into a real command center.
QRS-070debtTemplates gate by hardcoded min-plan, not admin config — the template library carries its own min-plan; not driven by Subscription Plans. Should key off the feature_code registry per ADR-0007 + ADR-0003.

Candidate findings — Razorpay & ZeptoMail integrations (2026-07-20)

From the API studies backing ADR-0002 (Razorpay) and ADR-0008 (email). These are build-time constraints and gaps to design around from the start.

IDCatSummary
QRS-071riskRazorpay plans are immutable — amount/period/interval can never be edited; a price change mints a new plan_id. The plans data model must be versioned immutable rows keyed by Razorpay plan_id (one logical tier ↔ many historical plan_ids + grandfathering). Biggest billing data-model constraint.
QRS-072riskNo GST-compliant tax invoice via Razorpay API — API invoices are payment receipts only (GST invoices are Dashboard-only, INR-only). QRSETU must generate GST-compliant invoices itself (or via a separate tool). Compliance gap to own.
QRS-073debtKeep recurring tier prices ≤ ₹15,000/cycle — cards + UPI Autopay (regular MCCs) require per-debit OTP/PIN above ₹15k, hurting churn. Pricing/design constraint; e-NACH only for high-value/enterprise.
QRS-074projectbilling-webhook EF + Razorpay secrets — Type-B webhook EF reconciling Razorpay events → user_subscriptions (grace on pending, revoke on halted, terminal cancelled→prefer pause/resume). Secrets RAZORPAY_KEY_ID/_KEY_SECRET/_WEBHOOK_SECRET per project (test on Dev, live on Prod); handlers idempotent + set-state-from-payload (no event-ordering guarantee).
QRS-075riskZeptoMail is transactional-ONLY (ToS) — promotional/marketing/bulk campaigns are prohibited and must use Zoho Campaigns (separate product/API/auth). Two integrations, not one; never route promo through ZeptoMail (suspension + deliverability risk). See ADR-0008.
QRS-076projectTransactional email EFs + ZeptoMail setupnotifications-send-email (Type A, template API, EMAIL_TEMPLATE config map, ZEPTOMAIL_SEND_TOKEN secret, India DC api.zeptomail.in) + notifications-email-webhook (Type B, bounce/complaint → suppression). Prerequisite: domain verification (DKIM/SPF/DMARC + tracking CNAME). Route Supabase Auth emails through it too.
QRS-077debtMarketing consent + unsubscribe model — needed before any Zoho Campaigns send (DPDP/GDPR): where consent/unsubscribe state lives (profiles column / table) and how it syncs to Zoho Campaigns.
QRS-078projectOTP channel = email (decided 2026-07-20) — ✅ product confirmed OTP is delivered by email via ZeptoMail; no SMS/WhatsApp BSP in the first release (ADR-0008 accepted). Design follow-up: the onboarding prototype's phone-OTP mode must be re-specified as email OTP (prompt logged in the onboarding review). Mobile number stays a profile field, not the OTP channel.
QRS-079projectZeptoMail (transactional) / Zoho Campaigns (promotional) split confirmed (2026-07-20) — ✅ both use cases acknowledged; built when their modules are scheduled. ZeptoMail first (OTP + verification critical path); Zoho Campaigns deferred to a future promotional/CRM module with its own ADR (auth, list/consent/unsubscribe). Never route promo through ZeptoMail.

Candidate findings — Vertical archetype platform & R1 scope (2026-07-20)

Surfaced designing the scalable core (the "new vertical = configuration, not code" mandate). Full model in ADR-0009. These are the workstreams + decisions that fall out of it.

IDCatSummary
QRS-080projectVertical archetype platform — the core schema build — implement the metadata-driven vertical model: vertical_archetype (~5), a business_domains config row that is the vertical (archetype + versioned field-schema + card template + feature set + entitlement defaults + compliance profile + onboarding steps + visibility), a common business_items spine (typed common columns + validated attributes jsonb) + item_media, and shared archetype-activated transactional tables (leads, appointments, orders/order_items). RLS on every table; expand-contract; promoted Dev→Prod. After this one build, new verticals are data. See ADR-0009.
QRS-081projectR1 domain set (6) across 4 archetypes — Salon (Booking), Real Estate + Car Dealer (Inventory), Carpenter + Loan Agent (Lead/Portfolio), Purohit (Catalog/Informational). E-commerce/cart confirmed as a 5th core archetype and confirmed in R1 (2026-07-20; product overrode the R2 rec) — the archetype is built in R1 though its first domains (Boutique/Home-Chef/Retail) are near-term, and Digital Menu's R2 migration rides on it. Field schemas are engineering-seeded for R1 (visual builder post-R1). Near-term shortlist (Interior Designer, Photographer, Event Planner, Fitness, Tutor, CA, Doctor/Clinic, Travel Agent) maps onto the same archetypes as config.
QRS-082riskCompliance profile per vertical is first-class, not documentationad_restricted / marketing_restricted / kyc_required / verified_badge_required / visibility(public|private|invite) / stores_sensitive_data live on the domain config and are enforced. Loan Agent = invite + kyc_required day one (design-partner user runs live; no public marketing until verified-badge exists). CA / Doctor / Lawyer = marketing_restricted → informational/discovery card copy only (ICAI / NMC / BCI advertising bans); Doctor scope stores no patient records/prescriptions/sensitive medical data. Confirm exact current rules per professional body before shipping copy.
QRS-083projectDigital Menu excluded from R1; re-homed onto the E-commerce/Catalog archetype in R2 — product decision 2026-07-20. Not in production for any customer, so its schema can evolve freely to become the reference instance of the E-commerce archetype under a dedicated R2 migration/integration plan. Kills the "menu is bespoke" debt and validates the archetype model on mature feature code. (Supersedes treating Digital Menu as a standalone R1 feature; the existing digital-menu from()/.js retrofit debt rolls into this migration rather than being fixed in place.)
QRS-084debtbusiness_domain_id === 1 hardcoded gate must dieuseMenuManagerEligibility encodes a vertical in code; replace with archetype + entitlement resolution (ADR-0009 + ADR-0007).
QRS-085projectJS→TS migration roadmap — TypeScript strict reaffirmed non-negotiable for all new code (already [ENFORCED] in CLAUDE.md); adopt a phased roadmap to migrate remaining core .js/.jsx modules to TS for type safety/maintainability. Digital Menu migrates under its own R2 plan (above); the rest of core progresses opportunistically-plus-planned. Archetype definitions, Zod field schemas, and resolvers are TS in the shared core from day one.
QRS-086debtAd-eligibility becomes a per-domain flag, not global — product accepts compliant third-party ad/promo placements on cards/dashboards; ad injection (ADR-0004) must honor the domain ad_restricted compliance flag (a doctor's card shows no third-party ads even if the global ad system is on).
QRS-087debtQuery perf for jsonb attributes — hot Inventory filters (e.g. real-estate "3 BHK under ₹1cr") need GIN indexes / generated columns on business_items.attributes; budget in the Inventory archetype build.

Candidate findings — Analytics read model / command-center scaling (2026-07-20)

Surfaced proactively evaluating how the archetype write model (ADR-0009) scales for the Admin Command Center's cross-domain dashboards/analytics. Full design in ADR-0010. The core risk: jsonb is a write-side win but an aggregation liability — analytics must run on a separate read model.

IDCatSummary
QRS-088projectAnalytics read-model build — implement the command-center read model: an append-only partitioned analytics_events fact stream + business_metrics_daily/domain_metrics_daily rollups (one row per dimension×period×metric) + a few materialized views, refreshed by pg_cron, read via admin-gated SECURITY DEFINER composite RPCs (one get_admin_dashboard per dashboard). Reuse the same rollups for merchant-facing "my analytics". RLS + expand-contract + promoted Dev→Prod. See ADR-0010.
QRS-089riskDon't query OLTP directly for analytics — the command center must never run cross-domain GROUP BY over business_items.attributes jsonb or contend with user-facing writes; enforce the read/write split (rollups + canonical metrics). This is an architectural guardrail, not a later optimization.
QRS-090projectCanonical metric/fact vocabulary — normalize each archetype's heterogeneous activity into shared metrics (supply_count, demand_count, conversion, revenue, engagement) + dimensions (business, domain, archetype, period) so one dashboard spans all verticals and adding a vertical adds no new dashboard query. Archetype defines the raw→canonical mapping.
QRS-091debtColumn-promotion discipline — the ADR-0009 schema-as-data gains analytical/indexed flags: any attribute that is filtered/sorted/grouped/charted becomes a typed (or generated) column with an index; jsonb stays display-only. Promoting a field later = expand-contract backfill. Prevents the jsonb-aggregation tar pit.
QRS-092riskpg_cron-on-Dev is now a prerequisite, not just debt — analytics rollups depend on scheduled refresh jobs; the existing "pg_cron not replicated to Dev (hardcodes Prod EF URL)" item must be resolved before the analytics read model can run on Dev, and refresh schedules are captured as migrations.
QRS-093riskChoose partition keys up frontanalytics_events (+ subscription_usage_logs, possibly orders) partition by time-range (sub-partition by domain_id if a few domains dominate); the key choice is expensive to change later, so decide it at the read-model build even if partitioning is switched on only past a volume threshold.
QRS-094debtOLAP move must stay a move, not a rewrite — keep the raw analytics_events stream + a clean fact contract so a later read-replica / column-store (ClickHouse-class) is hydrated from one source; define the volume/latency threshold that triggers it. Deferred, but designed for.
QRS-095debtAggregates cross tenants but leak no PII — admin analytics RPCs bypass per-tenant RLS by design yet return counts/sums only; row-level drill-down re-imposes RLS or an audited admin path; rollup tables store no PII.

Candidate findings — Mobile-first universal frontend (2026-07-20)

Product elevated mobile from north-star to Day One: Expo/React Native primary, Android-first R1, iOS deferred ($99 fee) with a PWA interim, R2 for both; structural parity required (Android app ≈ iOS PWA ≈ mobile web). Full decision in ADR-0011.

IDCatSummary
QRS-096projectSurface-matched frontend — two stacks (ADR-0011, revised to Option H 2026-07-20)Stack 1: React web (DOM, keep shadcn) for public service cards (SSR/SEO) + admin (dense, + installable PWA); Stack 2: universal Expo/RN for the merchant product app (Android native R1 / iOS PWA→native R2 / responsive web via RNW). shadcn is KEPT (public+admin), not retired; the RN rebuild is scoped to the merchant app only. Shared TS core + shared design-token package across both. See ADR-0011.
QRS-097riskTwo UI idioms must stay in lockstep (the accepted cost of Option H) — shadcn/Tailwind (DOM) + RN/NativeWind (RN) implement components twice; a shared design-token package is the single source of truth for color/spacing/radius/type/motion and a component-parity checklist governs both. Watch the one seam: a merchant previewing their card in-app (RN) vs the live public card (DOM) — must match (shared tokens or a webview preview). Every UI/UX implementation consumes the shared tokens — no per-stack divergence.
QRS-098projectShared design-token package — a framework-neutral token source compiled to both a Tailwind config (DOM) and a NativeWind/JS theme (RN); single source of truth for the look across both stacks. Confirm build pipeline (Style Dictionary vs hand-rolled TS module) — ADR-0011 open-Q3. Design invariants survive (soft corners, zero-hardcoded-colors, light/dark, cn()); .dc.html prototypes remain visual specs for both idioms.
QRS-099debtData layer native-enablement — TanStack Query/Supabase/RPC-EF run in RN as-is; add @react-native-async-storage/async-storage + react-native-url-polyfill for Supabase auth persistence and expo-auth-session/native Google sign-in in place of the web OAuth redirect.
QRS-100projectAdmin = Stack-1 web (DOM) + installable PWA — revised 2026-07-20 (Option H): admin lives on the DOM web stack (shadcn, desktop-first for dense authoring) with an installable PWA for mobile ops (private, behind login — satisfies "internal-only, never public stores"; no native binary needed). Carries the full admin surface except data-dense authoring (desktop-first). Non-negotiable on mobile: templates:demo-all field-demo delivered as mobile web (the real public Service Cards are DOM-rendered, so ops show them with sample data via the admin PWA — laptop-free pitching), plus high-level product-growth/user-growth/infra-health tiles. Admin is a separate stack from the merchant app ⇒ public app build has no admin code; RBAC (ADR-0006) gates it.
QRS-101riskiOS PWA push is limited — email-OTP (ADR-0008) avoids the worst; push-dependent features (reminders) need an in-app/email fallback on the iOS PWA until R2 native.
QRS-102projectApp-store billing compliance = "free companion app" + web-first Razorpay — decided 2026-07-20 (ADR-0002): native apps carry no in-app purchase UI/CTA; all purchase/upgrade on web via Razorpay (Apple 3.1.3(d) companion exemption; avoids Google Play Billing trigger; 0% store commission vs 15–30%). Purchase surface renders Platform.OS==='web' only. Built from R1 (Android) so nothing is retrofitted for R2 iOS. Store cuts would be 6–12× the Razorpay fee (₹150–300 vs ₹24 on a ₹1,000 plan). Fallbacks if in-app checkout ever justified: Google India User Choice Billing (−4pp → 11%), Apple external-link (US 0% post-Epic). Reverify store policy at R2 build (volatile; India stable to Sep-2027).
QRS-103debtCLAUDE.md "Mobile north-star (don't build yet)" + "UI Hybrid System (shadcn)" sections are stale — rewrite to mobile-first/Expo-primary/universal on ADR-0011 sign-off.
QRS-104projectR1 sequencing — R1 is large (archetype platform + entitlements + analytics + E-commerce archetype + universal frontend + Android native). Sequence: portable core + backend → universal shell + product tiers → Android packaging + heaviest E-commerce screens late-R1.
QRS-105debtMonorepo (packages/core + apps/*) — becomes natural with the universal app; realizes the "extraction is a move, not a rewrite" north-star. Timing (now vs after R1 core) is an open question in ADR-0011.
QRS-106projectPublic-page SEO/social — RESOLVED via dedicated DOM-web (Option H, 2026-07-20) — the public service cards (/b/:slug etc.) render on Stack 1 (React web, DOM, SSR/SSG), not RN Web — SEO + WhatsApp/social previews + fast first paint are uncompromised (product: SEO is the primary growth factor). Framework decided (2026-07-20): React Router v8 for both public + admin (one React stack) — solo-dev maintainability with SEO fundamentals uncompromised (SSR HTML, per-slug OG, JSON-LD, indexing); Astro kept as a later public-only CWV optimization escape hatch; Next.js ruled out (OpenNext/Cloudflare tax). Cloudflare cache >95% target still applies.
QRS-107debtDense admin uses DOM/shadcn (Option H) — Radix retained where it's best — the earlier "complex web-a11y in RNW" risk is moot: admin stays on the DOM stack, so Radix/shadcn's mature composite-widget a11y and data-grids are kept. No RN-a11y workaround needed for admin.
QRS-108projectlanding is a first-class SEO tier (2026-07-20) — clarified: both unauthenticated tiers (landing + public) are SSR on Stack 1 (RR8), indexed, cached. landing is the primary organic traffic driver (ranks for high-intent searches → converts to signups); its auth pages (/login, /signup) stay on RR8 but noindex. Full 4-tier→stack map in ADR-0011 + Tech Stack.
QRS-109improvementProgrammatic-SEO landing pages (post-R1 growth lever) — generate per-(business_domain × use-case × city) landing pages (/for/real-estate, /qr-menu-for-cafes, /digital-card-for-electricians-in-pune) from the domain registry (ADR-0009), each SSR'd for high-intent local search. Major organic-growth play for a domain-scoped SMB product; cheap on RR8 SSR + the registry. Plan deliberately (not R1).

iOS parity — native in R1 (Phase A, 2026-07-26)

Device testing found the iOS experience below standard and unlike Android. Product made alignment non-negotiable: every feature, component, interaction and animation ships on both natives in the same release, both builds requested together. iOS native moved R2 → R1 (ADR-0011 amendment), supported surfaces are now Android native · iOS native · Web PWA, and the iOS PWA is transitional.

Root cause — it was never Android-vs-iOS divergence in our code. The app has one Platform.OS branch outside the elevation adapter, and that adapter already emitted both iOS shadow* and Android elevation. What was tested was the iOS PWA, whose export had no manifest, no apple-* meta, and no viewport-fit=cover — so env(safe-area-inset-*) was 0, useSafeAreaInsets() returned zeros, and content collided with the notch and home indicator. Native iOS never had those defects.

IDCatStatusSummary
QRS-110bug✅ fixed 2026-07-26iOS PWA had no safe-area insets — no viewport-fit=cover, so env(safe-area-inset-*) resolved to 0. The single biggest cause of the reported drift. Fixed in the new apps/mobile/src/app/+html.tsx.
QRS-111bug✅ fixed 2026-07-26The app was not installable on iOS — no manifest.json, no apple-mobile-web-app-* meta, no apple-touch-icon. "Add to Home Screen" produced a Safari bookmark with browser chrome and a screenshot icon. Fixed via public/manifest.json + generated public/icons/ + +html.tsx.
QRS-112bug✅ fixed 2026-07-26iOS press feedback was missing entirelyandroid_ripple is a silent no-op on iOS, so iOS had scale+opacity where Android had scale+ripple. Added expo-haptics light impact on iOS in PressableScale as an approved OS-guideline carve-out (the HIG has no ripple equivalent); pinned by tests.
QRS-113bug✅ fixed 2026-07-26SetuCardPreview shadow was clipped on iOSoverflow: 'hidden' and useElevation('e2') on the same view. iOS clips a view's own shadow; Android draws elevation outside the clip, so the card was elevated on Android and flat on iOS. Split into an outer shadow view + inner clipping view.
QRS-114bug✅ fixed 2026-07-26Native splash background diverged from the surface token#0B1B2B vs #141C24, producing a visible colour jump into BrandSplash in dark mode, which is the seam the logo-less splash policy exists to prevent. Reconciled and pinned by native-splash.test.ts.
QRS-115bug✅ fixed 2026-07-26Every exported page had a blank <title> — expo-router emits a react-helmet <title> as the first head element and the browser honours the first one, so a title set in +html.tsx silently lost. Set via <Head> in _layout.tsx instead.
QRS-116debt🔵 openNo snapshot tests on the primitive layer — the parity ratchet is currently a manual checklist plus targeted unit tests. Add per-platform snapshots for src/ui primitives.
QRS-117debt🔵 openMaestro flows are not yet run on both natives — topology is decided, execution is not wired for iOS.
QRS-118debt🔵 openNo macOS CI runner — an iOS build break is only discoverable by hand until one compiles iOS on release branches. This is the weakest link in the parity guarantee.
QRS-119debt🔵 openGradientText/GradientHeadline/FitBox need a manual iOS pass — SVG text metrics differ per platform, so measured per-word gradient text can mis-size. Not observable in the test renderer.
QRS-120debt🔵 openreanimated layout animations unverified under RNW/SafariSheet drag, Toast enter/exit and PressableScale need a real mobile-Safari check; layout animations are the likely casualty.
QRS-121noteℹ️ acceptedexpo-haptics adds android.permission.VIBRATE to the merged Android manifest even though it is only called on iOS. Normal-level permission, no runtime prompt; stripping it would need a custom withAndroidManifest plugin, which is not worth the maintenance.
QRS-122noteℹ️ resolvedFree Apple provisioning is enough for R1 development — Simulator needs no account; a physical iPhone installs via Personal Team signing. The $99 buys distribution (TestFlight, push, Associated Domains), not development. Constraints: 7-day profile expiry, 3 apps/device. See guides/macos-ios-build-environment.md.
QRS-123decision✅ decided 2026-07-26EAS Build is not a substitute for the local iOS loop — evaluated on request. EAS Free gives 15 iOS + 15 Android builds/month (not 25), low priority, 1 concurrency, 45-min timeout. But EAS cannot install on a physical iPhone without a paid Apple Developer account — Expo's docs state internal/ad-hoc distribution "requires a paid Apple Developer account", since ad-hoc profiles need a distribution certificate and registered UDIDs, which free Personal Team signing cannot produce (its profiles are 7-day and machine-local, non-exportable to CI). Decision: local Mac builds for the iOS dev loop; revisit iOS-on-EAS when the $99 is paid for distribution. Rationale in guides/ios-build-and-device-testing.md.
QRS-124improvement🔵 openUse EAS Free for Android release builds — 15/month is ample, EAS manages the keystore, and it sidesteps the documented Windows OOM pathology that scripts/build-android.mjs exists to work around. Free, independent of the iOS decision, and a candidate to partly satisfy the missing CI runner. Needs an eas.json and a credentials decision.
QRS-125constraintℹ️ recordedEffective minimum iOS is 16.4, not 15.1 — RN 0.86 requires iOS 15.1, but expo-router depends on @expo/ui and expo-glass-effect, whose podspecs declare :ios => '16.4'. They are direct router dependencies, so the floor is not negotiable without leaving expo-router. Consequence: iPhone X (max iOS 16.7.x) works, with ~0.3 of headroom; any device that cannot reach 16.4 is unsupported. Re-check this whenever expo-router is upgraded — a bump to an iOS 17/18 floor would silently drop older hardware. Named device consequence (2026-07-26, prompted by a spare iPhone SE on 15.8.8): iOS 15.8.x is the security branch Apple maintains for hardware that cannot run iOS 16, so iPhone 6s / 6s Plus / 7 / 7 Plus / SE (1st gen) can never install QR Setu — permanently, not pending a phone update. iPhone SE 2nd/3rd gen reach iOS 18 and work once updated. Neither @expo/ui nor expo-glass-effect is referenced anywhere in our code (verified), so our minimum supported iOS is set by transitive dependencies we never chose and do not use — lowering it would mean patching third-party podspecs (clobbered on every npm install) or dropping expo-router, our entire navigation layer. Action: state 16.4 as the deliberate supported floor in user-facing docs/support material, so the first merchant who reports "it won't install" gets a known answer instead of an investigation.
QRS-126debt🔵 openapps/mobile/assets/expo.icon/ is leftover Expo scaffold (expo-symbol, blue gradient) unreferenced by app.json. Delete during asset cleanup.
QRS-127project✅ done 2026-07-26Dual-platform build & test made a standing, documented workflow — every feature is now built and tested on Android and iOS from the same commit, in parallel, on two machines (Windows = Android, Mac mini = iOS), per feature and per release, not just at the end of R1. New portal page guides/android-ios-build-and-test.md is the single front-door reference: two-machine topology (and the real asymmetry it documents — Android produces a portable .apk installable via adb on any authorized device, while iOS's free Personal Team signing has no portable artifact, expo run:ios --device builds and installs in one step only onto whatever iPhone is cabled to the Mac mini at that moment), the copy-paste pull → build → install → verify loop, and a troubleshooting quick-reference. Elevated to its own "Build & Release" top-level nav/sidebar section in the portal (previously these guides were buried inside the generic "Guides" list). CLAUDE.md's cross-platform parity standard now points here as the entry point, ahead of the deeper parity-verification.md procedure doc. Does not replace the existing deep guides (windows-build-environment.md, macos-ios-build-environment.md, ios-build-and-device-testing.md, parity-verification.md) — those remain the one-time-setup and "why" references; this page is the fast operational loop that cross-links them.
QRS-174bug✅ fixed 2026-07-26expo-router/head red-boxed every screen on native iOS — found on the very first Simulator run: Expo Head: Add the handoff origin to the Expo Config. expo-router/head is not a web-only shim: on iOS it resolves to ExpoHead.ios.js, which implements Handoff / Spotlight indexing and throws at render time unless an origin URL is declared in the expo-router config plugin. It was introduced by the QRS-115 <title> fix, whose code comment asserted "No-op on native" — wrong, and untestable on the web-only surface it was written for. Not fixed by adding the origin, which the error message suggests: there is no hosted origin yet, and Spotlight/Handoff indexing of merchant screens is an undecided product concern that would have been switched on silently to suppress a crash. Instead the web and native needs are split into @/ui DocumentTitle (.web.tsx real, native a genuine no-op) — platform-split files rather than a Platform.OS branch, so native never imports the module at all. Pinned by a test asserting the native file contains no such import, matched on the import specifier so the file's own explanatory comment can still name the module. Web export re-verified: exactly one <title data-rh="true">QR Setu</title>, first in <head>. Commit 046a46d. Lesson recorded in parity-verification: the document head is a platform-divergence seam — only the web has one, and the module that looks web-only is not.
QRS-175debt✅ fixed 2026-07-26Sentry.wrap was applied without Sentry.init, logging a warning on every launchApp Start Span could not be finished. Sentry.wrapwas called beforeSentry.init. With no DSN, initObservability() correctly skips Sentry.init by design, but the root wrapper still applied Sentry.wrap unconditionally. That warning therefore fired on every launch of every build — all of dev, all of CI, every un-provisioned build — which is exactly how a team learns to ignore console warnings. wrapWithObservability is now the identity function when no DSN is set, which is also what observability.ts already promised ("no DSN → zero Sentry involvement") and means no error boundary sits in the tree reporting nowhere. Two tests pin both branches. Commit 046a46d.
QRS-176bug✅ fixed 2026-07-26Sentry source-map auto-upload broke every Release build — the first iOS --configuration Release build died before producing an app: sentry-cli - error: An organization ID or slug is required, xcodebuild exit 65. Cause, confirmed by reading the plugin source rather than guessing: @sentry/react-native/expo's getSentryProperties() always returns a properties string (falling back to SENTRY_ORG/SENTRY_PROJECT env vars), so the "Upload Debug Symbols to Sentry" native build phase is injected unconditionally — there is no "no org configured, skip it" path. Debug tolerates the failure; Release treats it as fatal. No Sentry org is provisioned (deliberately — see QRS-019), so the upload cannot succeed and failing the build over it is pure obstruction. Fix: app.json passes { disableAutoUpload: true }, which bakes export SENTRY_DISABLE_AUTO_UPLOAD=true into the generated native build phase on every prebuild, on every machine — chosen over a per-developer shell export, which is not deterministic and would be rediscovered from scratch by whoever built Release next. The plugin applies the flag to Android too, closing the same latent break there. Upload only; runtime capture is still gated separately by the DSN. Pinned by a test (sentry-build-config.test.ts) because the failure mode is remote from the cause — removing the flag breaks nothing locally, nothing in jest and no debug build; it resurfaces weeks later as a confusing sentry-cli error. The test also asserts no authToken is present, which would ship a Sentry credential inside the app config. Commit 030294b. ⚠️ Must be reversed when Sentry is provisioned — without source maps, production stack traces point into minified Hermes bytecode and are close to useless; see QRS-025 and Sentry § source-map upload.
QRS-177bug✅ fixed 2026-07-26The build guides prescribed a parity comparison that was not one — step B built a release APK for Android while step E built a debug iOS build, and the guides then instructed comparing them side by side. Debug does not embed the JS bundle (fetched from Metro at every cold start, so it needs the Mac running and the same Wi-Fi, and re-bundles each launch), keeps dev warnings on, and runs JS + reanimated unoptimized — so animation smoothness, startup and perf differ for reasons that have nothing to do with iOS. That invites both false positives (logging a platform parity bug that does not exist) and false negatives (a real regression hidden inside the debug/release delta). Surfaced by a user question about whether the cable could be removed. Both guides now specify --configuration Release for parity work, and record that Release is also what makes the install behave like a normally-installed app (cable needed only during install; no Mac, no Wi-Fi). Commit 184df5e.
QRS-178debt✅ fixed 2026-07-26The documented free-signing procedure could not have succeeded as written — it listed adding an Apple ID and ticking "Automatically manage signing", omitting three of the four things actually required. Cost ~8 round trips on the first real device install. Now sequenced with the real cause of each failure: adding the Apple ID does not create a certificate (Manage Certificates → + → Apple Development, else CommandError: No code signing certificates are available to use); signing lives on the topmost navigator row (the app project, not the Pods project); and "your team has no devices from which to generate a provisioning profile" is caused by a Simulator still being the selected destination, not by anything about the device or the team — expo run:ios can only reuse signing assets, never create a certificate, register a device or mint a profile. Adds the codesign keychain-prompt section (it wants the Mac login password, not the Apple ID one) with the focus-the-field / verify-independently / fix-the-ACL / duplicate-identity paths, and explicitly rejects the widely-copied security set-key-partition-list -k <password> one-liner that puts a login password into shell history. Commit 184df5e.

| QRS-181 | bug | ✅ fixed 2026-07-26 | Text was clipped app-wide because the type scale's line-height ratios are below the fonts' natural line height. Reported from device screenshots on both platforms (page titles, the "More" heading overlapping its subtitle). Root cause: typography[*].lineHeight are CSS ratios, and CSS is forgiving — a line-height below the font's natural height lets glyphs overflow the line box and stay fully visible. React Native is not: an explicit lineHeight is a hard clip box. AppText multiplied fontSize × ratio and handed the result to RN unconditionally. Measured from the shipped .ttf files (hhea ascender/descender/lineGap ÷ head unitsPerEm): Plus Jakarta needs 1.26, Noto Devanagari 1.304, JetBrains Mono 1.32, Baloo 2 1.602, Akaya 1.196 — against a scale whose ratios run 1.05–1.55. So every variant clipped in the display face, and title (1.25) upward clipped in the UI face. Devanagari clips worse than Latin, which English-only testing could never surface. Fix: new fontMetrics in @qrsetu/tokens (the measured per-family floor, documented as CSS-vs-RN) and AppText now clamps Math.max(variantRatio, fontMetrics[family]), so the DS value still wins wherever it is already safe. Two hard-coded lineHeight values fixed the same way (WelcomeStory 27/34 in Baloo 2; a 7px micro-label at 1.21×). Guardrail: font-metrics.test.ts re-derives every family's natural height from the font binaries, so a font swap or version bump cannot silently invalidate the constants. Tokens deliberately not changed — they are correct for the DOM app that shares them. | | QRS-182 | bug | ✅ fixed 2026-07-26 | A caller's style={{ fontSize }} left a stale line box — the worst of the clipping, and a genuine API flaw in AppText. AppText computed lineHeight from the variant's fontSize, then the caller's style overrode fontSize only. So Avatar (initials sized off its diameter) and ClockPicker (a 40px readout on the default body variant) rendered 28–40px glyphs inside body's 22px box: the clock showed "07:00" as bottom-halves and the avatar initials were sliced top and bottom — exactly the reported screenshots. Blast radius: 32 fontSize overrides across 33 files, which is why this presented as "many unrelated screens" rather than one bug. Fix: AppText flattens the incoming style and reads fontSize before computing, so size and line height cannot desynchronise; letter-spacing now scales off the effective size too; an explicit caller lineHeight still wins deliberately. Also a coverage finding: AppText — the primitive every string in the app flows through — had no test at all, while 24 other src/ui primitives did. AppText.test.tsx added (8 tests, asserting the resolved style, the only place either defect was observable); both new suites fail against the old code. | | QRS-183 | bug | ✅ fixed 2026-07-26 | Haptics were absent on ~30 of 36 tappable controls — a coverage gap, not a haptics bug. Reported as "haptic feedback is not being triggered". expo-haptics and PressableScale were both correct and working; the problem is that PressableScale was used by 6 files while raw <Pressable> appeared at ~30 call sites, including everything on the Profile screen the reviewer actually tapped. Those controls had no haptic, no ripple and no scale — so QRS-112's "iOS press feedback fixed" was only ever true for the six. Fix (two parts): (1) product instruction that the tactile response must be consistent across platforms, with only the visual idiom differing — so haptics now fire on Android as well as iOS (ripple is the visual half, not a substitute; the VIBRATE permission is already merged per QRS-121). This reverses the earlier ADR-0011 carve-out and the test that asserted Android silence was updated deliberately, not deleted. (2) The 8 src/ui controls that bypassed the primitive (SettingRow, SegmentedControl, Switch, ChoiceCard, PillSelect, DateTimeField, LanguageSelect, Avatar) now route through PressableScale. Also stopped swallowing the failure silently — the rejection is now console.warned in dev, since an empty .catch(() => {}) is what made "haptics don't work" undiagnosable. ⚠️ Open: ~20 feature-level raw Pressable call sites still bypass it, and the fix is not durable until a lint rule forbids them — see QRS-184 tier 2. Diagnostic note for the reporter: iOS suppresses haptics entirely in Low Power Mode, and honours Settings › Sounds & Haptics › System Haptics. | | QRS-184 | improvement | 🟡 proposal — needs a scope decision | No layer of our testing validates that the UI looks right, and Playwright does not exist at all. Raised by the user after QRS-181/QRS-182 reached a device: "why were these not detected during E2E testing — what are our Playwright tests validating?" Answer: nothing — there is no playwright.config.*, no e2e/ directory and no e2e:* script anywhere in the repo. Playwright is a target in CLAUDE.md, never wired. Actual pre-merge inventory: 294 jest tests (assert props/state/strings — structurally cannot see a clipped glyph), one Maestro flow (launch, wait for a single welcome-story testID — never opens Profile, Settings or a picker), and type-check/lint/prettier (lineHeight: 22 on a 40px glyph is valid TypeScript). So this was not a test failure; it is a missing layer. Proposal written up in UI Quality Assurance with 5 tiers ranked by value-per-effort and an explicit warning against starting at the screenshot tier. Recommended now: tier 1 style-invariant tests on the primitive layer (partly delivered), tier 2 an ESLint ban on raw Pressable outside src/ui (the durable fix for QRS-183), tier 3 real per-screen Maestro flows. Next: tier 4 Playwright toHaveScreenshot() against the RNW web export (free, Linux CI) — with the honest caveat that CSS does not clip, so the web surface is the least sensitive place to catch this specific bug; it must not be sold as "we now catch truncation". Native screenshot diffing is gated on a macOS runner (QRS-118) — a baseline that cannot be regenerated in CI will be deleted within a month. Also flagged: any screenshot suite must include a non-English locale, or it passes while hi/mr is broken, and OS font-scaling at 200% is the highest-yield truncation case in the product with zero coverage today. | | QRS-188 | project | ✅ decided 2026-07-26 | Design governance made asymmetric (ADR-0015) — ad-hoc UI refinement during development is now sanctioned. Product raised that CLAUDE.mds absolute rule (pull the design before ANY UI work) was too slow for device-testing feedback, and proposed: baseline from Claude Design, small changes direct, periodic drift audits, batch sync. Three of four adopted; the audit was rejected on measured evidence — deferred reconciliation in this repo has a completion rate near zero (QRS-TBD survived ~170 items and two remediated security incidents until the user noticed — QRS-180; the lint gate was a green no-op for months — QRS-013; from()/caching/shared-location debt all still open). Also rejected the small-vs-large axis: of the six changes real device testing produced, four were token- or primitive-level (QRS-181, QRS-183, QRS-186, QRS-187) and only two were screen-level, so a workflow tuned for ad-hoc screen tweaks optimises the minority case — and "is this small?" is not decidable by two reviewers independently. Adopted: systemic surface (packages/tokens, apps/*/src/ui) is design-first with no exceptions because a divergence there forks BOTH UI idioms with no failing test (ADR-0011 implements components twice; the in-app-preview vs live-card seam breaks silently across two codebases); screen composition is code-first and unblocked; divergence is recorded at the moment it happens in a new drift ledger, converting reconciliation from discovery (skippable) into working a list (obviously incomplete if skipped); sync happens at the develop → uat promotion that already exists and that the user personally gates, not on a calendar with no owner. Mechanised: tools/check-design-drift.js + npm run check:design, wired into ci.yml as a PR-only step (on a push to develop the base IS HEAD, so it would be a green no-op — the QRS-013 pattern); fails closed if it cannot resolve a base ref; needs fetch-depth: 0. Verified firing against the real branch diff (37 systemic files, no ledger row → exit 1). Ledger seeded with the 5 genuine divergences to date, including the tab bar as a correction so nobody later "syncs" the design to itself. CLAUDE.md amended — a standard that is routinely violated is worse than an accurate one. Accepted residual risk, recorded not hidden: a ledger row can be satisfied with a low-quality entry; nothing prevents that, and it is caught (if at all) at the promotion review. | | QRS-189 | project | ✅ done 2026-07-26 | Playwright adopted as the web UI gate — and it found four real defects on its first run. Closes the "there are no Playwright tests" finding in QRS-184. Deliberate design choice: the primary layer is deterministic DOM measurement, not pixel diffing — no horizontal scroll, nothing past the right edge, no clipped leaf text, 44×44 minimum touch targets — because screenshot baselines need per-OS generation, flake, and (the decisive point) CSS does not clip glyphs, so the web surface is the least sensitive place to catch the very bug that prompted all this. Runs 4 viewports × en/hi; the locale axis is not decoration, since Noto Devanagari needs a 1.31× line box against Plus Jakarta's 1.26×. 334 assertions, 0 failures, verified deterministic over two consecutive full runs. Wired as its own e2e-web CI job (fresh export + Chromium; kept out of the workspace job so a lint failure is not slowed by minutes). Engineering notes worth keeping: workers is capped at 4 because unbounded parallelism raced React hydration; waits key on two identical non-zero samples (a settled tree) rather than networkidle, which measured an empty body on /profile; the clipped-text check is scoped to leaf text elements because RNW compiles a ScrollView to an overflow:hidden wrapper that legitimately "clips", and every attempt to exempt that stayed timing-dependent — narrowed for correctness, and said so in the code rather than quietly loosened. The most instructive moment: the original flaky wait was making tests pass vacuously — /profile measured zero controls because it had not rendered, so its touch-target check "passed" while 8 offenders existed. Fixing the wait revealed them. That is the green-no-op pattern (QRS-013) recurring inside the very gate built to prevent it. | | QRS-190 | bug | ✅ fixed 2026-07-27 | className is silently dropped on PressableScale — styling declared in classes never applies, at 6 call sites. Found by the new Playwright touch-target check: LanguageSelect's chips measured 16×17 / 14×17 / 10×17 instead of padded pills. Proved by dumping the DOM ancestry rather than reasoning about it: the PressableScale node (role=radio) has padding 0, radius 0 and no trace of its rounded-full px-2.5 py-1, while its plain-View parent one level up shows those exact classes applied with 4px padding and a 9999px radius. Cause: PressableScale wraps Animated.createAnimatedComponent(Pressable) and is never registered with NativeWind's cssInterop, so className is forwarded as an unknown prop and discarded. Affected: Button, ChoiceCard, DateTimeField, PillSelect, PlanNudge, LanguageSelect — every one declares padding / borders / radius / flex-direction in className. Deliberately not fixed in the same commit: the fix makes those styles suddenly apply across six shared components, which is a real visual change on the systemic surface and needs a device pass plus a drift-ledger row under ADR-0015 — not something to bundle into a tooling change. Current offenders are inventoried in KNOWN_SMALL_TARGETS, which fails if the list is not shrunk when this lands. | | QRS-191 | bug | ✅ fixed 2026-07-27 | 12 undersized touch targets across Profile/Settings/onboarding — all fixed, allowlist now empty.One claim in the original row was wrong and is corrected here: the 32×32 buttons were NOT unlabelled. They were "Back" and "Settings", labelled all along. The gate reported them as "" because it read textContent ?? aria-label, and for an icon-only button textContent is '' (empty string, not null) — so ?? never fell through. A measurement artefact was reported as an accessibility defect; the operator is now ||. The real defects were size-only. Root cause in QRS-193. Original text follows. Two defects in one control: be Two defects in one control: below the 44pt Apple HIG / 48dp Material minimum, and unlabelled, so a screen reader announces nothing. Found by the touch-target check. Profile additionally shows View public card at 158×38, Change at 53×22, and the four segmented tabs at 78×38 — all short of 44 in height, most of them downstream of QRS-190 since their padding comes from className. Fix the root cause first, then re-measure and shrink the known-offender list. | | QRS-192 | bug | 🔴 open | React hydration mismatch (#418) on the web export — the prerendered HTML is thrown away. Surfaced while diagnosing suite nondeterminism: /profile reports Minified React error #418 and, measured directly, has 18 nodes and zero text at networkidle, then 87 nodes and 220 characters ~500ms later. React fails hydration, discards the server-rendered markup, and re-renders client-side. Why it matters beyond the tests: static export exists to make first paint fast and to give crawlers real HTML; a mismatch forfeits both, so the Web PWA pays the cost of prerendering and gets none of the benefit. It also made the test suite flaky before the wait was made settle-aware, which is how it was found at all. Not yet root-caused — the likely candidates are the theme/locale stores rehydrating from localStorage (server render cannot know either) and the entry-gate redirect. Needs its own investigation; a suppressHydrationWarning would hide the symptom and keep the cost. | | QRS-193 | bug | ✅ fixed 2026-07-27 | The design system's own touch-target token was below every platform guideline — the root cause behind all 12 undersized controls. sizing.touchMin in @qrsetu/tokens was 40, against Apple HIG's 44pt minimum and Material's 48dp; sizing.control.sm is 32. Every offender in QRS-191 clustered on 32 / 38 / 40 because those are the values the tokens offer. Compounding it, 44 was not expressible at all: the space scale is a custom design scale (10 = 32px, 12 = 40px, 16 = 52px) with nothing at 44, so h-10 w-10 — which reads as 40px in stock Tailwind — silently rendered 32×32. Fixed by raising touchMin to 44 and documenting that control.* are VISUAL sizes which may legitimately be smaller than the tap box. 44 rather than 48 so the token matches the Playwright gate exactly (one number, not two). Note this is a token change = systemic surface, so it carries a drift-ledger row and needs syncing to the design project's tokens/sizing.css. | | QRS-194 | bug | ✅ fixed 2026-07-27 | Disabled buttons never actually dimmed — on any platform, since the primitive was written. Button set opacity: disabled && !loading ? 0.45 : 1 in its own style, but PressableScale composes style={[style, aStyle]} and its animated style sets opacity unconditionally (1 at rest). The later array entry wins, so the caller's value was discarded every time and a disabled button was visually indistinguishable from an enabled one. Two further problems in the same place: the value had drifted to 0.45 where the design system's Button spec says 0.42, and each component was hand-writing it. Fixed by moving disabled dimming into PressableScale's animated style — the only composition order that survives — with the value as a new opacity.disabled token. Found by a unit test on the new IconButton, not by review or by eye, which is the argument for asserting resolved style rather than presence. | | QRS-195 | bug | ✅ fixed 2026-07-27 | npm run test was broken from the moment Playwright landed, and I reported that commit as fully green. Playwright specs also end in .spec.ts, which matches jest's default testMatch, so jest loaded e2e/layout-invariants.spec.ts and Playwright's test.describe threw throwIfRunningInsideJest. 297 tests passed but the run exited non-zero, so the workspace CI job would have failed on develop at 9f54c4e had it run. Process failure, not just a config bug: the gate was declared green without being re-run after adding a file that changes what the gate collects. Fixed with testPathIgnorePatterns: ['<rootDir>/e2e/'] — the two runners must never see each other's files. | | QRS-196 | bug | ✅ fixed 2026-07-27 | The new lint guardrail silently protected only half the tree — a guardrail that reports success while covering nothing is worse than no guardrail. ESLint flat config replaces a rule's options when a later block re-declares the same rule for overlapping files; it does not merge them. The raw-Pressable ban was written as its own block for apps/mobile/src/**, and the pre-existing tier-boundary blocks re-declare no-restricted-imports for tiers/user/** and tiers/admin/** — which hold nearly all product code — so the ban was discarded exactly there. Caught by probing rather than reasoning: identical probe files errored under src/app/ and passed under src/tiers/user/. Fixed by hoisting the restriction into a shared constant composed into every block that declares the rule, re-verified with probes in all three locations plus the src/ui exemption. Same family as QRS-013 (the green no-op). | | QRS-197 | security | ✅ fixed 2026-07-27 | Avatar upload hardened — the public bucket was an SVG away from stored XSS on every Setu Card. manage-profile accepted any base64 and trusted the client-supplied content_type, with no size cap. Since the bucket is public and serves inline, an uploaded image/svg+xml is a scriptable document executing on our own origin, reachable from every public card — worse in kind than a data leak because it is active. Fixed: format is now identified from magic bytes (sniffImageMime), SVG is rejected explicitly and by test, the stored contentType is the sniffed value never the claimed one, and decodeBase64Image enforces a 2 MB decoded-byte cap (checked against the base64 length first, so an oversized payload is refused before it is materialised) and raises ValidationError → 400 instead of an uncaught atob throw → 500. The bucket migration adds its own allowed_mime_types + file_size_limit as a second line, because a limit that exists only in application code is one deploy from not existing. 10 Deno tests pin it, including near-misses (RIFF/WAVE posing as WebP, truncated JPEG signature, XML-declaration and leading-whitespace SVG variants). | | QRS-198 | bug | ✅ fixed 2026-07-27 | The profile-pictures bucket existed only on Prod, created by hand — avatar upload could never have worked on Dev. Same class as the pg_cron gap CLAUDE.md already flags: environment state that was never captured as portable schema and therefore silently diverged. manage-profile has referenced BUCKET_NAME the whole time, so on Dev it would have failed at the storage call with a bucket-not-found error that reads like a code bug. Now a migration (idempotent, so it is correct against both projects), with storage.objects RLS: public read (avatars render on public cards) but writes restricted to users/{auth.uid()}/ so one merchant cannot overwrite another's avatar. Every policy carries an explicit TO clause — an unqualified policy defaults to PUBLIC, which is exactly how QRS-001 happened. | | QRS-199 | security | ✅ fixed 2026-07-27 | Deleting an account left the avatar publicly readable forever. manage-account delete_account removed the auth.users row, which cascades to profiles — but storage objects are not in that graph. The photo stayed in a public bucket with no row anywhere pointing at it, so nothing could ever locate it again to clean up. A DPDP erasure failure, and Apple requires working in-app deletion for App Store approval. Now the object is purged before the auth row (afterwards we would have lost the only handle on it), best-effort with a loud log — refusing deletion because a storage sweep failed would deny erasure entirely, which is the worse outcome, and the residue is recoverable by prefix from the log line. | | QRS-200 | debt | 🔴 open | deno lint fails on every Edge Function test file — 42 no-import-prefix errors, all pre-existing. Test files import https://deno.land/std@0.208.0/testing/asserts.ts inline, which the linter forbids; they should come from a bare specifier declared in supabase/functions/deno.json. Not introduced by the avatar work (manage-account's test errors identically and was untouched), and npm run test:ef is green — 79 tests pass — so this is lint-only. Worth fixing as one mechanical pass rather than per-feature, and worth knowing before anyone wires deno lint into the backend CI gate expecting it to be clean. | | QRS-201 | bug | ✅ fixed 2026-07-27 | The app had two theme channels and they could disagree, so choosing "Light" on an OS-dark device rendered white cards and navy text on a DARK page — every screen at once, desktop and mobile web. Channel A (imperative: useThemeColors() → NativeWind useColorScheme()) followed the in-app preference; channel B (CSS variables behind bg-background etc.) was bound to the OS by a @media (prefers-color-scheme: dark) block in packages/tokens/src/theme.css. Its comment claimed "NativeWind applies these to :root when the OS is dark" — it does not: with darkMode: 'class' NativeWind toggles the dark class and never reads the media query (the compiled sheet confirms: --css-interop-darkMode: class dark). The media query was itself a patch for a second, real defect, which is why deleting it alone is not the fix: NativeWind's web runtime treats colorScheme.set('system') as "not dark" and removes the class even when the OS is dark, so 'system' would have gone light-on-dark. Root fix, one channel: @/lib/colorScheme resolves 'system' to a concrete scheme so NativeWind only ever receives 'light'/'dark', plus an Appearance subscription to restore live OS-following (which passing a concrete value gives up) — the same code path on Android, iOS and web. The media query is gone; color-scheme moved onto :root/.dark so UA scrollbars and form controls follow the app too; +html.tsx had the same OS-keyed bug independently in its body background and its paired theme-color metas, both now driven by the resolved scheme; and a pre-paint inline script sets the class before first paint so removing the media query does not reintroduce a flash. Why no gate caught it: playwright.config.ts pins colorScheme: 'light' for reproducibility, so every existing spec ran in the one OS state where the two channels agree — the bug was not under-asserted, it was unreachable by construction. New e2e/theme-consistency.spec.ts drives all six OS × preference combinations. | | QRS-202 | a11y | 🔴 open | Two design-token colours fail WCAG AA as text, in light theme only — found by the new contrast gate, pre-existing. accent-active (36 78% 46%) measures 2.90:1 on surface and 2.58:1 inside an accent-soft pill; content-tertiary (210 18% 52%) measures 3.82:1 on surface and 3.48:1 on surface-muted. AA requires 4.5:1 for body text. These are not stray call sites: accent-active is the focused tab-bar label, the plan pill, and the HeroCard/QuickActions captions (~15 usages), and content-tertiary is the standard subtitle/inactive-label ink, so it is nearly everywhere. Dark theme passes — there both tokens are pale ink on a dark surface. Deliberately not fixed in code: packages/tokens/** is design-first with no exceptions under ADR-0015, so darkening the brand amber and the tertiary ink is a design decision to be made upstream and pulled, not invented in a test file. Held as two exact-colour entries in AA_EXEMPT (e2e/theme-consistency.spec.ts) so every other low-contrast pairing still fails the gate; the set must shrink, never grow. | | QRS-203 | bug | ✅ fixed 2026-07-27 | The QRS-190 fix was correct on web and catastrophic on native: every PressableScale in the app rendered with NO style at all. Registering cssInterop(AnimatedPressable, { className: 'style' }) made the interop intercept className and take ownership of the style prop, discarding the [style, aStyle] array — so controls lost background, padding, radius, row direction and the press animation simultaneously. Observed on the API 36 emulator: the HeroCard CTA had no white pill and stacked its icon above its label, SponsoredStrip lost its tint/border/padding, PlanNudge's "Upgrade" lost its amber pill. The reason the registration is unnecessary on native: Reanimated's animated wrapper forwards unmapped props — className included — down to the wrapped Pressable, which NativeWind already registers, so class styles arrive without help. On web the wrapper does not forward, which is why QRS-190 was a real defect there (measured: LanguageSelect chips 10×17 → 30×25). Fixed by scoping the registration to Platform.OS === 'web'; precedence stays identical on both platforms (class styles pushed last) by two different routes. Two gates were blind by construction, in opposite directions: jest mocks createAnimatedComponent: (c) => c, so AnimatedPressable === Pressable and neither the original omission nor the double registration exists under test; and Playwright covers the web bundle only — precisely the platform where the registration is correct. Neither could have caught this. Found by building the APK and looking at it, which is now a required parity step rather than an optional follow-up. Same family as QRS-195 and QRS-196: a gate reporting success over ground it does not cover. | | QRS-204 | infra | 🟡 partial 2026-07-27 | C: back to 4.2 GB free — and the relocation had NOT regressed. TEMP/TMP, GRADLE_USER_HOME, ANDROID_HOME, PLAYWRIGHT_BROWSERS_PATH were all still on D:, and the AVDs (~7 GB) were on D: behind the .android junction. Three separate causes: (1) Docker Desktop's WSL2 disk, 15.1 GB, which the strategy structurally could not cover — every other relocation is an env var but Docker's data folder is a GUI setting, and a .vhdx never shrinks (docker system prune frees space inside the VM; the host file stays at its high-water mark), so it only ever grows; (2) caches never in scopenpm_config_cache unset → 0.76 GB on C:, ~/.cache/codex-runtimes 1.41 GB (not this repo's tooling), .expo 0.39 GB; (3) the QRS-012 guard was bypassed — an agent ran gradlew.bat assembleRelease directly instead of npm run build:android, which is exactly the stale-shell case the guarded script exists to prevent, and it OOM'd the Gradle daemon. A guard that can be walked around is a convention, not a control. Measurement trap that made the first diagnosis wrong and is worth remembering: Get-ChildItem -Recurse follows junctions, so the initial scan billed ~8 GB of D:\Android to C:; resolve the target before attributing size to a drive. Fixed: npm_config_cache + ANDROID_SDK_ROOT set to D:, npm cache moved to D:\DevCache\npm; new tools/check-disk-hygiene.js (npm run check:disk) asserting the invariants — env vars resolve through junctions off the system drive, unset vars flagged (an unset var falls back to a C: default, which is how npm's cache got there), C: free above a 15 GB floor (the page file is system-drive-bound — QRS-012), and the un-relocatable caches printed with sizes + their specific fix; wired advisory into the guarded build's preflight so a doomed build fails in the first second instead of ten minutes in. Documented in guides/windows-build-environment.md § Disk hygiene. Still open (needs the user, GUI-only): relocating/compacting the Docker disk — docker system df reports 18 images / 13.48 GB / 100% reclaimable, 0 active containers, so pruning then compacting reclaims ~13 GB without moving anything, and is preferable to a straight move because D: has only 24.9 GB free. | | QRS-205 | infra | ✅ fixed 2026-07-27 | Relocating dev artifacts off C: bounded where they land, not how many — so the drive that fills just changed letter. Days after QRS-204 moved Docker and the caches to D:, the measurement was C: 20.2 GB free / D: 21.8 GB free — the work drive was now the tighter of the two, carrying D:WorkSpace 41.4 GB + D:DevCache 11.7 GB + D:Android 8.1 GB. The reason is structural and was never addressed: dev artifacts are monotonic. Every release build writes a fresh ~49 MB APK, every OOM-killed Gradle daemon leaves a multi-MB replay_pid*.log (4 were sitting in the repo), every Metro run seeds another scratch dir, and Gradle's build cache is unbounded by default (1425 entries here). QRS-204 built a check that asserts the layout; nothing anywhere removed anything, so the trend line was unchanged and only its slope moved. Fixed by making retention a mechanism rather than an intention: new tools/clean-dev-artifacts.js — a two-tier policy engine over a published retention schedule (safe tier: build scratch 3d, crash dumps 0d, superseded APK/AABs newest-per-variant-then-7d, Playwright output 7d, Gradle daemon logs 7d, Expo cache 14d, Claude scratchpads 14d, Xcode DerivedData 14d on the Mac; deep tier: Gradle build-cache entries 30d, superseded wrapper dists 60d, native build dirs 21d), dry-run by default, deleting only regenerable data — never source, .env*, ~/.claude memory, or a warm same-day cache. Three triggers: a per-user scheduled task (daily 02:00 + 15 min post-logon, catch-up enabled, no admin — an automation that needs elevation is skipped once and then forever), after every guarded Android build (the moment artifacts are actually created), and escalation on measured pressure, not on a calendar (--auto-escalate runs the deep tier only below the 15 GB floor; a fixed weekly deep sweep either discards warm caches for nothing or misses the week the drive fills). check:disk extended to match: the work drive now carries the same 15 GB floor, the repo location is asserted, and — closing the QRS-204 lesson properly — the check fails when the scheduled sweep is not registered, because an uninstalled automation is indistinguishable from a working one right up until a drive fills. Codified as a non-negotiable standard in CLAUDE.md; policy + rationale in guides/windows-build-environment.md § drive standard. Two traps worth carrying forward: the sweeper must never follow reparse points (.android is a junction — a recursive delete through it destroys the target, not the link), and it deliberately does not age out caches/<version> dirs because a directory's mtime does not update when Gradle writes into its subdirectories, so an age test there can delete the version in use. Verified: task registered and force-run end-to-end (exit 0, 107 MB freed, logged to D:DevCachelogsdev-cleanup.log); check:disk green on all invariants. | | QRS-206 | bug | ✅ fixed 2026-07-27 | The QRS-201 theme fix was correct on web and reintroduced the very split it removed on BOTH natives — and the reason is that React Native never had a working class-driven dark theme at all; a media query had been carrying it by accident. Reported from an iOS native build: dark cards on a white page. Root cause, in two layers — the first is the one that matters and it took a device to find: (1) react-native-css-interop compiles this stylesheet with darkMode defaulting to {type:'media'} (css-to-rn/index.js:22), and NativeWind's Metro transformer passes it only ignorePropertyWarningRegex + groupingnever a darkMode option (nativewind/dist/metro/common.js). In media mode isRootDarkVariableSelector returns false for every selector, so no class-based dark block can ever register. The compiler switches to class mode only on encountering an @cssInterop set darkMode class dark; at-rule, which nothing in our pipeline emitted (NativeWind emits a --css-interop-darkMode declaration, but extractCSSInteropFlag reads only the at-rule form). (2) Independently, the dark block was declared on a bare .dark, which isRootDarkVariableSelector also rejects — it requires .dark:root or :root[class~="dark"]. What QRS-201 actually did: the @media (prefers-color-scheme: dark) { :root { … } } block matched isRootVariableSelector + isDarkModeMediaQuery, which are independent of the darkMode option, so it was the only thing registering dark tokens on native — and css-interop keys it to colorScheme (app-controlled), not the OS, so on native it behaved correctly. On web the same block was real CSS evaluated against the OS, which is the bug QRS-201 fixed. One line was simultaneously a web defect and native's sole dark source. Fails silently by construction: cssVariableObservable builds each dark observable with fallback: light, so unregistered dark tokens resolve to their light values with no warning — native rendered light bg-* classes while useThemeColors() (imperative) returned dark. Fix (both halves required; either alone is inert): emit @cssInterop set darkMode class dark; as the first rule (the compiler walks rules in document order, so a later marker is too late) and declare the dark block on .dark:root. Browsers ignore unknown at-rules, so web is untouched. Measured with the exact options NativeWind passes: before → --surface = {light:[0,"0%","100%"]} (dark dropped); after → {light, dark:[210,"30%","11%"]}, 6/6 root variables carrying dark. Verified on the Android emulator (x86_64 release, cold Metro cache): page rgb(20,28,36) = dark --surface, cards rgb(28,38,48) = dark --surface-raised, 98.6% dark pixels — against rgb(255,255,255) page / 54% before. Light theme still correct. Web re-verified: 96/96 theme e2e. Parity test now pins all three invariants (no @media; .dark:root not bare .dark; the flag present and ordered first). Three process defects this exposed, each now closed: Playwright covers the web bundle only — the fourth defect in that family (QRS-195, QRS-196, QRS-203); Metro's cache key does not include @imported files, so editing a token leaves global.css byte-identical and a stale stylesheet is reused, which manufactured a convincing false negative mid-investigation (now --reset-metro, required for any packages/tokens change); and the arm64-only local APK cannot run on an x86_64 emulator (SoLoaderDSONotFoundError: libreactnative.so), which makes native verification look impossible and pushes you back onto the web gates (now --abi=x86_64 / build:android:emulator). | | QRS-207 | bug | ✅ fixed 2026-07-27 | Android press states painted a grey block with SHARP CORNERS over rounded controls; iOS and web were correct. Surfaced once QRS-203 restored native styling — the artifact had been invisible while PressableScale was rendering with no background or radius at all. Two independent defects in one line (android_ripple={{ color: c.overlay }}), either sufficient alone: (1) wrong tokenoverlay is the modal-scrim token (hsl(210 48% 18% / 0.42) light, hsl(210 52% 4% / 0.62) dark); a 42–62% opaque navy is a backdrop dimmer, and as a ripple tint it paints a near-solid block rather than a hint; (2) rectangular mask — RN's useAndroidRippleForView installs a RippleDrawable as the view's native background, and a bounded ripple's mask is the view rect, not its borderRadius. RN exposes no corner-radius option there; borderless: true only trades square corners for bleed outside the shape, and a clipping wrapper is unreachable from this component because the radius arrives via the caller's className. The codebase had already voted: nine call sites passed rippleColor={null} (tab bar, FAB, Calendar, ClockPicker, ghost Button) to switch the ripple off one control at a time — the tell that the default was wrong, not that those controls were special. Fix: android_ripple and the rippleColor prop removed; the existing reanimated scale+opacity is the platform-neutral visual, so press feedback is now identical on Android, iOS and web, with the haptic retained on both natives. This supersedes the ripple half of QRS-183, which specified a material ripple on product instruction — the tactile half (haptics on both natives) is unchanged, and apps/*/src/ui/** is a design-first surface under ADR-0015, so the press-feedback spec should be confirmed upstream in the design project; recorded as a correction row in the drift ledger meanwhile. | | QRS-208 | improvement | 🟡 partial 2026-07-27 | Cross-platform parity is now automated in layers, because the gates we had could not reach the platforms where it breaks (ADR-0017). Four defects in two days shared one shape — a change correct on the surface it was tested on and broken on one it was not (QRS-190, QRS-203, QRS-206, QRS-207) — and all four passed npm test AND npm run e2e, so more assertions in those places would have caught none of them: Playwright runs the web bundle only, and jest mocks Reanimated's createAnimatedComponent to identity so native-only wrapper behaviour cannot be asserted at all. Shipped (layers 0–1): three Claude Code hooks — a PreToolUse Bash guard that blocks the bypasses which have actually caused incidents (direct gradlew assemble*, all-ABI expo run:android --variant release, prettier --write on theme.css), a PostToolUse hook that runs the parity gate the moment a systemic-surface file is edited and states what will not count as verification for it, and a Stop hook that reports from git whether systemic changes are committed and pushed (round two of QRS-206 was lost to telling the user to git pull work that had never left the machine); plus tools/check-parity.js (npm run check:parity), 7 rules each citing the incident that produced it, wired into pre-commit, a new pre-push hook, and CI. Every rule is mutation-tested, which immediately paid for itself: R1d could not fail as first written — it searched the whole file for the @cssInterop flag and theme.css documents that flag in its own header, so the prose satisfied the rule. A rule that cannot fail is worse than none because it reports safety. The Bash guard needed the same treatment in the other direction: its first act was to block the command that was testing it (the pattern sat inside a quoted JSON payload), so matching is now quote-aware — a guard that fires on discussion of the thing it guards is one people switch off. Also found and fixed a real gap while wiring it: an undocumented Platform.OS === 'ios' branch in Sheet's KeyboardAvoidingView (correct — Android already resizes the window via adjustResize, so padding there double-counts the inset — but unwritten), and one over-broad rule of my own (elevation is legitimate inside src/ui/theme/, the seam that pairs it with the iOS/web shadow* props). Specified, not built (layers 2–4): a dev-gated /__parity route where the app reports SCHEME/VAR_SURFACE/IMP_SURFACE/COHERENT so one text assertion covers all three surfaces under Maestro and Playwright alike (COHERENT=false is QRS-201 and QRS-206); an Android-emulator CI job; an iOS simulator job; axe-core; Linux-only visual baselines; and a weekly audit that reports coverage gaps rather than gating. Open decision: iOS CI cadence — macOS runners bill at 10× on this private Free-plan repo (~16 runs/month before overage), so the recommendation is Android per-PR + iOS on the develop → uat promotion and nightly, rather than per-PR iOS. Deliberately rejected: cross-platform pixel diffing (fails on legitimate rasterisation/shadow differences and produces the always-red gate people learn to ignore) and any pre-commit hook that builds native (a ten-minute pre-commit is bypassed on day two, taking the fast checks with it). | | QRS-209 | project | ✅ resolved 2026-07-28 | The P3 blocking pre-flight FAILED, and it was the only thing standing between a drop table and 11 rows of production data. The reminders plan's central decision — build greenfield rather than adopt the legacy schema — rested on the premise "Prod confirmed to hold no reminder data", and its first blocking gate said to prove that rather than assume it: "A destructive migration on unverified data is not acceptable regardless of expectation." Measured, per project ref via the Supabase MCP tools rather than a connection label (the QRS-179 trap — the pgAdmin entry labelled "Dev" points at Prod): qr-setu-prod (ygmqxyrbnemhwkiyoboc) holds 11 rows in public.reminders across 4 distinct users, every one of whom has a profile; qr-setu-dev (dyhjofjjuazhyqcvlrkx) holds 0. reminder_categories is empty on both. The premise was simply wrong, and nothing in the codebase would have revealed it — the only consumers of these tables live in legacy/**, which is neither built nor linted, so a grep for live callers returns clean and says nothing about stored rows. Characterisation (the data is dev/QA scratch, but that is a conclusion, not an assumption): titles are test ×2, hi, Hjjnn, Bhhgh, Need to vist, and one row containing our own "7-Phase Migration Roadmap 📋" pasted into the title field; 11 of 11 carry is_completed = true, which no real usage pattern produces. Consequence — the strategy changed from drop to expand-contract archive. The legacy tables are moved to a non-API-exposed legacy schema: rows preserved, RLS/grants revoked, PostgREST reach removed (Supabase exposes only configured schemas), and the canonical public.reminders name freed for the new model. The actual DROP becomes a separate one-line contract migration gated on owner sign-off, which is what expand-contract prescribes anyway and which CLAUDE.md already mandates ("never a breaking change in one step") — so the safe path and the standard-compliant path turned out to be the same path. Also corrected: a contradiction inside the plan itself. It directed dropping "the three profiles reminder columns" while its own scheduler spec "honours reminder_notifications_enabled" — mutually exclusive. Resolved in favour of the scheduler: only the unmaintained counter reminder_count is dropped (QRS-211); the two genuine preferences (reminder_notifications_enabled, reminder_notification_time) are kept and actually read. Two further schema facts the plan had wrong, found by reading the live catalog instead of the baseline file: the legacy notes column is description, not notes; and reminders.category defaults to lowercase 'general' while every stored row holds 'General'/'Work'/'Personal' — the free-text-versus-reminder_categories drift the plan predicted is already measurable, not hypothetical. Prevention: Dev and Prod schema fingerprints were compared (md5 over the column catalog: identical, 7a07b4dd…, 25 columns) before writing one migration for both, rather than applying and discovering divergence. ✅ Closed 2026-07-28 — owner sign-off received: the 11 rows are dummy/QA data and disposable, which matches the characterisation above rather than overriding it. Contract migration 20260728070933_drop_legacy_reminders.sql drops both archived tables and then the legacy schema itself; applied and verified on Dev (legacy schema absent, the 3 new reminders tables intact). Two deliberate choices in it, both for the same reason as the archive migration: no IF EXISTS, so a run against the wrong database fails instead of silently "succeeding"; and DROP SCHEMA ... RESTRICT rather than CASCADE, so anything unexpected later placed in legacy blocks the drop loudly instead of being deleted without a word. Not yet on Prod — Prod has had none of the reminders migrations, so its first db push will archive-then-drop in one pass, which is the same net effect the sign-off authorised. The lesson worth keeping is not about reminders. The blocking pre-flight was the only thing between a drop table and live rows, the premise it tested was wrong, and nothing in the codebase could have revealed that — the only consumers live in legacy/**, which is neither built nor linted, so a grep for callers returns clean and says nothing about stored data. Reading the database beat reasoning about the code, twice in one round (this row and QRS-210). | | QRS-210 | bug | 🔴 open 2026-07-27 | The missing-idempotency defect is not theoretical — it has already happened three times on Prod, and the duplicate rows are still there. The reminders plan listed "no idempotency on manage-reminder" as a High-severity predicted defect against CLAUDE.md's "idempotent mutations" standard. Reading the 11 legacy Prod rows to characterise them surfaced the defect as data: three pairs of rows, identical title, identical due_date, same user_id, created seconds apartInterview of new staff at 10:32:58.606 and 10:32:59.277 (0.7 s), Hjjnn at 10:33:31.036 / 10:33:32.13 (1.1 s), Bhhgh at 10:34:46.284 / 10:34:54.009 (7.7 s). That is the double-tap/retry signature, on a form with no client-supplied idempotency key and no server-side dedup window. 6 of 11 rows — over half the table — are duplicates of each other. Worth stating plainly: the legacy write path was a direct supabase.from('reminders').insert(), so there was no server-side mutation layer that could have deduped; the new path routes through the manage-reminder Edge Function, which is what makes a fix possible at all. Fix (lands with P3, not deferred): manage-reminder requires a client-generated idempotency_key, persisted with a UNIQUE constraint so a replay returns the original result rather than creating a second row — the same pattern CLAUDE.md mandates for webhooks. Test that would have caught it: an idempotency-replay case in the EF's Deno suite (same payload twice → one row, identical response), now in the P3 test matrix. Value of the finding: it converts a plausible-sounding requirement into a measured one, and it is the second time this round that inspecting real data beat reasoning about code — the first being QRS-209 itself. | | QRS-212 | debt | 🟡 partial 2026-07-27 | npm test and npm run type-check silently skip all 8 packages/* — the same green-no-op that QRS-013 already burned this repo on, now in the two gates the whole TS-first architecture leans on. Both scripts are npm run <script> --workspaces --if-present, and only apps/mobile defines either script. So --if-present finds nothing in packages/{analytics,data,domain,i18n,observability,schemas,tokens,utils} and reports success. Measured, not inferred: packages/domain was export {} and apps/mobile/tsconfig.json was the only tsconfig.json in the repo outside legacy/. Why it matters more than it looks: ADR-0012's entire premise is "data logic is written once, in packages/, only UI twice", and the reminders plan puts the correctness-critical work (recurrence expansion, DST, the iOS 64-notification budget) in packages/domain because it is L1-testable. It was not testable — there was no runner that would ever execute it. Type coverage was partial-by-accident: tsc follows imports, so package code IS checked while apps/mobile imports it, and a package file that nothing imports yet is checked by nothing. This is the identical failure shape as QRS-013 (lint --workspaces --if-present matched zero workspaces and passed CI green for months) — the lesson was recorded, the fix was applied to lint only, and the same construction survived in test and type-check. Fixed for packages/domain: a real tsconfig.json (extending @qrsetu/typescript-config/base) + type-check script, and "test": "node --test \"src/**/*.test.ts\"" running 67 assertions — zero new dependencies, because Node 22.23 strips TypeScript types natively and node --test is already the established pattern here (test:hooks). Cost of that choice, stated: Node's ESM resolver does no extension guessing and does not map ./x.js./x.ts (both measured, not assumed), so relative imports inside that package carry an explicit .ts and the package sets allowImportingTsExtensions. Metro and jest both resolve an explicit .ts path unchanged. The alternative — jest per package — needs jest as a new dependency in each (it is not hoisted; only babel-jest/@babel/core/@babel/preset-typescript are), which this repo does not spend lightly. ⚠️ Open: the other 7 packages still have no test/type-check script and are still silently skipped. The durable fix is a gate that FAILS when a workspace containing src/*.ts declares neither script, rather than another round of remembering — that is a check:workspaces rule, and it belongs with QRS-208's static layer. Until it exists, this row is the only thing standing between us and a third instance. | | QRS-213 | bug | ✅ fixed 2026-07-27 | The profile-pictures bucket migration had never been executed against any database, and failed on first contact. QRS-198 captured the hand-created Prod bucket as migration 20260727113720 and the work was marked done — but the file was written, reviewed, and committed without ever being run, because Prod already had the bucket from manual Studio creation and Dev was simply never pushed to. The first real execution (against Dev, this session) failed immediately: ERROR: must be owner of relation objects (SQLSTATE 42501) on COMMENT ON POLICY "profile_pictures_public_read" ON storage.objects. Cause: Supabase grants the migration role enough to CREATE POLICY on storage.objects but not ownership, and COMMENT ON requires ownership. The two CREATE POLICY statements before it were fine; only the comment was refused — a genuinely easy thing to get wrong, and undetectable by reading. Fix: both COMMENT ON POLICY … ON storage.objects statements replaced with -- comments carrying the same rationale, which are equally durable in the migration history and cannot fail. Applied to Dev and verified. The real finding is the process one: check:sql parses migration TEXT and cannot execute it, so a migration can pass every gate in the repo while being unrunnable. Nothing in CI applies migrations to a real database (test:db/pgTAP needs the local Docker stack, which is not in CI yet). Same shape as QRS-198 itself and as the pg_cron gap CLAUDE.md flags: environment state that was never executed anywhere is not "done", it is untested. Worth noting what caught it — pushing to Dev because a different task needed it, not any control. | | QRS-214 | security | 🅿️ Dev fixed 2026-07-27 · Prod promotion PARKED by owner decision 2026-07-28 | Every function in public was executable by anon — including an unauthenticated privileged WRITE. 27 functions on Dev, 22 on Prod, 19 of them SECURITY DEFINER. Found by asking the catalog whether the reminders RPC hardening had actually worked, rather than by re-reading the migration that claimed it. The two-channel root cause, which is the whole lesson: a Postgres function can be reachable by anon two independent ways, and each fix looks complete on its own. (1) Supabase's ALTER DEFAULT PRIVILEGES grants anon=X explicitly on every new function in publicQRS-002 found and closed this root cause for tables (defaclobjtype='r') and never looked at functions ('f'). (2) PostgreSQL's own CREATE FUNCTION grants EXECUTE to PUBLIC by default, and anon is a member — functions differ from tables here, which is exactly why the table-shaped fix did not generalise. Consequence: CLAUDE.md's prescribed RPC idiom — REVOKE ALL … FROM PUBLIC + GRANT EXECUTE TO authenticated — closes channel 2 and leaves channel 1 wide open. Every RPC in this repo follows it faithfully, so get_reminders was born anon-executable while carrying the correct-looking hardening. My own first remediation then closed channel 1 and left channel 2 open, and looked equally correct. Two fixes, each covering the other's blind spot, neither complete — caught only because the verification asked proacl after the answer disagreed with the file. What was genuinely exploitable (Supabase exposes public functions at POST /rest/v1/rpc/<name>, and the anon key ships inside every client build, so "anon can execute" means "anyone on the internet"): bulk_enable_features_for_domain(p_domain_id, p_feature_ids, p_admin_user_id, p_reason)SECURITY DEFINER, no authorization check of any kind, and it takes the admin's identity as a PARAMETER, so the caller simply asserts who they are. An anonymous request can enable or disable platform features for any business domain and attribute the change to any admin uuid. This is an unauthenticated privileged write — worse in kind than QRS-001, which was read-only. rollback_bulk_operation(p_bulk_operation_id, p_admin_user_id) is the same shape and soft-deletes domain_features rows. log_subscription_usage(…) (both overloads) lets an anonymous caller INSERT into subscription_usage_logs — billing-adjacent poisoning. get_user_subscription(user_id) / user_has_feature_access(user_id, …) / user_has_exceeded_limit(user_id, …) take an arbitrary user_id and never consult auth.uid(), so they are also a horizontal privilege escalation between signed-in merchants, not merely an anon problem — which is why the fix revokes authenticated from those too, not just anon. Measured mitigations that limited real-world impact: get_user_subscription queries a subscriptions table that does not exist on Prod (it would error, not disclose — mitigation by accident, not by design); get_reminders's internal auth.uid() IS NULL → raise check meant anon got an exception rather than rows, which is the argument for writing that check in every SECURITY DEFINER RPC regardless of what the grants are believed to be. Fix (Dev, applied + verified): migrations 20260727150300 + 20260727150400 — close BOTH default-privilege channels (REVOKE EXECUTE ON FUNCTIONS FROM anon and FROM PUBLIC), revoke the dangerous set from anon+authenticated+PUBLIC while keeping service_role so Edge Functions still work, and restate the retained anonymous surface as 6 explicit grants so "who can call this" is answerable from the ACL rather than inherited from a catalog default. Consumer impact measured before writing, not assumed (the QRS-001 discipline): policies referencing / functions calling / views using each target were counted — is_admin() is referenced by 1 RLS policy and is therefore deliberately untouched (revoking it would make queries on that table ERROR rather than return no rows, trading a security gain for a self-inflicted outage; it is also the only one with a real auth.uid() guard and reports on the caller, disclosing nothing). Verified on Dev: anon-executable functions 27 → 7, all 7 the intended public surface; get_user_subscription now anon=false authenticated=false service_role=true; function default ACL now {postgres, authenticated, service_role} with anon and PUBLIC gone, so a new function is finally unreachable until a migration says otherwise — the property QRS-002 established for tables and wrongly believed it had established for functions. ⚠️ PROD IS STILL EXPOSED and needs a decision: 22 anon-executable functions, all four dangerous ones reachable, and domain_features holds 23 live rows that bulk_enable_features_for_domain can write anonymously right now. The runbook's same-day rule for security migrations applies (precedent: QRS-001 and QRS-002 both promoted same-day), but promotion is entangled — db push would also apply the four reminder migrations, and QRS-209's archive step moves Prod tables holding 11 rows that are still awaiting owner sign-off. So the two security migrations need either that sign-off or an out-of-order targeted apply. Prevention: tools/check-sql-grants.js gains a rule for exactly this shape (below). ⏸️ PROD PROMOTION PARKED — owner decision 2026-07-28, recorded here rather than left implicit. Rationale given: these are legacy functions from the pre-standards schema, nothing in the current app calls them, Prod carries no live traffic and no real users, and the intent is to harden each Edge Function and its RPC surface as the feature that needs it is built rather than spending a pass on all 22 now. What that decision does and does not buy, stated plainly so it can be revisited on evidence rather than memory: it is defensible only while Prod has no traffic — bulk_enable_features_for_domain is anonymously callable from the public internet today (the anon key ships in every client build), needs no credentials, and writes domain_features, which holds 23 live rows. The exposure is not reduced by the absence of users; only the consequence is. Therefore this row is a release blocker, not merely a backlog item: the two migrations (20260727150300 + 20260727150400) are already written and verified on Dev, so promotion is a db push away whenever Prod stops being empty — and it MUST precede the first real user, the first public launch, or any announcement of a Prod URL, whichever comes first. What is already true and does not depend on the decision: Dev is fixed on both channels; the function default ACL on Dev no longer grants anon or PUBLIC, so new functions are unreachable until a migration says otherwise; and check:sql rule 3 now fails any new SECURITY DEFINER function that does not state its anon reachability, so this class cannot be reintroduced silently on either project. | | QRS-215 | debt | 🔴 open 2026-07-27 | Reminders (P3) — the four things deliberately NOT built, plus one genuinely unsolved problem. Recorded as one row so the deferrals are auditable rather than folklore; each is referenced from the code that would otherwise look incomplete. (1) idempotency_keys retention is UNSOLVED, and this is the real debt here. The table is the replay ledger and the rate-limit source (QRS-210), rows are useful for minutes, and nothing deletes them — so it grows monotonically forever. The natural fix is pg_cron, and CLAUDE.md already records that cron is not replicated to Dev because the existing job hardcodes Prod's own Edge Function URL; adding a second environment-specific cron to fix a retention problem is the wrong trade for a table with zero rows today. Deliberately written into the migration header rather than left implicit, because an unbounded table with no owner is invisible until it is expensive. (2) Swipe-to-complete. The plan asked for swipe plus a checkbox. The checkbox shipped (accessible, 44×44, accessibilityRole="checkbox" + accessibilityState, asserted in tests) and swipe did not, and the reason recorded on 2026-07-27 was partly false and is corrected here on 2026-07-28, because a wrong rationale in the tracker is worse than none. What was wrong: it claimed ReanimatedSwipeable is absent from gesture-handler 2.32. It is not — it ships at the react-native-gesture-handler/ReanimatedSwipeable subpath; only the root barrel omits it (the root index.d.ts exports the deprecated legacy Swipeable alone), and the check behind that claim read the barrel and stopped. What was overstated: jest.setup.ts does not mock gesture-handler "wholesale" — it spreads ...actual and replaces only GestureDetector and the Gesture builder, so ReanimatedSwipeable would import fine but its internal Gesture.Pan() is inert under jest. That does mean a swipe ships with no unit coverage — but Sheet's pull-down dismiss already shipped under exactly that condition, so the honest conclusion is "needs a Maestro/device test", not "cannot be built". The reason that survives, and the one that should have been written down: manage-reminder implements create·update·complete·skip·delete and no action deletes an exception row (see (3) below), so completion is irreversible; a swipe is the lowest-friction commit in the app, and pairing the easiest gesture with the only action that cannot be undone is the actual defect. Ordering follows from that: (3) is the prerequisite for (2), not an unrelated deferral. Secondary constraints, real but solvable: a horizontal pan inside the vertical list needs activeOffsetX or it steals scroll, it competes with the row's own PressableScale, and a left-edge swipe collides with iOS swipe-back and Android predictive back on two of the three surfaces. (3) Un-completing a done occurrence. In the sparse-exception model "pending" is the ABSENCE of a row, so undo means DELETING the exception — a distinct EF action, not a toggle. The row therefore treats Done as terminal; half-building it would present a checkbox that looks reversible and is not. (4) Two smaller gaps: the composer has no category picker (table, service method and token-keyed colour all exist; only the picker is missing), and the feed requests a single 100-row page although the RPC and stub both implement keyset pagination and return next_cursor. Also open, and NOT a deferral — simply not reached: the notification scheduler (needs expo-notifications, ~1.0–1.5 MB/ABI, pre-approved in the plan's app-size table), pgTAP coverage for the new tables (✅ DONE 2026-07-28 — see QRS-220; 48 assertions including the repo's first two-user RLS isolation test. Docker was restored by a reinstall, and building the local stack from scratch immediately surfaced QRS-219, a migration that could not apply to a fresh database. The diagnosis while it was blocked, kept for the record:: Docker Desktop's WSL2 engine is wedged — the Windows-side apiproxy forwards every request to dockerd and each one times out at exactly 10 s with context deadline exceeded, so the CLI reports a 500 on every route including /version. Disk was the obvious suspect given QRS-204 and was ruled out by measurement (19.4 GB free on C:, above the 15 GB floor). wsl --terminate docker-desktop and docker desktop start both failed to bring the engine back; after the terminate the named pipe stopped existing altogether and docker desktop status sat at starting indefinitely. Remaining options are a host reboot or Docker Desktop's Reset to factory defaults, which deletes every local image, container and volume — an owner decision, not something to do unilaterally mid-session. The DB work was validated against the Dev project instead, which is the correct first promotion target regardless; what pgTAP would add on top is RLS isolation across two real users, proof the RPC projection leaks no columns, and grant assertions — none of which the Dev checks replace), /reminders in the Playwright ROUTES list, and the on-device Android/iOS parity pass. One thing worth keeping: the screen suite's first two mocking strategies both failed silently and identicallyjest.requireMock ran the module factory a second time, and import * as data was snapshotted by Babel's _interopRequireWildcard — each producing "every populated case renders the empty state", which reads exactly like a data bug. Method delegation resolving at call time is the pattern that works; it is documented in the test file so the next feature does not rediscover it. | | QRS-211 | security | 🟡 partial 2026-07-27 | All 8 RLS policies on the legacy reminder tables carry no TO clause — the exact shape of the QRS-001 breach, on Prod, found while archiving them. reminders_{select,insert,update,delete}_own and reminder_categories_{select,insert,update,delete}_own all report roles = {public}, meaning they apply to every role including anon, and both tables hold table-wide GRANT SELECT/INSERT/UPDATE/DELETE TO authenticated. Not currently exploitable — each predicate is auth.uid() = user_id, which is NULL for anon, so no row is admitted; this is the 89-policy residue QRS-002 measured and deliberately deferred, and these two tables are part of that count. But the standard in ADR-0014 is that an unqualified policy is a defect regardless of whether today's predicate saves it, because the failure mode is one careless predicate edit away — which is precisely how QRS-001 happened. Resolved for these two tables by the archive rather than by rewriting them: moving them into the non-exposed legacy schema takes them out of PostgREST's reach entirely, and the migration additionally revokes anon/authenticated privileges so the policies are moot on both axes — belt and braces, because "unreachable via the API" depends on a Supabase config setting and should not be the only thing holding. The new reminders / reminder_occurrences / reminder_categories tables state TO authenticated on every policy from the first line, per the plan's security table. Also drops profiles.reminder_count — a cached counter with no maintainer anywhere in the tree, measured at 0 for all 11 Prod profiles while reminders held 11 rows, i.e. already 100% wrong. It is not repaired with a trigger because nothing reads it; the new model derives counts from the rows. Detected by check:sql-adjacent catalog inspection during P3, not by an automated gate — tools/check-sql-grants.js scans new migration text and cannot see pre-existing live policy state, which is a real limitation of the static layer worth recording. ⚠️ Open: the remaining unqualified policies schema-wide (QRS-002's deferred set) are untouched by this. | | QRS-216 | bug | 🔴 open 2026-07-28 | The Playwright layout-invariants gate measures the page BEFORE hydration, so it is green while 9 real touch-target violations sit on two shipped screens. Found by building an agent driver for the web export (apps/mobile/.claude/skills/run-mobile/) and comparing what the gate sees against what a settled page contains. The gate's settle heuristic is "two identical non-zero text-length samples", which fires on any momentarily stable state — including a prerendered shell or a loading skeleton, because both are perfectly stable while the real tree is still on its way. Measured, phone-small (360×740), at gate time vs 6 s later: /dashboard 21 → 724 characters, hiding 5 sub-44px targets (including the 32×32 Notifications bell and the 32×32 Profile button); /settings 8 → 549 characters, hiding 4 (the INR / English / System pills at 32 tall, and a 48×28 switch); /reminders 9 → 2412 characters — clean, but the gate was measuring 0.4% of the screen. The other 8 routes are prerendered-complete and genuinely pass. Two distinct defects, worth separating. (1) The wait is wrong — the gate needs to additionally wait for [data-testid$="-loading"] / skeleton markers to clear, which the driver already does and which is the entire difference between the two columns above. (2) The assertion is wrong — "renders content" is text length > 0, which passes at 8 characters, so a route that renders nothing but a title is indistinguishable from a route that works. KNOWN_SMALL_TARGETS being empty is therefore not evidence of anything. Why this belongs in the same family as QRS-203/QRS-206/QRS-207: it is another green gate that proves less than it appears to, and this one has been green over shipped violations rather than over a change. Fix: port the driver's settle logic into e2e/layout-invariants.spec.ts, raise the content assertion to something route-specific, then expect the gate to go RED and fix the 9 targets (per CLAUDE.md the known-exceptions list must shrink, never grow). e2e/theme-consistency.spec.ts shares the same walk and therefore the same blind spot — and that is not hypothetical: it is why the dark-theme contrast gate is green over QRS-218, a 1.54:1 heading on three shipped screens. That gate already asserts 4.5:1 in dark mode and was written specifically to catch this shape of bug (QRS-201); it misses this instance purely because of when and where it measures. Also captured while measuring (documented in the skill, not defects): probe cannot read contrast on a gradient-painted card because the walk reads backgroundColor only and sails past background-image, which reported a legible heading at 1.05:1 — those are now skipped and counted rather than reported as findings. | | QRS-217 | project | ✅ shipped 2026-07-28 | Notifications (P4) — what was built, and the three decisions that deviate from the plan on purpose. The screen was an honest "coming soon" placeholder gated on a push backend; it now ships a derived feed and needed no backend at all. Items: overdue reminders (7-day lookback), reminders due within 3 days, an offline Setu Card, an incomplete profile — grouped New · Earlier, each routing to where the action completes. Derivation is pure in @qrsetu/domain (25 tests); the screen is composition over already-parity-verified @/ui primitives. (1) No NotificationsService in packages/data, though the plan asked for one. With no server feed in R1 it would have exactly one implementation returning [] — a speculative abstraction (CLAUDE.md forbids) and a misleading one, implying a fetch where there is a computation, so the next reader hunts for a backend that does not exist. useNotificationFeed is the real seam: server notifications later become one more source merged into deriveNotifications, additively. (2) No plan/upgrade item, though the plan's sketch said "plan/card status". An "upgrade to unlock" row inside the native app is an in-app-purchase CTA under Apple 3.1.3(d) — a store-review risk, not a preference (ADR-0002) — and telling a free-tier merchant they are on the free tier is noise anyway. A unit test and a screen test both assert no upsell can appear, so a regression fails a gate rather than a store review. (3) DashboardSummary.hasNotifications was REMOVED from the read model, not left unused. It was a server boolean the stub hardcoded to true, so the header bell's unread dot was permanently lit regardless of whether anything was pending. A derived list and a server flag are two sources of truth for one dot and the flag is the one that cannot be right — the same reasoning that dropped the unmaintained profiles.reminder_count (QRS-211). Design details that are load-bearing rather than incidental: item ids embed their KIND, so an item read while merely due comes back unread once missed — that transition is the one thing this feed exists to catch, and a per-occurrence id would let a glance at "due at 6pm" permanently suppress "you missed 6pm"; the reminder slice is reserved at 20 of 30 slots so a crowded reminder list cannot evict the card/profile nudges (a single urgency-sorted cap would make the feed look full and say nothing actionable); derivation waits for both source queries so a half-loaded state cannot briefly tell a merchant whose card is live that it is not. Two smaller things fixed on the way, both pre-existing: a settings test selected by getByRole('switch'), which silently meant "the only one" until a second switch existed (now by label, which is what a screen reader uses); and the shared date formatters moved from features/reminders/utils/format.ts to @/lib/datetime when a second feature needed them, because one feature importing another's utils/ is how features quietly couple. ⚠️ Open: read-state is device-local and does not sync across surfaces (accepted for R1, documented, needs a server table to fix), and the native device pass is outstanding — it now requires expo prebuild first, since P3 added a native module. | | QRS-218 | bug | 🔴 open 2026-07-28 | Every PRERENDERED heading in the web export keeps LIGHT-theme ink in dark mode — measured 1.54:1, effectively invisible, on /notifications, /reminders and /settings. Found by driving the actual export with the run-mobile driver (which waits for hydration) rather than by reading code. The evidence is in the style attribute's FORMAT, which is what identifies the cause: the heading carries color:rgba(32,61,91,1.00) — unspaced, two-decimal alpha, the serialisation Node's prerender pass emits — while a row rendered after hydration in the same tree carries color: rgb(242, 245, 248), the browser-normalised form React writes at runtime. Same component, same useThemeColors() call, two different values: the prerendered node was never repainted. Root cause: expo export -p web prerenders in Node with the light theme, and React hydration does not correct mismatched inline style attributes — it reuses the server markup. Text that exists in the prerendered HTML therefore keeps light ink forever; text created client-side (anything behind an async read, which is why the reminder rows and captions are correct) gets the right colour. The background is unaffected because bg-background is a CSS class driven by variables, which do flip — so the page is dark and the heading is navy. This is the QRS-201 split-channel defect returning through a third channel: QRS-201 reconciled the imperative useThemeColors() channel against the CSS-variable channel; neither fix touched prerendered inline styles, which are a snapshot of channel A taken at build time. Scope: web export only (no prerender on native), but it hits any full page load or refresh in dark mode, not just deep links, and it is pre-existing and systemic — not introduced by the notifications work (verified on /reminders and /settings, which predate it). Why no gate caught it: theme-consistency.spec.ts already asserts 4.5:1 in dark mode for exactly this bug class, but measures with the pre-hydration settle heuristic described in QRS-216, so it does not measure the settled heading the driver does. Fixing QRS-216 should turn this red. Fix direction, deliberately NOT applied in the notifications PR: drive static text colour through the class channel (className="text-content-primary", which AppText already documents as its intended colour path) instead of an inline style={{ color: c['content-primary'] }}, so the CSS-variable channel owns it and the prerender carries no baked colour. That is a sweep across every screen and it touches theme plumbing — a systemic surface under ADR-0015, so per CLAUDE.md the native builds are the gate for it and it needs its own change with a device pass. Slipping it into a feature commit is precisely how QRS-203/206/207 happened. Interim honesty: the two natives are unaffected, so this is not a release-wide blocker; it is a web-PWA defect that should be fixed before the PWA is shown to anyone on a dark-mode device. | | QRS-219 | bug | ✅ fixed 2026-07-28 | The QRS-214 security migrations could not be applied to a FRESH database — they aborted the entire chain, so CI's pgTAP job and every new environment would have failed. supabase db start on a clean volume died at ERROR: function public.rls_auto_enable() does not exist (SQLSTATE 42883) while applying 20260727150300. Cause: both revoke migrations named all 24 target functions by exact signature, unguarded, and rls_auto_enable() exists on the Dev project but not in the Prod-derived baseline squash the local stack builds from. REVOKE has no IF EXISTS, so a literal signature is an assertion that the function is present — and the two projects' function sets differ (27 on Dev vs 22 on Prod, measured during QRS-214). This is QRS-213 again in a different costume: a migration verified against exactly one database, where "verified" meant "it worked where I ran it". QRS-213 was a migration that had never been run anywhere; this one had been run somewhere, which is a weaker guarantee than it appears — the chain is only correct if it applies to a database that has never seen it. Found by running it, not by reading it, which is now the third time this round that executing beat inspecting (QRS-209 pre-flight, QRS-210 duplicate rows, this). It surfaced only because Docker came back and the local stack could finally be built from scratch — i.e. it would have reached CI otherwise. Fix: both migrations now resolve every target through pg_proc at run time inside a DO block, keyed on function NAME, and RAISE NOTICE when a name is absent rather than aborting. Two benefits beyond portability: overloads are covered by construction (the signature version needed two hand-written lines for log_subscription_usage and would have silently missed a third), and absence is visible in the migration output instead of either fatal or invisible. Edited in place rather than fixed forward because neither migration had been committed — shipping a broken migration plus a follow-up would leave the broken one in every fresh environment's path permanently. Verified: the full 12-migration chain now applies to a clean database, emitting exactly the two expected notices for rls_auto_enable; Dev is unaffected (the recorded version is unchanged and the resulting privileges were already correct). | | QRS-220 | debt | ✅ fixed 2026-07-28 | pgTAP now proves RLS isolation, not just RLS configuration — and the residual unqualified-policy count dropped 89 → 81. Every existing pgTAP file asserts against the CATALOG (privileges held, RLS enabled, policies present, policy audiences). Those are necessary and caught real incidents, but they prove a configuration: "RLS is enabled and a policy exists" is equally true of a table whose policy is USING (true). reminders_test.sql is the first test here that creates two users, authenticates as each, and checks what one can reach of the other's data — 48 assertions covering grants, two-user isolation, and the RPC projection. Three of them are worth naming. (1) Bob cannot attach an occurrence to Alice's reminder even when he supplies his own user_id, because the BEFORE trigger derives it from the parent and RLS WITH CHECK runs after triggers — the horizontal-escalation attempt a "client supplies user_id" design would have allowed. (2) Bob's own reminder DOES work, which is the assertion that stops every isolation test above it from passing vacuously against a policy set that simply denies everything. (3) get_reminders projects an exact key set, asserted literally, so a future alter table add column gets caught in this file instead of shipping user_id to a client because someone used row_to_json. Two mechanics documented in the file because both are silent traps: the identity switch is written inline rather than wrapped in a become(uuid) helper, since SET LOCAL ROLE inside a PL/pgSQL body has scoping that depends on the function's own SET clause — the kind of convenience that makes an isolation test pass because nothing was ever switched; and every switch does reset role FIRST, because an authenticated session cannot set role to anything else and a second switch without the reset silently keeps the first user. Also corrected: anon_least_privilege_test.sql pinned the residual TO-less policy count at 89; it is now 81, because archiving and dropping the legacy reminder tables (QRS-209) removed their 8 unqualified policies — the exact QRS-001 shape (QRS-211). No policy was fixed to get there; eight were deleted with the tables they guarded, which closes the exposure just as effectively. The constant now carries an explicit "this number must only ever shrink" note, so a failure above it reads as "a new unqualified policy was introduced" rather than an invitation to bump the number. pgTAP total: 59 → 107 tests, all passing against a clean local stack. | | QRS-221 | bug | ✅ fixed 2026-07-28 | expo-notifications was installed but never registered in app.json plugins — so autolinking made the JS API work while none of the native configuration existed. The whole cost is invisible until a device build: Android needs a white-on-transparent status-bar icon and falls back to the full-colour app icon, which the system renders as a featureless white square, so a reminder fires and looks broken; no accent colour, so the tint is the OEM default; no named channel, so alerts land in a bucket the merchant cannot tune. Now registered with the existing android-icon-monochrome asset, a tint derived from brand.qrFrom via the repo's own HSL→hex conversion (native config is read by the platform and cannot consume hsl(var(--x)), so a literal is required — deriving it is how the zero-hard-coded-colors standard survives that), and enableBackgroundRemoteNotifications: false, stated explicitly because enabling it adds the iOS remote-notification background mode and a push entitlement that App Review reads as a claim the app receives pushes. R1 has none. Pinned by 6 assertions in app/__tests__/notifications-build-config.test.ts, following sentry-build-config.test.ts — a config omission whose only symptom is on a device is exactly what that pattern exists for. | | QRS-222 | bug | 🟡 fix shipped, native confirmation pending 2026-07-28 | The bottom sheet renders SQUARE top corners on native, breaking the soft-corner invariant — and it was never a regression: it has never worked on a native build. Reported as a regression, investigated as one, and the history says otherwise: src/ui/Sheet.tsx was created in d98ed7f already carrying className="rounded-t-3xl", has no earlier inline radius, and was not touched by 8b8a148 or 52f4955 (the reminders/notifications work). It renders correctly on web — which is the surface it had been reviewed on — and square on native, so the first native build in a fortnight is what made it visible. Mechanism: the class sits on an Animated.View, i.e. createAnimatedComponent(View), a component absent from the registry NativeWind populates for React Native's own components. That is the identical mechanism as QRS-190 and QRS-203, which cost two incidents on PressableScale and produced the careful web-only cssInterop guard there — a guard nobody generalised to the other three Animated.View call sites (Sheet, Skeleton, Toast). Fix: the radius is now also set inline, derived from radius['3xl'] rather than typed as 40, so it cannot be lost to interop behaviour and cannot drift from the class. Deliberately marked "pending" rather than fixed: src/ui is a systemic surface and CLAUDE.md is explicit that the native builds are its gate — I cannot observe native, so calling this verified would repeat the error that produced QRS-203. If the corners are still square after a rebuild, the remaining suspect is the Android elevation outline, not the radius. WHY EVERY GATE WAS GREEN, which is the part worth keeping: jest mocks reanimated with createAnimatedComponent: (c) => c, so under test Animated.View === View, which IS registered — the test double actively simulates the working case; Playwright runs the web bundle, the one platform where the class genuinely works; and check:parity's cssInterop rule checks that existing registrations are correctly guarded, not that a component carrying className is registered at all. Three layers, none able to see it. Prevention (the actionable output): a parity rule that fails when a reanimated Animated.* element in apps/*/src/ui/** is passed a className, unless the file registers cssInterop for it or states the inline fallback — mutation-tested like the other seven. The documentation was never the gap: CLAUDE.md already states the rounded-3xl shell invariant and Sheet.tsx cited it in its own comment while not honouring it. | | QRS-223 | bug | ✅ fixed 2026-07-28 (remainder closed by QRS-226) | The Reminders screen put its only create action at the BOTTOM of the scroll, so the more reminders a merchant had, the harder it was to add one. Raised by the product owner, and the rationale did not survive contact: EmptyState carried the primary CTA, and a secondary "Add reminder" Button was appended after the groups so the action existed in the non-empty state too. That is completeness, not layout design — it ended up last because that is where the composition loop ended, and nobody asked what the screen feels like at scale. Two things make it less defensible: Reminders has no upstream design (confirmed when planning), so this was code-first composition — which ADR-0015 sanctions as process and which says nothing about the result being good; and SegmentedControl, IconButton and Chip were already in @/ui, so the pieces were there and went unused. The worse defect underneath it, which the report did not name: the done bucket was uncapped while the other three were capped — bucketOccurrences pushed every completed occurrence in the 30-day window, so five daily reminders kept up for a month put ~150 finished rows between the user and anything useful. Fixed: header lifted OUT of the ScrollView (title + a 40×40 + IconButton, chosen over a FAB because the tab bar already owns a centre Create button and a floating + would compete with it); a SegmentedControl Open · Done · All defaulting to Open, with the open count on the label; done capped in @qrsetu/domain via a new doneLimit (default 30) plus a doneTotal so the surface can say "showing 30 of 87" — a silent cap would trade an unusable list for a quiet lie about how much history exists. Emptiness is judged per scope, so a merchant filtering by Done is not shown "create your first reminder". ⚠️ STILL OPEN, and measured rather than predicted: the filter did not fix scale, it fixed reachability. Driving the real export shows Open 48 from roughly SEVEN reminder rules, because a recurring rule expands to one row per occurrence across the 60-day horizon — "Cash count and deposit" appears ~15 times in a row. Fifteen identical titles is not information. The fix is to collapse a recurring series to its next occurrence with the existing repeat Chip carrying "Every day", and to expand a series only on demand; that is a genuine design decision about what a feed row REPRESENTS (a rule or an occurrence) and it should be taken deliberately rather than bolted on. Verified on web (0 layout violations, 0 sub-44px targets, add action reachable with a 12-reminder list); native still outstanding. | | QRS-224 | bug | ✅ fixed 2026-07-28 | The Notifications screen had NO controls at all — its only affordances lived one level below it, inside an opened reminder. Raised by the product owner with a screenshot: the entire header was a title. The pattern behind it is the same one as QRS-223 and worth naming, because fixing one screen did not fix the class: a screen's chrome was being composed per screen, so an improvement to one could not reach the other. Reminders got a header + filter that same day and Notifications did not, purely because nobody re-opened it. Fix — one primitive, not two screens patched: @/ui ScreenHeader (title · trailing actions · optional full-width controls · closing rule) now renders the chrome on both, so the next change to either lands on both by construction. Design-first, as src/ui requires (ADR-0015): pulled components/app-shell/AppShell.jsx first, which specified the header we did not have — and its borderBottom: 1px solid var(--border-subtle) is exactly the product owner's second point, that controls must be separated from content rather than floating above it. Three divergences from that spec are deliberate and on the drift ledger (no backdrop-filter blur — the RN header is a sibling above the ScrollView, not an overlay, so a blur would cost expo-blur for nothing; title stays at 22px rather than the spec's container-scoped 15px; a 1dp rule rather than hairlineWidth, which is 0.33dp at 3× and vanishes at some Android densities). A real alignment bug fell out of the pull: both headers were written className="px-5" while their list content used paddingHorizontal: 20, and space[5] is 18px on this scale — every screen title was rendering 2px inboard of the cards it labelled. Both ends now read space[6] through an exported SCREEN_GUTTER. Notifications controls, and why these: a lens filter All · Reminders · Setup chosen to be orthogonal to the New/Earlier sections (those split by when an item was seen, the lens by what it is about — a filter that repeated the sections would add a control and no capability), with kind→lens as an exhaustive Record<NotificationKind, …> so a new kind in @qrsetu/domain fails the build until it is classified; the count on All, matching the reminders idiom; and a settings action routing to where alerts are actually switched on and off. Two things deliberately NOT added: a "mark all read" button, which would be a no-op because the screen already marks everything read on open — a control that cannot change anything is worse than its absence; and any coupling between the lens and read-state, so a visit still clears the bell whichever filter is selected (asserted by a test). An empty lens says "nothing under this filter", never "you're all caught up", because the all-clear while an overdue reminder sits one tap away is a false statement about the merchant's business. Verified: 8 new screen tests (incl. one asserting the header is outside the scroll container — the structural property that makes it reachable, which a "is it visible?" assertion would pass on a header that scrolls away at item 10) + 5 on the primitive; all gates green; driven on the real web export in both themes. Native re-verification rides with QRS-222src/ui is a systemic surface and the native builds are its gate. | | QRS-225 | debt | 🔴 open 2026-07-28 | Settings and Profile render their headers INSIDE the ScrollView, so the back button, the title and (on Profile) the Basic/Business/Social section tabs all scroll away. Found while fixing QRS-224 — same defect class, two more screens: SettingsScreen/index.tsx puts IconButton back + title as the first child of a ScrollView padded to 20, and ProfileScreen/index.tsx does the same with its identity header and tab row. On Profile it is the worse of the two: the tabs are that screen's primary navigation, and they are unreachable from the bottom of a long form without scrolling back up. Both also lack the border-subtle rule the design specifies for screen chrome. Deliberately NOT fixed in QRS-224. The product owner's instruction on that change was explicit — "changes for one feature should not unintentionally affect unrelated parts of the application" — and Settings/Profile were not in its scope. Logged so it is a decision rather than an oversight, and so the fix is one ScreenHeader adoption per screen when it is scheduled. Note the migration is not purely mechanical on Profile: its header carries the avatar and identity block, so what belongs in the sticky chrome (title, back, tabs) versus what should keep scrolling (the avatar) is a real layout call. | | QRS-226 | bug | ✅ fixed 2026-07-28 | The Reminders landing page had no back control, and its list read as a wall of repeated rows. Three reports in one, from the product owner. (1) No back affordance. /reminders and /notifications are pushed routes with headerShown: false, and Settings and Profile have carried a chevronLeft since they were written — so two screens were reachable with no way out but the OS gesture, which on the web PWA means the browser chrome. ScreenHeader gained an onBack slot; both screens use the established router.canGoBack() ? router.back() : router.push('/dashboard') so a deep link is not a dead end. (2) "The Add action isn't on the landing page" and "Create Reminder is inside the Upcoming section" — both describe the screen at 52f4955 and earlier; QRS-223 moved the action into the header the day before, and the current export shows a 44×44 + in the header with no trailing button anywhere. Worth recording rather than dismissing: the report was correct about the build the owner was running, which is the same class of confusion as QRS-222 (a defect visible on one surface and not another) — a fix that is not on the reviewer's machine is indistinguishable from a fix that does not exist. (3) The layout, which was the substantive part. Four changes: bucketOccurrences now collapses a recurring rule to its next occurrence per group with the remainder disclosed as moreInGroup ("+29 more") — this is the open remainder of QRS-223, and the measured effect is Open 48Open 5 for the same data, because the count now describes obligations rather than instances (done is exempt: a log whose entries are merged is not a log). Rows moved into Card variant="list" for the iOS grouped-list reading — flush rows, hairline separators, one tracking column instead of a stack of free-floating blocks. The metadata became one truncating caption line (30/07 · 17:54 · Every day · +29 more) instead of a wrapping row of filled Chips: three grey pills per row read as three competing badges twelve deep, and the third wrapped to its own line at 360dp for exactly the rows carrying a priority — so the list lost its rhythm at the rows that mattered most. Section labels carry counts (UPCOMING · 5). A severity inversion fell out of it: urgent was mapped to the info tone, so the most severe priority rendered CALMER (blue) than high (amber). Chip gained the danger tone the design already specifies on its status pill, and the ladder now escalates neutral → amber → red. One decision reversed by measurement: the title was one line for uniform row height until the export showed "Reorder packaging st…" — the metadata line is the one whose parts are all recoverable elsewhere, so it is the one allowed to truncate. Titles wrap to two. Verified: 420 jest + 115 domain + 505 Playwright; driven on the real export in both themes. Native rides with QRS-222. | | QRS-227 | bug | ✅ fixed 2026-07-28 | Reminders had NO entry point anywhere in the app — the only route in was tapping a notification that happened to be about a reminder. Raised by the product owner as "why are Reminders nested inside Notifications?", and the codebase says they were right about the thing that matters. Architecturally they are siblings: separate route (app/(user)/reminders.tsx), separate feature directory, separate packages/data seam, separate @qrsetu/domain module, and the feed derives from reminders rather than containing them. But a grep for /reminders outside the two features returned only its own route file — so navigationally Reminders was a sub-page of Notifications, which is precisely how it looked. This is mine and it was already owed: the P3 plan specified <DashboardSlot name="reminders"> as the home-screen surface, the slot was built in P1 to hold it, and I never filled it — so the feature shipped with its planned entry point missing and nothing flagged it, because no gate can see a missing link. Fix: a dedicated clock IconButton in the console header, immediately beside the notifications bell — two adjacent, independent entry points, neither inside the other, which is what the owner asked for. Also removed: the All · Reminders · Setup filter added to Notifications one iteration earlier (QRS-224). It worked and was tested, and it was the most likely trigger for the report: a control inside Notifications whose segments name other features advertises that those features live in there. The capability was thin regardless — the feed is bounded to 30 items and typically holds two to five — so it went, replaced by a test that guards against it returning. If the feed later gains genuinely different sources (announcements, order events), a filter comes back with labels describing notifications, not modules. Two related fixes rather than three new sub-44px targets: the header's controls were hand-rolled PressableScales at h-10 w-10 (32px on this token scale, below the 44 floor — the QRS-191 pattern), so adding a third would have traded an IA standard for an accessibility one. All three are now IconButton with size={sizing.control.sm}: the 32px look is unchanged, the tap box is 44. Still owed, and now tracked rather than implied: the dashboard reminders slot itself, which is the proactive surface ("2 due today") as opposed to a navigation icon. | | QRS-228 | bug | ✅ fixed 2026-07-28 | The console header had two controls for one destination: the workspace chip and a person icon both called go('/profile'). Raised by the product owner, and it is exactly what the code said — onWorkspace={() => go('/profile')} beside onProfile={() => go('/profile')}, adjacent, in the same row. Pure duplication, and it had been there since the header was written; it survived a Round-1 design review, a hierarchy pass and QRS-227 (where I added the reminders icon next to the redundant one without noticing the redundancy). Fix: the person icon is removed. The chip wins on every axis — it is the larger target, it already shows whose profile it opens, and it is where a merchant looks for their own identity — and Profile remains reachable from the More tab, so nothing is stranded. The header is now one identity entry plus the two features a merchant uses daily: avatar → Profile · clock → Reminders · bell → Notifications. The accessibility half is not incidental: the chip's label is the business name, so with no visible "Profile" text anywhere it announced who it was about but not what activating it does. It now carries accessibilityHint={nav.profile}, asserted by a test — the same test that asserts the second entry point has not come back. Worth noting for R2: if workspace_id multi-tenancy makes the chip a workspace switcher, Profile needs its own home again, and this row is where that trade-off is recorded rather than rediscovered. | | QRS-229 | project | ✅ done 2026-07-28 | Notifications no longer navigates into Reminders; a reminder notification is resolved in place instead. Product decision by the owner, taken after QRS-227 gave Reminders its own header entry: Reminders is reached from that icon and nothing else, so the feed must not be a side door into it. I had argued the other way — a notification about a reminder linking to the reminder is what a notification is — and the decision stands; recorded here because the reasoning matters for the next person. The consequence I would not ship without: a row that cannot navigate must still be actionable, or the screen becomes a passive display and fails the proactive-value gate outright. So DerivedNotification.route is now string | null and reminder items carry occurrence: { reminderId, dueAt } — the row completes the occurrence through the reminders data seam and the item then stops being derivable at all, because a completed occurrence is a stored exception. Self-clearing, not "mark as read". Setup nudges keep their route: you cannot complete "add your GSTIN" from a feed row, so the split is resolve in place where possible, navigate where the work is a form. NotificationRow therefore has two shapes chosen by the item — pressable + chevron when navigable, inert with a check button when not — and no chevron on the inert shape, because a chevron on a row that stays put is a lie users only discover by being surprised. Also in this pass: sections regrouped from New · Earlier (recency) to Needs action · Good to know (the domain's tone), each with a count, since "what needs me?" is the question a merchant arrives with; the recency grouping's one worthwhile property — rows must not restyle under the user's eyes when the mark-read effect fires — survives as a per-row unread marker driven by the open-time snapshot. Paging added at PAGE_SIZE = 10 with a "Show N more" control, and stated plainly as scaffolding: the feed is capped at 30 by deriveNotifications, so it cannot grow past that from this source and the paging bites only above 10 items. It is real (10 rows mount, not 30) and it is where a server feed's fetchNextPage lands, but it is not solving a measured problem today. One test lesson worth keeping: the first version of the resolve test waited for the row to disappear, which meant waiting on optimistic patch → mutation → invalidate → refetch → re-derive; it passed alone and failed in the full parallel run, because Date.now is frozen in that suite and the testing library's elapsed-time budget is therefore unreliable. It now asserts at the seam (what the screen hands to completeOccurrence), and the feed-empties behaviour stays covered by the reminders suite. 21 screen tests. | | QRS-230 | bug | ✅ fixed 2026-07-28 | The reminder sheet opened straight onto action buttons with none of the reminder's information — including Delete, which is irreversible. Raised by the product owner: tapping a row showed skip/edit/delete under a title, so the description, the exact due time, the repeat rule and the priority were all invisible from the one surface that offered to destroy the thing. Offering an irreversible action above the information needed to judge it is the wrong order, not a missing nicety. Fixed with a real detail sheet (ReminderActionsSheet.tsx): description first (the only content the app did not generate), then metadata as label/value rows — due · repeats · priority · category · "also due" for a collapsed series — each omitted when it has no value rather than rendered blank, because "Category —" is noise pretending to be information. Then a hairline, then the actions: Mark done alone as the primary (the sheet is a detail view now, so the primary action belongs in it), Edit and Skip paired on one row as peers, and Delete last, danger-toned, below a second hairline so it cannot be hit on the way to anything else. Priority filter pills added alongside, in the sticky header under the scope control: a horizontally scrolling Chip row rather than a second SegmentedControl, because five options do not fit a fixed track at 360dp — and pills are exactly what the design's Chip spec describes, so Chip gained the onPress/selected half of its own contract (drift ledger). Two deviations from the request, both deliberate: the pills include Urgent, which was not listed — it is a real value in the schema's CHECK constraint, so omitting it would make urgent reminders unreachable by filter, which is a trap rather than a shortcut; and the no-filter pill reads "Any", not "All", because the scope control directly above it already says All and two adjacent controls with the same accessible name are ambiguous to a screen reader. That second one was found by a test failure (Found multiple elements with accessibility label: All) — the ambiguity was real for users too, so the fix was the label, not the selector. Priority composes with scope rather than replacing it, and the Open count follows the filter, because a count that ignored the active filter would contradict the list underneath it. 7 new screen tests + 4 on the primitive. | | QRS-231 | bug | ✅ fixed 2026-07-28 | Three consistency defects, and one of them is a governance failure worth more than the fix. (1) An em dash shipped in user-facing copy. The product owner asked whether the rule existed, why it was overridden, and how the tests passed. Answered honestly: the rule existed in FOUR portal pagesdesign-system/pdpr-prompt.md §1 ("DO NOT use em dashes anywhere in any generated copy, documentation, labels, or examples. Use commas, colons, parentheses, or short sentences instead."), foundational-screens.md, screen-coverage-mandate.md, onboarding-experience-spec.md — and nothing overrode it, because all four are prompts addressed to Claude Designs. They govern what the design project generates; they were never in CLAUDE.md, never in the ESLint guardrails, and never asserted anywhere. So the implementation side had no way to know, and no gate could fail: six strings × three languages carried an em dash with every check green. That is the shape of failure to remember — a rule that only exists in a prompt is not enforced, it is hoped for. Fixed: all 18 strings rewritten with a colon or two sentences ('Add the things you must not forget: filings, stock, follow-ups.'); the rule added to CLAUDE.md under Brand & typography, marked ENFORCED and cross-linked to the portal pages; and gated in i18n-catalogs.test.ts → "copy typography", which walks every catalog leaf in every language and fails with the key path and the offending string. En dash is checked too, as the obvious near-miss substitute. @qrsetu/i18n is the correct chokepoint because all user-visible copy comes from a catalog. Scope is copy, not prose: comments, READMEs and tracker rows still use em dashes, and sweeping them is a separate decision rather than a silent 135-file rewrite. (2) The back control did not match Profile/Settings. Measured, not eyeballed: theirs is a bordered IconButton at left 20, top 20; ScreenHeader's was variant="ghost" at left 4, top 6 — a different position and a different style, so two idioms sat in one app. Now the default outline variant at the shared gutter. (3) The + was 8px from the top edge, which is exactly the kind of defect a notch hides: on a device the safe-area inset supplies the space, in a browser there is none, and the owner was looking at the browser. Padding now reuses Settings' own numbers (SCREEN_GUTTER both axes). Also settled: the title. It was left-aligned 22px — the Apple large-title idiom, defensible alone, and still a third header style in a four-screen app. It is now the centred 17px nav-bar title Settings and Profile already use, with a trailing spacer so a centred title is actually centred. Verified on the real export: back at left 20, top 20, border 1px on /reminders, /notifications and /settings; + at right 20, top 20; title midpoint 180px of a 360px viewport at 17px. 441 jest + 117 domain. This also shrinks QRS-225: with the look now identical, Settings/Profile adopting ScreenHeader is a purely structural change (sticky + the rule), not a visual redesign. |

| QRS-232 | debt | ✅ fixed 2026-07-28 | A disk cleanup on the Mac made the iPhone X vanish from Xcode, and the recovery order was documented nowhere. Reported by the user after reclaiming ~15 GB. Root cause is a documentation defect, not a machine fault: guides/ios-build-and-device-testing.md § "Disk space" listed what is safe to delete (DerivedData, Simulator runtimes) and never listed what is not, so a size-sorted cleanup reached the paths that hold the trust pairing (/var/db/lockdown), the signing certificate (keychain), the provisioning profile (~/Library/MobileDevice/Provisioning Profiles/) and the device-support symbols (~/Library/Developer/Xcode/iOS DeviceSupport/). The governing insight, now written into the guide: nothing is both large and dangerous — every path that frees real space is regenerable cache, and every path that breaks the device is kilobytes. So a cleanup must be allowlist-based; sorting by size has no upside and a guaranteed downside. A grep for DeviceSupport, unpair, lockdown, disappear and Devices and Simulators across all 13 guides returned zero hits before this row: the guide covered first-time device setup (Steps 5–7) and the 7-day expiry, but had no path for "it worked yesterday and the device is now absent", which is the failure a second machine actually hits. Fixed by adding (1) an allowlist/denylist reclaim table with per-path verdicts, (2) a six-rung recovery ladder ordered so each rung's symptom cannot mask the next — USB bus check → trust pairing (incl. Reset Location & Privacy to force the trust prompt back) → Developer Mode → iOS DeviceSupport regeneration (the 5–20 min "Preparing debugger support" wait that most resembles broken hardware) → warning-triangle triage → certificate and profile restored separately, because a missing certificate and a missing profile present identically → rm -rf ios && prebuild with the reminder that a fresh ios/ needs signing steps 6c+6d redone, (3) four symptom-first troubleshooting entries so the ladder is reachable from the error text, (4) a cross-reference from macos-ios-build-environment.md, since freeing disk is an environment action with a device consequence and that seam is exactly what was missed, and (5) a note that --configuration Release on a free Personal Team is a local build for a registered device, not a distributable one. Not verified by me — the ladder is written from Apple's documented behaviour and this repo's own setup history; the user executes it on the Mac. Also fixed in this pass: the next-free-id banner still read QRS-224 while rows through QRS-231 existed — the allocator was not bumped when those eight rows landed, which is the one failure the scheme cannot recover from. |

| QRS-233 | bug | ✅ fixed 2026-07-28 | The dev portal did not build, and no gate runs docs:build — so "if it isn't documented, it isn't done" rested on a build nobody executed. Found incidentally while validating the anchors added for QRS-232: npm run docs:build failed with Error parsing JavaScript expression: Did not expect a type annotation here. Cause: VitePress compiles every page as a Vue template, and Vue interpolates {{ … }} even inside inline code spans (writing this row reproduced the defect once, which is the tidiest possible demonstration that the gate below is the actual fix). Three tracker rows quote RN props verbatim — style={{ fontSize }}, android_ripple={{ color: c.overlay }} and style={{ color: c['content-primary'] }} — and the latter two look like TypeScript type annotations to Vue's expression parser, which is a hard build error. git blame puts them in e4ce3fc, 0dc75af and 8b8a148, so the portal has been unbuildable across at least three commits. The first one is subtler and was silently wrong rather than fatal: is a valid Vue expression, so it compiled and rendered as empty, meaning that row has been quietly missing the very code it is about. Fixed by wrapping all three in <span v-pre>, the documented VitePress escape hatch. The real defect is the missing gate, not the three rows: docs:build appears in package.json and in no workflow under .github/workflows/, so nothing checks that the portal compiles or that its internal links resolve — and VitePress validates dead links at build time, which is exactly the class of rot a docs-heavy repo accumulates. Ironic in the specific way QRS-231 was: a documentation standard asserted by no executable check. Follow-up (open): add docs:build to ci.yml — cheap (~45 s) and it converts the README/portal discipline from an agreement into a gate. Note it must run docs:gen first, since the generated EF index is an input. |

| QRS-234 | bug | ✅ fixed 2026-07-28 | expo-notifications writes an iOS push entitlement unconditionally, which a free Personal Team cannot sign — the iOS device build was blocked outright. npx expo run:ios --device --configuration Release failed with three errors that are one root cause: "Personal development teams … do not support the Push Notifications capability", "Provisioning Profile … does not support the Push Notifications capability", "Entitlements file defines the value aps-environment which is not registered for profile". Cause: expo-notifications@57's withNotificationsIOS.js does if (!config.modResults['aps-environment']) config.modResults['aps-environment'] = mode — every iOS build, no opt-out, and not gated on enableBackgroundRemoteNotifications. notifications-build-config.test.ts asserted that flag was false with a comment claiming it avoided "a push entitlement"; that comment was wrong and is corrected. Why removing it is right, not a workaround: aps-environment declares APNs remote push. The reminders scheduler is local-only (UNUserNotificationCenter), local notifications need no entitlement, remote push is out of scope for R1, and a grep confirms no call to getExpoPushTokenAsync/getDevicePushTokenAsync anywhere in apps/ or packages/. The entitlement claimed a capability the app does not have. Fixed with a local config plugin apps/mobile/plugins/withoutPushEntitlement.js. The instructive part is the ordering. Expo's withMod runs the last-registered mod FIRST and then delegates to the previous one via modRequest.nextMod, so array order is the reverse of execution order. Registered after expo-notifications — the natural reading of "delete it after the thing that adds it" — the plugin deletes a key that does not exist yet and the entitlement is then written by the mod that runs after it: a config that reads as correct and changes nothing. Caught only by npx expo config --type introspect, which showed 'aps-environment': 'development' still present; moving the plugin before expo-notifications yields entitlements: {}. This convention was already documented in apps/mobile/plugins/README.md ("Ordering reads backwards…") and was still implemented backwards — so the fix is not the prose, it is the four new assertions in notifications-build-config.test.ts, including an array-index assertion on the ordering itself. Same failure shape as QRS-231 and QRS-233: a rule that exists only in a document is not enforced. Blind spot this exposes: entitlements exist only in the generated, gitignored ios/, so jest, Playwright and the Android build are all structurally incapable of seeing them — expo config --type introspect is the only cross-platform observation point, it needs no Xcode, and it therefore runs on Windows. Not yet confirmed on device — the entitlement is proven absent from the resolved config; the user re-runs prebuild + run:ios on the Mac. Found while recovering from QRS-232, which is how a second machine surfaces defects the primary one cannot. |

| QRS-235 | bug | ✅ fixed 2026-07-28 | The reminder composer was UNUSABLE on a physical iPhone: the keyboard opened on mount and the form's lower half, including Save, was clipped outside the sheet with no way to scroll to it. Reported as "the Notes field was focused and scrolling stopped working". Two independent causes, both real. (1) Sheet's height cap ignored the keyboard. It used maxHeight: H * 0.9, and on iOS the keyboard OVERLAYS the window and reports no inset — so useWindowDimensions().height stays 812 on an iPhone X with 336dp of keyboard on screen, and the cap over-promised by exactly the keyboard height. The composer is ~560dp of content in a ~476dp window: the overflow was not merely tight, it was unreachable, because Sheet deliberately does not force-scroll its content. This explains every part of the report, including why it did not reproduce elsewhere: Android resizes its window (adjustResize), so its reported height already excludes the keyboard and the bug cannot occur; and the iOS Simulator defaults to a hardware keyboard, so nothing overlays and the cap is never exceeded. A defect visible only on a physical device of one platform is precisely the class CLAUDE.md says the automated gates cannot see. (2) autoFocus on the title field raised the keyboard before the sheet finished animating in, so the clipped state was the state it OPENED in — and it contradicted the behaviour Android already had, which the owner correctly identified as the intended one. autoFocus inside an animated modal is inconsistent across platforms by nature, since it lands differently depending on when the modal window takes focus. Fixed: the cap is now (H - keyboardOverlap) * 0.9 via a keyboardWillChangeFrame/keyboardWillHide listener that is iOS-only (subtracting on Android would double-count); the content wrapper gained flexShrink: 1 + minHeight: 0 so a consumer's ScrollView receives a bounded height (minHeight: 0 is what makes it hold on RNW, where a flex child otherwise refuses to shrink below its content — without it this would have been a native-only fix, the exact shape of QRS-203/206/207); the composer now owns a ScrollView per Sheet's documented convention; and autoFocus is gone. Six new assertions, including the cap shrinking under a simulated keyboard event. Not yet confirmed on device — the user re-runs it on the iPhone X. | | QRS-236 | bug | ✅ fixed 2026-07-28 | Android reminder notifications never fired, and FOUR independent defects each sufficed to cause it. Reported as "I tested reminder scheduling on Android but nothing was triggered". The native plumbing was fine — the merged manifest ships POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, WAKE_LOCK and NotificationsService — so all four were ours. (1) A permission grant re-armed nothing. applyReconcile correctly refuses to prompt and returns skipped: 'not-permitted', but the reconcile callback depended only on [port, horizon, enabled, showDetail, tr], so a GRANT changed nothing it watched: the merchant tapped Allow, the OS granted, and no pass ran until the horizon happened to change. The hook now tracks permission as state and requestPermission re-arms on success. Relying on AppState churn around the OS dialog is not a mechanism — on Android the dialog is an overlay in the same task, so active may never be re-emitted. (2) The Android channel never existed. app.json passes defaultChannel: 'reminders', which is easy to read as "the channel exists"; the plugin writes only com.google.firebase.messaging.default_notification_channel_id, which applies to remote FCM push and has no effect on a local notification. Android 8+ needs a runtime setNotificationChannelAsync, so alerts fell back to expo's generic channel at default importance — no merchant-recognisable name in settings, and no heads-up. Now created via a new optional port.prepare(channelName) called before any schedule, at HIGH importance, with the name from @qrsetu/i18n because the merchant reads it. Our own test asserted the prop and its comment claimed the channel made alerts "tunable" — the assertion was true and the conclusion was wrong. (3) No foreground handler. Without setNotificationHandler, expo-notifications suppresses presentation while the app is open, so a reminder coming due with the app in use produced nothing at all. Installed at the app root (global to the process, and an import-time native call is untestable — it broke the screen suite outright). (4) The blocked state was SILENT. Copy for it (alerts.denied, alerts.openSettings, alerts.webLimited) had existed in @qrsetu/i18n since P3 and was rendered nowhere, so a merchant who tapped "Not now" once had no route back and no indication that every future reminder would pass in silence. Now a Banner with a CTA that prompts when undetermined and opens system settings when denied. A bug I introduced and the test caught: inferring denied from skipped: 'not-permitted' collapsed undetermined into denied, which made the CTA unable to ever ask. Permission is read from the port instead. Still outstanding and NOT fixed here — an owner decision, see QRS-237: exact alarms. | | QRS-237 | bug | 🟡 fixed-pending-device 2026-07-29 | Scheduled reminders never notified at their scheduled time. The reminder simply appeared as due, after the fact. Reported on-device 2026-07-29, and the same symptom class as QRS-236 surviving that fix. This row was originally scoped to the Android exact-alarm decision alone; it is the umbrella for the whole delivery failure, because a code read found FOUR independent causes and the alarm question was only one of them. The id is kept rather than reissued (ids are permanent, and f77ffe1 already cites QRS-237 for cause C). First: the build was not stale, which is what made this interesting. The tested APK was timestamped 2026-07-28 23:38, five minutes after the QRS-236 fix commit f77ffe1 at 23:33, so every fix in that commit was present and the alerts still did not arrive. Cause A — THE RECONCILER'S ENTIRE LIFETIME WAS ONE SCREEN. Fixed. useReminderNotifications was mounted in exactly one place, RemindersScreen, and nothing else in the app ever built the port or ran a pass. Three consequences, all of which the hook's own header comment claimed were handled: (1) a cold launch resolves to /dashboard, not /reminders, so the app could start, run and be used all day having armed nothing at all; (2) navigating away unmounted the AppState listener, so the rolling horizon re-arm — the mechanism that exists because alerts fire and fall out of a 50-item window — only ran while the merchant happened to be sitting on that one screen; (3) the same unmount killed the post-reboot re-arm, which several Android OEMs make mandatory by clearing pending alarms on restart. The reconciler is a property of this merchant has reminders, not of this screen is visible. Now a session-scoped ReminderAlertsProvider mounted in the root layout, with RemindersScreen demoted to a pure consumer. Deliberately NOT a new (user)/_layout.tsx, which was the first design and is the tidier expression of scope: it introduces a nested navigator, and QRS-203/206/207 were all structural changes that were correct on the surface they were tested on and broken on another. A headless provider changes no routing, no navigator nesting and no back-stack behaviour. The session gate is a conditional child rather than a boolean prop, and that is a real bug avoided: passing enabled: false while signed out is read by applyReconcile as the merchant's PREFERENCE being off, which correctly CANCELS everything armed — and since email is briefly null on every cold start before the persisted session rehydrates, a boolean prop would have wiped the horizon on every launch. Asserted by two tests. Cause B — WITHDRAWN. I was wrong. I reported that permission was never requested at the moment of creating a reminder, and that the alerts banner was the only route. It is not: RemindersScreen has prompted contextually after the first successful save since P3, via its own sheet shown before the OS dialog so a "not now" costs nothing. Recorded rather than deleted because the false claim was in the same list as four real ones, and a defect list that quietly loses an entry is not auditable. Found by reading the file before building against the claim. Cause C — Android 12+ delivered on INEXACT alarms. Fixed, pending device confirmation. Measured, not inferred: expo-notifications' ExpoSchedulingDelegate.kt:106 branches on SDK_INT < S || alarmManager.canScheduleExactAlarms() and falls back to setAndAllowWhileIdle when false. The library declares no exact-alarm permission, confirmed from the merged manifest of a real release APK (only POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, WAKE_LOCK, VIBRATE), so that check was false on API 31+ and every reminder took the deferrable branch, which Doze may batch and defer by minutes to hours. This was the leading explanation for the reported case specifically, because the symptom was late/never rather than nothing armed. Resolved by declaring SCHEDULE_EXACT_ALARM (app.jsonexpo.android.permissions; Expo's own withPermissions does a Set union and ensurePermissions is additive, so no custom plugin and no new dependency). USE_EXACT_ALARM was rejected on store-compliance grounds, verified against current Play policy rather than memory: it is a restricted permission limited to apps whose core user-facing function genuinely requires precise timing, and "apps declaring the new restricted permission that do not meet these criteria will not be permitted on Google Play"; Google's own guidance names SCHEDULE_EXACT_ALARM as the alternative. QRSETU is a business assistant with a reminders feature, which is arguable rather than clear. Asserted in both directions by notifications-build-config.test.ts. The version boundaries matter and I got one wrong first time: SCHEDULE_EXACT_ALARM is pre-granted on install on Android 12 and 13, and is NOT pre-granted on Android 14+ for apps targeting SDK 33+ (this app targets 36, read from the merged manifest). So most devices need no merchant step at all, and Android 14+ needs one trip to system settings. There is no JS route to canScheduleExactAlarms()expo-notifications exposes none, and RN's PermissionsAndroid.check() is actively misleading because a special app-op permission reports GRANTED whenever it is merely declared. So the state is derived from Platform.Version and the UI is worded as an ACTION ("Allow exact alarms") rather than a status claim, since an already-satisfied action is a redundant row whereas an unverifiable status claim is a lie. Reading the real state needs a native module, which is an app-size decision to surface before taking, not to slip in. Cause D — a created reminder does not survive the process. DEFERRED, with the consequence stated. packages/data/src/reminders/service.stub.ts is in-memory by design and the Supabase client is not wired, so if Android kills the app the reminder is gone on relaunch and the reseeded fixture takes its place. Not fixed here: building a persistence layer into a stub whose whole purpose is to be deleted is speculative work, and the real fix is the outstanding client wiring. The cost of deferring is a verification limit, not a product bug: the QRS-237 device matrix ("create 80 reminders, background, confirm the soonest still fire") cannot be run honestly across a process kill until the backend is real. Stated so it is not rediscovered as a mystery. Cause E — OEM battery optimisation. Documented, no code. Xiaomi/Oppo/Vivo/Samsung suspend background alarms for apps not exempted, independently of A–D. REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is itself Play-policy restricted, so this is a device-setup step in the build-and-test guide rather than an in-app prompt. Verification status: code-complete and unit-proven (89 reminder tests, 509 total, all gates green), NOT yet device-confirmed. Causes A, C and the tap fix are all things only a device can finally settle, and per CLAUDE.md the native build is the gate for anything this systemic. Related QRS-236, QRS-242, QRS-243. | | QRS-242 | bug | ✅ fixed 2026-07-29 | Every reminder mutation generated a FRESH idempotency key on each retry, which defeated the entire mechanism. newIdempotencyKey() was called inside mutationFn at useReminders.ts:201, 214, 226 and in the create/update paths, so TanStack Query's retry — and the networkMode: 'offlineFirst' queue replaying a mutation after reconnect — sent a different key on every attempt. The server then treats attempt 2 as a new request and writes a second row. CLAUDE.md states the rule explicitly: generate the key when the user commits the action and reuse the same key across retries. Not hypothetical: QRS-210 recorded that 6 of 11 rows in the legacy production reminders table were double-tap duplicates, which is why the key was made a required argument rather than an optional one — and the requirement was then satisfied in the one position where it cannot work. The offline queue makes it worse than a plain double-tap, because a retry after a long offline period is the expected path, not an edge case. Fixed by making idempotencyKey a REQUIRED field of each mutation's variables type, so the compiler refuses a call that has not decided on a key, and having each hook expose a named action (completeOccurrence, skipOccurrence, deleteReminder, createReminder, updateReminder) that mints it once per user action. The retry path reuses the same variables object, hence the same key. Call sites in RemindersScreen, DashboardHome and NotificationsScreen updated. Six tests, and the shape of them is the point: they assert the KEY the service was handed across a forced retry, not the resulting row count — the stub already dedupes on the key, so counting rows would have passed even with the bug. One test asserts two separate actions get DIFFERENT keys (the property a naive "hoist the key to module scope" fix would break), and one asserts the key is a non-empty string so two undefineds cannot pass as "reused". Why it shipped: nothing asserted the retry path, and the file's own header comment stated the correct rule two lines above code that violated it. | | QRS-243 | bug | 🟡 fixed-pending-device 2026-07-29 | Tapping a reminder notification did nothing. No addNotificationResponseReceivedListener or useLastNotificationResponse existed anywhere in apps/mobile/src, so an alert that successfully broke through left the merchant on whatever route they were last on, not the reminder it was about. More than a nicety: the proactive principle in CLAUDE.md requires a nudge to lead to a meaningful action, and an alert whose tap is a dead end is the textbook nudge a merchant learns to ignore — after which every future nudge is spent too. Fixed with a ReminderTapRouter that navigates to /reminders?focus=<id>&due=<ms>, which RemindersScreen reads to open that occurrence's detail sheet. The payload already carried what was needed (reminderId + dueAt are round-tripped through content.data so listArmed can rebuild the diff identity), so this was wiring rather than a modelling gap. due is carried because a recurring rule has many occurrences and only one of them fired. Both halves, because they are different events: a warm tap arrives on the listener; a tap that LAUNCHED the app was delivered before any JS listener existed and needs getLastNotificationResponseAsync. Handling only the former fixes the case that matters least, since the app being closed is the entire premise of scheduling an alert. Two design decisions worth recording. (1) It is a SEPARATE component from ReminderAlertsProvider, not a hook inside it: useRootNavigationState() THROWS ("Couldn't find a navigation object") rather than returning undefined when no navigation container is above it, so folding it in made the provider unmountable in any test that did not stand up a navigator — and an untestable control is a belief, not a control. Conceptually they are also different jobs with different dependencies. (2) router.navigate, not push: a tap arriving while the merchant is already on Reminders would otherwise stack a second copy of the screen, so backing out would land on Reminders again. The cold-start navigation is gated on a real navigation state because it races app/index.tsx, which resolves the entry route and <Redirect>s on mount — a push issued before the navigator exists is dropped silently. Seven tests, including the navigator-not-ready case, single-consumption across re-renders, URL encoding of a reserved character in an id, and a signed-out merchant never being routed. Device-pending: only a real tap on a real alert settles this. Related QRS-237. | | QRS-244 | debt | 🔵 open 2026-07-29 | Signing out does not cancel armed reminder alerts, so a signed-out device can still buzz. Noticed while reviewing the QRS-237 lifetime change rather than reported: ActiveAlerts unmounts when the session clears, and an unmount runs no reconcile pass, so whatever was armed stays armed until the OS fires it. Pre-existing, not introduced — the reconciler previously lived in RemindersScreen, and navigating away on sign-out unmounted it just the same. Recorded now because that lifetime is explicit and owned for the first time, which makes the gap a decision rather than an accident. Bounded but real. The lock-screen body is generic by default ("You have a reminder due", ADR-0016), detail is opt-in, and QRS-243's tap router refuses to route a signed-out merchant — so nothing leaks and nothing lands them somewhere they cannot be. What remains is a device alerting for an account that is no longer signed in, which is confusing and, on a shared or handed-on phone, mildly wrong. The fix is not simply "cancel on sign-out": the honest question is whether alerts should survive a sign-out at all (a merchant signing back in on the same device would expect their reminders to still fire, and re-arming is free on the next launch). Cancelling on sign-out and re-arming on sign-in is the likely answer, but it needs a deliberate call and a test, not a reflex. | | QRS-245 | infra | ✅ resolved 2026-07-29 | The local box cannot run the full Playwright matrix — both attempts on 2026-07-29 were killed by memory, not by test failures. Attempt 1 stopped silently at test 138 of 712 with exit 1, no not ok line and no summary; attempt 2 was killed by the owner at ~100% RAM, already at --workers=2. check:disk concurrently reports C: 11.2 GB against the 15 GB floor with clean:dev finding nothing reclaimable, so retention policy cannot fix it. The failure mode is the finding. Every emitted line was ok, so any truncated view read as a clean run; only the exit code plus the 712-vs-138 count exposed it. That is QRS-240's lesson arriving from the opposite direction — there | tail -8 cut real failures off, here the absence of a summary was the only signal. Both are now one rule in CLAUDE.md: check the summary, the exit code AND the count. Resolved by splitting the gate rather than shrinking it. New npm run e2e:quick = layout-invariants at phone-small with one worker: 99 tests, ~2.4 min, and it completes here — which is a real gate, not a token one, because layout-invariants is the deterministic primary layer and 360px is the tightest real viewport. The full 712-test matrix stays in CI, which starts from a clean runner. Local workers also drops to 2 off CI, with the honest note in the config that 2 is not a guaranteed fix since the killed attempt was already there. Rejected: capping to 1 worker for the FULL matrix (an hour-plus run nobody will wait for, so it becomes a gate that is skipped rather than a gate that passes) and sampling viewports at random (non-reproducible). Still open underneath this: the page file on D:, which is QRS-011's long-standing durable TBD and needs admin plus a reboot. e2e:quick makes that non-urgent rather than unnecessary. Related QRS-012, QRS-205. | | QRS-246 | debt | 🟡 partially fixed 2026-07-29 | SonarQube was a documented non-negotiable standard with ZERO tooling behind it. CLAUDE.md has named SonarQube as part of the engineering standard since the programme began, while separately listing sonar-scan under "Target adds" — so the vision was written down and never implemented. Confirmed by search: no sonar-project.properties, no .sonarlint/, no Sonar step in any of the four workflows, nothing. Found the way these things always are: the owner opened one random file in the VS Code SonarQube extension and it reported ten findings, with similar counts across many files. Measured, so the response is proportionate. eslint-plugin-sonarjs + eslint-plugin-security over the real tree report 130 violations across 53 files — nothing like NEFOXX's measured 12,792, because this codebase is 8 months younger. By area: tools/ 47, app source 42, tests 25, packages/ 16. The most important measurement is what ESLint CANNOT see. sonarjs/prefer-read-only-props (S6759) — the rule that dominates the IDE's findings — does not fire at all in our ESLint pass, because it needs type information and the config is not type-aware. So an ESLint-only answer would have looked like coverage while silently missing the single most common finding. That is the concrete argument for two layers rather than one. Scanned LOC is ~31.8k (apps/mobile/src 20.5k + packages 6.7k + supabase/functions 4.2k + tooling 0.4k), which would fit SonarCloud's 50k private free tier. Self-hosted CE chosen anyway: apps/web does not exist yet and the six R1 archetype domains (ADR-0009) are the bulk of R1, so the surface is expected to roughly double; and SonarCloud means uploading a private proprietary codebase to a third party. legacy/ is excluded — 57,395 lines of retired SPA would have made 64% of every report unactionable. Done in this pass (layer 2, the authoritative gate): sonar-project.properties, a sonar job in ci.yml, and .github/scripts/sonar-baseline-check.cjs. Every trap came pre-documented from nefoxx-reference-docs/guides/sonarqube-integration.md rather than being rediscovered: vm.max_map_count>=262144 before the container starts, which is why this uses docker run and not a services: block (a service container starts before any step, so the sysctl fix could never apply); readiness polled on /api/system/status for "status":"UP" rather than an open TCP port; its own job because CE wants 2-4 GB on a ~7 GB runner; and a single-run token minted via the REST API instead of admin/admin, since a default credential that works is a habit and habits leak somewhere that does not die in ten minutes. The gate is a RATCHET, not zero-tolerance, because an ephemeral server has no previous analysis and therefore no meaningful "New Code" — Sonar's own default gate cannot work here. It fails only when a count exceeds sonar-baseline.json, and it tells you when reality is better so the number gets ratcheted down. A gate that is red on arrival is bypassed within a week. First run will fail ON PURPOSE: bootstrapped: false, because the real counts cannot be known until the engine has scanned once, and inventing them would either wave through real debt or block the branch on fiction. The run prints the measured table to the job summary; paste it in and set the flag. One deliberate red run beats a green one that measured nothing (QRS-013). Still outstanding — layer 1, and it is the half that answers "why wasn't this caught before commit": wiring eslint-plugin-sonarjs/-security into the existing --max-warnings=0 gate. Both are installed as devDependencies (dev-only, never bundled, so no app-size implication) but deliberately not yet enabled, because enabling them today means 130 errors and a red lint gate that blocks all work — that has to be a sequenced change, not a side effect. Tracked as QRS-247. A demonstration worth recording: the IDE flagged four findings in sonar-baseline-check.cjs while I was writing it — a super-linear regex (S8786) and, precisely as reported, a nested ternary (S3358) and two nested template literals (S4624). Fixed immediately. Without the gate, building the gate produced the same class of debt it exists to stop. | | QRS-247 | debt | ✅ 158 of 158 fixed, every rule ON 2026-07-30 | Layer 1 of the Sonar answer: eslint-plugin-sonarjs + eslint-plugin-security, now TYPE-AWARE, in the whole-tree lint gate. Split from QRS-246 because it needed a decision plus fixes. THE FINDING THAT MATTERED, and it was invisible until it was measured: the first pass reported 73 findings and recorded that as the backlog. That number was wrong, and wrong in the direction that flatters the gate. eslint-plugin-sonarjs silently skips every rule that needs type information — it does not warn, it does not error, the rules simply never fire — and ~15 of them were already error in the recommended set this repo spreads, including prefer-read-only-props (S6759), null-dereference, argument-type, different-types-comparison and deprecation. Supplying a projectService program took the measured total from 73 to 158. Not one rule was added; the config was already asking for them. This is the exact shape of the defect that opened QRS-246 — a standard that is configured but not executable — reproduced one layer down, which is why it is written here at length rather than summarised. Burn-down: 158 → 17, verified. Cleared entirely: prefer-read-only-props 92, prefer-specific-assertions 12, no-nested-template-literals 8, super-linear-regex 5, deprecation 5, no-alphabetical-sort 3, prefer-regexp-exec 2, no-misleading-array-reverse 1, void-use 1, different-types-comparison 1, S1135 5. Those rules are now ON, deleted from the sequenced list rather than left at a count of 0. Two real defects, not style. (1) packages/data/src/reminders/service.stub.tsdifferent-types-comparison said a v !== undefined filter was always true, and it was right: TypeScript's Object.entries overload DROPS undefined from the value type of optional properties, so the inferred type was string | Recurrence | null. The rule's suggested fix would have introduced a data-loss bug — Zod echoes a key back when a caller passes it EXPLICITLY undefined, and spreading that over the stored row WIPES the field, so the filter is load-bearing for PATCH semantics. Resolved by making the type honest (as [string, unknown][]) rather than by deleting the guard. The type system was lying and a type-aware rule caught the lie. (2) packages/observability/src/scrub.ts EMAIL_RE was quadratic on the crash path, over arbitrary exception text — see QRS-253 for why the first fix for it was measurably worthless. Deprecations taken and refused, both with reasons. runOnJSscheduleOnRN in src/ui/Sheet.tsx (react-native-worklets marks the old name @deprecated; same mechanism, args passed directly). That import then broke 8 jest suites, because react-native-worklets reaches for a native binding under jest and mocking react-native-reanimated does not cover a separate package — fixed with a matching scheduleOnRN double in jest.setup.ts. Refused: getLastNotificationResponseAsync in expoPort.ts, see QRS-251. eslint-disable-next-line DOES NOT WORK WITH A WRAPPED REASON — worth its own line because it fails silently and looks correct. A -- reason continued onto a second comment line makes the COMMENT the "next line", so the directive lands on the comment and the code below stays flagged. Caught only by re-measuring. RESOLVED: 17 findings across two rules — all 17 now fixed and both rules ON (2026-07-30). The measured backlog was no-nested-conditional (8) and cognitive-complexity (9, not the 8 the config comment claimed). The ninth was this programme's own code: .github/scripts/sonar-baseline-check.cjs crossed the threshold when QRS-257 added the liveness check a day earlier. A hand-written backlog note goes stale the moment the code moves, which is exactly why the burn-down was re-measured before being worked rather than read off the config — and it is the second time in this programme that a recorded number turned out to be lower than reality (the first was 73 vs 158). How the 17 were cleared, in risk order — pure logic first, screens last. packages/domain/src/reminders/recurrence.ts at 38 was the worst function in the repo and also the safest thing to restructure, because 117 unit tests name its behaviour: expandOccurrences became a dispatcher over resolveUpperBound · expandOneOff · createEmitter · expandMultiDayWeekly · expandFixedStep · stepCursor, with 117/117 still green after each step. schedule.ts (19) split the same way. One latent bug was closed on the way: the old switch in the stepping loop ASSIGNED with no default, so an unhandled frequency would have left the cursor unchanged and emitted the same instant MAX_STEPS times; the extracted stepCursor returns from every arm, making a new frequency a compile error instead. The five screens carried one shape — isLoading ? … : isError ? … : empty ? … : content — and it was replaced not with a nested-ternary rewrite but with a per-screen SHELL component plus top-level guard returns, so the rendered element tree is unchanged. That property is what made it safe to do to seven files at once, and it was chosen over threading a dozen values through as props, which is the version that introduces bugs. A shared @/ui async-boundary primitive would be the DRY answer and was deliberately NOT built: apps/*/src/ui/** is the systemic surface, so it needs a design pull and a drift-ledger row first (ADR-0015) — logged as QRS-259 for the auth round, when auth screens add more instances of the same ladder. Parity: this is broad JSX churn in the shape that produced QRS-203/QRS-206/QRS-207, so a green web gate is explicitly NOT the verification — a fresh arm64 APK and the iOS build are. The owner's decision to FIX rather than exempt .tsx is unchanged and is what closed this. Gates green at hand-off: typed eslint . --max-warnings=0 exit 0 · format:check · type-check 9 workspaces 0 errors · 509 jest + 117 domain · check:readmes/parity/design/sql/test:hooks · e2e:quick 99 passed. | | QRS-251 | debt | 🔵 accepted deviation 2026-07-29 | getLastNotificationResponseAsync is deprecated in SDK 57 and we are keeping it, on purpose. Surfaced by sonarjs/deprecation once type-aware analysis was switched on (QRS-247). The replacement is the useLastNotificationResponse hook, and that is the problem: createNotificationPort() returns a plain object so the reconciler can be unit-tested with a fake port and no React at all, which is what makes the iOS 64-pending-alert budget testable without a device (ADR-0016). Adopting the hook would move notification plumbing back into the component tree and delete that property, to satisfy a lint rule. Also, expo does not actually say to stop using it — its own docs for the hook read "If you don't want to use a hook, you can use Notifications.getLastNotificationResponseAsync() instead." So this is a deprecated type annotation on a supported alternative, not a removal notice. Recorded as a row rather than a bare eslint-disable because that is the difference between an accepted deviation and an unexplained suppression: two narrow inline disables carry the reason and point here. Revisit when a future SDK removes the function — the existing typeof guard already degrades to "no initial tap" rather than crashing, so the failure mode is safe. This is the cold-launch tap path (QRS-243), which is the case that matters most for a reminder, so it is worth re-verifying on both natives whenever the SDK moves. | | QRS-252 | infra | 🔵 known limitation 2026-07-29 | The SonarQube CE scan cannot be run locally on the 16 GB Windows box. Attempted during QRS-247 to get an authoritative count before CI: the server container booted fine (status UP after ~280 s) and a scan token was minted, but the sonar-scanner-cli container exited unexpected EOF and took Docker Desktop with it — subsequent docker ps returned "Docker Desktop is unable to start". Cause is resource exhaustion, not configuration: embedded Elasticsearch, a 3 GB scanner heap, and indexing a bind mount over node_modules (tens of thousands of files over a virtualised filesystem), on a box where check:disk already reports C: under its 15 GB floor. Same family as QRS-245 (the full 712-test Playwright matrix also cannot finish here), and the same conclusion: CI, which starts from a clean runner, is the authority. A gate-reading trap worth recording: the backgrounded scan reported exit code 0 while having failed, because the command was piped into tail and the pipeline's exit status is the last stage's. That is QRS-240 recurring in a new place, and it is why CLAUDE.md says never to pipe a gate through tail. Locally, the substitute already exists and is what exposed the whole problem: the VS Code SonarLint extension. It is also strictly broader than our ESLint layer — it reported S7781 and S4036, neither of which eslint-plugin-sonarjs implements. Not worth engineering around: the ask is a count, CI produces it, and the local IDE gives per-file feedback while editing. | | QRS-253 | debt | ✅ fixed 2026-07-29 | A ReDoS fix that was measurably worthless, and the measurement is the point. packages/observability/src/scrub.ts's EMAIL_RE ([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}) was flagged super-linear-regex. The obvious reading is that the ambiguity is in the domain half — . sits inside the character class AND is required literally after it — so the first fix rewrote it as unambiguous dot-separated labels, with a confident comment explaining why that removed the backtracking. It did not. Timed against the original on a 32 k adversarial input: original 1388 ms, "fixed" 1445 ms. The quadratic behaviour was never in the domain half at all — it is [a-z0-9._%+-]+@: with the g flag the engine retries every start position, and at each position inside a long run of local-part-legal characters the leading + consumes the whole run before failing to find @ and giving it all back. The rewrite fixed a different input shape and left the one that mattered untouched. What actually worked: bounding every quantifier to its RFC 5321 limit (local part ≤64, each domain label ≤63), which makes per-start-position work constant. Measured 1388 ms → 15.65 ms at 32 k and ~2x per doubling out to 128 k, i.e. linear. A lookbehind variant measured faster still (0.3 ms) and was rejected: it needs RegExp lookbehind, which is an engine-support question on Hermes, and 14 ms is not worth a portability risk in the crash path. Why this is a permanent lesson and not a war story: this regex runs over arbitrary exception messages and breadcrumb values — the least trustworthy strings in the process — on the crash path, so its worst case is a real availability concern. And a plausible explanation of a performance fix, written in a comment, is not evidence that the fix works. PHONE_RE on the adjacent line had the same unbounded shape and was bounded in the same pass even though no rule flagged it. Verified with an equivalence harness over a corpus of real-shaped addresses: 0 regressions, one input (user@192.168.0.1) newly redacted, which is the over-redaction direction this file's own header declares safe. | | QRS-254 | security | 🟡 triaged, 0 exploitable, 8 blocked upstream 2026-07-29 | All 9 open Dependabot alerts triaged — and the "3 high" headline is misleading in a way worth writing down: NOT ONE of them is in code we ship. Every alert was traced to its manifest and its call path rather than read off the severity column. By manifest: 4 in legacy/package-lock.json (react-router high, brace-expansion high/medium/low) · 4 in documentation/portal/package-lock.json (vite high + 2 medium, esbuild medium) · 1 in the root lockfile (uuid medium). legacy/ (4, incl. 2 of the 3 highs) — not exploitable, and PERMANENTLY unactionable. legacy/ is the retired Vite SPA: not built, not linted, not a workspace, never deployed (CLAUDE.md). Note the mechanism, because it is the interesting part: .github/dependabot.yml deliberately does not list /legacy for updates, but alerts are repo-wide — they come from the dependency graph, not from the updates config — so excluding a directory from dependabot.yml does nothing about them. These 4 will re-appear forever. documentation/portal (4, incl. 1 high) — dev-server only, and BLOCKED UPSTREAM. VitePress has its own package.json and is never part of the app build; the CVEs are vite/esbuild dev-server issues, reachable only against npm run docs:dev on a developer machine (CI builds statically). Measured why it cannot simply be patched: the fixes land in vite 6.4.x, but vitepress@1.6.4 — the latest STABLE — pins vite: ^5.4.14, and we have 5.4.21. The only release that carries a patched vite is vitepress@2.0.0-alpha.18 (vite: ^8.1.3). Forcing vite 6 under VitePress 1.x via overrides would run the docs site on a major its framework was not built against. Moving the documentation portal to an alpha to fix dev-server CVEs is the worse trade, so this waits for a stable VitePress 2. uuid (1, the only root-lockfile alert) — NOT REACHABLE, verified by reading the call site, not by assuming. Path is expo-splash-screen → @expo/config-plugins → xcode → uuid@7.0.3, i.e. expo prebuild tooling that never enters the app bundle. More decisively: the advisory is "missing buffer bounds check in v3/v5/v6 when buf is provided", and its own text notes that v4()/v1()/v7() do throw RangeError — while node_modules/xcode/lib/pbxProject.js:90 calls uuid.v4() and nothing in xcode or @expo/config-plugins calls v3/v5/v6 at all (grepped). So the vulnerable function is never invoked. It is also pinned at uuid 7 by xcode@3.0.1: forcing 11.1.1 crosses four majors and a CJS→ESM named-export change, which would plausibly break expo prebuild — a real regression traded for a theoretical fix. Verdict: no code change. This is a triage row, not a fix row, and the distinction is the point — "9 open alerts" and "9 unaddressed vulnerabilities" are different claims, and only the first was ever true. Re-check triggers, so this does not become a permanent shrug: (a) uuid — when Expo bumps @expo/config-plugins/xcode, it resolves itself; (b) portal — when VitePress 2 goes stable, upgrade and these 4 close together; (c) legacy/ — needs an owner decision (below). One decision for the owner, and it is a real one: 4 permanent alerts, including 2 of the 3 highs, come from a directory we have already declared dead. A list that always shows unactionable highs is the anti-pattern CLAUDE.md names in the product principle — noise trains you to stop reading the channel, and then the alert that matters arrives into a list nobody checks. Options: delete legacy/package-lock.json (it is never installed, so it serves no build purpose — cleanest, removes the entries from the dependency graph at the source), or dismiss the 4 as not_used in GitHub with a comment pointing here (reversible, keeps the file, but must be re-done for every future legacy alert). Not actioned unilaterally: dismissing alerts changes the repository security record, so it is the owner's call. | | QRS-255 | debt | ✅ fixed 2026-07-29 | The two-speed lint config SILENTLY DELETED source code — specifically, the suppressions only the other speed can justify. Introduced and caught within one commit of each other, which is the only good thing about it. What happened. QRS-247 split linting into a typed whole-tree gate and an untyped eslint.fast.mjs for lint-staged (typed linting costs +65% on a small staged set and pre-commit must stay fast). lint-staged runs that config with --fix. ESLint 9 defaults linterOptions.reportUnusedDisableDirectives to 'warn', and --fix DELETES a directive it believes is unused — so on the commit that introduced the fast config, both documented // eslint-disable-next-line sonarjs/deprecation lines in expoPort.ts were stripped out of the file by the pre-commit hook, leaving blank lines behind. CI's typed lint then failed on exactly the two lines they had been protecting. Why it is worse than a red build: the fast pass did not merely fail to see a finding, it edited source to remove an intentional, reasoned deviation (QRS-251) — a deviation whose whole justification is a comment block immediately above it. Had CI been less strict, the app would have kept working and the record of the decision would just be gone. Root cause, stated as a rule because it generalises: only a pass that actually runs a rule may decide that a suppression of that rule is dead. The untyped config never runs sonarjs/deprecation, so every directive naming it looks unused there. Nothing about ESLint's behaviour is wrong; the configuration asked it to judge something it could not see. Fix. reportUnusedDisableDirectives: 'off' in eslint.fast.mjs only, with the reasoning inline. Left ON (by default) in the typed root config, which runs every rule and can therefore judge staleness correctly — that asymmetry IS the fix, not a weakening of it. Verified by reproducing the failure, not by re-running the suite: counted the directives, ran eslint --config eslint.fast.mjs --fix over the file, counted again — 2 before, 2 after (previously 2 before, 0 after) — then confirmed the typed whole-tree gate exits 0. How it was found is the part worth keeping. Every local gate was green: typed lint, format, type-check across 9 workspaces, 509 jest + 117 domain, all five static gates, e2e:quick. The defect was invisible locally because the damage happens during git commit — the hook rewrites the file after the gates have run, so no pre-commit or pre-push check can observe its own side effect. It surfaced only because the PR was opened deliberately to make CI fail on the un-bootstrapped Sonar baseline (QRS-246). A run intended to fail for one reason caught a different, real defect — which is the argument for that "one deliberate red run" design, made better than the design document made it. Consequence for the Sonar bootstrap: workspace failed, so the sonar job (needs: workspace) was skipped and the baseline is still un-bootstrapped. That is a sequencing lesson too — a gate chained behind a broad job cannot bootstrap while anything upstream of it is red. | | QRS-256 | debt | 🟡 fix pushed, awaiting CI 2026-07-30 | The sonar job sent the admin password in a URL QUERY STRING, and hid the error that said so. The Sonar baseline has still never been bootstrapped, and — this is the correction — not for the reason QRS-246 and QRS-247 claimed. Both rows said the first run "fails on purpose" at the baseline gate. It did fail, but it never got there: it died at step 7 of 10, Mint a single-run token, and Scan + Baseline gate were skipped. A row that predicts a specific failure and is then satisfied by any failure is not evidence, so the distinction is written down rather than smoothed over. Symptom. POST /api/users/change_password returned HTTP 400, and the entire diagnostic output was curl: (22) The requested URL returned error: 400 — because curl -f discards the response body, which is exactly where SonarQube puts its reason. The engine itself was healthy (status=UP after ~85 s, 16 poll attempts). Two defects, and only one of them is the bug. (1) Wrong on principle, independent of the 400: previousPassword and password were interpolated into the URL. Credentials in a query string land in server access logs and in our own CI logs — GitHub had already masked part of that line, which was the tell sitting in plain sight in the log we were reading. (2) The actual likely cause: a form parameter sent as a query parameter is a Bad Request, which is precisely what 400 means. Both are fixed by the same change, which is why it is worth doing even if the 400 turns out to have another cause. Fix. --data-urlencode for every parameter, so curl builds a form-encoded POST body and does the escaping itself; this also deletes the printf %s "$PW" | jq -sRr @uri dance and the quoting bugs that come with hand-rolled encoding. Plus --fail-with-body in place of -f, so a 4xx still fails the step but prints SonarQube's own JSON error first — the next failure explains itself instead of costing another CI round-trip. set -o pipefail added so the | jq on the token call cannot mask a failed request, and the generated password is ::add-mask::ed. Measured, because it bounds how bad the old form was: openssl rand -base64 24 produced a URL-special character (+, / or =) in 19 of 40 samples. So roughly half of all runs were exercising hand-rolled percent-encoding of a credential inside a URL — a coin-flip dependency on a code path that had never been tested. Not the confirmed root cause, but not a risk worth keeping either. Not reproduced locally, deliberately. Booting SonarQube CE while the guarded Android release build was running is how this box OOMs (QRS-012, QRS-245, QRS-252); the encoding half of the hypothesis was checked without Docker instead, and the rest is now self-describing in CI. Workflow YAML re-parsed with js-yaml after editing — 10 steps, structure unchanged — because a malformed workflow does not fail loudly, it silently does not run. | | QRS-257 | debt | ✅ fixed 2026-07-30 | The Sonar gate reported bugs 0 · vulnerabilities 0 · code_smells 0 · security_hotspots 0 — and every one of those numbers was fiction that was one paste away from becoming the committed baseline. Why it was not believable. A type-aware scan of ~32k analysable lines cannot find zero code smells, and SonarLint in the IDE was reporting findings at that moment. The scan itself was genuinely fine: 279 files indexed, 249 analysed by the JS/TS sensor over 38.8 s. So the analysis worked and the measurement was wrong. Root cause: the scanner finishing is not the analysis finishing. sonar-scanner SUBMITS a report and exits — it prints this itself, in the log we had already read: "you will be able to access the updated dashboard once the server has processed the submitted analysis report", plus a Compute Engine task id. The CE processes it asynchronously. Timeline: scanner submitted at 04:50:34.446, exited 04:50:35.205, and the baseline gate queried /api/measures/component at 04:50:35.771.3 seconds later. It read a project with no analysis applied and got measures: []. Not an error. Empty. Second defect, in this repo's own code, and the worse of the two. sonar-baseline-check.cjs used measures[m] ?? 0 in the BOOTSTRAP path while its own file header promised it "refuses to treat a missing metric as zero" — and the gated path below it did exactly that correctly. So the one code path whose output a human is instructed to paste in as ground truth was the one path that silently invented zeros. The bootstrap number is the only number nobody can sanity-check against a previous run, so it needed the stricter handling, not the looser. Fix, in two layers. (1) A new CI step reads ceTaskId from .scannerwork/report-task.txt and polls /api/ce/task to a terminal state before the gate runs; FAILED/CANCELED fail the job, because an unprocessed report means no measurement. (2) The script now fetches ncloc as a liveness metric and hard-fails when it is 0 or absent — ncloc cannot be 0 for this repo, so that condition proves the data is not real — and the ?? 0 is gone. Verified by replaying the exact production failure, not by re-running CI. A stub measures API was pointed at the script for four shapes: measures: [] (the real one) → fails with the liveness error; ncloc: 0 → fails; a processed analysis → prints real counts (3/0/214/7) and the bootstrap message; one gated metric absent → refuses to bootstrap. Before this change, shapes 1, 2 and 4 all printed 0. The generalisable lesson, which is the reason this row is long: an asynchronous system will answer a question you ask too early, and the answer will be well-formed and wrong. 0 and "no data" are not the same claim, and any gate that cannot tell them apart is the QRS-013 green-no-op wearing a number instead of a checkmark. A gate needs a liveness assertion, not just a threshold — something that is false when the measurement did not happen. That is what ncloc is doing here, and it is the piece the original design was missing. Sequenced after QRS-256, which is what finally let the scan run at all. AMENDMENT (2026-07-30): the first fix did not work, and the reason is the same class of error as the bug it was fixing. The wait step read ceTaskId from .scannerwork/report-task.txt, and the comment justifying it said "the repo is bind-mounted, so it lands on the host". That was an assumption presented as a fact. The next run failed with missing .scannerwork/report-task.txt while the scan step itself printed EXECUTION SUCCESS — the scanner image resolves its working directory to a container-internal path, so the metadata file never appeared in $PWD. The gate was skipped, so no wrong number was published; the step failed loudly, which is the design working. Corrected by removing the artefact dependency entirely rather than guessing a second path (that would have been a third guess in the same place): poll /api/ce/component?component=qrsetu, which is the server's own answer to "is anything still processing for this project?", and require queue EMPTY and current.status == SUCCESS. The queue-empty half is what stops a stale current from an earlier analysis reading as done — unnecessary on an ephemeral server, but the check should not depend on that being true. Verified before pushing, against a stub, in 4 scenarios — queue drains then SUCCESS (exit 0) · CE FAILED (exit 1) · queue empty with no current, i.e. this bug's original shape seen through the new API (exit 1, keeps waiting) · stale SUCCESS while our task is still queued (correctly not treated as done). The harness extracts the run block straight out of ci.yml via js-yaml, so the thing under test is what CI executes rather than a paraphrase of it, with only the port and the sleep substituted. jq is not installed on this Windows box and installing it would put a binary on C: (QRS-205), so a 20-line node shim implements exactly the two filters the step uses. The lesson, restated because I had already written it one day earlier: an unverified claim about a system boundary is the same defect whether it is about timing (querying before the Compute Engine finished) or about location (assuming a container write is visible on the host). Both produce a confident, well-formed wrong answer. The fix in both cases was to ask the authority instead of inferring from a side artefact. | | QRS-258 | debt | ✅ fixed 2026-07-30 | The Sonar job's token step failed on roughly one run in three, and the code was identical each time. Run 2 minted a token and scanned; run 3, on the same step, returned HTTP 400. The body — visible only because QRS-256 had just added --fail-with-body — said {"result":"Password must contain at least one special character"}. Cause: a random password checked against a character-class policy. openssl rand -base64 24 emits 24 bytes as 32 base64 characters with no = padding (24 divides by 3), so the only "special" characters the alphabet can produce are + and /2 symbols in 64. That gives P(no special) = (62/64)^32 ≈ 36%, and, lurking behind it, P(no digit) = (54/64)^32 ≈ 0.4% — a second failure mode that would have surfaced about once every 270 runs and read as pure flake. Fix: a fixed Aa1! suffix covering upper/lower/digit/special, making the step deterministic. Entropy is unchanged — the 24 random bytes are still the secret, the suffix only satisfies the policy, and the credential lives for one job inside an ephemeral container. Worth recording as a pattern, not just a fix: generating a credential randomly and hoping it satisfies an unstated policy is a probabilistic gate. It is the same class as QRS-257 (believing an answer that arrived too early) — both produce a result that is well-formed, plausible, and not what it claims. And it is the first time a fix from the previous round paid for itself immediately: without --fail-with-body this was a bare curl: (22) and would have been debugged as "SonarQube is flaky". | | QRS-259 | improvement | 🔵 open — sequenced for the auth round | Five screens now hold five hand-written copies of the same loading/error/empty/content shell. Clearing QRS-247 replaced a nested ternary in each of DashboardHome, NotificationsScreen, RemindersScreen, SettingsScreen and ProfileScreen with a per-screen shell component plus guard returns. That was the right call for THAT change — the rendered element tree is provably unchanged, which is what made it safe to touch seven files in one pass — but the duplication is real and it is now visible in one place. Why it was not fixed in the same commit, stated so it does not read as an oversight: the DRY answer is a shared <AsyncBoundary>-style primitive in apps/mobile/src/ui, and apps/*/src/ui/** is the systemic surface under ADR-0015 — design-first, no exceptions, so it needs the design pulled from the MCP project and a correction row in the drift ledger BEFORE implementation. Bundling a systemic-surface change into a static-analysis burn-down would have been exactly the kind of scope creep that makes a refactor unreviewable, and it would have changed the render tree for all five screens simultaneously rather than provably preserving it. Why the auth round is the right moment: the auth flow adds more instances of the same ladder (sign-in, OTP verify, biometric unlock, session rehydration), so the primitive gets designed against more than five call sites instead of exactly five — which is the difference between extracting an abstraction and guessing at one. CLAUDE.md's "no speculative abstractions" rule cuts both ways here: five instances is evidence, but the shape of the sixth through ninth is what fixes the API. | | QRS-260 | debt | 🟢 closed — 77 → 0, baseline ratcheted to zero 2026-07-30 | The Sonar ratchet is finally LIVE — and its first real measurement is 77 findings: bugs 1 · vulnerabilities 7 · code_smells 69 · security_hotspots 0 (CI run 30519960490, commit f37f7ed). sonar-baseline.json is bootstrapped at those numbers, so from now on the gate fails when any count goes UP. Read the number correctly, because two other numbers in this repo say "zero". npm run lint is at zero with every sonarjs rule enabled (QRS-247), and that is not a contradiction: SonarQube CE runs a strictly WIDER ruleset than eslint-plugin-sonarjs. This is CLAUDE.md's standing "never assume ESLint == Sonar" warning arriving as a measured quantity — predicted before the run, at 77 rather than the ~2 the earlier S7781/S4036 observations suggested. Why the baseline was committed at 77 instead of holding out for zero. The ratchet's own design note answers this: a gate that is red on arrival gets bypassed within a week and then protects nothing. A live gate that blocks the 78th finding today is worth more than a red one that blocks nothing while 77 are triaged. The 77 are a burn-down target, not an endorsement — stated in the baseline file itself so a passing gate is never misread as a clean scan. Why this row exists rather than a straight fix: the gate reported counts with no identities. code_smells 69 names no rule, no file and no line, and the server that knew is destroyed with the job — the ephemeral-container trade-off (see QRS-252, which is why it cannot be reproduced locally on the 16 GB box). So the first successful measurement produced a number nobody could act on. Fixed by a Finding inventory step that queries /api/issues/search while the server is still alive and writes two tables to the job summary: grouped by rule (findings cluster hard by rule, and the rule is the unit a fix is reasoned about) and then every finding with file, line and message. It is continue-on-error: truenot merely if: always(), because with set -o pipefail a bad jq filter would fail the step and report a quality regression that did not happen. The gated path was verified against a stub before pushing, 6/6: exact match → PASS · code_smells 69→70 → FAIL · a new vulnerability 7→8 → FAIL · a burn-down 69→40 → PASS and tells you to ratchet down · ncloc 0 → the QRS-257 liveness assertion still fires · a metric absent → refuses to read it as zero. Worth noting the harness bug on the way, because it is a trap in any Node test that stubs HTTP: the first version used in-process servers on the default host and hung with no output for minutesfetch keeps the socket alive in undici's pool, so server.close() never resolves. Child-process stubs bound to an explicit 127.0.0.1 with connection: close. Next: the inventory lands on the next run; triage by rule, fix, ratchet the baseline down. Do not raise a number in this file without a written reason in the same commit. UPDATE (2026-07-30, same day): triaged and fixed almost all of it. Re-measuring the 77 by hand (the CI job's own ephemeral server was already gone) split them into four groups: 12 suppressed with a written, per-rule reason in sonar-project.properties — 5× javascript:S4036 and 2× S5443 (dev-script PATH/tmpdir patterns already accepted in the ESLint securityScripts layer), 2× typescript:S1607 (Playwright's conditional test.skip, a parser false positive), 1× css:S4662 (NativeWind's @cssInterop at-rule, unknown to Sonar's CSS parser), 2× typescript:S7741 (typeof process !== 'undefined' — REQUIRED, not stylistic: packages/* are platform-agnostic per platform-globals.d.ts's own comment that process is "NOT universal", so the literal-comparison fix would throw ReferenceError in a plain browser bundle). 7 removed by deleting dead codetools/install-missing-components.js, a "one-off shadcn/ui dependency helper (legacy SPA era)" per its own README entry, zero references anywhere. ~53 genuinely fixed, in risk order: the 7 Edge Function cognitive-complexity findings first (below), then ~10 mechanical tools/ findings (.find() over .filter()[0], String.raw, replaceAll, startsWith), then ~30 mechanical findings across apps//packages//supabase/ (Number.parseInt/parseFloat, Object.hasOwn, readonly, ??=, .at(), codePointAt, Math.hypot, a Set, an unreachable ?? {}), then the substantive ones. Two of the "mechanical" findings were real bugs, not style. validate-user-input's email regex (S8786, flagged super-linear) was bounded to RFC 5321 lengths on a public, UNAUTHENTICATED endpoint — measured first (the same discipline as QRS-253): growth stayed linear in V8 for every attack string tried up to 160k chars, so this exact pattern does not reproduce catastrophic backtracking here, but the bound costs nothing and is correct regardless. Far more serious: ProfileScreen's HoursTab keyed its holiday rows on array index (S6479) while its own "Remove" button deletes from the MIDDLE of the list — a real bug, since every row after the removed one would have had its TextField's internal input state reattached to a DIFFERENT holiday's data. Fixed with a locally-tracked id per row, kept in lockstep with add/remove (patch never touches it), because PlannedHoliday has no id field and gaining a client-only one would leak into the persisted planned_holidays_ooo jsonb. 4 more S6479 findings suppressed, not faked. GradientHeadline's line-break marker, OtpInput's digit cells, StepProgress's segments and ProgressDots are all fixed-length rows of decorative, stateless slots with no content to key by — restating the same index under a different name would obscure the finding, not resolve it, so each is suppressed by rule+path with the reasoning written down. The other 5 got real content-derived keys (a static weekday letter, a month-scoped calendar cell, Children.toArray's own .key, a static bar height). Two real perf findings, both fixed: (tabs)/_layout.tsx's tabBar render-prop was an inline arrow recreated every render (S6478) — hoisted, since it closed over nothing; ReminderAlertsProvider's context value was a fresh object literal every render (S6481) — wrapped in useMemo, with exactAlarmStatus() moved into the render body so exhaustive-deps could see it as a real (not merely convenient) dependency. Gates, all green: typed eslint . · type-check ×9 · deno check on all 20 EFs · 108 EF + 509 jest + 117 domain tests · check:readmes/parity/design/sql/test:hooks · e2e:quick 99 passed · fresh arm64 APK built. Left, honestly: ~13 findings pending the next CI run's number — the arithmetic (77 − 12 − 7 − ~53 ≈ 5, but several fixes each closed more than one listed count, e.g. the EF complexity pass; the real number is whatever the next scan reports, and THAT is what gets ratcheted into sonar-baseline.json, not an estimate). CLOSING UPDATE (2026-07-30): the real number was 3, not ~13. CI run 30530248594 (commit d4f2f36) measured 3 remaining code smells, all minor: 2× typescript:S6571 (Promise<any | null> — the | null is redundant since any already subsumes it, in _shared/observability.ts) and 1× typescript:S7776 (RETRYABLE_STATUSES as an array with .includes() instead of a Set with .has(), in _shared/cloudflare.ts). Fixed in commit 2933c6e; deno check clean, 108/108 test:ef green, and confirmed pre-existing that a separate deno lint no-explicit-any finding in the same file predates this work and is out of Sonar's scope. Re-ran CI (run 30530248594) and got bugs 0 · vulnerabilities 0 · code_smells 0 · security_hotspots 0 — gate PASS. sonar-baseline.json ratcheted to all-zero in the same commit. The Sonar burn-down is done. | | QRS-261 | bug | 🟡 Dev fixed + captured as migrations, Prod promotion pending 2026-07-30 | Signup on Dev created an auth.users row and NO profiles row, because the trigger that provisions profiles existed only in Prod's dashboard and was never a migration. Found by the ADR-0018 auth pre-flight, which exists precisely to stop assuming this. Measured on both projects rather than read off the migration history: the FUNCTION handle_new_user() is present on both (it came through the baseline squash), but the TRIGGER on_auth_user_created was present on Prod only — created by hand, never captured. Dev: zero triggers on auth.users. Why this blocked the auth work rather than being cosmetic: ADR-0018 derives isNewAccount from whether the caller has a profile row, so a missing trigger makes every brand-new signup indistinguishable from "the profile read failed" — a silent permanent misclassification, not a loud error. This is exactly the drift CLAUDE.md's "capture cron schedules + storage buckets as migrations, not just live DB state" rule names, in a third category nobody had listed: triggers on auth.*, which supabase db diff does not surface because auth is not a user schema. Fixed as two migrations (20260730163036, 20260730163053), both idempotent so one file promotes cleanly to a project that already has the object and one that does not: the trigger is DROP … IF EXISTS then CREATE (a no-op re-bind on Prod, the actual fix on Dev), and the function is CREATE OR REPLACE. The function was hardened while it was open, and the guard was chosen by measurement not by defensiveness: it runs INSIDE the signup transaction, so any exception it raises rolls the signup back and surfaces as the opaque "Database error saving new user". Checking the schema first showed public.profiles has exactly two NOT NULL columns — id (supplied) and country (defaulted 'India') — and only two unique constraints, id and slug (never set here). So a duplicate id is the ONLY realistic failure and ON CONFLICT (id) DO NOTHING closes it precisely; DO NOTHING and not DO UPDATE, because an existing profile holds real merchant data that signup-time provider metadata must never overwrite. Genuine schema errors still propagate deliberately — a user with no profile is a broken account, so failing loudly in development beats creating one silently in production. full_name is coalesced across full_name and name because the providers disagree: email signup sends neither, Google sends both, Apple sends it on the FIRST authorization only — so a key mismatch there is unrecoverable, which is why it is a tested case and not an assumption. avatar_url is deliberately NOT captured from provider metadata: that would store a Google/Apple CDN URL and make the public Setu Card hotlink a third party, which is a privacy and availability decision, not a migration detail. check:sql caught a real gap in the first draft — the trigger function is SECURITY DEFINER and had no REVOKE, so the gate flagged both the PUBLIC channel and the separate anon channel (QRS-214's two-channel lesson, now enforced automatically). The revoke needed proof, not reasoning: the caller is Supabase's own supabase_auth_admin, so if PostgreSQL did check EXECUTE when firing a trigger, revoking would have broken every signup. Verified empirically on Dev inside a rolled-back transaction — with EXECUTE revoked from PUBLIC and anon, an insert into auth.users still provisioned the profile and still carried the name. (It is also unreachable directly either way: a function returning trigger raises "trigger functions can only be called as triggers".) Also delivered here: get_my_auth_context() — the RPC ADR-0006 specified and recorded as "planned but not built", returning {profile_exists, onboarding_completed, role} in one round trip. An RPC and not an Edge Function per CLAUDE.md's decision rule (a two-column read of one row, no secrets, no external HTTP) — routing it through an EF would add a hop and a cold start to the most latency-sensitive moment in the app, the tap after entering the OTP. profile_exists is an explicit field rather than a NULL return so that "brand-new account", "the read failed" and "transport error" cannot collapse into the same data: null on the client — which is the same confusion the trigger bug above caused, and worth designing out once. Verified on Dev that role comes from the TABLE and not the JWT: with a claim of authenticated and a table value of admin, the RPC returned admin — which is ADR-0006's actual requirement and the thing its flagged user_metadata.role smell gets wrong. pgTAP: auth_context_test.sql, 16 assertions covering the binding three ways (exists · points at handle_new_user · is ENABLED — a disabled trigger passes an existence check and provisions nothing), provisioning behaviour against real auth.users inserts for all three metadata shapes, both functions' grants, and the RPC refusing an unauthenticated caller. Could not be run locally: Docker Desktop will not start on this box (the same failure as QRS-252/QRS-245), so npm run test:db is unavailable and backend-ci.yml is the gate. Prod is deliberately NOT promoted yet — it holds 11 real users (11 users / 11 profiles, consistent, so the trigger has been working there) and promotion is an owner-gated step per PROMOTION_RUNBOOK.md. On Prod the trigger migration is a no-op re-bind and only the RPC is new. backend-ci caught a real regression the first push introduced, not a flake. throws_ok's error-code argument wants the SQLSTATE (42501), not the condition NAME (insufficient_privilege) — reminders_test.sql already establishes this convention and my first draft didn't follow it. More seriously: the CI stack's OWN ephemeral database had never had this trigger before, so get_public_profile_by_slug_test.sql's fixture — which inserts into auth.users and then separately inserts a full profiles row for the SAME id — had never raced the trigger and always won the insert outright. With the trigger now live everywhere (including every future ephemeral CI stack), that fixture's plain INSERT collides on the primary key the trigger already filled. Fixed with ON CONFLICT (id) DO UPDATE, verified against Dev before committing — which is also the realistic shape going forward: any fixture or future migration that inserts a full profile row for a user must now upsert, not insert, because the trigger's bare row is unconditionally there first. Checked the other two test files that touch auth.users (auth_context_test.sql itself, reminders_test.sql) for the same pattern; only reminders_test.sql inserts users, and it never separately inserts matching profiles rows, so it was never at risk. | | QRS-262 | debt | 🔵 open 2026-07-30 | AuthService.verifyOtp's real implementation has no error UI for one specific failure: the OTP verifies (a real Supabase session exists) but the immediate follow-up get_my_auth_context call fails twice in a row. packages/data/src/auth/service.supabase.ts's fetchAuthContext retries once (300ms) then throws rather than fabricating an isNewAccount/onboardingCompleted verdict — deliberately, since guessing either way risks routing a real merchant to the wrong screen (re-onboarding someone who already finished, or skipping setup for someone who didn't). But useEmailAuth.verifyCode and AuthScreen have no try/catch around this today, so the thrown error currently surfaces as an unhandled rejection rather than a retry affordance the merchant can act on — the user has a valid session but no visible path forward. Needs: a caught-error state in AuthScreen with a "try again" action that re-calls get_my_auth_context without re-sending the OTP (the session is already valid). Not blocking P2/P3 — this is the failure path for an already-rare double-RPC-failure, not the happy path — but it is a real gap, not a hypothetical one, and should close before the auth work is considered fully done. | | QRS-248 | debt | 🟢 closed 2026-07-30 | Stub-era account persistence is a local stand-in for profiles.onboarding_completed and must be deleted, not carried, when real auth lands. Two collaborating pieces: sessionStore.knownAccounts (the emails this device has completed onboarding for, replayed at boot via hydrateStubAccounts) and AuthService.seedAccounts in packages/data/src/auth/service.stub.ts. Together they exist so that "sign up → finish setup → log out → sign in" lands on the dashboard instead of replaying the wizard on a real device, which is what made the first-run/returning-user split parity-verifiable on web and both natives before any backend existed. Why this is a tracked row and not a comment: it is a correctness hazard at the moment auth lands, not a cleanup. The device's opinion and the server's opinion of "has this account finished setup" will disagree the first time a user onboards on a second device, and the local one is the one that currently wins — so leaving both in place after wiring Supabase would silently skip onboarding for an account that never completed it. Delete order matters: the server becomes the authority in the same change that removes seedAccounts, or there is a window where neither is. Surfaced by sonarjs/todo-tag during the QRS-247 burn-down — it was two TODO(QRS-###) placeholders naming an id that had never been allocated. CLOSED as part of QRS-261/ADR-0018 P2. sessionStore.knownAccounts and hydrateStubAccounts are deleted; authService.seedAccounts no longer exists on the real AuthService type (the stub-only method stays on StubAuthService for tests that want an in-memory fake). Server truth is now the ONLY source: sessionStore.syncFromAuthContext, driven by apps/mobile/src/lib/authBootstrap.ts's onAuthStateChange listener, re-derives email/onboardingCompleted from get_my_auth_context on cold start and every auth-state change. Delete order was respected — the real AuthService (server-authoritative) and the removal of knownAccounts landed in the same commit, so there was never a window where neither was authoritative. | | QRS-263 | bug | 🔵 open 2026-07-30 | security.yml's gitleaks job has been failing on every recent PR run, and it is a false positive — but a blocking gate that is red on a known-clean commit is providing no signal. Confirmed by pulling the actual job log (run 30563117264): gitleaks' curl-auth-user rule fires on two lines in ci.yml's Sonar-bootstrap step — curl -u admin:admin (the well-known SonarQube default credential, being changed away from on the very next line) and curl -u "admin:$NEW_PW" (a shell variable reference, not a literal secret; the real per-run password is generated with openssl rand -base64 24 and already masked via ::add-mask::). Neither is a real leaked credential. .gitleaks.toml already exists with a documentation-placeholder allowlist (added for a different false-positive class), but has no entry for these two fingerprints (a9c8422a24915af5988f707ae6eee37e22e453c4:.github/workflows/ci.yml:curl-auth-user:173, c6fa4d165f4be195f2a963db9287decae4b0cc09:.github/workflows/ci.yml:curl-auth-user:157). Why this matters beyond the one gate: security.yml's own comment calls this step "BLOCKING… rotate the secret, then add a scoped allowlist entry for any confirmed false positive" — that remediation step was never taken, so every PR has shown a red security check for at least two days, which is exactly the condition that trains a team to stop reading a gate before it ever catches a real secret. Fix: add both fingerprints to .gitleaks.toml's allowlist with the same "verified not a real secret" documentation pattern already used there. | | QRS-264 | bug | 🟢 closed 2026-07-31 — all 10 promoted, Dev/Prod verified at exact parity | Prod is missing 10 migrations Dev has, including both of QRS-261's auth migrations — and Prod's OWN pre-existing (undocumented, dashboard-created) handle_new_user() function is right now callable by anon and authenticated via /rest/v1/rpc/handle_new_user, with no REVOKE. Confirmed via list_migrations on both projects (Prod: 4 migrations; Dev: 14) and Prod's live security advisor. The missing set spans the entire reminders backend (reminders_model, get_reminders_rpc, idempotency_keys, two least-privilege passes, drop_legacy_reminders) plus the two auth_user_created_trigger / get_my_auth_context_rpc migrations P2's real AuthService already depends on. This is not just a sync-hygiene item. QRS-261's new migration hardens handle_new_user() with an explicit REVOKE ALL … FROM PUBLIC, anon (verified empirically not to break trigger firing, since the caller is supabase_auth_admin), but that hardening only reaches Prod when the migration is promoted — until then, Prod's original, unaudited copy of the same-named function stays anon-executable exactly as the advisor reports it right now. Promoting these migrations is the fix, not a separate task — the trigger migration is DROP … IF EXISTS + CREATE (idempotent), so it closes this gap as a side effect on top of fixing the sync drift. Promotion is owner-gated per PROMOTION_RUNBOOK.md (Prod holds 11 real users) — recommend scheduling it before or alongside P3 rather than after, since P3 will add more code that assumes Prod has get_my_auth_context. CLOSED 2026-07-31. Owner approved; all 10 promoted. Sequence, in order: (1) migration repair --status applied on the four repo versions whose SQL the MCP tool had already applied under its own timestamps, plus --status reverted on the four orphan rows it created — see QRS-267 for how that drift got there; (2) db push for the remaining six, dry-run first, all applied clean (the only output was the expected public.rls_auto_enable absent here notices, which those migrations document as legitimate project variance); (3) verification. handle_new_user() is now hardened on Prod, which was this row's live concern — the REVOKE ALL … FROM PUBLIC, anon arrived with 20260730163036. Verified, not assumed: migration list shows identical history on both projects with zero orphans and zero pending, and the Prod advisor's anon_security_definer_function_executable findings dropped from 14 to 5 — the remaining five being exactly the deliberately-retained public surface (get_public_profile_by_slug, get_universal_features, is_item_available_now, get_item_availability_window) plus is_admin(), which 20260727150300 documents as intentionally untouched. Identical migration history turned out NOT to mean identical schema, which is the finding that mattered most here and produced two further migrations the same day — see QRS-265 and QRS-268. | | QRS-265 | debt | 🔵 open 2026-07-30 | Prod's security advisor reports 12 ERROR + 52 WARN findings, almost entirely in schema that predates the Standards Program and was never retrofitted. Measured directly (get_advisors, type: security), not inferred. The concentrated risk: a cluster of legacy feature-entitlement/admin SECURITY DEFINER functions — bulk_enable_features_for_domain, rollback_bulk_operation, is_admin(), get_universal_features, validate_feature_dependencies, log_subscription_usage, user_has_feature_access, user_has_exceeded_limit, and others — are all executable by both anon and authenticated, and at least the first two take an admin-identity parameter (p_admin_user_id) as a plain argument rather than deriving it from the caller's JWT, which is the classic shape of a privilege-escalation gap if the identity argument isn't independently re-checked inside the function body (not yet verified either way — flagging the shape, not asserting exploitability). Separately: 10 public_page_ops_* tables have RLS disabled entirely (cron jobs, cache logs, edge-log/cache-ops partitions, housekeeping config); 15 functions have a mutable search_path, violating CLAUDE.md's own SET search_path = public standard for every RPC; and the profile-pictures storage bucket's public-access policy allows listing, not just fetching-by-URL, letting anyone enumerate every uploaded avatar. Deliberately not fixed in this pass — this is pre-existing legacy-schema debt, unrelated to the current auth work, and revoking EXECUTE on functions the app may (even unintentionally) already call from the client needs its own audit of call sites first, not a reflexive REVOKE. Recommend a dedicated remediation pass before the next Prod-facing feature push, prioritized: (1) the admin-parameterized SECURITY DEFINER functions, (2) RLS on the public_page_ops_* tables, (3) profile-pictures bucket policy, (4) search_path sweep. The 3 rls_policy_always_true findings on digital_menu_qr_scan_analytics/engagement_response_contacts/engagement_responses are likely intentional (public QR-scan/feedback endpoints) but should be confirmed, not assumed, and paired with app-level rate limiting if so. PARTIALLY CLOSED 2026-07-31, and the measured reality was worse than this row first stated. Promoting QRS-264 closed item (1) — the anon-executable admin/entitlement SECURITY DEFINER cluster — because 20260727150300/150400 were among the promoted migrations; the advisor's anon findings went 14 → 5, all five intentional. Items (2) and (3) were then closed by two new migrations, but only after measuring the live catalog rather than reading the advisor, and the measurement changed the severity: the 11 public_page_ops_* tables were not merely RLS-disabled, they also carried table-wide authenticated SELECT/INSERT/UPDATE/DELETE, so any signed-in merchant could read, alter and DELETE cache-ops rows, cron-job configuration, edge logs and housekeeping config through PostgREST. Dev happened to have RLS on and Prod did not, so only Prod was live. Fixed by 20260731071555 (RLS + revoke, and dropping four superseded hand-made storage policies on Prod — one of which, Public Access, carried the {public} no-TO-clause shape of the QRS-001 breach) and 20260731071930 (QRS-268, the partitioned-parent gap the first one missed). Item (3), the profile-pictures bucket listing policy, is now a single explicitly-scoped policy on both projects. Still open, deliberately: item (4), the 15-function search_path sweep, plus the one security_definer_view ERROR (vw_public_page_ops_cron_job_status) and confirming the 3 rls_policy_always_true public-insert policies. Those are behaviour-affecting changes to legacy entitlement/menu code and want their own pass — the parts closed here were all privilege/RLS changes verified to have no non-service-role consumer. | | QRS-266 | debt | 🔵 open 2026-07-30 | Commit 808ae4b — the commit that wires the real Supabase client + email-OTP AuthService (QRS-261 P2) — has never had a completed CI run. Confirmed via gh run list/gh pr view: its ci.yml run (30565818759) shows every job either cancelled (workspace, e2e-web, sonar) or already-superseded; no run against this exact commit has a green lint/type-check/test/sonar/e2e-web result. The commit before it (4d328ec) did pass in full, including a clean Sonar baseline check, which confirms the mechanism works — it just never got to run against the auth-wiring diff specifically, because the Free-tier Actions quota was exhausted immediately after (see the ci.yml/backend-ci.yml/security.yml concurrency fix landed the same session). Local verification (jest, type-check) passed before commit, but CLAUDE.md's own standard is that CI, not local runs, is authoritative for Sonar and the full e2e matrix. Recommend getting one clean, completed CI run against the current tip before layering P3 (Google/Apple sign-in) on top, once the quota resets. | | QRS-270 | bug | 🟢 closed 2026-07-31 | startAuthBootstrap() threw at MODULE SCOPE on a missing env var, so expo export -p web failed outright and took the whole e2e-web gate with it — on develop, undetected. Found while running the web gate for P3, not by any gate: no workflow sets EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY (grepped all four), there is no apps/mobile/.env, and app/_layout.tsx calls startAuthBootstrap() at module scope — so Metro's module evaluation raised during a static export and npm run web:export exited 1 with Metro error: ... must both be set. This is QRS-266 made concrete. P2 (808ae4b) introduced the throw; the CI run that would have caught it was cancelled for Actions quota, so a change that breaks a required gate reached develop and the evidence of it was the cancellation itself. The lesson is not "add the env var" — it is that a gate whose run was cancelled has told you nothing, and must not be read as absence of failure. Fixed by not throwing at that call site. The "no silent production fallback" rule is untouched, because it was never this function that enforced it: initSupabaseClient still refuses to construct a client from a missing value and getSupabaseClient() still throws for any caller reaching for one that does not exist. So skipping init creates NO path rather than a permissive one, and the first real auth call fails loudly at the point of use, which is where it is actionable. The handler also console.warns and calls syncFromAuthContext('error') — the 'error' sentinel and not null, so an un-provisioned build leaves any persisted cache intact instead of presenting itself as a deliberate sign-out, and the entry gate stops waiting on a re-sync that can never run (that last part matters: without it verifying stays true forever and the splash never resolves). Verified: web:export exits 0 and prints Exported: dist. Still owed, separately: real values for those two vars in the e2e-web job so the exported bundle constructs a client at all, rather than relying on every e2e spec avoiding auth. | | QRS-269 | bug | 🟢 closed 2026-07-31 | AuthScreen pre-filled the OTP field with the STUB's fixed code, and kept doing it after real Supabase auth went live — so a real merchant received a real emailed code and found a different, wrong one already typed in. setOtp(STUB_OTP) was correct while the stub was the only implementation; it became a live defect the moment QRS-261 P2 swapped in createSupabaseAuthService(), because the prefilled value is now guaranteed NOT to match the code Supabase actually sent. Verification would fail until the merchant noticed and cleared the field — on the first screen of the product, at the exact moment of highest abandonment risk. Why nothing caught it, which is the more useful half: five tests DEPENDED on the prefill. Their shared helper reached the OTP step and pressed Verify without entering anything, which only worked because the field arrived populated — so the test suite was not merely blind to the defect, it was built on top of it, and removing the prefill turned all five red immediately. That is the signature of a test asserting an implementation detail instead of the user's actual path. Fixed by starting the field empty, splitting the helper into reachOtpStage / reachOtpStageAndEnterCode so a caller must state which it needs, and adding a direct assertion that the field is '' after a code is sent. STUB_OTP remains exported and is still used legitimately by auth-service.test.ts, which tests the stub itself. | | QRS-272 | project | 🔵 open — deliberately deferred, PRE-RELEASE BLOCKING 2026-07-31 | Sign in with Apple's console setup (Apple Developer Program enrollment, App ID/Services ID/Key creation, both projects' Redirect URLs) is deliberately postponed — owner decision, not a discovered blocker. Google OAuth's Console + Supabase provider + redirect-URL allow-list configuration exists on both qr-setu-dev/qr-setu-prod, but "confirmed 2026-07-31" as written here was premature — QRS-273 was reopened the same day: Dev's Google Client Secret is still invalid and no Google sign-in has completed successfully on any environment as of the latest log entry. Apple's console work (the setup guide's Part 4, §4.0–4.6) has not been started — no Apple Developer account exists yet, and the owner chose to finish the rest of the auth work first rather than let Apple's enrollment lead-time (paid membership, approval can take hours to a couple of days) and its own unresolved question (§4.0: whether the console pages are even reachable pre-enrollment) sit as a drag on everything else. Why this is tracked as its own row rather than left as a sub-bullet of P3: it is a genuine release blocker, not a nice-to-have — Apple Guideline 4.8 requires an equivalent-prominence login option offering email privacy whenever a third-party login (Google) is present, so shipping to the App Store with Google but no working Apple sign-in is a predictable rejection (ADR-0018 D6). What is NOT blocked by this deferral, and already works today: the code path is fully built (socialAuth.native.ts's iOS branch, signInWithProviderIdToken, the nonce helper, i18n, the Apple button rendered iOS-only) and falls back cleanly — with no Apple provider configured in Supabase, the button is present but a real tap would fail at the Supabase exchange step rather than crash, and no other surface (Google, email OTP) is affected. Scope to close before release: Apple Developer Program enrollment → confirm §4.0 (console reachability) → App ID + Services ID + Key (§4.1–4.4) → paste into both Supabase projects (§4.5) → add the native entitlement + rebuild (§4.6) → the manual test matrix (guide Part 6, steps 6–7: first-authorization name/email capture, Hide My Email as a separate account) on a physical iPhone. Do not let this slip past the pre-release checklist — re-open and schedule it explicitly once the remaining email-OTP/Google testing work this deferral was made to prioritize is done. | | QRS-273 | bug | 🟢 CLOSED 2026-07-31 — owner corrected the paste; sign-in verified end-to-end | CORRECTED (2026-07-31, first pass): this row originally theorized an automatic, tap-free re-launch of the Google OAuth browser flow. That theory is wrong, and is retracted here rather than quietly edited away, because the correction is itself the useful part. Reading expo-web-browser's own BrowserProxyActivity.kt directly (not assumed) shows it has NO auto-retry or auto-reopen logic at all — onResume only calls returnToMainApp() once, when wasPaused is true, then finishes permanently. So the repeated browser launches this row originally reported were not automatic. The real, confirmed cause, found in Dev's own Supabase auth logs: every tap on the Google button correctly reached Google's real consent screen (four separate successful /authorize → 302 → "Redirecting to external provider" entries) — the exchange then failed at /callback with 500: Unable to exchange external code / oauth2: "invalid_client" "The provided client secret is invalid.". The Google Client Secret pasted into qr-setu-dev's Supabase provider config does not match what Google Cloud actually issued. Fix identified as owner action: regenerate/re-copy the Client Secret in Google Cloud Console → Credentials (qr-setu-dev Web OAuth client), paste into Supabase with no surrounding whitespace, re-test. This row was then marked closed without that fix ever being verified as applied — the mistake this reopening exists to correct. Re-pulled qr-setu-dev's auth log while investigating the separate Site URL confusion (QRS-276) and found the IDENTICAL invalid_client: "The provided client secret is invalid." error on every single /callback entry across the entire log, right up to the most recent attempt at 2026-07-31T11:36:12Z — i.e. Google sign-in on Dev has not worked even once, including after this row was closed. Why "closed" was wrong to write: the row recorded the diagnosis and the fix instructions but never recorded independent confirmation that the owner had performed the dashboard step, and closing status was set anyway — confirmation was assumed, not verified, which is exactly the failure mode CLAUDE.md's "Verified, never assumed" standard exists to catch. Also invalidates the "confirmed 2026-07-31" claim in QRS-272's text below, which was written on the same false assumption. Not fixed by this reopening — it is still an owner-only dashboard action (Google Cloud Console + Supabase Google provider secret), unchanged from the original diagnosis. Do not mark this closed again without re-pulling the auth log AFTER a real sign-in attempt and confirming a SIGNED_IN/successful /callback (status 302 with no accompanying error log line) — a claim of "pasted the new secret" is not sufficient, per the standing rule. ARCHITECTURE RE-VERIFIED AGAINST OFFICIAL DOCS 2026-07-31, because the owner reasonably challenged whether the whole flow was wrong ("we created only a Web Application OAuth client and did not configure platform-specific clients… I have concerns the flow may not be aligned with platform-specific best practices"). That challenge was worth making and the answer is that the implementation is the documented one, so no architectural change is warranted and the narrow secret fix is the correct and complete remediation — established from Supabase's own docs, quoted in the setup guide's new "This matches Supabase's official documentation" subsection, not argued from memory: (a) /docs/guides/auth/social-login/auth-google instructs "choose Web application for the application type… Under Authorized redirect URIs add your Supabase project's callback URL"; (b) Android/iOS OAuth client types (SHA-1 / bundle ID) are inputs to the native Credential-Manager signInWithIdToken flow only — the docs explicitly contrast "the OAuth flow which requires the use of a web browser" with "the native Sign in with Google flow on Android [which] uses the Credential Manager library" — and this app deliberately uses the former (see packages/data/src/auth/oauth.ts's header for the nonce-driven reversal of ADR-0018 D3); (c) /docs/guides/auth/native-mobile-deep-linking documents the app's exact code shape and puts the custom scheme in Supabase's Redirect URLs allow-list, never in Google Cloud. The decisive evidence that the plumbing is already correct is the error's POSITION in the flow, not an opinion about the design: invalid_client is a token-endpoint error (RFC 6749 §5.2; Google: "The OAuth client secret is incorrect"), and the token endpoint is only reached after Google has already validated the client ID, matched the registered redirect URI, and issued an authorization code — the log even shows the issued code (Unable to exchange external code: 4/0A…). A wrong client type or an unregistered callback would have failed earlier, at the authorization step, as redirect_uri_mismatch on Google's own error page, and the user would never have returned to /callback at all. So the observed error is positive proof that client type, callback URL, consent screen, allow-listed deep link and the app's return-leg plumbing are all working, and that only the credentials pair is wrong. Also verified clean in code while reassessing, so these are ruled out rather than assumed: flowType: 'pkce' is explicitly set in supabaseClient.ts (supabase-js's default is implicit, which would return tokens in a URL fragment instead of an exchangeable code); detectSessionInUrl is correctly Platform.OS === 'web' only; native redirectTo is Linking.createURL('/sign-in')qrsetu:///sign-in (matching app.json's scheme: "qrsetu", and matching what the logs show arriving at /authorize), while web sends origin + pathname — the correct per-platform divergence the owner asked about, from one code path with one platform-specific value. Guide hardened so this is not re-litigated: a new Part 3.6 diagnostic table maps each observable symptom (invalid_client · redirect_uri_mismatch · Supabase's redirect-not-allowed page · landing on Site URL · 400: OAuth state parameter missing) to the step that failed and its actual fix, so the next failure is read rather than guessed at. ⟶ NARROWED BY DIRECT TEST 2026-07-31, AND THE "REGENERATE THE SECRET" ADVICE IS RETRACTED AS WRONG. The owner pushed back on being told to regenerate the Client Secret — "We haven't changed the OAuth client configuration, and I already have the current client secret stored locally. Could you explain why regenerating is necessary?" — and they were right: regeneration was never necessary, it was a convenience presented as a requirement, and following it would have invalidated a working credential for no reason. The real requirement is only that the ID and secret in Supabase belong to the same Google client and match what Google holds. Settled by asking Google directly instead of reasoning about it, with a deliberately malformed code against the token endpoint, which isolates client authentication from everything else: POST https://oauth2.googleapis.com/token with the locally-stored client_id/client_secret, grant_type=authorization_code, code=dummy. Google returned invalid_grant / "Malformed auth code." — i.e. it authenticated the client successfully and rejected only the fake code. invalid_grant is a grant error; reaching it requires client authentication to have already passed. So the locally-stored credential pair is VALID, and the value stored in qr-setu-dev's Supabase Google provider config is NOT that value. Candidate causes, in the order worth checking: the Client ID in Supabase belongs to a different Google OAuth client than the tested pair; Dev and Prod credentials swapped between projects; a truncated paste (Google's console shows the full secret only at creation and truncates it in later views, which is the one real hazard behind the retracted advice); trailing whitespace or a newline; values pasted into the wrong field; the dashboard save not taking effect; or several comma-separated Client IDs with the web one not first. Fix: compare the Client ID stored in Supabase against the tested one, then re-paste the tested pair — do NOT regenerate. Deliberately NOT over-claimed: this test does not prove the redirect URI is registered, because the malformed code short-circuits before Google necessarily validates redirect_uri. That fact is established separately and more strongly by the real flow's own logs, where Google issued a genuine authorization code (4/0A…), which it only does after accepting the client ID and the redirect URI. A worked diagnostic procedure of this shape is now in the setup guide's Part 3.6 table so the next credential question is answered by one command rather than a guess. Process note worth keeping: the first command handed over used bash-style \ line continuations and was pasted into PowerShell, where \ is not a continuation character — so only the first line ran, as a body-less POST, and Google answered 411 Length Required. That looked like a finding and was purely a shell-syntax error on the instruction side. CLAUDE.md names PowerShell as the primary shell on this box; commands handed to the owner must be in PowerShell syntax, single-line or backtick-continued, and must use curl.exe rather than curl (which is an Invoke-WebRequest alias that does not accept -d). CLOSED 2026-07-31 to the standard this row itself demanded — a log read after a real attempt, not a claim that the secret was pasted. The owner found and corrected a copy-paste error in Supabase's Client Secret field, which is exactly what the token-endpoint test predicted (credentials valid at Google, Supabase's stored copy wrong). /callback then returned 302 with no error line, and a subsequent full sign-in on the Android release build reached a real session — auth.users shows provider google with last_sign_in_at matching the attempt, profiles row provisioned, routed to onboarding. Two app-side defects stood behind this one and were fixed separately (QRS-279 missing crypto global, QRS-280 vault encoding), which is why fixing the secret alone did not immediately produce a working sign-in. Prod is explicitly UNVERIFIED and must get the same treatment before any installed build is pointed at it: its Google provider config cannot be inspected from here (Supabase exposes auth provider config only via the Management API — auth.config is not a queryable table, confirmed by a failed execute_sql), and no Google sign-in has ever been attempted against qr-setu-prod, so its client ID/secret pair has never been exercised even once. | Lesson for next time: an earlier failure mode on this same flow (400: OAuth state parameter missing, from before the secret was pasted at all) was visible in the same log stream and looked superficially similar — always re-pull the auth log for the CURRENT attempt rather than reasoning from a symptom (repeated browser opens) that has more than one possible cause. | | QRS-275 | bug | 🟢 closed — root-caused and fixed 2026-07-31 | colorScheme.ts's applyColorScheme() has never actually applied a scheme on a real native device, since the day it was written (QRS-201) — it only ever worked in the jest test suite. Root cause: the guard if (typeof window === 'undefined') return; was written to skip exactly one case — the web static export's Node-side prerender pass, which has no window. But window is also undefined on every real Android/iOS device (Hermes never defines it — that's normal, not a Node-SSR signal), so the guard fired there too, and colorScheme.set() — NativeWind's own scheme setter — never ran on native at all. useThemeColors() (the OTHER theme channel, QRS-201's "channel A") still read the OS/preference correctly, so the two channels disagreed, producing text and backgrounds that resolved from different, uncoordinated defaults — the extremely-low-contrast "blank screen" reported during the device pass. Why the test suite stayed green through this: jest-expo's test globals define window, even though real Hermes does not — so every existing test exercised the "browser" branch and none exercised the real native shape. The test file's own docstring even states "the suite runs under jest-expo's NATIVE environment, where document is genuinely absent — same as on a real device," while quietly relying on a window the real device does not have. A green suite was not evidence the native path worked; it was evidence the SUITE couldn't see the native path at all. Fixed by scoping the guard to web specifically (Platform.OS === 'web' && typeof window === 'undefined'), so native always proceeds regardless of window. A new regression test closes the exact gap: it simulates the real native shape (Platform.OS = 'android', window deleted) and asserts colorScheme.set() IS called — proven to actually catch the old bug by reverting the fix and watching this exact test fail before restoring it. 528/528 jest tests, type-check, lint, format all clean after the fix. VERIFIED ON-DEVICE 2026-07-31 — the release APK was installed on the Android emulator and the onboarding welcome screen rendered with full, correct contrast in light theme: brand gradient on the "QR" wordmark, readable ink on every surface, the mock dashboard card's tones all resolving as designed. The two theme channels now agree on native, which is exactly what the guard fix was for. (An earlier attempt at this verification appeared to show a blank screen; that turned out to be a degraded emulator instance, not the app — see QRS-277, which also records why adb shell ping must not be used to judge emulator connectivity.) Still owed for full parity per CLAUDE.md: the same visual check on iOS native (blocked on the Mac mini hand-off) and on the Web PWA (blocked on QRS-274's export crash). | | QRS-274 | bug | 🔴 open — CONFIRMED 2026-07-31 | expo export -p web now fails outright (ReferenceError: window is not defined, exit 1) once real Supabase credentials are present — a regression the QRS-270 fix's own env-var-missing early-return had been silently absorbing. apps/mobile/.env did not exist anywhere before this session (confirmed: not on disk, not in git, and QRS-270 itself is the row that documents no workflow setting these vars) — so every prior web:export succeeded only because startAuthBootstrap() hit its missing-env early return and never actually constructed a Supabase client. The moment a real .env was added (needed for the Android device pass), the same export command failed with a full stack trace rooted at startAuthBootstrap → initSupabaseClient → new SupabaseAuthClient → _initialize/_recoverAndRefresh → getItemAsync, thrown during Expo Router's static-rendering pass — which runs the route tree in Node (no window/localStorage) to pre-render HTML, not in a browser. Constructing a real createClient(...) with persistSession: true apparently touches window-dependent storage during that Node-side render, which a Node process can never satisfy. Why this matters beyond local dev: it directly blocks the already-open follow-up in QRS-270 ("add EXPO_PUBLIC_SUPABASE_* to ci.yml's e2e-web job") — doing that exact follow-up, as currently scoped, would reproduce this crash in CI and break the e2e-web gate the moment someone attempts it. Needs a fix that keeps static rendering Node-safe (e.g. skip/guard client construction during the server-render pass, or defer session recovery to a client-only effect) before that follow-up can land. Not yet fixed — found while re-exporting to verify a stale build was the cause of a separate question about whether Google sign-in was actually working on web. | | QRS-276 | bug | 🟡 diagnosed, owner action pending, 2026-07-31 | The localhost:3000 seen throughout this feature's testing — on the web, and now confirmed on a physical Android device with a real screenshot showing ERR_CONNECTION_REFUSED — is Supabase's own default Auth Site URL setting, not an app or build-configuration defect. Every Supabase project ships with Site URL defaulting to http://localhost:3000, and GoTrue uses it as an internal fallback redirect target in more than one code path, independently of the Redirect URLs allow-list (Part 3 of the setup guide) — which was configured correctly and separately. The tell, in hindsight: every /authorize//callback log entry pulled from qr-setu-dev all session carried the exact same "referer":"http://localhost:3000", regardless of what redirect target the app actually requested — a value that never changes no matter the input is a static setting leaking through, not a per-request fact, and that pattern should have been read as the giveaway much earlier than it was. Why this reached a physical device at all: the original guide mentioned Site URL once, verbally, as an aside ("your canonical web origin... used as Supabase's default fallback") but that line was never actually committed to the guide file — a real process gap, since a spoken aside is not a checklist item. Fixed in the guide with a new mandatory Part 3.5, explicit for both projects. Not yet fixed in Supabase itself — that is a dashboard setting only the project owner can change (Authentication → URL Configuration → Site URL, both qr-setu-dev and qr-setu-prod, recommended value https://qrsetu.com). No app rebuild is required — this is a live server-side setting, unlike everything else baked in at build time, so the fix applies instantly to the already-built arm64 APK. This finding also retroactively explains part of QRS-273's original confusion (the referer field's constant value was visible in that investigation too, and not recognized as a project setting until now). Correction, same day: this row was initially framed as the explanation for a physical-device localhost:3000 screenshot; it is really only half of it. GoTrue routes to Site URL specifically on its error path — and every Google sign-in attempt on Dev has hit the QRS-273 invalid-secret error, so every observed redirect so far has been an error redirect, landing wherever Site URL pointed at the time. Changing Site URL changes where the failure lands, not whether sign-in succeeds. Site URL still needs fixing (recommend a real origin, https://qrsetu.com — not a native deep-link scheme, since it is the global fallback for every auth flow, not just this one), but QRS-273 is the blocking defect. | | QRS-280 | bug | 🟢 fixed 2026-07-31 — the actual sign-in blocker; every vault read failed on device | sessionVault could never read back anything it wrote on Android, because AESSealedData.fromCombined rejects the base64 string its own TYPE says it accepts. This is the defect that was actually breaking Google sign-in, and it is separate from QRS-273 (the invalid Supabase client secret, owner-fixed) and QRS-279 (the missing crypto global). It was invisible until diagnostics were added to the vault's silent catch, at which point the device named it in one line: [vault] DECRYPT FAILED for sb-…-code-verifier (blob discarded): [fromCombined] Cannot convert 'ZEnmCvaz…dFcg==' to a Kotlin type. Value is a string, expected an Object. Why the API lies, read from the shipped source rather than the types: expo-crypto declares BinaryInput = string | Uint8Array | ArrayBuffer and fromCombined's JSDoc says "When providing a string, it must be base64-encoded." But its helper is convertBinaryInput(input, useBase64 = false), which only ever converts bytes → base64 and returns a string unchanged — and fromCombined calls it without useBase64 (unlike aesEncryptAsync, which passes true for its AAD). So a base64 string is handed straight to the Kotlin module, which wants a byte array. The type is not merely loose, it is wrong for this function on native. Blast radius, which is larger than the symptom that led here: the vault is the Supabase client's storage adapter, so EVERY read failed — the PKCE code_verifier vanished between starting a sign-in and exchanging the code (producing PKCE code verifier not found in storage, a client-side failure that makes no network request and therefore leaves no trace in Supabase's auth log at all), and no session could ever persist across launches. The second half had not yet been noticed only because sign-in never succeeded, so there was never a session to fail to restore. Two silent-failure design choices conspired to hide it, and both are now fixed: getItem's catch {} discarded the reason, and on its way out it deleted the blob — so the evidence destroyed itself and the next read reported a clean "never stored". getItem now logs a miss vs. a decrypt failure distinctly, and setItem logs and rethrows rather than letting a failed write look successful. Fixed by storing the sealed blob as HEX and decoding to a Uint8Array before fromCombined. Hex over base64 deliberately: hex ↔ bytes is a total function with no padding rules or alphabet variants, and it needs no atob — which React Native does not visibly provide, and which this file's header already refuses to assume about Buffer. Cost is 2× characters on a few-KB blob; the 2048-byte limit that motivated the envelope belongs to SecureStore, not AsyncStorage. BLOB_PREFIX is bumped to v2 so no v1 blob is ever fed to the new decoder (they were unreadable anyway). hexToBytes validates its input rather than silently producing NaN bytes that would resurface later as an opaque decrypt failure. ⚠️ THE TEST-FIDELITY LESSON IS THE MOST REUSABLE PART, AND IT COST TWO ROUNDS TO GET RIGHT. sessionVault.test.ts was green throughout because its expo-crypto mock modelled the type instead of the behaviour: fromCombined: (combined: string) => … accepted precisely what the real platform rejects. A mock more permissive than the real thing is not a weak test, it is an actively misleading one — the same shape as QRS-275, where jest-expo defined a window real Hermes does not. The mock now throws on a string, mirroring Kotlin. Then, on deliberately reverting the fix to check the new tests actually catch it, only one of two went red — because the mock's combined() ignored its encoding argument and returned bytes regardless, so the suite still could not reproduce the defect it was written to guard. Fixing the mock to honour combined('base64') produced the real result: 4 tests fail with the bug restored, all 10 pass with the fix. Two new tests pin the contract from both sides — fromCombined is asserted to receive a Uint8Array (asserting the ARGUMENT, since a round-trip assertion passes for any self-consistent encoding pair) and the stored blob is asserted to match /^[0-9a-f]+$/. The three tests that hardcoded blob.v1. inline now derive the prefix from one constant, since that spelling is what broke them for reasons unrelated to the behaviour under test. VERIFIED ON-DEVICE 2026-07-31, both halves. (1) Google sign-in completes end-to-end on the Android release build: no decrypt failure, no exchangeCodeForSession failed, and the app routed to /onboarding/setup — which is where resolveEntryRoute sends a brand-new authenticated account. Confirmed against the server rather than the screen alone: auth.users holds digiousplatforms@gmail.com with provider google and last_sign_in_at 16:31:10Z, matching the tap to the second, with profile_row_exists: true (so QRS-261's handle_new_user trigger fired) and onboarding_completed: false (so the routing verdict was correct, not a coincidence). (2) The session now SURVIVES A COLD START — after am force-stop and relaunch the app resumed straight to onboarding rather than the sign-in screen, and the cold-start log contained zero [vault] lines at all: no miss (the blob was there) and no decrypt failure (it read back). That is the half that had never worked in the app's history, and it was only observable once this bug was fixed. The full chain is therefore proven: consent → Supabase callback → PKCE exchange with a real S256 challenge → session → profile provisioned → get_my_auth_context → routed. Surfaces: Android (measured defect, fix pending on-device confirm) · iOS (same native module and same string/bytes contract, so presumed identically affected and identically fixed — NOT yet run, do not claim it) · Web PWA (never affected: webVaultStorage is a plain AsyncStorage pass-through with no AES at all). | | QRS-279 | bug | 🟢 fixed 2026-07-31 — SECURITY: PKCE was cryptographically hollow on both natives | Hermes ships NO crypto global at all, so on Android and iOS the PKCE code_verifier was generated with Math.random() and then transmitted in plaintext as the code_challenge. Both halves of the security property ADR-0018 D3 rests on were false in the shipped build. Found during the P3 device pass while chasing a sign-in failure, from two independent pieces of device evidence that agree — measured, not inferred: (1) logcat carried supabase-js's own warning, WebCrypto API is not supported. Code challenge method will default to use plain instead of sha256.; (2) the code_challenge in the live authorize URL was ccv~UL_FFZId6TnHIVMKVP.anYd9Oy~xGjrtk-ot2Taz…, and ~/. appear only in auth-js's Math.random() fallback charset (…0123456789-._~) — had crypto.getRandomValues existed the verifier would have been dec2hex-joined and hex-only. So the fallback branch, guarded by typeof crypto === 'undefined', had been taken: the absence is the whole crypto object, not just crypto.subtle. The two consequences, which compound: the verifier was predictable (Math.random() is not a CSPRNG in Hermes), and with no crypto.subtle auth-js falls back to code_challenge_method=plain, where the challenge IS the verifier — so the secret that is supposed to never leave the device was being sent through an external browser in the authorize URL. Either alone weakens PKCE; together they remove its protection, because an attacker who intercepts the authorize URL holds the verifier outright. Why this is a documented-decision failure and not just a hardening opportunity: packages/data/src/auth/oauth.ts justifies choosing the browser code flow over the native Google SDK — and justifies NOT buying that SDK's licence for nonce support — on the explicit grounds that "PKCE is not a downgrade in security… the authorization code is single-use and bound to a code_verifier this client never transmits, so an intercepted code is useless without it." On native, the verifier was weak AND transmitted, so the stated rationale did not hold in the runtime it was written for. The decision itself is still sound; what was missing was the primitive it assumed. This is also the second time this exact platform split has been called in advance and shipped anyway. @qrsetu/utils's newIdempotencyKey doc comment already warned, in writing, that crypto.randomUUID() "IS available on web, so the naive call would work in the browser and throw on both natives: exactly the platform-divergence shape that has caused three defects in this repo, and one no web-only gate would catch." That prediction was correct and had already shipped: ProfileScreen's HoursTab calls crypto.randomUUID() on three lines and would therefore throw on any real device when a holiday row is added. The lesson: a hazard written in a doc comment is not a control. It stopped the author of that file and nobody else. Fixed with apps/mobile/src/lib/cryptoPolyfill.ts, installed as a side-effect import placed above every other app import in app/_layout.tsx. The side-effect form is deliberate and load-bearing: ES imports are hoisted, so import { installCryptoPolyfill } followed by a call in the module body would run AFTER every imported module had already been evaluated, and supabase-js reads crypto while BUILDING the authorize URL. It fills getRandomValues, randomUUID and subtle.digest from expo-crypto, which is already bundled (nonce.ts and sessionVault.ts both use it) — so zero added app weight and no new native module, where the conventional answer (react-native-get-random-values) would add a dependency for a subset of what is installed. expo-crypto's CryptoDigestAlgorithm values are the same strings WebCrypto uses ('SHA-256', verified against its shipped .d.ts) and auth-js passes exactly 'SHA-256', so the algorithm argument needs no mapping. Only digest is shimmed on subtle, deliberately — it is the one primitive PKCE needs, and a fuller fake would advertise encrypt/sign/deriveKey that do not exist, converting a clear undefined is not a function into a silent wrong-crypto bug. Each capability is probed independently so a REAL implementation always wins, which is what keeps web parity: RNW runs in a browser with a complete SubtleCrypto that must not be replaced by a one-method shim. 6 tests (cryptoPolyfill.test.ts) assert the Hermes shape gets all three capabilities, that digest returns 32 bytes so the derived challenge is actually S256, that randomness comes from expo-crypto (asserting the SOURCE, since any assertion on the bytes would be probabilistic), that a genuine WebCrypto is never clobbered including its other subtle methods, that a partial crypto is only topped up, and that re-installation is idempotent under Fast Refresh. Also landed here: exchangeOAuthCode now logs the real supabase-js error instead of discarding it and returning a bare invalid_code. That opacity is what made this expensive — the server log showed /callback succeeding with 302 while the app showed a generic "did not complete", and the step in between named itself nowhere. Note that a missing PKCE verifier fails with no network request at all, so it leaves no trace in Supabase's auth log either; without a client-side log line that failure mode is invisible from both ends. VERIFIED ON-DEVICE 2026-07-31: the WebCrypto API is not supported warning is GONE from logcat (grep count 0) across boot and a real Google sign-in, so PKCE now runs S256 over a CSPRNG verifier on Android. And the open question this row deliberately refused to answer is now settled: this was NOT the cause of the exchangeCodeForSession failure — that was a separate storage-encoding defect, QRS-280. Both had to be fixed; neither alone was sufficient. Keeping them as two rows was correct, because the security defect would have survived unnoticed behind a working sign-in once QRS-280 was fixed. Surfaces: Android (measured, fix pending on-device confirmation) · iOS (same Hermes engine, same defect, same fix, not yet run) · Web PWA (never affected — real WebCrypto present — and the tests pin that the shim stays out of its way). | | QRS-278 | bug | 🟢 fixed (native) + web half open 2026-07-31 | A server-side OAuth failure was being swallowed as a user CANCEL, so a real broken configuration presented to the merchant as "the Google button did nothing and sent me back to the sign-in page". Reported by the owner from an emulator test — "the authentication process appeared to complete, but it redirected me back to the same authentication page" — and root-caused in our own code, not the config. The mechanism: when Supabase cannot complete the token exchange with the provider it still redirects to the app's redirectTo, but with ?error=…&error_description=… and no code. socialAuth.native.ts asked only "is there a code?", so outcome.type === 'success' (the deep-link scheme matched) plus a missing code was indistinguishable from the merchant dismissing the sheet — and cancelled is a deliberately SILENT outcome per that file's own contract ("Showing 'sign-in failed' after someone taps Cancel is the single most common bug in this flow"). Correct instinct, wrong discrimination: it collapsed two genuinely different events into the silent one. Why this is more than a UX nit, and why it cost real hours: it made QRS-273's invalid Google client secret invisible from the app. The only place the truth existed was Supabase's server-side auth log, so every diagnostic cycle required dashboard access, and the symptom the owner could actually see ("nothing happens") pointed at the app while the fault was entirely server-side. An app that reported "could not complete sign-in" plus the provider's own error_description in logcat would have named the cause on the first attempt. Fixed by checking for error/error_code (GoTrue uses both spellings) BEFORE the code check and returning { kind: 'error', error: 'exchange_failed' }, plus a console.warn carrying error_description — the returned variant tells the merchant "that did not work", the log line tells us WHICH of the many OAuth misconfigurations it was. Mapped onto the existing exchange_failed variant rather than a new one because that is precisely what it is (the exchange failed on the server's leg rather than ours), so the existing error branch and its copy already fit and no i18n or UI change was needed. Five tests added (socialAuth.native.test.ts, the first test for this file), with Linking.parse given a real URLSearchParams implementation rather than a canned return so the tests actually exercise the parsing the fix depends on. The two regression tests were PROVEN to catch the old bug by neutering the new branch and watching exactly those two fail, then restoring — the same verify-the-test discipline used for QRS-275. The silent-cancel path is asserted to stay silent (no code, no error → cancelled, and console.warn NOT called), so the fix cannot regress into the opposite defect of showing "failed" on a genuine cancel. ⚠️ WEB PARITY IS OUTSTANDING AND DELIBERATELY NOT CLAIMED (CLAUDE.md's fix-must-be-re-verified-on-every-surface rule). The web half cannot reuse this fix: socialAuth.web.ts performs a full-page navigation and never observes the result, so the return leg is handled by supabase-js's detectSessionInUrl — which, given ?error=, simply creates no session and surfaces nothing, i.e. the identical silent failure still exists on Web/PWA. Fixing it means reading the error params where the page reloads (the sign-in screen or authBootstrap), which is screen-level work with an i18n surface, so it is scoped separately rather than bolted on unverified. Track it with QRS-262's retry UI and QRS-277's entry-gate failure state — all three are the same "we know it failed, tell the merchant something actionable" gap on three different paths. Surfaces verified for the native fix: Android (the reported path, unit-tested; on-device re-verification pending the next build) · iOS shares the identical code path with no platform branch in it · Web PWA explicitly NOT fixed, see above. | | QRS-277 | bug | 🔴 open — ROOT CAUSE NOT YET ISOLATED, measured not guessed, 2026-07-31 | A release build can reach a permanently blank white screen with NO error, NO retry affordance, NO log output and NO crash — observed on the x86_64 emulator, still stuck and byte-identical after 6 minutes. Recorded with the measurements rather than a theory, because this has already been misdiagnosed once in this area and a third guess is worth less than an honest open row. What was measured, and therefore what is RULED OUT: the process is alive (pidof returns a PID; no FATAL/AndroidRuntime crash, no SoLoaderDSONotFoundError); the ABI is correct and was verified against the emulator BEFORE installing (unzip -l | grep lib/x86_64, getprop ro.product.cpu.abix86_64 — the mixup that wasted two earlier cycles); the JS bundle runs (ReactNativeJS: Running "main", ExpoModulesCore: ✅ JSI interop was installed, libreanimated.so loaded); fonts are NOT the blocker (12 .ttf are bundled in the APK and @expo-google-fonts/*/400Regular does a local require('./*.ttf'), so useFonts needs no network); env vars ARE baked in (no [auth] … are not set warning appeared, which that path would have emitted); and EnvironmentBadge is not at fault (its warning/warning-soft/content-primary tokens all exist in packages/tokens, and its one hook runs before its early return so hook order is sound). The observable state: the last app-side log line is AsyncStorageExpoMigration: No scoped database found at 17:31:00.964 and there is nothing after it, ever — no error, no warning, no render. uiautomator dump returns ERROR: null root node returned by UiTestAutomationBridge, i.e. the native view tree is empty, so React rendered nothing at all rather than rendering something invisible (which distinguishes this from QRS-275's contrast defect and means QRS-275's fix is still unverified on-device, not disproven). The screen is pure white, matching app.json's light-mode splash backgroundColor: "#FFFFFF", and the emulator confirms Night mode: no. The strongest lead, stated as a lead and not a conclusion: the emulator has no working network (ping to the Supabase host = 100% packet loss, while wifi is enabled and airplane mode is off — a host-side emulator DNS problem, not an app one), and nothing in the boot path has a timeout anywhere. app/index.tsx:22 holds <BrandSplash> while !splashDone || !hydrated || verifying, and verifying clears only when authBootstrap.ts:86's un-timed void resync() completes. The counter-evidence that stops this being called the answer: getCurrentAuthContext calls client.auth.getSession() first, which reads local storage and should return null fast on a fresh install with no stored session, clearing verifying without any network at all. So either something upstream of that hangs (the sessionVault SecureStore/expo-crypto envelope adapter is the prime suspect — QRS-274's own stack trace already implicates getItemAsync during client construction) or the block is in RootLayout before index.tsx is ever reached. Not yet determined which, and the row stays red until it is. Why this is a launch-relevant defect and not an emulator quirk to wave off: whatever the trigger, the app demonstrably has a state where it renders a blank screen forever and emits nothing — so a merchant on a patchy connection, a locked keystore, or any un-anticipated failure gets an app that never loads, with no error and no way to retry. authBootstrap.ts:67-71 already clears verifying for the missing-env case and its own comment says "same reasoning as a network blip" — but the network blip it names is not actually handled, because that path has no timeout. Fix direction (design, then implement): a bounded timeout on the cold-start resync that falls back to the 'error' sentinel (never null, which would read as a deliberate sign-out and wipe the cache), plus a visible failure state on the entry gate rather than an indefinite splash. That overlaps QRS-262's retry UI and should be designed with it, and it touches src/ui/**/entry routing so it needs a design pull + drift-ledger row per ADR-0015. Next diagnostic step, cheapest first: restart the emulator to restore its DNS and relaunch — if the app then boots, the network-dependent hang is confirmed and the timeout is the fix; if it still hangs with working network, the block is local (sessionVault/fonts/render) and the emulator's network was a red herring. Do that before writing any code. ⟶ THAT STEP WAS RUN AND IT DID NOT REPRODUCE. The blank screen is NOT an established app defect, and the "no working network" lead above is RETRACTED as unproven — read the rest of this row with that correction applied. After emu kill and a clean relaunch, the same APK booted correctly in under 25 seconds: full contrast, correct light theme, every colour resolving, the onboarding welcome screen rendering as designed. That also verifies QRS-275's fix on a real device build, which was the check still outstanding when this row was opened. Two measurement lessons, both worth more than the bug would have been: (1) ping is not a connectivity test on the Android emulator. QEMU's user-mode network stack does not reliably pass ICMP, so 100% packet loss and unknown host from adb shell ping are known false negatives — they were read as "the emulator has no network" and very nearly became a published root cause. The authoritative source is dumpsys connectivity, which on the healthy instance reported WIFI CONNECTED … Capabilities: INTERNET&VALIDATED … firstValidated 49266 — Android had itself proven external reachability. Use dumpsys connectivity or a real HTTP request, never ping, to judge emulator networking. (2) A degraded emulator instance produces app-shaped symptoms. The bad instance also logged Failed to initialize 101010-2 format and userfaultfd: MOVE ioctl seems unsupported: Connection timed out, both emulator-level, and gave a byte-identical screenshot across six minutes with an empty accessibility tree — indistinguishable from a hung app. Restart the emulator once (~2 min) BEFORE investigating a blank screen as an app defect; it would have saved this entire investigation. What genuinely remains, and the only reason this row stays open rather than closing as not-reproducible: the code finding never depended on the emulator and still stands. authBootstrap.ts:86 fires void resync() with no timeout, and app/index.tsx:22 holds the splash while verifying is true, so there is no bounded path out of the entry gate if that resync neither resolves nor rejects — while authBootstrap.ts:67-71 handles the missing-env case and its comment even claims "same reasoning as a network blip" that is not actually handled. Re-scoped from "bug hunt" to a HARDENING task: a bounded timeout on the cold-start resync falling back to the 'error' sentinel (never null, which would read as a deliberate sign-out and discard the cache), plus a visible failure state instead of an indefinite splash — designed together with QRS-262's retry UI, since both are the same "we have a session but not a verdict" problem and both touch entry routing (design pull + drift-ledger row per ADR-0015). Do not re-file the blank screen as a defect without a reproduction on a freshly restarted emulator or a physical device. | | QRS-288 | project | 🔴 OPEN — HIGHEST PRIORITY, hard deadline 2026-08-15 | There is no release management. Every backend change to date has been applied by hand, one statement at a time, with nothing verifying the two environments match afterwards. Raised by the owner 2026-08-01 ahead of the first production release. This is not speculative — 2026-08-01 alone produced five independent proofs: (1) qr-setu-dev had ZERO Edge Functions while Prod had 21, undetected for weeks (QRS-283); (2) Prod runs manage-reminders while the repo says manage-reminder, so promoting it would create a second function rather than update the first (QRS-286); (3) config.toml declares verify_jwt = true for five functions deployed as false, so a CLI deploy would silently break the 401 contract (QRS-284); (4) Prod's SMTP points at the wrong provider entirely (QRS-285); (5) QRS-267 — MCP apply_migration stamped its own timestamps into Prod's schema_migrations, leaving four orphan rows and four repo files still reading as unapplied. Every one of these is a drift the current process cannot see. What already exists and must NOT be rebuilt: supabase/docs/PROMOTION_RUNBOOK.md (environments, promotion order, parity-verification SQL), tools/deploy-functions.js, Supabase's own migration versioning, four CI workflows, and the documented develop → uat → main branch model. The gap is that all of it is manual and unenforced, and CLAUDE.md states plainly that no deploy workflow exists — so "promoted deliberately, Dev first" is a habit, not a control. The design constraint the proposal must absorb, and the reason this is harder than Dev→Prod copying: once the app is on the stores, multiple app versions are live simultaneously (store review latency + users updating on their own schedule), so the backend can never assume the client matches it. Expand-contract stops being a style preference and becomes structural, and a release must record which app versions its backend half supports, with a minimum-supported-version kill switch. Three hard constraints on any automation: the local Supabase CLI account cannot see the qr-setu-* projects at all (so this must run in CI with its own SUPABASE_ACCESS_TOKEN, not from a laptop); GitHub Actions Free is 2,000 min/month and has already been exhausted once (QRS-263); and MCP apply_migration must never be the promotion mechanism, per QRS-267. Deliverables: environment drift detection (schema + EF inventory + grants + config) as a CI job that fails loudly; a promotion pipeline gated on explicit approval; a release manifest binding migrations + EF versions + secrets + config to one version tag; and rollback. Sequencing and the Aug-15 minimum cut to be agreed before build. | | QRS-289 | decision | 🟢 ADOPTED 2026-08-01 · AMENDED 2026-08-02 — the shared-build-identity conclusion was WRONG | ⚠ AMENDMENT, and it corrects a closed row rather than adding a new decision. This row concluded that ios.buildNumber should be "the same number as a string, so both stores share one build identity", and check-version.js stamped both platforms from a single --build slot. That is wrong, and the owner's question about store rejections is what exposed it. Both stores require a new build number for every resubmission, so if Play approves and the App Store rejects, iOS resubmits at build 1 while Android stays at build 0 — 26000100 vs 26000101 — and the two diverge permanently even though the code is byte-identical. The old contract made that state unrepresentable, and check:version would have failed on a legitimate app.json. Corrected: --build-android N / --build-ios M alongside the existing shared --build (still the default for a normal release); checkAppVersion validates each platform independently — each number must encode the same version at some legal slot — and monotonicity is enforced per platform, which is what the stores actually require. A new buildSlotFor() recovers the slot from a number. Verified end to end: stamping --build-ios 1 moves iOS to 26000101, leaves Android at 26000100, and the gate reports the divergence as expected rather than as an error. The user-visible version never diverges — both stores permit a new build under the same version name, so every surface still reads 26.0.1; only the internal integer differs, and the release build ledger records why. 19 tests (up from 14), including the store-rejection case and a boundary test proving 26000199 is 26.0.1 build 99 while 26000200 is 26.0.2 build 0. apps/mobile/README.md's Versioning section carried the same wrong claim and is corrected. Original resolution, retained below. RESOLUTION (2026-08-01), recorded above the original proposal because the adopted formula is NOT the one proposed.The proposed mapping YY*10000 + minor*100 + patch (26.0.1 → 260001) had a defect that would have been permanent, and it was caught before the first upload rather than after. Play requires a versionCode strictly greater than any previously uploaded to the track, and it applies that per upload, not per version name — so a rejected AAB, or a bad build noticed after upload, burns its code forever. Under the proposed formula the only way to re-upload "26.0.1" would be to publish it as 26.0.2, inflating the user-visible version because of an upload accident. Adopted instead — a build slot: versionCode = YY*1_000_000 + minor*10_000 + patch*100 + build, giving 26.0.1 b0 → 26000100, a re-upload b1 → 26000101, 26.0.2 → 26000200, 26.1.0 → 26010000, 27.0.0 → 27000000. Ceiling 99999999, well under Play's 2100000000; 100 rebuilds per patch, 100 patches per minor, 100 minors per year. Still inspectable at a glance, which is why the derived form was preferred over a bare CI build number. Known and accepted limit, recorded rather than left latent: the scheme breaks in 2100, when YY wraps to 00 and codes would go backwards. Question (2) is answered: one release train stamps both halvesdeploy-prod.yml's release manifest records the app's version/versionCode/buildNumber alongside the backend promotion, so "what backend is 26.0.1 running?" has an answer. It is recorded, not enforced: a backend-only promotion between app releases is legitimate and must not be blocked. Implementation: tools/version/derive.js (pure) + tools/check-version.js (npm run check:version gate · npm run version:set -- <version> [--build n] stamper), 14 tests including a monotonicity property test across every minor/patch/build combination — examples can agree with a wrong formula, that cannot. The gate found a real pre-existing inconsistency on its first run: app.json held version 1.0.0 with versionCode 1 and buildNumber "1", which do not agree under any derivation, and nothing had ever checked. Now wired into ci.yml. The stamper refuses a non-increasing code (verified: --set 26.0.0 and a bare re---set 26.0.1 are both rejected; --set 26.0.1 --build 1 is accepted) — the one check that cannot be undone if it is wrong. It also edits the three values in place rather than round-tripping JSON, after the first version reformatted app.json's inline permissions array; a version stamp is now exactly a three-line diff. app.json is stamped at 26.0.1 / 26000100. expo prebuild must run to propagate into the native projects; eas.json still does not exist, so CI does not yet own the bump — that remains open. Original proposal, retained for history: Adopt year-based versioning (26.0.1), Apple-style. Owner proposal: major = release year (26 = 2026), minor = feature release within the year, patch = fixes. First production release 26.0.1 on 2026-08-15; 26.0.2 a fix; 26.1.0 the first feature drop; 27.0.0 the first of 2027. Reads clearly, dates itself, and stays simple. Supersedes the current app.json 1.0.0 / versionCode: 1 — safe, because code 1 was never published to any store. Two things to settle before this is adopted rather than after: (1) versionCode must be a monotonically increasing integer forever, across year boundaries26.1.0 does not map to one on its own, and Play rejects a non-increasing value permanently. Options: a derived integer (e.g. YY*10000 + minor*100 + patch, giving 260001260100) or simply the CI build number (monotonic by construction, but then it carries no meaning). The derived form is preferred because it is inspectable, but it must be fixed before the first store upload — it cannot be changed downward later. iOS buildNumber has the same monotonic requirement. (2) Does the backend share the version? The app half is versioned by necessity; the backend currently has no version at all. Recommendation: one release train version stamps both, so "what backend is 26.0.1 running?" has an answer — that traceability is the point of QRS-288, and two independent version lines would defeat it. Existing policy this must reconcile with: app.json is SSOT, expo prebuild syncs the native fields (never hand-edit build.gradle), and CI owns the bump — though no eas.json exists yet, which is its own pending action. | | QRS-291 | feature | 🟡 BUILT 2026-08-01, both halves — NOT YET DEVICE-VERIFIED on any surface, and NOT yet promoted | Minimum-supported-app-version kill switch — the last P0 of the Aug-15 release-management cut (QRS-288). The argument for building it now rather than when it is first needed is an asymmetry, not a preference: once the app is on the stores, multiple versions run simultaneously (store review latency + users who update whenever they feel like it), and the only way to reach an installed app is through code it already contains. A kill switch added in 26.1.0 could never be applied to a 26.0.1 user. Both halves therefore ship in the first release even though neither does anything on day one. Server: migration 20260801143000 adds app_release_policy (one row per platform: min_supported_version_code, latest_version_code, blocked_version_codes, update_url), RLS on with no anon/authenticated policy, and get_app_release_policy(text)security definer, search_path pinned, REVOKE ALL FROM PUBLIC, GRANT EXECUTE TO anon. The anon grant is required, not an oversight: a merchant whose build was retired may be unable to authenticate at all — that can be why it was retired — so the check must work with no session. The function projects only policy fields; updated_by is deliberately not exposed. Seeded PERMISSIVE (min = 0, blocks nobody) so the mechanism is provably inert before it is armed, and a fresh environment never starts life blocking its own clients. blocked_version_codes is carried from day one because a range cannot express the real case: ONE bad build ships, later builds are fine, and it cannot be un-installed from a device — and adding that field later could never apply to 26.0.1 users. Client: evaluateReleasePolicy in @qrsetu/domain (pure, 13 node --test cases), releaseService in packages/data, and apps/mobile/src/release (poll + UpdateGate), mounted after <Stack> in _layout.tsx. THE GOVERNING RULE IS FAIL OPEN: every unknown — no policy, failed fetch, unreadable manifest, malformed minimum — resolves to ok. A kill switch that blocks on a network hiccup is a self-inflicted outage with a wider blast radius than whatever it was meant to prevent, and it would fire hardest on exactly the flaky connections our merchants have. The service resolves null rather than rejecting, so a caller cannot get it wrong by forgetting a catch. 6 of 8 component tests assert that NOTHING renders, deliberately: the dangerous defect is a false block, which reaches everyone at once and cannot be undone from the merchant's device. Not in the entry gate's blocking path — it renders OVER the app once it has an answer, specifically to avoid repeating QRS-277's un-timed-call-in-the-splash shape. No new dependency: the running build is read from the embedded manifest via expo-constants (already bundled) rather than expo-application, so the app-size budget is untouched. Deliberately NOT built, each for a stated reason: the update_available soft nudge (verdict is computed and returned, nothing renders it — a nudge needs a real home and a design pull, and interrupting every merchant on a slightly-old build is exactly the noise CLAUDE.md warns trains people to ignore every future nudge); an icon (the set has no download glyph and src/ui/** is the systemic surface — ADR-0015 needs a design pull + drift-ledger row, and borrowing a glyph that means something else is worse than none); an admin surface for the row (belongs with the ADR-0007 command center — inventing a write policy before there is a caller would be a guess about who may fire the switch). Side effect worth recording: packages/data now imports @qrsetu/domain for the first time — sanctioned by ADR-0012's schemas → domain → data DAG — which required allowImportingTsExtensions on its tsconfig, the same allowance apps/mobile already carries for the same QRS-212 reason. OPEN / owed before this can be called done: device verification on Android and iOS (neither has run; iOS has never run at all), a Web PWA check of the reload path, and promotion of the migration to Dev then Prod. Copy is in all three catalogs and passes the em-dash gate. | | QRS-290 | decision | 🟡 DEFERRED by the owner 2026-08-01 — Prod stays in ap-southeast-2 for the 26.0.1 launch | qr-setu-prod is in ap-southeast-2 (Sydney) while qr-setu-dev is in ap-south-1 (Mumbai), and the merchant base is in India. Found while supplying the project refs for QRS-288's drift workflow — the Management API returns region per project, and nobody had compared the two. Measured facts, not estimates: Prod created 2025-11-13 in Sydney, Dev created 2026-07-10 in Mumbai. Mumbai→Sydney is roughly 130-160 ms RTT versus ~10-30 ms intra-India, and it is a floor no application-level optimisation can go below — it applies to every RPC, every Edge Function's database leg, and every auth call. Edge Functions execute near the user, but they then talk to a database in Sydney, so regional invocation does not help. Second-order consequence worth naming separately: Dev is FASTER than Prod, so every perf measurement taken on Dev understates the real thing. Any Web-Vitals or RPC-latency budget validated against Dev is measuring the wrong environment, in the direction that hides the problem. There is no in-place fix — verified against Supabase's own docs rather than assumed: "Each Supabase project is provisioned on hardware in the chosen region, so it is bound to a region at the infrastructure level. Therefore, the process to change the region of a Supabase Project is to create a new project in the desired region and migrate your existing project." Project transfer does not help either — the docs state transfers move projects between organizations and "cannot be used to transfer between different regions." What the move would have cost, measured on 2026-08-01 so the number is on record rather than re-derived later: Prod holds 21 MB, 11 auth users (3 signed in within 30 days, newest signup 2026-05-07, i.e. ~3 months stale), 1 storage bucket with 10 objects. The largest tables are digital_menu_items (1,149) and digital_menu_categories (152) — legacy Digital Menu data that ADR-0009 already excludes from R1 and re-homes onto the E-commerce archetype in R2, so a migration would have carried forward data already slated for restructuring. The recommended option, recorded because it expires: create the new project in ap-south-1 and rebuild from supabase db push rather than pg_dump/restore. That would have made Prod's schema_migrations exactly equal the repo's 21 files, taking drift from 19 REVERSE → 0 in one move instead of ticket-by-ticket, permanently retiring QRS-267's orphan-version legacy, forcing a deliberate decision on each of the 17 unmirrored Edge Functions (QRS-283) and dissolving QRS-286's manage-reminder/manage-reminders split as a side effect. Two constraints that shaped the recommendation: the org is on the Free plan, so (a) "Restore to another project" (physical-backup clone) is Paid-only and any clone would be a manual pg_dump/pg_restore, and (b) Free caps the org at two projects — already at two — so the new project could not stand up alongside the old one without ~$25 of Pro for one month. Deleting the only Prod before its replacement is verified is an irreversible ordering and was not recommended. Blast radius if this is ever revisited (the ref changes, so these break): the Google Cloud Console authorized redirect URI https://<ref>.supabase.co/auth/v1/callback — miss this and Google sign-in dies, and it belongs first on any checklist; SMTP config (re-enter on the new project, and it is broken anyway per QRS-285); EXPO_PUBLIC_SUPABASE_URL + anon key in the mobile build; Cloudflare Pages env vars across three projects; the GitHub Actions variable SUPABASE_PROD_REF; the profile-pictures bucket and its 10 objects; and the pg_cron job that hardcodes Prod's own Edge Function URL. The literal ref appears in 10 repo files, but those are documentation — the breakages are all external configuration. Owner decision 2026-08-01: skip the migration, ship 26.0.1 on Sydney. The cost of this decision only rises — it is bounded today by 11 rows and 21 MB, and becomes a real migration project with real merchant downtime once the platform has live traffic. Revisit before the user base makes it unaffordable, and treat the latency floor as a known, accepted characteristic of 26.x rather than a bug to be re-investigated when someone reports the app "feels slow". | | QRS-281 | bug | 🟢 fixed 2026-08-01 — gate-verified, not yet device-verified | Sign Out silently did nothing, and the sign-out path could also strand the merchant in an un-dismissable sheet. Reported by the owner exercising the emulator: tapping Sign Out left them on Settings with the session intact. Root cause (read, not guessed): authService.signOut() follows this feature's own AccountResult convention — it resolves {ok:false} on a logical Supabase error rather than throwing — but useSignOut's onSuccess fired unconditionally on ANY resolved promise. So local session state was torn down and every cached query cleared even when supabase-js's own session was still live underneath (_signOut does not always call removeCurrentSession() on error). That is a state divergence, not a missing toast, and it was an inconsistency rather than a judgement call: SecuritySection already branched on r.ok for the sibling mutations in the same feature, and useAccountActions.ts's own header comment documents the convention the hook broke. Second, independent defect found while fixing it: ConfirmSheet disables BOTH the backdrop-tap and Cancel while loading, so an unresolved promise left no way out of the sheet at all. A specific supabase-js mechanism (_acquireLock) was investigated as the cause and ruled out — this app's client is constructed with no lock option — but a hung fetch remains possible, so a 10s bounded-timeout wrapper is kept on its own merit. Fixed: onSuccess branches on result.ok; AccountSection surfaces settings.error on both {ok:false} and a genuine rejection; withTimeout bounds the call; failures report through @qrsetu/observability. 9 tests added, including both failure paths — a happy-path-only test could never have caught this class. Commit e88fa35. | | QRS-282 | bug | 🟢 fixed 2026-08-01 — gate-verified, not yet device-verified | Onboarding collected five screens of merchant data and threw it away. Reported alongside QRS-281: owner name, mobile, brand, industry and slug never reached profiles. Root cause: packages/data/src/index.ts exported the in-memory stub as onboardingService — there was no Supabase-backed implementation at all — and both call sites were fire-and-forget void, so even the stub's failures were invisible. The backend was mostly already there, which is why this was narrower than "onboarding isn't wired": manage-profile's general POST already upserted the fields and its complete_onboarding action already flipped the flag. Three concrete gaps blocked wiring it straight through: slug was absent from ProfileUpdateBody; validateFullName ran unconditionally although profiles.full_name is nullable and profileUpdateSchema promises "all optional, diff-only"; and the client/server slug regexes genuinely disagreed (client permitted leading/trailing/double hyphens, server did not). Fixed: new onboarding/service.supabase.ts + barrel swap; finish() awaited, reporting via observability, and not navigating or flipping the local flag on failure; CelebrateStep gains a loading state so its CTAs cannot be double-tapped. Hardening found while in there, each a real latent bug: buildProfileUpsertRow generalised to one allow-list mechanism (the per-field-conditional style is what let full_name diverge in the first place); a mirrored Zod schema server-side, because full_name had been the only field with any format check — brand_name, pin_code and mobile_number reached the database unvalidated; default_currency's || 'INR' fallback removed, since it was in every upsert row and therefore silently reset a merchant's chosen currency on any partial update (and was redundant with the column's own DEFAULT); 23505 on slug now returns a 409 rather than an opaque 500 to Sentry; and slug made write-once at both layers (EF guard + BEFORE UPDATE trigger), because adding it to the accepted fields removed the accidental protection it had from simply being unreachable. Commit e88fa35. | | QRS-283 | bug | 🔵 open — 2 of ~21 EFs mirrored, the rest is an owner decision | qr-setu-dev had ZERO Edge Functions deployed while Prod had 21, and nothing checks this. Found 2026-08-01 while deploying the QRS-282 changes. The 2026-07-10 baseline verified 58/58 tables and 118/118 RLS policies identical, which made it natural to assume the whole backend was mirrored — schema parity was verified; function parity never was. The failure mode is nasty because a missing EF returns 404, which surfaces in the app as a generic failure indistinguishable from a bug in your own code; an afternoon went into "why doesn't onboarding save" partly for this reason. Now on Dev: manage-profile, validate-user-input, manage-settings, manage-account, manage-reminder (5). Still absent: the remaining ~16, mostly public_page_ops_* and the feedback set. Recommended control: a CI step diffing list_edge_functions across the two projects, so this is asserted rather than remembered. Deploying the rest is a scope decision, not an obvious yes — several are legacy/feedback-era functions that R1 may not need. | | QRS-303 | bug | 🟠 owner-decided 2026-08-03, fix pending — production change, needs a Change Record | verify_jwt on Prod is the INVERSE of what supabase/config.toml declares. Measured via list_edge_functions: manage-profile, manage-account, manage-settings, manage-reminders and get-dashboard-data are all verify_jwt = false, while public_page_ops_cache_invalidate, _health_check and _metrics_collector are true. config.toml says Type A user-facing = true, Type B cron/internal = false. Stated precisely, because the alarming reading is the wrong one: every affected function reads the Authorization header and calls supabaseClient.auth.getUser() before doing any work — verified by reading the recovered source, not inferred — so the in-function gate is what has been enforcing auth. This is a removed defense-in-depth layer plus config drift, not an open endpoint. It still matters: the platform gateway is the cheap layer, it is off on the function that owns account deletion, and Dev has all five at false too. Note QRS-284 changed config.toml to state each function's real auth model — so at some point reality was documented rather than corrected, and this row is the correction. Owner decision 2026-08-03: not deliberate, set true on the Type A functions. Sequencing: confirm each function's in-function requireAuth (or equivalent) runs before any side effect, flip verify_jwt, then probe live — this is one of the nine gate-invisible change classes, so a claim that a value was set is not evidence (QRS-273 was closed on exactly such a claim and sign-in had never worked). | | QRS-302 | bug | 🟠 2 of 11 recovered 2026-08-03; 9 remain, custody in supabase/_recovered_prod_functions/ | 11 of Prod's 21 Edge Functions have NO SOURCE IN THIS REPOSITORY. Found during the Prod↔Dev sync audit. The repo contains 11 EFs; Prod runs 21; they are not the same 11. Missing entirely: get-dashboard-data, manage-reminders, and nine Feedback-Forms functions (create-/update-/update-…-status/publish-/delete-feedback-form, get-feedback-forms, get-feedback-form-details, get-feedback-responses, submit-feedback). The provenance is visible in their deployment metadata: several carry entrypoint_path: file:///WorkSpace/DevArea/QRsetu/**xtract**/supabase/functions/… — a different working copy, not this repo. So for these functions the repo has never been the source of truth, and nothing in it said so. Two consequences: if the Prod project were rebuilt this code would be gone permanently; and any future "make Prod match the repo" cleanup would delete 11 live functions. Recovery verified working via the Supabase MCP get_edge_function tool, which returns full file contents. get-dashboard-data and manage-reminders are now under version control, quarantined outside supabase/functions/ because backend-ci.yml runs deno test with working-directory: supabase/functions and recovery is not conformance — none of them use the _shared kit, all carry an inline cors.ts with *, hand-rolled logging and hand-rolled auth, and none ship tests. The 9 Feedback ones are deliberately deferred and that is stated rather than silently skipped — live in Prod, out of R1 scope. Two findings fell out of reading the recovered source: QRS-286 is not a plural/singular rename — Prod's manage-reminders writes a flat reminders table while the repo's ADR-0016 model is rules + sparse reminder_occurrences, i.e. two different data models, so remindersService cannot simply be pointed at it; and QRS-210 root cause is now confirmed from sourcecreateReminder does a bare insert with no idempotency key of any kind, which is exactly why 6 of 11 rows in the live table are double-tap duplicates. | | QRS-301 | bug | 🟢 Dev fixed 2026-08-03 (753 rows, probes green); Prod promotion pending | reserved_slugs held 752 rows on Prod, 8 on Dev, 8 in the repo — and all 752 were unversioned. The repo's 20260731173531_seed_platform_reserved_slugs.sql header states the table "was schema-only … zero rows, confirmed by direct query"; that was true of Dev. A read-only query against Prod returned 752 rows across 23 categories, seeded straight into the live database and never captured as a migration — the [TRANSITIONAL] live-DB-state gap, second instance after the pg_cron job and storage bucket. The dangerous direction: the protection was better than the repo claimed, so nobody goes looking for a control that appears to be absent on purpose. It also means a db reset would have silently discarded all 744. Fixed by 20260803094212_capture_prod_reserved_slugs.sql — a no-op on Prod (every row conflicts), restores 744 on Dev and on any fresh local stack. Verified functionally, not just by row count: is_slug_reserved now returns true for privacy, mumbai and zomato, and false for the real merchant slug hotel-krushna. Dev ended at 753, not 752 — the extra is www, which the repo seed has and Prod does not, because that seed is one of the five migrations Prod never received. QRS-267 re-confirmed with a measurement: the repo file was authored as 20260803094500, and MCP apply_migration recorded it as 20260803094212 — its own timestamp, 2m48s off. The file was renamed to match the recorded version so db push does not see a phantom unapplied migration. This is now twice-observed behaviour, not an anecdote: never let MCP name a migration version — read it back and reconcile. | | QRS-300 | debt | 🔵 open — raised 2026-08-03 while fixing QRS-299 | deploy-prod.yml embeds ~40 lines of JavaScript in a node -e string, and NO gate can see it — not eslint, not Prettier, not Sonar, not a unit test. It is the code that decides whether a deploy to Production may proceed. Demonstrated concretely during QRS-299: the same edit left a dead const crypto = require('crypto') in two places — in tools/check-release.js SonarLint flagged it (S1128) within seconds of the edit, and in the workflow it survived every gate and was only removed because it was looked for by hand. The asymmetry is the point: identical defect, one caught automatically, one caught only by luck. Fix: extract the preflight script to tools/release/preflight-guard.js and have the workflow execute it, exactly as the QRS-299 fix did for the hash — that puts it under the typed eslint . gate, Prettier, the Sonar ratchet and node --test in one move. Other inline blocks in the same file should follow. Not urgent, but it is on the shortest path to Production and currently unverifiable, so it should land before 26.0.1's real deploy rather than after. | | QRS-299 | bug | 🟢 fixed 2026-08-03tools/release/manifest-hash.js + 15 tests, all green | The G4 approval hash covered targets[], so following the documented deploy sequence voided the approval and refused the rest of the deploy — the release framework blocked its own procedure. check-release.js hashed { ...manifest, approvals: undefined }, i.e. the whole manifest including status, targets[] and builds[], while process.md mandates "update targets[] as each lands". So on deploy day: backend goes live → set targets[supabase_prod] = "deployed"check:release exits 1 in pre-push and release-gate.yml, and every remaining surface is refused. Found during 26.0.1 planning, i.e. by the first release to use the system, three days after it was built. Two further defects in the same three lines: the formula was triplicated (check-release.js, deploy-prod.yml's inline node, and written out again in prose in releases/lld.md) so CI and the local gate could drift silently; and JSON.stringify preserves insertion order, so reformatting release.json would have voided a live approval with no semantic change. Fixed: one shared definition in tools/release/manifest-hash.jscheck-release.js imports it, deploy-prod.yml executes it via a CLI so the two cannot disagree by construction, and lld.md now points at it instead of restating it. Non-binding fields are approvals/status/targets/builds, each with a written reason; serialization is recursively key-sorted. The exclusion is a DENYLIST on purpose — an unrecognised new field binds by default, because a missed progress field is a loud spurious VOID while a missed scope field is a silent hole in the control. A fourth defect found while testing the fix: release-gate.yml ran node --test tools/release/validate.test.mjs by filename, so the 15 new assertions would never have executed in CI — an enumerated list silently omitting whatever is added next, the same failure class as the lint --workspaces --if-present green no-op (QRS-013). Now a quoted glob. | | QRS-297 | debt | 🔵 open — raised 2026-08-02, process-only, no code | Parity is verified too late: G3 is currently the first gate that looks at three surfaces, which is what makes "no exception path" expensive. Consequence of retiring the platform-exception mechanism (QRS-296, ADR-0011 amendment): with no sanctioned deferral, a platform blocker discovered at G3 blocks the whole release, and it is discovered there because nothing forces the question earlier. Two changes, both free: (1) G0 entry criterion — a scope item touching a divergence seam (camera, push, storage, share, clipboard, deep links, offline) must name its implementation-or-fallback per surface in its acceptance criteria before work starts, so a feature is never built all the way to a blocker; (2) G3 collects evidence, it does not generate it — the Definition of Done already requires per-feature parity verification, so G3 should be assembling proof that already exists rather than running the first three-surface pass on a release's worth of accumulated risk. The principle: scope is the pressure valve, not an exception. A feature that cannot reach three surfaces simply does not enter the release; blocking a feature is cheap, blocking a release is not. Depends on QRS-296 for the late-discovery case — without a flag, the only late option is reverting merged code under freeze pressure, which is exactly when a gate gets bypassed. | | QRS-296 | debt | 🔵 open — raised 2026-08-02; arguably a precondition for the parity standard just adopted | There is no feature-flag mechanism anywhere in the codebase — verified, zero matches for featureFlag/feature_flag/isEnabled across every .ts/.tsx/.sql — although CLAUDE.md has listed "feature flags / kill-switches (decouple deploy from release)" under Reliability since the standards program began. Why it matters now rather than eventually: retiring the platform-exception path (ADR-0011 amendment, owner 2026-08-02) removed the only sanctioned way to handle a platform-specific blocker, and nothing replaced it. Without a flag, a blocker found after scope freeze leaves exactly one option — revert merged code under release pressure — which is the situation in which gates get bypassed. With a flag it becomes: ship the release with the feature dark, light it up next release. Design, reusing what exists: app_release_policy is already a per-platform remote-config table with a public RPC and a fail-open pure evaluator in @qrsetu/domain (QRS-291); a flag table is that same shape, so this is a repeat of a proven pattern, not a new subsystem. One design point worth pinning: on a fetch failure a flag falls back to its build-time default, not to off — that default is what shipped and what was tested. Default rule is all-or-nothing: if a feature cannot ship on all three surfaces, it is off on all three. A per-platform flag is otherwise just a parity exception wearing a different hat, and merchants would get an inconsistent product, which is the exact thing the standard exists to prevent. The per-platform capability stays in the table for genuinely platform-scoped cases (an iOS-only permission string) but carries the same written no-effect-elsewhere justification the build ledger already demands. | | QRS-295 | debt | 🔵 open — the workflow exists and has never executed; hardened 2026-08-02 | parity-native.yml (ADR-0017 layer 3) has never run on a runner, so the layer that would have caught three of the four defects that produced it is unproven — its own header has said so since it was written. Everything else we run covers the web bundle: Playwright builds expo export -p web, and jest mocks Reanimated's createAnimatedComponent to identity so native-only wrapper behaviour cannot be asserted at all. This is the largest recurring cost of the release process: until it is green, every release pays a manual three-surface pass at G3. Static review done 2026-08-02 before spending any minutes — checked and found sound: the APK path matches build-android.mjs's outputs/apk/release/app-release.apk; scheme: qrsetu and both bundle ids are in.digious.qrsetu, matching the Maestro flows; the build script's system-drive guard is IS_WIN-gated so it no-ops on Linux; and a runner has no .env.development (gitignored) but authBootstrap warns and skips instead of throwing (QRS-270), so the app still boots. One real defect found and fixed: the iOS build step was expo run:ios --configuration Release --no-bundler \|\| npx expo run:ios --configuration Release, and the fallback cannot succeed — without --no-bundler the command starts Metro and stays attached, so it could only hang to the 60-minute job timeout. macOS bills at 10x on a private repo, so one hung run was ~600 billable minutes, about a third of the monthly allowance, from a fallback that was never capable of passing. Fallback removed; step timeouts (25 min build, 15 min Maestro) added as the backstop. Run it Android-first via workflow_dispatch — Linux is 1x, and the shared shape (prebuild, Maestro install, probe deep-link) is what will break first. Owner-gated: it costs minutes, and QRS-263 exhausted the quota once. | | QRS-294 | debt | 🔵 open — raised 2026-08-02 while building the RMS | documentation/portal/architecture/lld.md does not exist, although CLAUDE.md lists HLD and LLD as mandatory portal deliverables (Phase 5.3) and architecture/hld.md has existed for weeks. The gap went unnoticed because no gate reads prosecheck:readmes asserts a README.md per module, and nothing asserts that a named portal deliverable exists. Found only because the release framework needed an LLD convention to follow and there was none to copy. releases/lld.md now establishes the shape (data model as erDiagram · state machine · module relationships · automation points · notifications · audit trail · traceability), so the platform-wide one has a template. Not written here on purpose: the platform LLD covers RPC signatures, EF request/response shapes and the tier/data-access contracts — that is its own piece of work, and absorbing it into a release-management change would have been scope creep. | | QRS-293 | project | 🟢 DELIVERED 2026-08-02 — the governance spine of QRS-288 | Release Management System: documentation/portal/releases/ is now the single source of truth for every production change, and the deploy pipeline enforces it. The idea the whole thing rests on: release documentation is LOAD-BEARINGdeploy-prod.yml reads release.json and refuses to deploy a production change that is not declared in it, and compares the declared migration set against what is actually pending in both directions. Documentation stops being a chore done afterwards and becomes the thing without which the deploy does not run. That inversion is deliberate: this repo has a measured ~0 completion rate on deferred reconciliation (QRS-180), a lint gate that passed as a green no-op (QRS-013), and a SonarQube standard documented for months and never implemented (QRS-246). Delivered: portal releases/ with HLD (4 diagrams — context, lifecycle, component interaction, approval sequence), LLD (6 diagrams — erDiagram data model, 13-state machine, module relationships, deployment sequence, traceability, multi-surface divergence), process, production-state, a _template/ of 8 artifacts, and 26.0.1/ in draft. tools/check-release.js + tools/release/validate.js (Generation-B convention: thin shell, pure validator owning its own report formatter, 37 node --test cases, traceability rule mutation-verified — 2 tests go red when it is neutered). Exit codes 0/1/2, verified on real data: an undeclared migration exits 1 naming the file (it found 39 on the current branch), a corrupt manifest exits 2, clean exits 0. Five design decisions worth carrying: (1) A release is NOT atomic — backend and web are push, the stores are submit-and-wait and can reject, so status is derived from per-surface targets[] and partially_deployed is a normal state rather than an error; (2) a release is not a Build — every store resubmission needs a new build number, so Android and iOS legitimately diverge with the same user-visible version, and a "no functional delta" claim is proven by comparing commit SHAs; (3) the compatibility floor — a contracting change must declare requires_min_app_build and the gate fails if it exceeds the oldest live build, which is the enforceable form of "multiple app versions are live"; (4) the kill-switch interlock — raising a platform's min_supported_version_code above its live build would brick every user on it, and the gate refuses it; (5) approvals bind to a commit SHA + a manifest hash, so editing the manifest voids the approval — a date pins nothing and cannot answer what exactly was approved? One plan correction found during implementation: the gate was to be a step in ci.yml, but ci.yml's paths-ignore excludes both supabase/** and documentation/** — precisely the paths it validates — so it would have been a green no-op, the QRS-013 pattern exactly. It lives in a dedicated path-filtered release-gate.yml instead, which is also cheaper on the Free-tier minutes (QRS-263). Honest limit: there is one maintainer and no required-reviewers on a private Free repo, so G4 is a guarded self-dispatch, not peer review, and every artifact says so. Phase-2 rules (risk-register completeness, success-criteria checkability, metric emission) are deferred until 26.0.1 proves the shape — and if Phase 1 proves heavy, Phase 2 should be cut rather than carried as dead ceremony. | | QRS-292 | debt | 🔴 open — raised 2026-08-02, the real control behind QRS-284 | The four public_page_ops_* Edge Functions have no authentication of their own, and verify_jwt = true is a mitigation, not a fix. config.toml's comment claimed for months that each "should enforce its own internal shared-secret check"; grepping all four for secret|authorization|requireAuth|optionalAuth|x-internal|service_role matches nothing — they call getSupabaseClient() (service role) straight after handleCors. QRS-284 set them to verify_jwt = true so the gateway stands in front, which closed a trivially-open cache_refresh. But the gateway accepts ANY valid JWT and the anon key is public — it ships in the client — so the honest description is that the bar moved from "curl with nothing" to "curl with a published key". public_page_ops_cache_invalidate purges Cloudflare cache by tag or URL pattern; a loop against it with the anon key still collapses the >95% cache-hit budget the public-page performance target depends on and writes unbounded rows to public_page_ops_cache_log. Not launch-blocking, and the reason is measured rather than assumed: Prod has exactly one pg_cron job, it is active = false, and it targets only cache_refresh — so nothing invokes any of these on a schedule, and the public-page surface they serve (Stack 1, apps/web) does not exist yet. They are dormant infrastructure. Fix: a shared-secret header checked in _shared (constant-time compare, secret from an EF env var), applied to all four, then flip them back to verify_jwt = false via the deploy workflow's recorded allow_loosening input — that is the combination the original design intended. Sequence it with the pg_cron rewrite, which is owed anyway (the job hardcodes Prod's own function URL, so Dev cannot have one until it is project-URL-aware) and is the natural place to start sending the header. Do not close QRS-284 into this row: that one is about config.toml telling the truth, this one is about the functions defending themselves. | | QRS-284 | debt | 🟢 RESOLVED 2026-08-02 — config.toml now states each function's own auth model, and the guard passes (4 tightenings, 0 loosenings, exit 0) | Resolution, recorded above the history because the adopted rule is not the one first proposed. The obvious fix was "set everything to true to match Prod" — and that would have been wrong for three functions. validate-user-input's own header says it plainly: "Called pre-signup, deliberately has NO auth gate. Do not add a requireAuth call here, that would be a real regression, not a security improvement." It and get-public-{menu,feedback} use optionalAuth, attaching a session to logs when present and never gating on it, and the latter two serve the public service cards, the primary SEO/growth surface, where anonymous access is the entire point. A gateway check buys nothing there anyway (it accepts any valid JWT and the anon key is public) while risking 401s for any caller that is not supabase-js: a crawler, a WhatsApp link preview, a raw fetch. Adopted rule: the value states the FUNCTION'S OWN AUTH MODEL. requireAuthtrue (defence in depth, the gateway rejects before an invocation is billed); documented-callable-without-a-session ⇒ false; no auth of its own ⇒ true, because the gateway is all there is. Two facts were checked rather than reasoned about, and both changed the answer. (1) Prod is NOT dormant — its edge-function log shows manage-profile returning 200 within the last 24 hours, and since requireAuth throws without a valid user JWT, the live caller is already sending one, which is what makes tightening the manage-* set provably safe rather than hopefully safe. (2) Tightening cache_refresh breaks nothingcron.job shows Prod has exactly ONE job, it targets that function, it passes headers := jsonb_build_object() (empty, no Authorization, which is exactly why it was deployed false), and it is active = false. Net effect: the deploy footprint fell from 7 tightenings + 3 loosenings to 4 tightenings + 0 loosenings, one of which closes a currently-open unauthenticated endpoint. The three optionalAuth functions now match what Prod runs, so they are a deploy-time no-op. The first Prod deploy is no longer blocked by this row. What is NOT fixed and must not be read as fixed: verify_jwt = true is not authentication — the anon key is public, so the bar moved from "curl with nothing" to "curl with a published key". The internal shared-secret check those four functions were always supposed to have is still missing and is tracked separately as QRS-292. Original finding, retained for history: | ⚠ CORRECTION, same day, and the correction is the important part. This row originally described five functions drifting in one direction. Re-measured against qr-setu-prod's full deployed set while building the promotion pipeline (QRS-288), it is eleven of eleven, in BOTH directions — and the direction this row never mentioned is the dangerous one. Seven Type A functions (manage-profile, manage-settings, manage-account, manage-reminder, validate-user-input, get-public-menu, get-public-feedback) are declared true and deployed false — deploying tightens them, which is safe. Three Type B cron functions (public_page_ops_cache_invalidate, public_page_ops_health_check, public_page_ops_metrics_collector) are declared false and deployed true — deploying removes their only authentication. public_page_ops_cache_refresh is already false on both sides, i.e. already exposed today. Why "removes their only authentication" is a measured claim and not a worry: those four functions were grepped for secret|authorization|requireAuth|optionalAuth|x-internal|service_role and match nothing — no shared secret, no caller check of any kind. They call getSupabaseClient() (service role) straight after handleCors. config.toml's own comment says they "should enforce [their] own internal shared-secret check — see the Known follow-ups note in PROMOTION_RUNBOOK.md", and that follow-up was never built, so the gateway's verify_jwt is the entire control. The concrete exposure: public_page_ops_cache_invalidate purges Cloudflare cache by tag or URL pattern. Unauthenticated, a trivial loop collapses the >95% cache-hit budget the public-page performance target depends on and drives every request to origin, while writing unbounded rows to public_page_ops_cache_log. Now enforced rather than noted: tools/check-function-config.js (npm run check:fn-config) parses config.toml, diffs it against the Management API's deployed state, and fails the deploy-prod preflight on any loosening change — verified against real Prod data, where it exits 1 and names all three. Tightening is reported but never blocks, because it cannot create an exposure. Pure comparator + 11 tests, mutation-verified (3 go red when the guard is neutered). Two valid resolutions, and picking one is the work this row now tracks: (a) implement the shared-secret check in the four public_page_ops_* functions, then promote using the workflow's recorded allow_loosening input; or (b) correct config.toml to verify_jwt = true for those three so the file matches reality and the deploy is a no-op for them. (b) is the smaller, safer change and does not leave cache_refresh exposed — but it should be taken deliberately, because it flips the Type A/Type B distinction the file's comments describe. Original finding, retained for history: supabase/config.toml declares verify_jwt = true for functions that are deployed with false on BOTH projects. Measured 2026-08-01 across all 5 Dev functions and their Prod counterparts. false is the correct runtime setting — each function calls requireAuth() itself and returns the shared kit's JSON 401, whereas platform-level rejection returns a different shape the client does not parse — but the config file now describes something that is true nowhere. Why it is more than untidiness: supabase functions deploy from the CLI reads config.toml and would flip all of them to true, silently changing the error contract for every authenticated call. That is a latent, one-command outage sitting behind a file nobody re-reads. Fix by correcting config.toml to match reality (and documenting why false is right), not by changing the deployments. Surfaced by the subagent that performed the Dev deploys, which flagged it unprompted rather than following the instruction blindly. | | QRS-285 | bug | 🔴 open — email OTP cannot reach any real merchant | Supabase custom SMTP points at Hostinger while the domain's verified transactional sender is ZeptoMail, and it is failing authentication. Probed 2026-08-01: a live OTP request to Dev returns HTTP 500, and the auth log gives the reason verbatim — 535 "5.7.8 Error: authentication failed: (reason unavailable)". Custom SMTP is enabled (both projects) but configured as smtp.hostinger.com:465, while ZeptoMail is the verified sender: its console shows qrsetu.com Verified, and both records resolve — DKIM 31152624._domainkey and CNAME bounce-zemcluster89.zeptomail.in. Second, independent problem: SPF publishes v=spf1 include:_spf.mail.hostinger.com ~all with no zeptomail include, so even once the credential is fixed, ZeptoMail-sent mail would fail SPF. _dmarc is a bare v=DMARC1; p=none with no rua=, so no failure reports are being collected either. A useful side effect that hid this: Supabase rolls the signup back when the confirmation email fails, so a failed OTP leaves no orphan auth.users row and looks exactly like nothing happened. Two documentation defects this exposed, both of which cost real debugging time and are fixed in the same pass: integrations/zeptomail.md names the bounce CNAME bounce when ZeptoMail actually issued bounce-zem, and the DKIM selector is account-specific (31152624) rather than guessable — I concluded "the records are missing" from guessed names and was wrong, which is now called out in the guide so it is not repeated. Blocks QRS-076. Google sign-in is unaffected and works. | | QRS-286 | bug | 🔴 open — blocks Prod promotion of the reminders EF | Prod runs an Edge Function named manage-reminders (plural); the repo and REMINDERS_EDGE_FN both say manage-reminder (singular). Found 2026-08-01 while deploying to Dev. Deploying the repo's version to Prod would therefore create a second function rather than update the existing one, leaving an orphan serving old code and an app pointed at whichever name it happens to hold. Dev was deployed as the singular (matching the repo + the constant, which is what the app calls), so the two projects are now knowingly inconsistent until this is resolved. Decide one name, then rename on Prod and delete the loser — before any reminders promotion. | | QRS-287 | debt | 🔵 open — a stub that disagrees with the database is a second source of truth | packages/data stubs carry hand-written fixtures with no automated tie to the real schema, and one of them caused QRS-249. The dashboard stub's DOMAINS array copied the app's invented ids 1-12 and its own comment said it "mirrors constants/domains.ts until get_business_domains lands" — so three artefacts (app array, stub, test) agreed with each other and all three disagreed with business_domains. The new domains stub is pinned to the migration's real ids by a contract test (domains-contracts.test.ts), which is the pattern worth generalising: every stub whose values are FOREIGN KEYS or otherwise round-trip to the database needs an assertion that fails when the schema moves. The remaining stubs (profile, settings, account, dashboard, reminders) have no such guard, and they are about to be replaced by real implementations one at a time — which is exactly when a silent id/enum mismatch would land. | | QRS-271 | project | 🔵 open 2026-07-31 | No Privacy Policy, Terms of Service, or homepage exist for qrsetu.com — and Google OAuth consent-screen verification (needed before Prod launch, see the new Google OAuth setup guide) cannot be submitted without live URLs for all three. Found while writing that guide: the OAuth consent screen's "Application privacy policy link" / "Terms of service link" / "Application home page" fields were left blank rather than filled with a placeholder, specifically so this gap wouldn't get papered over. Why this blocks more than Google verification: DPDP Act compliance (already load-bearing — see QRS-001's remediation) and Apple's App Store review both also expect a reachable privacy policy; this is one deliverable serving three separate requirements, not three separate tasks. Not urgent for Dev/Testing-mode sign-in — Google's Testing publishing status works with zero of these fields filled, which is why P3 was not blocked on this. Is a real blocker for: submitting either Google project for consent-screen verification, and for Prod's eventual public launch. Recommended scope, sized correctly rather than gold-plated: a single static page (privacy policy: what's collected — email, mobile, business details per the onboarding schema; how it's used; DPDP-compliant deletion/export rights) + a short terms page + whatever minimal homepage the landing-page work (Stack 1, apps/web) already plans to ship — this does not need to precede or block that work, it needs to land no later than it. Action: plan this into the production-readiness / landing-page milestone; do not let "we'll fill it in before verification" become the QRS-180-shaped deferred-reconciliation pattern this tracker already has a measured near-zero completion rate for. | | QRS-268 | bug | 🟢 closed 2026-07-31 | The migration that closed the public_page_ops_* RLS gap missed the three PARTITIONED PARENT tables, which are the more reachable half — and Supabase's own security advisor never reported them, before or after. Found by reading back 20260731071555's own result instead of trusting that it worked, which is the entire point of the rule added in QRS-267 an hour earlier. The defect: that migration's catalog loop filtered c.relkind = 'r' (ordinary tables), so it walked the 11 leaf partitions and silently skipped every parent, which carry relkind = 'p'public_page_ops_cache_operations, public_page_ops_cron_executions, public_page_ops_edge_function_logs. Measured on Prod straight afterwards: leaves RLS-on with zero API grants (correct), parents RLS-off with authenticated SELECT/INSERT/UPDATE/DELETE (still open). Why the parents are the worse half, not a rounding error: PostgREST addresses a relation by name and the parent is the undated, guessable, logical one (/rest/v1/public_page_ops_edge_function_logs), and a read or DELETE routed through a parent fans out over every partition. Closing the leaves while leaving the parents open closed the door and left the hallway. ⚠️ The advisor blind spot is the durable lesson. rls_disabled_in_public listed exactly the 11 leaves and none of the 3 parents, in the run before the fix AND the run after — so this exposure was invisible to Supabase's linter and would have survived any number of clean advisor reports. An empty advisor report is not proof of RLS coverage on a partitioned schema; query pg_class for relkind IN ('r','p') instead. Fixed by 20260731071930_close_partitioned_parent_rls_gap.sql — a new migration, not an edit to the applied one (CLAUDE.md: never edit a merged migration). It matches relkind IN ('r','p') and deliberately retains 'r' so it reaches the correct end state independently of whether the earlier migration ran; both statements are idempotent. Applied Dev first, then Prod, both reporting 14 relation(s) (11 leaves + 3 parents). Verified by read-back on both projects, byte-identical: 14 total / 14 rls_on / 0 api_grants_left. Safe for the real writers for the reason verified before either migration was written: the only consumers are the four Type B public_page_ops_* Edge Functions via _shared/database.ts, which uses SUPABASE_SERVICE_ROLE_KEY, and the service role bypasses RLS and holds its own grants. | | QRS-267 | bug | 🟢 closed 2026-07-31 — process fix landed, Prod history repaired and verified | Three assumption-driven errors in one session, one of which put real drift into Prod's migration history. Raised by the product owner, and the reason CLAUDE.md now opens its standards list with "Verified, never assumed". All three share one shape: an inference was acted on as though it were a verified fact. (1) A transient outage was read as a permanent denial. The safety classifier returned claude-sonnet-5[1m] is temporarily unavailable — an availability error, textually distinct from the Blocked by classifier denial it also returns — and it was treated as a hard block. The working supabase CLI path was abandoned mid-task and the recovery escalated to asking the owner for a production database password that was never needed; a retry minutes later showed the CLI had been functional throughout. The generalisable rule: a transient failure and a permanent denial demand opposite responses, so the category must be read before the response is chosen. (2) Supabase's MCP apply_migration was pointed at Prod without first verifying how it registers versions. It stamps its OWN timestamp rather than the migration filename's, so four migrations applied correctly (profile_pictures_bucket_and_rls, archive_legacy_reminders, reminders_model, get_reminders_rpc) while four orphan rows (20260731054816, 20260731055311, 20260731060010, 20260731060050) landed in supabase_migrations.schema_migrations and the four corresponding repo versions still read as unapplied. That is the precise drift class PROMOTION_RUNBOOK.md already documents from the July orphan-version repair, reintroduced by not reading the runbook's own lesson forward. The cheap check that would have caught it existed and was skipped: one read-only migration list after the FIRST apply, before the other three. Consequence if undetected: supabase db push re-runs all four and aborts on CREATE TABLE public.reminders already existing. Repair required (owner-run, needs the settings grant below): migration repair --status applied on the four repo versions + --status reverted on the four orphans, THEN db push for the remaining six. (3) Cancelled CI runs were re-triggered without diagnosing why they were cancelled — judged "low-risk and non-destructive", which was true and irrelevant: the owner had cancelled them because the Actions quota was exhausted, so the retry consumed the exact resource that had run out (QRS-263). Also found while diagnosing (2), and the actual reason CLI writes kept failing: .claude/settings.json's permissions.allow contained only Bash(supabase link *) and Bash(deno test *), so every supabase WRITE command (db push, migration repair, secrets set, functions deploy) fell through to the auto-mode classifier, which denies production-database writes — while read-only migration list passed. Proven by contrast on identical credentials minutes apart, not inferred. tools/hooks/guard-bash.mjs was checked and cleared (it blocks only gradle, expo release builds, and prettier-on-theme.css). Fix is a settings grant the owner must apply — an agent widening its own privilege allow-list is correctly refused by the classifier, and that refusal should stay. Process changes landed here: CLAUDE.md's Definition of Done now requires every hand-off claim to be verified rather than inferred, and a new first standard ("Verified, never assumed") states the six sub-rules with the incidents that produced them — error-category discipline, no production tool use before verifying its side effects, diagnose-before-remediate, impact analysis as part of the change, risk surfaced before it is realised, and never presenting partial completion as completion. | | QRS-249 | debt | 🟢 CLOSED 2026-08-01 — and it was worse than this row predicted; see the closure note at the end | The onboarding industry step hardcodes 12 business domains that are supposed to come from the backend. apps/mobile/src/tiers/user/features/onboarding/constants/domains.ts mirrors the prototype's 12 domains as a literal array; id is written to profiles.business_domain_id and slug to profiles.business_type, so these are persisted foreign-key-shaped values invented on the client. That is the actual risk, and it is why this is not cosmetic: if the real business_domains table numbers its rows differently, every profile created before the switch points at the wrong domain, and the fix is a data migration rather than a code change. It also cuts against ADR-0009 — a new vertical is supposed to be configuration, and a hardcoded client list means adding one requires an app release. Resolve by sourcing the list through the packages/data seam (cacheable read, since it changes rarely) and reconciling the existing ids in the same change. Surfaced by sonarjs/todo-tag during QRS-247. CLOSURE (2026-08-01) — the predicted risk was real and the numbers were worse than "if the table numbers its rows differently": the table did, and the two taxonomies were not even the same KIND. The app's 12 were occupation-level (Cafe, Salon, Electrician, Purohit); business_domains held 10 sector-level rows (Restaurant & Cafe, Professional Services). Same id space, real FK between them, so 10 of 12 choices would have written the wrong industry (app id 2 "Salon / Spa" = DB id 2 "Retail & Shopping") and ids 11-12 would have failed fk_profiles_business_domain outright. Caught before any of it reached real data — the new app had never successfully written the column, because the saveProfileStep that would have done it was itself stubbed until QRS-282. A near-miss worth recording: the first instinct was to seed Dev with Prod's 10 rows, which would have turned a loud 23503 FK error into silent wrong data. Fixed by building the ADR-0009 archetype layer properly (migrations 20260801084314 / 085211 / 091104): 12 verticals at pinned ids 11-22 each mapped to one of 5 archetypes, the 10 legacy sectors retired (not dropped — domain_features still FKs them), a composable capability model, and a get_business_domains() RPC. App side: new packages/data/src/domains/ seam, constants/domains.ts deleted, IndustryStep reads the RPC with loading/error/retry states. The old test asserted "contiguous ids 1..12" — i.e. it pinned the bug in place — and is replaced by domains-contracts.test.ts, which asserts the real ids. Full rationale: the ADR-0009 implementation addendum. | | QRS-250 | improvement | 🔵 open 2026-07-29 | The Home greeting has one salutation per time-of-day bucket, which is the shape that makes a proactive surface feel canned. dashboard/greeting.ts picks morning/afternoon/evening/night and the screen resolves home.greet.<tod> from i18n. R1 deliberately ships one variant per bucket in en/hi/mr; the reviewed design calls for a personalized library (~40–50 greetings weighted by time, recent activity, business category and season) sourced from the backend, and category is already threaded through the function for exactly that. Why it is worth a row rather than a comment: this is a direct instance of the core product principle, where the failure mode is noise — a greeting the merchant has read forty times is not engagement, it is furniture, and it trains them to skip the region of the screen where action items also live. The function is pure and deterministic in its inputs, so variant selection and weighting stay L1-unit-testable when the source moves server-side. Surfaced by sonarjs/todo-tag during QRS-247. | | QRS-238 | improvement | ✅ done 2026-07-28 | The header icons now carry COUNTS, not a dot. Requested by the owner: due reminders on the clock, unread notifications on the bell. Before this the bell had a bare dot and Reminders had nothing, so the header could say "something exists" and never "how much" — and those are different decisions: 2 due and 11 due warrant different behaviour, while a dot that appears every morning is a thing you learn to stop seeing. That is the anti-noise rule in CLAUDE.md applied to the indicator itself. New src/ui/CountBadge, which the design project does not have (its Badge is an uppercase status pill for Live/Draft — a different job), recorded ahead in the drift ledger with a sync target. Decisions worth knowing: zero renders nothing (a "0" badge draws the eye to reassure); it caps at 9+ so a three-digit count cannot widen past the icon; negative/NaN render nothing rather than surfacing an arithmetic bug as "-1"; the accessible name is passed in already pluralised from @qrsetu/i18n, because a bare "3" announced beside a bell means nothing and a component has no business guessing plural rules for three languages; and it uses the design's soft-fill/strong-ink tone pair rather than filled-red-on-white, because there is no danger-contrast token and danger is lighter in dark mode (white would land near 2.6:1 — a filled variant needs that token first, with QRS-202). Due = overdue + today, counting collapsed occurrences so the badge and the list can never disagree (QRS-226). | | QRS-239 | improvement | ✅ done 2026-07-28 | The dashboard reminders slot is FILLED — the proactive surface the plan specified and nothing ever provided. <DashboardSlot name="reminders"> was reserved in P1 and left rendering null, which meant the reminders feature had no presence on the one screen every merchant opens: complete, reachable, and still reactive by construction. RemindersPanel now surfaces the next due reminder by TITLE (not just a count — "1 due" is a number, "File GSTR-3B" is a decision) and lets the merchant complete it in place, which is what makes it an action rather than a link wearing a card. Against the CLAUDE.md evaluation gate: proactive value (the obligation appears without navigating), meaningful action (one tap completes it), timely (renders only when something is overdue or due today), and the intelligence comes from bucketOccurrences in @qrsetu/domain — the same pure derivation the badge and the list use, reading the cache the header already populated, so no query is added and the three cannot disagree. It shows ONE reminder deliberately: /reminders already lists everything, and duplicating that here would lengthen the home screen without making any decision easier. A defect found while wiring it: DashboardSlot tests !children, and a React ELEMENT is truthy even when the component returns null — so passing the panel unconditionally emitted an empty View, i.e. a phantom 16px gap in a gap: 16 column on every home screen with nothing due (most of them). Gated at the call site. A flaky test I wrote and caught: the fixture used Date.now() + 2h, which crosses local midnight into upcoming whenever the suite runs after 22:00 — it would have passed all day and failed at night. Clock pinned to midday IST. | | QRS-240 | bug | ✅ fixed 2026-07-28 | Two real defects the Playwright gate had been reporting for a while, plus the reason nobody read it. (1) WCAG AA failure on the reminder priority chip. theme-consistency measured "Urgent" in rgb(225, 62, 51) has 3.64:1 contrast against its background (WCAG AA needs 4.5:1) on /reminders in LIGHT mode. The danger tone pair was danger ink on danger-soft, which is 3.64:1 in light; dark already cleared it at 4.63:1, so this was light-theme-only. Fixed by adding a danger-strong token (light 4 74% 44%4.94:1, dark 4 82% 68% → 5.23:1) and using it as the on-soft ink in Chip, Banner and CountBadge. Additive on purpose: danger itself is unchanged, so every existing FILL keeps its weight and nothing else in either idiom moves — darkening danger would have been a palette-wide change to fix one pairing. theme.css was updated alongside tokens.ts so the DOM half of ADR-0011 cannot drift. A filled danger surface still has no ink token; that remains QRS-202. (2) A 43×44 touch target. layout-invariants reported <button> 43x44 :: "Any" — the priority filter's shortest pill. Chip set minHeight: sizing.touchMin and not minWidth, so a short label shrank the box on the horizontal axis only, which is invisible until measured. Both axes now carry the floor. KNOWN_SMALL_TARGETS stays empty, which is the direction the standard requires. (3) Why these sat unread, which is the part worth keeping. I ran npm run e2e | tail -8 and reported "506 passed" as green — but Playwright prints failures BEFORE the summary, so tail cut off 29 of them. A JSON-reporter run gave the real picture: 712 total, 506 passed, 29 failed. Of those 29, 28 were my own export mistake — the parity probe is compile-time gated on EXPO_PUBLIC_PARITY_PROBE, and setting the flag is not enough because a warm Metro cache re-serves the previous bundle; it needs npx expo export -p web --clear. The spec's error text has been rewritten to say that, since it previously named the flag and not the cache and I misread it twice. Lesson, and it is not about Playwright: truncating a gate's output converts a red gate into a green one more effectively than disabling it, because it leaves a number behind that looks like evidence. Read the summary line, or read the JSON. After the fixes: 535 passed, 177 skipped, 0 failed — up from 506 because the probe's 28 tests now actually run. | | QRS-241 | decision | 🟡 observing 2026-07-28 | Process held under observation rather than changed: collect per-request delivery evidence first. Raised by the product owner after several small changes took over an hour, with a reasonable hypothesis — that running the full validation suite on every localised change is disproportionate — and a proposal to split into focused suites. Measurement overturned the hypothesis: the entire unit gate set is 74s (static gates 6.6s · lint 15s · type-check 12s · 477 jest + 117 domain 40s), so focused suites would save ~40s a run and would not have moved a 4-hour session. The real cost centres were the e2e loop at 8–13 min per cycle run 4 times (~30 min of it waste from a compile-time-inlined probe flag defeated by a warm Metro cache), six self-inflicted rework cycles, and ~7,400 words of prose for 988 lines of code. I proposed tiering the gates by change class plus three small fixes; the owner's decision was to change nothing yet and instead instrument the process, on the grounds that a proposal built from one session is exactly the isolated incident that should not drive policy. That is the better call and it is recorded as theirs. Mechanism: delivery-log.md — machine-measured columns (wall-clock, gate runs, rework cycles, prose volume) beside self-reported narrative (went well / cost / avoidable / improve), kept deliberately separate so the narrative alone cannot become an opinion accumulating unchallenged, since the party being measured writes it. Review trigger is explicit — 10 entries or 2026-08-31, whichever first — because open-ended observation is indistinguishable from no decision, and this repo has form: QRS-180 ran the tracker without ids for months, and the drift ledger records the local completion rate for deferred reconciliation as ~0. One item deliberately left open rather than actioned: whether making npm run e2e impossible to run against a stale export counts as a process change or a footgun fix. It cost 30 minutes in a single session and will recur, but it was raised inside the proposal the owner declined, so it waits for their word rather than being slipped in under a different name. |

SDLC / CI-CD foundation (Track A — 2026-07-20)

Adopting the R1 "enterprise-grade SDLC" must-have list. Track A (CI/quality foundation on the current repo, zero rewrite, zero-burn) landed 2026-07-20; Track B (mobile loop) and Track C (SSR web gates) are staged behind their prerequisites.

IDCatSummary
QRS-128projectCI foundation seeded (Track A) — added .github/dependabot.yml (npm root + portal + github-actions), .github/workflows/security.yml (gitleaks OSS binary + Semgrep OSS), frontend-ci.yml (type-check/lint/test/build), backend-ci.yml (path-filtered supabase/**: Deno EF tests + pgTAP), first pgTAP test (supabase/tests/database/profiles_test.sql) + test:db script. Also excluded documentation/portal/** from the app's root ESLint (it was tripping eslint . on VitePress prebundled cache and blocking all commits). Committed + pushed; PR #9 (→ develop) is GREEN on all 5 checks (frontend-ci build-and-test, backend-ci pgTAP + Deno EF, gitleaks, semgrep) after fixing 3 pre-existing issues (rows below). Awaiting merge (user gate); dependabot.yml scheduled updates activate from the default branch once merged. pgTAP passing confirms the baseline migration applies cleanly (profiles + RLS + policies present).
QRS-129debtsupabase db start seed is broken — breaks local + CI DB bootstrapsupabase/seed.sql inserts into public_page_ops_cron_jobs and fails with relation does not exist on a fresh stack, even though the baseline migration creates that table (line 1677). Root cause not yet pinned (migration applies fine per pgTAP; seed sees no table). Worked around by disabling [db.seed] in config.toml so the DB comes up for tests. Proper fix: diagnose the seed/apply mismatch (search_path? partitioned-table apply order? CLI seed connection), fix seed.sql, re-enable seed. Affects anyone running supabase db start locally.
QRS-130debteslint@9 vs @typescript-eslint@^5 peer conflict — breaks strict npm ci (works locally via legacy peer resolution). Worked around with .npmrc legacy-peer-deps=true. Proper fix: the flat config (eslint.config.mjs) doesn't even reference @typescript-eslint/*, so remove the stale @typescript-eslint/eslint-plugin + parser devDeps (or upgrade to v8 if TS-aware linting is wanted) and regenerate the lockfile.
QRS-131referencegitleaks history findings were doc placeholders (verified 2026-07-20) — 8 generic-api-key hits, all documentation code-examples: truncated JWT-header stubs (eyJ…9...), pk_live_abc123 anti-pattern example, template_key slugs. Allowlisted precisely in .gitleaks.toml (real secrets still caught). No rotation needed.
QRS-132risk46 Dependabot alerts on the default branch (2 critical, 16 high, 24 moderate, 4 low) — surfaced by GitHub on push 2026-07-20. Legacy/pinned deps (e.g. react 2.30.0-era stack). Track A's Dependabot will start proposing grouped update PRs once merged to default; triage criticals first, mind the frontend @supabase/supabase-js 2.30.0 pin + EF 2.39.7 pin when bumping.
QRS-133riskCodeQL + GitHub native secret-scanning need GHAS on a PRIVATE repo (paid) — breaks zero-burn, so substituted free OSS equivalents: Semgrep (SAST) + gitleaks binary (secrets). Enable CodeQL + SARIF→Security-tab if the repo goes public or adopts GH Advanced Security.
QRS-134debtSemgrep is report-only (non-blocking) for now — the scan step swallows its exit code; mid-transition codebase ⇒ legacy findings expected. Triage the first report, then remove the non-blocking guard to make SAST a hard gate.
QRS-135debteslint-plugin-security + eslint-plugin-sonarjs not yet wired — add warn-first (not error) after a triage pass so the green baseline holds; then promote to error. (CLAUDE.md ESLint plan already lists these.)
QRS-136riskError-tracking (Sentry-class) deliberately deferred out of Track A (2026-07-20) — user decision. Cross-refs the standing "observability is the top deferred risk — don't let it drift" item; revisit as its own decision (Sentry free tier vs self-host GlitchTip).
QRS-137debtCoverage gate not enforced in CIfrontend-ci runs npm run test, not test:coverage; wire the 70%/80% thresholds once known-met.
QRS-138debtgh CLI not authenticated in the dev envgh auth login needed for PRs/releases (toolchain-bootstrap rule).
QRS-139riskBranch protection + required checks + CODEOWNERS pending — GitHub Free-plan constraint (deferred, see memory). Once resolved, mark frontend-ci/backend-ci/security as required checks and add CODEOWNERS.
QRS-140projectMaestro E2E topology decided (2026-07-20, 16GB constraint) — keep Maestro in WSL (== CI Linux), bridge to the Windows adb server via WSL2 mirrored networking; run the Maestro suite in CI (Linux runners), not locally; local flow-authoring on a physical Android phone (preferred, zero emulator RAM) or the capped Medium_Phone_API_36.1 AVD; cap WSL2 (.wslconfig memory=4GB); never co-run the local Docker Supabase stack + emulator — point the app at remote Dev Supabase during device sessions.
QRS-141projectTrack B — mobile loop (pending Expo app)eas login + scaffold apps/mobile Expo app → boot on emulator → Jest + RNTL + one Maestro flow + EAS Update (OTA). Forces the monorepo-timing decision (ADR-0011 open-Q); scaffold incrementally without restructuring the existing web app first.
QRS-142improvementTrack C — SSR-web gates (pending RR8 migration)Lighthouse CI (CWV/perf) + an SEO-regression gate (per-slug OG/meta present, JSON-LD valid, sitemap) + visual-regression (Playwright screenshots — guards the two-idiom design-token parity). Attach as RR8 SSR + the shared token package land.
QRS-143improvement🔵 planned — deferred until iOS parity (Phase A) is complete. Automate the workflow gates: pre-push git hook + a README-proximity check + the honest limits of what a hook can enforce. Raised by product 2026-07-26 ("introducing hooks to automate and enforce these processes"). Framing correction that shapes the plan: the ask is a git-hook (husky) concern, not a Claude Code hooks one — Claude Code's hook system fires only around tool calls inside an agent session, so it would do nothing for a human pushing from a terminal or for a future contributor, and is at best a session-scoped backstop. husky + CI is the durable mechanism; both are already partly in place (husky v9 pre-commitcheck:readmes + lint-staged, and ci.yml). Scope, in priority order: (1) pre-push hook mirroring CItype-check, test, lint, format:check, check:readmes, check:sql, so failures surface before leaving the machine rather than as a red CI run; this also closes the standing "type-check fan-out has the same opt-in hole as lint did" gap (row above — project-wide type-check currently runs only in CI, never locally). Must stay fast enough not to be routinely bypassed: scope to changed workspaces where possible, and treat a habitually --no-verify-d hook as a design failure, not a discipline failure. (2) README-proximity check — fail when a file under apps/*/src/tiers/<X>/features/<Y>/ changes without <Y>/README.md changing in the same commit; the existing check-readmes.js gates presence only, so a stale README (explicitly "a bug" per the README-everywhere standard) currently passes. Needs a documented escape hatch for genuine no-doc-impact changes. (3) commit-msg hook validating the type(scope): subject convention + the QRS-### reference the tracker discipline expects. ⚠️ Explicitly NOT hook-enforceable — do not fake it: "verify iOS checks were executed." A hook on the Windows box has no way to know whether the Mac mini ran expo run:ios --device and it passed — separate machine, no shared state. The only real closures are (a) a macOS CI runner compiling/testing iOS on push (already tracked as the weakest link in the parity guarantee, iOS-parity section) or (b) a human control — PR checklist + CODEOWNERS sign-off (itself blocked on the Free-plan branch-protection constraint, row above). A hook that merely checks whether a box was ticked manufactures false confidence and is worse than no check — the same failure mode as the green-no-op lint gate this repo already got burned by. Sizing the macOS runner is part of this work item, since it is the only thing that actually automates the iOS half.

Frontend greenfield monorepo (ADR-0012 — 2026-07-20)

Frontend is a greenfield rebuild (no production constraint; backend + Digital Menu excepted). Structure finalized in ADR-0012.

IDCatSummary
QRS-144projectMonorepo structure accepted (ADR-0012) — npm workspaces; apps/{web,mobile} over bounded packages schemas → domain → data + leaves tokens/utils/i18n/analytics/observability + tooling/{typescript-config,eslint-config,tailwind-config}. One-way acyclic dep graph. 4 tiers split: landing/public/admin → apps/web (RR8), user/merchant → apps/mobile (Expo). Data logic written once (packages); UI twice (shadcn/DOM + RN/NativeWind). Client state = Zustand; server state = TanStack Query; strings via @qrsetu/i18n (English R1). src/ Vite SPA = reference (not migrated); Supabase backend unchanged; Digital Menu preserved, re-homed R2.
QRS-145projectREADME-everywhere standard [ENFORCED] — every app/package/tooling/feature dir ships a living README.md (template in ADR-0012). Enforcement: tools/check-readmes.js CI gate (presence — build with the skeleton) + PR-checklist/CODEOWNERS (freshness). Added to Definition of Done in CLAUDE.md.
QRS-146projectESLint package-boundary rule — add to tooling/eslint-config: enforce the one-way graph (apps → packages → schemas; no packages → apps; no cycles). Pairs with the guardrail rules the plan already lists (no from(), no inline EF names, tier boundaries, no hard-coded colors).
QRS-147projectTrack B revised (mobile loop on the monorepo) — build the workspace skeleton (tooling/, empty packages/* with README + barrel), then scaffold apps/mobile (Expo + Router + NativeWind) + seed packages/{schemas,data,tokens} with the first shared slice; prove Jest/RNTL + Maestro + EAS Update. eas login still pending (user action).
QRS-148debtsupabase/setup-cli bumped 1 → 3 by Dependabot (PR #12) — major-version jump in backend-ci.yml; confirm the pgTAP job still goes green on the next supabase/** change (setup-cli v3 behavior).

Track B progress (mobile app on the monorepo)

IDCatSummary
QRS-149projectB1 done — apps/mobile scaffolded — Expo SDK 57 + Expo Router + TypeScript; trimmed the tabs demo to a clean minimal screen. Monorepo Metro config (watches workspace root, resolves @qrsetu/*). Shared-slice proof: @qrsetu/schemas PLATFORM export → PlatformBadge → rendered on the home screen + a passing RNTL test. Zod env.ts. Validated headlessly: type-check ✅, jest (jest-expo + RNTL) ✅, check:readmes ✅.
QRS-150debtMobile lint deferredexpo lint isn't CI-safe (tries to spawn an eslint-config install). The intended lint home is the shared @qrsetu/eslint-config (currently a stub). Wire it (eslint-config-expo base + QRSETU guardrails) into apps/mobile (+ apps/web later) and restore a lint script; until then mobile has no lint (CI --if-present skips it).
QRS-151referencejest-expo needs @react-native/jest-preset peer — RN 0.86 split the RN jest preset into its own package; added @react-native/jest-preset@0.86.0 as a devDep so jest-expo loads.
QRS-152debt11 moderate npm vulns in the Expo/RN dependency tree — surfaced on apps/mobile install; Dependabot will propose bumps. Triage after the tree stabilises (don't hand-bump Expo-managed versions — use expo install).
QRS-153projectB2 (styling + E2E) — ✅ DONE (device loop closed 2026-07-21)NativeWind 4.2.6 + tailwindcss 3.4.17 wired into apps/mobile (babel jsxImportSource, withNativeWind metro, global.css, nativewind-env.d.ts) sourcing the shared token pipeline: @qrsetu/tokens (radius invariant + provisional color palette) → @qrsetu/tailwind-config preset → apps/mobile/tailwind.config.js. Home screen + PlatformBadge converted to className. Proven headlessly: type-check ✅, Jest+RNTL ✅, and a Tailwind CSS compile confirming .text-brand → rgb(32 138 239) (=#208AEF) resolves all the way from @qrsetu/tokens (single-source-of-truth pipeline works). Device loop (2026-07-21): npx expo run:android built + installed in.digious.qrsetu on Medium_Phone_API_36.1, NativeWind confirmed rendering on device (screenshot), and maestro test .maestro/smoke.yaml green from WSL (launch + assert home) — proves the WSL-Maestro ↔ Windows-emulator bridge. Fixes committed (6b3ecfa): native run:* scripts, narrowed Metro watchFolders, Maestro extendedWaitUntil.
QRS-154referenceWindows build environment & repo relocation — full runbook. Getting the native build green on Windows required solving the MAX_PATH 260-char limit (RN CMake/ninja paths overflow at a deep OneDrive path) and a Metro TreeFS crash (watching the whole monorepo root). Resolved by relocating the repo C:\Users\…\OneDrive\WorkSpace\DevArea\qrsetuD:\WorkSpace\DevArea\qrsetu (short path, off OneDrive), relocating dev caches to D:\DevCache (GRADLE_USER_HOME/npm/Playwright), and narrowing Metro's watch scope. The clone-based move + Claude Code memory migration (path→.claude/projects slug mapping) are documented as the finalized reference — use it instead of re-deriving. See Windows Build Environment & Repo Relocation.
QRS-155debtProvisional token palette@qrsetu/tokens color values are placeholders (brand = #208AEF) to prove the pipeline; radius is the real soft-corner invariant. Replace color (+ add light/dark mapping) from the design-system token extraction; no per-stack divergence.
QRS-156projectMaestro-in-CI deferred to B4 — the CI workspace job already runs mobile type-check/lint/Jest across workspaces; running the Maestro suite on a Linux CI emulator needs a build artifact (EAS dev-client), so it lands with B4 (EAS build/submit/update) alongside eas login.

Brand assets & app identity (2026-07-20)

App identity wired into apps/mobile/app.json and shared brand source homed in packages/tokens/assets/. First real assets (logo + favicon rasters, Bunya fonts) added by the user 2026-07-20 — interim, not release-grade. The rows below gate the first store build / PWA release.

IDCatSummary
QRS-157projectBrand-asset validation gate — validate all icon/splash/favicon assets (names, sizes, formats) before the first EAS build or PWA release. Required manifest, none yet at spec: App icon apps/mobile/assets/images/icon.png 1024×1024, opaque (no alpha), sRGB; iOS icon set assets/expo.icon (Icon Composer) from that master; Android adaptiveandroid-icon-foreground.png 1024² with artwork inside the ~66% (≈432px) safe zone (transparent), android-icon-background.png 1024² or a solid brand-token color, android-icon-monochrome.png 1024² single-color/transparent (Android-13 themed icons); Splash splash-icon.png ~1024² transparent, mark centered (current imageWidth:76 + #208AEF bg are Expo-template defaults — set to brand); Web favicon favicon.png ≥48²; PWA icons 192×192 + 512×512 (+ a maskable variant) in the web manifest. Verify each filename + pixel size + format + brand color before build. Best source: SVG masters (packages/tokens/assets/brand/qr-monogram.svg + wordmark.svg) exported to each size.
QRS-158riskBunya is personal-use licensed — design-time source only, must not be bundled — files renamed *_PERSONAL.ttfBunya-{Regular,Bold}.ttf on 2026-07-20 (rename ≠ relicense; still personal-use, confirmed by user). Bunya is the logo wordmark only, not a UI font. Mitigation (decided 2026-07-20): bake the wordmark into outlined vector artwork (packages/tokens/assets/brand/wordmark.svg) so no font file ever ships → sidesteps the redistribution/embedding clause; never load Bunya via expo-font/@font-face. Before shipping the logo, acquire a commercial desktop/logo license (cheapest tier — covers creating the artwork; the pricier embedding/webfont tier is unnecessary while outlining). Only if Bunya is ever needed as live text does the embedding license become mandatory.
QRS-159debtProvided brand rasters are undersized for shippingQR_setu100_50_logo.png (312×156 wordmark) and QR_setu_50_50_favicon.png (156×156) are preview-grade. Store icon needs 1024²; PWA needs 192/512. Obtain vector masters (or full-size exports) and generate the manifest above; then repoint app.json from the Expo-template placeholder PNGs to the real marks.
QRS-160debtapp.json still references Expo-template placeholder articon.png, splash-icon.png, android-icon-*.png, favicon.png, and the expo.icon set under apps/mobile/assets/ are the scaffold defaults (react/expo logos). Replace all with QR Setu marks as part of the validation gate.
QRS-161referenceApp identity decided (2026-07-20) — launcher/home-screen label "Setu" (expo.name + web.shortName); full/marketing name "QR Setu" (web.name, store listings); slug qrsetu-mobile; deep-link scheme qrsetu; icon = the "QR" mark. Bundle id / package in.digious.qrsetu — ✅ LOCKED (user-confirmed 2026-07-20). This is the permanent store identity for both iOS and Android.

Onboarding — WelcomeStory audit + setup flow (2026-07-23)

WelcomeStory refinements landed (gradient highlights, two-line scene 3, Baloo display face, Akaya Kanadaka wordmark, new monogram + icon pipeline, de-duplicated splash, tightened spacing). The lean setup wizard (auth → name → [brand → industry →] slug → celebrate) is built against a stubbed OnboardingService with exhaustive tests; nothing is committed. Docs: onboarding feature, brand & typography. Rows below gate release + the real-wiring PR.

IDCatSummary
QRS-162riskiOS-PWA parity verification pending (WelcomeStory + setup) — neither flow has been validated on iOS Safari as an installed PWA. Blocks parity-complete sign-off; needs an iOS device. Per CLAUDE.md cross-platform parity, this is the one outstanding surface (Android native ✅ for WelcomeStory; Web PWA builds via expo export -p web).
QRS-163debtWeb-PWA on-device/browser walk pending — both flows build and serve (expo export -p web, routes /onboarding/{welcome,setup}); a manual desktop + mobile-browser walk of business & individual paths is not yet release-signed-off.
QRS-164debtQrGlyph primitive unbuilt (inert) — referenced in the design vocabulary but not implemented; no screen depends on it yet. Build when a real QR render is needed (celebrate card / dashboard).
QRS-165debtElevation token e0 absent — the elevation scale starts at e1; a flat e0 (no shadow) token was assumed by one call site. Add e0 to @qrsetu/tokens or standardise on omitting elevation.
QRS-166referenceMaestro asserts testID, not scene-0 text (documented deviation) — the smoke flow keys on testID rather than visible copy so it survives copy/i18n changes; intentional, noted so a future reader doesn't "fix" it to text matching.
QRS-167projectOnboarding/Profile/Settings backend wiring (own PR) — DELTAS RE-VALIDATED against source 2026-07-23. Most of the backend already exists (baseline profiles is rich; business_domains table exists; EFs manage-profile/manage-settings/manage-account/validate-user-input exist). Actual deltas to swap the stub behind the same OnboardingService: (1) [CRITICAL] extend manage-profile buildProfileUpsertRow allow-list — it currently drops slug, onboarding_completed, gstin, name, description, business_hours, social_media_links, planned_holidays_ooo; onboarding literally cannot persist the slug or mark completion without this. (2) extend manage-settings allow-list — covers only default_currency/show_ads; missing language (LanguageSelect persistence — respect the en/mr/hi CHECK) + reminder prefs. (3) seed business_domains — table exists but has NO seed migration and Dev has 0 rows; add a seed matching Prod's rows and align/retire constants/domains.ts. (4) design get_* read RPCs — none exist for these screens (reads go through manage-profile GET select *); add projected get_profile/get_dashboard_summary/get_business_domains per the data-access rule. (5) auth OTP decision — no OTP EF; choose Supabase Auth native signInWithOtp/verifyOtp vs a ZeptoMail EF (ADR-0008). (6) promotion — Dev needs the profile-pictures storage bucket (avatar upload) + business_domains seed. Then add the "never calls supabase.from()" compliance test. CORRECTION: earlier rows claiming business_domains and a slug-uniqueness EF were missing were wrong — both exist (validate-user-input does slug format + DB uniqueness).
QRS-168projectProgressive profile-completion (dashboard) — the steps cut from first-run: location (country/state/city/pin), the notifications-permission ask (contextual on first enquiry/booking, never blocking), the feature-highlights as "get-started" cards, plus hours/social/GSTIN. Same field contract + primitives; wired when the dashboard/console lands.
QRS-169projectFirst-run dashboard + mobile console — the celebrate hand-off currently lands on a thin /dashboard placeholder; the real first-run dashboard is a separate feature/tier.
QRS-170debtResponsive desktop onboarding — screens render on desktop web via RNW at mobile width (parity by construction); a dedicated responsive desktop layout is deferred.
QRS-171projectMerchant app slice roadmap (Onboarding→Dashboard→Profile→Settings) — planning locked, P0 in progress (2026-07-23). FE-first on a frozen stubbed service seam, then one backend-integration PR (deltas in the onboarding-wiring row above). Locked: lean first-run home (legacy menu-centric dashboard is R2/Digital-Menu, N/A to R1) + minimal bottom-tab shell. Full roadmap: merchant-slice-roadmap.
QRS-172projectSetu Card supersedes Service Card; BioLink RETIRED (2026-07-23 product decision). Setu Card = universal public identity every user gets (qrsetu.com/<slug>); designed ground-up as its own future feature (not this slice — appears only as the onboarding SetuCardPreview + a Dashboard placeholder entry). BioLink never deployed → ignore its pages/schema/bio_pages//b/:slug/debt entirely; no migration. Done: onboarding code + i18n (en/mr/hi) + tests renamed Service→Setu Card (73 tests green). Remaining (opportunistic mechanical cleanup): ADRs 0001/0009/0011, CLAUDE.md "3 pillars", portal overview/glossary still say biolink/service card.
QRS-173debtFonts over budget in the APK — whole families bundled instead of 11 weights (confirmed 2026-07-23). The first onboarding release APK ships 46 .ttf / 8.2 MB of fonts though app/_layout.tsx registers only 11 weights. Cause: importing from the @expo-google-fonts/<family> root resolves the family index.js, which require()s every weight's asset — Metro can't tree-shake asset require()s, so all weights ship. Fix: import the per-weight subpath (e.g. @expo-google-fonts/plus-jakarta-sans/400Regular), which exists for each package. Est. savings ~6.4 MB (8.2 → ~1.8 MB fonts), dropping the arm64 APK from 48 MB → ~42 MB (back inside the 30–45 MB baseline in [[app-size-and-versioning-policy]]). Fix APPLIED 2026-07-23_layout.tsx now imports the 11 weights via per-weight subpaths (@expo-google-fonts/<family>/<weight>); type-check/lint/prettier green. Savings realize on the next APK build (deferred at user request — they'll rebuild next round from the recent build).

How to promote a candidate

A candidate already has an id — promoting it does not change the id.

  1. Reproduce / confirm.
  2. Move the row out of its Candidate findings — … section into the relevant confirmed section, keeping its QRS-### unchanged.
  3. Fill the full field set (repro, impact, category, owner, status).
  4. Reference the id in the fixing commit.

How to add a new item

  1. Take the next free id from the banner at the top of this page.
  2. Append the row to the relevant section.
  3. Bump the next-free-id in that banner in the same edit — it is the allocator, and two people taking the same number is the one failure this scheme cannot recover from.