Skip to content

Risks · Gaps · Assumptions

Risks · Gaps · Assumptions

CHANGE SUMMARY

  • Updated: Replaced the “no AI proxy” gap with the realities of the new managed Anthropic path — service-role env dependencies, rate-limit UX, and BYOK exposure (supabase/functions/anthropic-proxy/index.ts, src/api.js, src/supabase.js).
  • Updated: Added risks about ai_usage visibility and Edge Function deployment drift relative to migrations.
  • Updated (2026-04-23/24): Removed stale How-It-Works risk (SUR-215 deleted LandingPage/HowItWorksPage). Added SUR-209 broken in-app help links risk. Added SUR-238 capture nav note.
  • Updated (2026-04-26, codebase-wide audit): Removed BYOK risk (SUR-91 fully sunset — no direct browser→Anthropic path remains). Removed OCR-coverage gap (resolved by api.test.js / photoAdapter.test.js / capture.test.jsx). Removed schema-probe single-shot risk (SUR-61 fixed in useAuth.js:175). Removed full-table sync risk (SUR-62 — fetchAllCloud(since) is now incremental). Marked SUR-209 help-links as resolved (SUR-223 Phase 1). Updated managed-quota numbers (free tier is 1000/month, not 30). Added: Edge Function debug logs include user_id; rate-limit race window between check and write; individual-note image cleanup still missing; full re-decrypt on every sync; transfer-blob auto-expire is best-effort; no PWA update prompt; db.js has no dedicated unit tests; SUR-257 duplicate-source UX gap; outbox failure mode is opaque; sync image fetch is sequential.
  • Updated (2026-04-26, SUR-261): Added email-OTP signin assumption around Signups not allowed for otp error string and shouldCreateUser:false defence-in-depth. Added gap: branded magic-link template lives in repo (supabase/email-templates/magic-link.html) but must be uploaded to Supabase dashboard manually with no CI sync. Added gap: approved waitlist rows with user_id IS NULL cannot complete email-OTP signin until invited.
  • Updated (2026-04-29, SUR-92 fixes): Free-tier default corrected to 50/month (SUR-92 changed from 1000 hard-coded constant to month_limit DEFAULT 50). Noted cross-action enforcement: getMonthlyUsage now sums all action types (no per-action carve-out). Noted fire-and-forget telemetry fix: emitTelemetry is non-blocking. Noted trigger name correction: trg_ai_usage_month_delta.
  • Updated (2026-04-30): assetlinks.json now enables Google Smart Lock credential sharing for the TWA — no direct risk change, but noted in architecture docs.
  • Updated (2026-05-01, SUR-242): Added risks for Azure Content Safety dependency (fail-open gap, latency per call, no dashboard visibility on guardrail hit rate), client-side PII detection (regex-only, NER deferred to SUR-246), and the v1.4 warn-not-block policy gap.
  • Updated (2026-05-01, SUR-256): Added blog deployment note — posts are static and require a full rebuild per publish.
  • Updated (2026-05-02, SUR-233): Added device-label gap (device_label is set once at enrolment; no update path if label becomes wrong). Noted AddIdeaSheet free-tier cap TODO (SUR-235 CTA comment in source).
  • Updated (2026-05-02, SUR-237): “Transfer-code auto-expire” risk revised — authoritative TTL enforcement moved server-side via select_fresh_transfer_blob RPC (migration 0014). Client-side TRANSFER_MAX_AGE_MS / TRANSFER_MAX_FUTURE_SKEW_MS removed; TRANSFER_SANITY_FUTURE_SKEW_MS (5 min) remains as defense-in-depth.
  • Updated (2026-05-04, SUR-303 refactor/sur-303-extract): Added assumption for src/constants.js Vite-neutral constraint and the eval-harness import contract on prompts.ts.
  • Updated (2026-05-04, SUR-303): Resolved SUR-209 Phase 2 (in-app help renderer now live). Added build-time bundling gap for in-app help articles. Added PolicyPage note (Termly embed now served in-app).
  • Updated (2026-05-03, SUR-233 follow-up): Resolved the Settings-open Supabase refetch loop — fetchDeviceList is now memoized with useCallback. Updated the device_label immutable gap to reflect that the count is always refreshed on Settings open. Added regression-test gap note: src/test/useKeyManagement-stability.test.jsx asserts reference stability but does not test the modal data path end-to-end.
  • Updated (2026-05-10, SUR-360 + SUR-361): Auth dispatch hardening landed. Generalised the SUR-261 magic-link-template-only-in-repo gap to both magic-link.html and confirm.html (still no CI sync). Added new ops dependencies on Resend SMTP (RESEND_API_KEY env var) and Cloudflare Turnstile (SUPABASE_AUTH_CAPTCHA_SECRET + VITE_TURNSTILE_SITE_KEY) — three new env vars on the production Auth boundary, no monitoring or alerting on either. Recorded the OAuth-captcha exemption-by-design assumption with line refs into supabase-js v2.100.0 (GoTrueClient.js:669-677) so a future supabase-js bump that does forward captchaToken is treated as a behavioural change worth re-examining.
  • Updated (2026-05-14, SUR-351): Stripe billing — silent webhook failure on customer-id race. Three-layer fix landed: (L1) race-safe ensureStripeCustomer in create-checkout-session via conditional UPDATE filtered on stripe_customer_id IS NULL; race-loser best-effort stripe.customers.dels its leaked customer and re-reads the survivor. (L2) stripe-webhook/handler.ts adds resolveProfileForSubscription — metadata.user_id fallback + self-heal of stripe_customer_id, applied to both handleSubscriptionUpsert and handleSubscriptionDeleted. The fallback is guarded against stale at-least-once deliveries (skip_self_heal_subscription_id_mismatch no-op when stripe_subscription_id doesn’t match event.data.object.id) so a delayed customer.subscription.deleted for an old sub cannot clobber a user active on a newer one. Unrecoverable miss now logs profile_not_found_for_customer (previously silent — that silence was the failure mode). (L3) New getSubscriptionIdFromInvoice resolves invoice.parent.subscription_details.subscription first (the post-2024-10-28 location for the pinned 2026-04-22.dahlia API version) → legacy → lines[0].subscription. Without L3 every invoice.paid / invoice.payment_failed had been silently returning no_op: invoice_without_subscription in production. Two new gaps recorded below: (a) the L2 guard scope is narrow on purpose — direct customer-id matches in handleSubscriptionDeleted remain unguarded against stale-cancel-on-same-customer; (b) the project’s CI workflow does not gate the changed Edge Functions, so SUR-351 tests live outside CI.
  • Updated (2026-05-28, SUR-501): Stripe billing — create-checkout-session self-heals a stale stripe_customer_id. On a Stripe resource_missing naming the customer (deleted, or a test↔live mode switch — the SUR-500 incident), the function clears the stored id, recreates via ensureStripeCustomer, and retries the Checkout Session once; a price resource_missing or a second failure surfaces as internal_error. The clear is conditional on the observed stale id (.eq('stripe_customer_id', staleId)) so two concurrent healers can’t clobber a freshly-recreated id or mint a second customer (the SUR-351 race, re-closed for this new write). New risk recorded below: the loud self-heal log is the only signal a deploy-wide test↔live key mismatch would surface, and the heal-vs-surface narrowing depends on Stripe’s param value (unverified until the test-mode E2E).
  • Updated (2026-05-20, SUR-371): Partial closure of the SUR-361 silent-failure gap on VITE_TURNSTILE_SITE_KEY. The Auth-boundary risk row below (line 54 — “a missing one causes either a hard auth failure or silent SMTP fallback”) is now half-closed: the client-side VITE_TURNSTILE_SITE_KEY-unset case is gated on two surfaces — a build-time Vite plugin (vite-plugins/sur-371-turnstile-key-guard.js) that hard-fails npm run build in production mode, and a runtime banner with role="alert" + console.error in EmailSignInFlow that catches anything that still slips through. The 2026-05-10 incident (form looked functional, 100% of email-OTP submissions rejected by GoTrue with captcha protection: request disallowed) cannot recur with the new guards in place. Still open in the same risk row: the SUPABASE_AUTH_CAPTCHA_SECRET and RESEND_API_KEY unset cases — those are server-side, are checked by neither the new client-side banner nor the client-side Vite guard, and would still cause a hard auth failure (captcha enabled, secret unset → captcha_failed) or silent SMTP fallback (inbucket for local Supabase masking broken production wiring during staging tests). The same defensive pattern (build-time + runtime guard) is not applied here yet — server secrets sit outside vite build’s scope and the runtime detection surface for “the server returned 401 because its captcha secret is wrong” is the existing supabase-error banner, which conflates this with many other auth errors. Tracked as still-open below. Also recorded: the original SUR-371 ticket asked to update supabase/.env.example, which holds the server secret — the actual fix went into the PWA root .env.example (where VITE_* vars belong) and CLAUDE.md’s Auth dispatch hardening bullet; the misfiled doc target is corrected.
  • Updated (2026-05-11, SUR-370): Intent-aware AuthScreen + unified auth_landing_viewed telemetry. Added two coupled risks: (1) upgrade_gate_viewed event rename — saved PostHog insights / funnels referencing the old name will silently zero out at merge; mitigation is to grep PostHog saved insights and update references in lockstep with the merge. The event has only existed since 2026-05-10 (SUR-352) so cumulative impact is small. (2) UTM_KEYS array is duplicated across the repo boundary (surfc/src/lib/utmParams.js and surfc-web/src/scripts/preserveUtm.ts) — there is no shared build artefact across the sibling repos, so adding a new key (e.g. ttclid for TikTok) requires a dual-edit. Same shape as the PRICE_COPY duplication noted in CLAUDE.md. Recorded the catch-all <Navigate> preserves search invariant as an assumption so a future App.jsx refactor that drops the search field re-breaks the marketing CTA path.
  • Updated (2026-06-28, SUR-711): Auth signup-framing retired + consent-bypass fixed. Recorded the telemetry risk: app_signup_started deleted (SUR-367 signup funnel goes dark until replacement instrumentation lands) and auth_landing_viewed.intent from authscreen fixed to the constant 'signin' (shape unchanged). Updated the SUR-370 “catch-all <Navigate> preserves search” invariant — it now protects ?open_email=1 + UTMs, not ?intent=signup (which is gone); marketing CTAs deep-link to /signin. Cross-repo with surfc-web (signupUrl()/signin).
  • Updated (2026-06-28, SUR-675 + SUR-618/619): Braird rebrand + compliance internalization. Recorded the in-app Surfc→Braird rebrand (PRs #323 surface / #324 crypto-adjacent): the WebAuthn RP_ID stays surfc.app through the brand window (passkeys are scoped to it — it changes only at the SUR-692 domain cutover, or every enrolled passkey orphans), the auth-email templates + app.surfc.app origin are not yet rebranded, and the wordmark renders in EB Garamond not the brand Lora 500. Added the Termly-internalization compliance items (SUR-618 Privacy / SUR-619 Terms in-house rewrites, lawyer-gated): the live Termly policies carry material legal inaccuracies (claim “no sensitive data” while users photograph notes; broad UGC licence that contradicts E2EE; US data-location claims while data is EU/Supabase and the controller is Swiss), and Termly is a load-bearing dependency for both policy text and the consent banner whose removal is sequenced last (SUR-620 Klaro → SUR-621 decommission).
  • Updated (2026-06-28, SUR-673): Auth-email templates rebranded to braird. The Magic Link + Confirm-signup templates (supabase/email-templates/{magic-link,confirm}.html) now carry the braird wordmark + cool-paper/forest-ink palette and braird.app links; all Go-template vars and the SUR-705 link+code structure are preserved. config.toml [auth.email.smtp] flips the sender hello@surfc.apphello@braird.app / sender_name = "Deji @ braird" (local-CLI parity only). This partially supersedes the SUR-675 “auth-email templates not yet rebranded” note above — rebranded in repo, but prod still ships nothing until the templates are pasted into the Supabase dashboard by hand (the standing no-CI-sync gap) and the braird.app sending domain is verified + warmed in Resend (SUR-669). The prod From identity is owned by SUR-674. Deliverability assumption below updated from surfc.app to braird.app.
  • Updated (2026-07-02, SUR-739 / SUR-740 / SUR-652): Server sync watermark + LWW guard. Recorded the new change_seq visibility watermark + the t01_lww_guard / t02_change_seq triggers on all 8 synced tables (migrations 0050/0051), the PWA cursor cutover to per-table change_seq keyset pagination (legacy lastSyncAt kept as a rollback fallback), the founder-scheduled migration-window dependency, the accepted exact-ms-tie non-convergence residual, and the braird-core client-leg follow-up that consumes the same watermark. New risk row below.
  • Updated (2026-07-02, SUR-743): change_seq made commit-ordered. Migration 0052 replaces 0051’s per-table nextval sequences — where allocation order ≠ commit order, so a keyset puller could advance past an earlier-allocated-but-later-committed row and miss it permanently — with a per-user commit-order counter (public.user_change_seq) bumped under a per-user pg_advisory_xact_lock held to COMMIT, so per user allocation order == commit order and the keyset cursor is skip-safe. Server-only, no client change; applies in the same founder-scheduled window as 0050/0051. Closes the SUR-743 exposure; the risk row below is updated in place.

Organized per AGENTS.md requirement to separate confirmed facts, assumptions, and unknowns.

Confirmed risks / gaps

  • Monolithic shell: src/App.jsx is ~500 lines and still owns auth gates, layout, modal/overlay refs, the route tree, and prop fan-out. A single render bug or prop-rename ripples through every screen. Refactor is acknowledged in CLAUDE.md debt list and tied to v1.1.
  • Oversized hooks as service layers: src/hooks/useNoteForm.js (~470 lines) and src/hooks/useSettings.js interleave UI state, Dexie persistence, Supabase writes, ingest invocation, and PostHog events. Regressions in either simultaneously impact local data, sync, and AI flows.
  • Manual Supabase migration apply: Schema is under supabase/migrations/*.sql with a codified contract (scripts/schema-contract.js) and CLI verifier (scripts/check-schema.js) plus a matching unit test (src/test/check-schema.test.js). Drift detection is automated, but apply is still manual via the Supabase SQL editor — a missing migration on prod won’t show until a sync fails. No CI gate prevents shipping client code that depends on an unapplied migration. [SUR-60]
  • Sync-watermark migration must apply in a scheduled window (SUR-739 / SUR-740 / SUR-652): Migrations 0050_sur740_lww_guard.sql + 0051_sur739_change_seq.sql add a BEFORE UPDATE LWW guard (t01_lww_guard, rejects a strictly-older updated_at) + a server change_seq visibility watermark (t02_change_seq; per-table sequence + backfill + NOT NULL + (user_id, change_seq) index) to all 8 synced tables, and the PWA cursor cuts over to keyset pagination on change_seq with per-table meta.lastSeq:<table> cursors. They are additive + backward-compatible (an un-upgraded client keeps working on the updated_at path; the guard passes its always-fresh writes) and rollback-safe, but they are still a manual apply in a founder-scheduled window (per the SUR-60 manual-apply gap above), and the migration + client leg land together. Accepted residual: two devices writing different values to the same row in the same millisecond don’t converge (strict-> LWW keeps local; NTP-bounded, pathological) — a deterministic tie-break would be wire-visible and is deferred. Ops caveat: a service-role backfill that intentionally writes an older updated_at is silently skipped by the guard — bump updated_at or drop the trigger for the window. braird-core consumes the same change_seq watermark in its own pull cursor as a cross-repo follow-up (SUR-739 core leg). Updated (SUR-743): 0052_sur743_commit_ordered_change_seq.sql supersedes the per-table sequence with a per-user commit-order counter (public.user_change_seq, bumped under a per-user advisory lock held to COMMIT), closing the nextval allocation-order skip hole so the keyset cursor is skip-safe; it applies in the same scheduled window, is server-only (no client change), and is likewise rollback-safe.
  • Managed proxy env fragility: The Edge Function depends on SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL, SUPABASE_ANON_KEY, and ANTHROPIC_API_KEY (anthropic-proxy/index.ts:386-411). Missing env yields HTTP 500s; the client surfaces only generic “Transcription failed: …” copy via useNoteForm.js:254, with no operator-actionable detail in the user-facing toast.
  • Edge Function logs user identifiers: anthropic-proxy/index.ts:399-400, 423 emits console.log lines containing user.id and the action type on every authenticated request. This becomes a per-user audit trail in Supabase Function logs, which is broader than necessary and outside the explicit privacy posture of the rest of the app. Tighten or strip before any production-grade load.
  • Azure Content Safety fail-open gap (SUR-242): supabase/functions/anthropic-proxy/guardrail.ts fails open on any Azure 5xx, network error, or timeout — managed AI calls still succeed and _failOpen: true is appended to the response JSON. There is no alerting, no Supabase dashboard metric, and no PostHog event emitted when guardrails degrade. An operator has no way to detect that injection/harm protection is silently inactive without actively monitoring Azure’s own portal. Missing AZURE_CONTENT_SAFETY_ENDPOINT or AZURE_CONTENT_SAFETY_KEY also triggers fail-open (not a hard outage). Target: add a PostHog guardrail_fail_open event in a follow-up.
  • Azure Content Safety latency budget per call (SUR-242): Each managed request runs up to three Azure calls in sequence: shield(input) → Anthropic → spotlight(transcription)moderate(output) (transcribe path). Each Azure call has a default timeout; on the retry path (429) up to 5 attempts fire with exponential back-off. Under Azure degradation, a single transcribe request can add ~5–15 s of wall-clock latency before fail-open kicks in. The current timeout is not documented in guardrail.ts; confirm and cap before shipping to users at scale.
  • Client-side PII detection is structural/regex-only (SUR-242): src/safety/piiRegex.js detects six structured PII patterns (card/Luhn, IBAN/mod-97, NIN, SSN, phone E.164, email) but has no Named-Entity Recognition for names, addresses, or free-text medical information. checkNerPii() and checkPromptInjection() are exported stub no-ops — their bodies are deferred to SUR-246 (on-device Llama Prompt Guard 2, GLiNER NER). The v1.4 policy is warn-not-block: the review BottomSheet fires but the user can dismiss it and proceed. The gap is that unstructured PII (a person’s name, home address) passes through undetected and is sent to Anthropic and stored in Dexie/Supabase in plaintext.
  • Blog requires full rebuild per post (SUR-256): surfc-web/src/content/blog/ posts are MDX files baked into a static dist/ at build time. There is no CMS, draft preview URL, or incremental rebuild path. Publishing a new post requires a commit + Netlify/Cloudflare Pages rebuild cycle. Any typo fix triggers a full site rebuild and deploy. For a solo founder this is acceptable; reconsider if the posting cadence increases.
  • Rate-limit race window: The check (getMonthlyUsage) and write (recordUsage) in anthropic-proxy/index.ts are not atomic. The code documents a worst-case overrun equal to in-flight requests; acceptable at the 50/month default (SUR-92), but tightens once paid tiers with higher caps land. Strict enforcement requires a DB-level lock or a CHECK constraint on the upsert_ai_usage RPC. Note: getMonthlyUsage now sums across all action types — the race window applies to the cross-action total, not a per-action subtotal (SUR-92 cross-action fix, 2026-04-29).
  • Per-request quota lookup latency (low impact, SUR-92): Each managed-AI call now performs one extra indexed single-row SELECT on user_profiles (filtered by user_id). Latency ≈ 1-3 ms warm, negligible vs the 1-5 s Anthropic round-trip. At 10k users × 50 calls/month (peak free-tier scenario) that is ~17k extra queries/day — well within Supabase’s free-tier limits. Becomes a concern only at sustained tens-of-requests-per-second; mitigation would be a 30-60 s in-memory {userId → resolvedLimit} cache in the Edge Function. Not needed for v1.5; revisit if managed-AI traffic grows ≥ 10×. No new failure modes — the SELECT is on the same Supabase project the function already depends on.
  • Usage opacity: Managed calls are rate-limited and logged in ai_usage_daily, but the UI only surfaces a “Monthly limit reached” toast at the cap (supabase.js:284-291). Users cannot see remaining quota or token use even though ai_usage_daily is queried per request. No surface in ProfileScreen or SettingsModal shows current usage.
  • No per-note image cleanup: Soft-deleting a note (deleteNote in db.js:203-205) toggles deleted:1 and bumps updatedAt; cloudWrite mirrors that to Supabase, but neither path removes the image from the note-images bucket. deleteCloudData (supabase.js:331-349) does sweep storage objects on account deletion, but per-note cleanup has no counterpart to uploadImage. Active accounts accumulate orphaned blobs indefinitely.
  • Outbox single queue, opaque failure mode: db.outbox stores books/notes/custom-ideas writes together; one stuck payload (e.g. an oversized sourceMeta rejected by RLS) blocks the rest of the queue. useAuth.js:266-273 catches any upsert error and re-enqueues — schema errors, RLS rejections, and transient 5xxs all look the same to the user, with no distinct UI surface or quarantine for poison messages.
  • Full re-decrypt on every sync: useAuth.js:223-231 reloads all notes via loadAll(decryptFn) after merge, decrypting every encrypted note text via Web Crypto on each pass. Combined with image downloads inside the same loop, sync time scales linearly with library size. There is no encryption-only delta path, no batching, and no progress indicator during decrypt.
  • Sync image fetch is sequential: useAuth.js:213-221 downloads missing images one at a time inside a for loop. On a fresh device with N notes the wall-clock sync time is O(N) round-trips. Failures are silently swallowed (the comment says “non-fatal”) so a flaky network can leave images permanently absent until the next sync.
  • Decrypt-failure recovery is dead-end: Notes that fail to decrypt are flagged with decryptError: true and emit a PostHog decrypt_failure event (useAuth.js:127-130, 232-235). There is no UI path to retry unlock without a full sign-out, no surfaced count to the user, and no remediation copy beyond “contact hello@surfc.app” in useKeyManagement.js:200-203.
  • Transfer-code TTL enforcement upgraded to server-side (SUR-237): Migration 0014 added a DEFAULT ((extract(epoch from now()) * 1000)::bigint) to wrapped_key_blobs.created_at and a select_fresh_transfer_blob SECURITY DEFINER RPC that compares the stored created_at against the DB clock, not any device’s wall clock. redeemDeviceTransfer now calls this RPC instead of the old client-side TRANSFER_MAX_AGE_MS age check. The 60 s TTL window is therefore authoritative and clock-skew-proof. A tight 5-second upper bound (created_at <= server_now + 5000) prevents future-dated blobs from extending the window. Residual best-effort risk: the JS-side deactivation (is_active:false upsert after 60 s) still swallows errors — a device that goes offline immediately after generating a code leaves the row is_active:true until the next createDeviceTransfer call deactivates stale blobs. This is cosmetic: the RPC’s TTL window is the real gate. TRANSFER_SANITY_FUTURE_SKEW_MS (5 min) is retained in deviceTransfer.js as a defense-in-depth bound for absurd future timestamps on the client side.
  • No PWA update prompt: vite.config.js:21 registers VitePWA with registerType: 'autoUpdate' and no onNeedRefresh UI. New deploys eventually take effect on next page load, but users won’t be told a new version exists. Combined with the deprecated apple-mobile-web-app-capable meta tag noted in CLAUDE.md, the iOS standalone install path is the most exposed.
  • Accessibility gaps: Critical actions (edit/delete) depend on long-press detection via src/hooks/useLongPress.js; there is no keyboard shortcut, right-click context menu, or visible action button alternative for desktop or assistive-tech users. The bottom-nav-hidden state on capture/note views (SUR-238) intensifies this — escape paths are gesture-only on mobile.
  • db.js has no dedicated unit tests: Sync behaviour is covered transitively via src/test/{encrypt-sync,incremental-sync,export-import,outbox}.test.*, but the CRUD helpers (saveNote, updateNote, deleteBook cascade, mergeCloudRecords, importMerge/Replace) and the v1→v9 Dexie upgrade chain have no direct coverage. CLAUDE.md flags this as known debt; a regression in a migration step would only surface via integration tests.
  • Duplicate-source UX gap (SUR-257): The inline add-source drawer surfaces a duplicate hint for case-insensitive title+author matches but is non-blocking — users can still create a true duplicate if they Apply anyway. There is no merge or de-duplication path; orphan sources accumulate until a future cleanup tool exists.
  • Branded auth-email templates live in repo only (SUR-261, SUR-360): Both supabase/email-templates/magic-link.html (magic-link / 6-digit code) and supabase/email-templates/confirm.html (signup confirmation, added by SUR-360) are sources of truth in the repo but must be manually pasted into Supabase → Authentication → Email Templates. There is no CI step that diffs the dashboard against the repo, so a future template edit can silently fail to roll out. Two templates now share the same drift-risk shape; track each dashboard sync in the relevant PR description.
  • Auth dispatch boundary now depends on Resend SMTP (SUR-360): supabase/config.toml wires [auth.email.smtp] to smtp.resend.com:587 (sender hello@surfc.app), reading RESEND_API_KEY from Supabase Vault in production. The same key is also required by the approve-waitlist Edge Function — a single rotation event affects two surfaces. Locally, Inbucket on port 54324 intercepts every auth email regardless of the SMTP block, so a placeholder local-dev value is fine (supabase/.env.example). No monitoring, no alerting, no PostHog event on Resend send failures — operator visibility comes from the Resend dashboard only.
  • Auth dispatch boundary now depends on Cloudflare Turnstile (SUR-361): supabase/config.toml’s [auth.captcha] block enables Turnstile (provider turnstile, secret from SUPABASE_AUTH_CAPTCHA_SECRET); client bundles via VITE_TURNSTILE_SITE_KEY. With captcha enabled, GoTrue rejects every dispatch (signup / password / OTP send / recover) without a valid token as captcha_failed. There is no monitoring on Turnstile widget-load failures or token-issue rates — the only client-visible failure mode is the “Couldn’t verify your browser” copy in the email-OTP flow. Local dev uses Cloudflare’s documented always-pass test secret (1x0000000000000000000000000000000AA); production secret rotates separately from the Resend key. Three new env vars on the Auth boundary surface (RESEND_API_KEY, SUPABASE_AUTH_CAPTCHA_SECRET, VITE_TURNSTILE_SITE_KEY) — a missing one causes either a hard auth failure (captcha enabled, secret unset) or silent SMTP fallback to inbucket for local Supabase, which can mask broken production wiring during staging tests. Partial closure (SUR-371, 2026-05-20): the VITE_TURNSTILE_SITE_KEY case is now gated on two surfaces — the Vite plugin in vite-plugins/sur-371-turnstile-key-guard.js hard-fails npm run build in production mode when the var is unset, and EmailSignInFlow in src/components/AuthControls.jsx renders a visible role="alert" misconfig banner + console.error if a production build reaches the runtime without it. The 2026-05-10 incident (Netlify deploy without the var, 100% of email-OTP attempts silently rejected by GoTrue) cannot recur. The server-side cases (SUPABASE_AUTH_CAPTCHA_SECRET, RESEND_API_KEY) remain open with the same shape — those sit outside vite build’s scope and have no equivalent runtime detector beyond the existing supabase-error banner.
  • PostHog event rename: upgrade_gate_viewedauth_landing_viewed (SUR-370): SUR-352’s upgrade_gate_viewed event was renamed to auth_landing_viewed with an added surface: 'upgrade_gate' prop, and AuthScreen.jsx now also fires the same event with surface: 'authscreen'. Any saved PostHog insight, funnel, or cohort filter built against upgrade_gate_viewed between 2026-05-10 (SUR-352 ship) and 2026-05-11 (SUR-370 merge) will silently zero out. Pre-merge mitigation: grep saved PostHog insights for the old name and update in lockstep with the merge. The SUR-367 funnel rebuild is built directly against the new name and is not affected.
  • PostHog app_signup_started removed + auth_landing_viewed.intent fixed to 'signin' (SUR-711): SUR-711 retired the ?intent=signup framing and deleted the app_signup_started funnel anchor (founder: replaced by separate instrumentation). Any saved PostHog insight, funnel step, or cohort built on app_signup_started will silently zero out at merge — the SUR-367 signup funnel goes dark until the replacement lands; grep saved insights and flag the funnel owner in lockstep with the merge. auth_landing_viewed keeps firing but its intent dimension from surface: 'authscreen' is now the constant 'signin' (the value it organically carried once marketing stopped sending intent=signup), so the event shape is unchanged — only the now-absent 'signup' value disappears. Cross-repo with surfc-web (signupUrl()/signin).
  • UTM_KEYS duplicated across repo boundary (SUR-370): The seven canonical UTM/click-ID keys (utm_source/medium/campaign/term/content, gclid, fbclid) are defined in both surfc/src/lib/utmParams.js and surfc-web/src/scripts/preserveUtm.ts. There is no shared build artefact across the sibling repos so the duplication is structurally forced. Adding a new key (e.g. ttclid) requires a dual-edit with no compiler enforcement of sync. Both files cross-reference each other with a sync note (same pattern as the PRICE_COPY duplication in CLAUDE.md). Risk is missed-key attribution drift if one side is updated and the other isn’t.
  • handleSubscriptionDeleted does not check sub.id matches the profile’s stored stripe_subscription_id on the direct customer-id match path (SUR-351 narrow scope): SUR-351 added a skip_self_heal_subscription_id_mismatch guard to the metadata-fallback path in resolveProfileForSubscription, so a delayed customer.subscription.deleted carrying the same metadata.user_id as a newer subscription cannot clobber the live state. The same protection does not apply when findProfileByCustomer resolves directly: a delayed delete for an old sub_id on the same stripe_customer_id (e.g. user cancelled, re-subscribed on the same Stripe customer, then Stripe replays the older delete) would still flip subscription_status to 'canceled' over a live subscription. The pre-existing .neq('subscription_status', 'canceled') filter on the invoice-handler updates does not protect the delete handler. Bound and severity: only fires when (a) Stripe replays an old customer.subscription.deleted after the user re-subscribed on the same customer, and (b) the delivery lands. Probability is low at current scale; a future hardening would add stripe_subscription_id = sub.id to the UPDATE filter inside handleSubscriptionDeleted itself, covering both resolution paths uniformly. Track separately if it bites.
  • CI does not gate the changed Edge Functions in SUR-351: .github/workflows/edge-functions-test.yml only type-checks anthropic-proxy, approve-waitlist, delete-account, waitlist-signup and only runs waitlist-signup/handler.test.ts. Neither create-checkout-session nor stripe-webhook is type-checked or unit-tested in CI. SUR-351’s 50-test suite (14 new for race + self-heal + invoice payload shapes) was verified locally via deno test after installing Deno 2.7.14, but a future regression in either function would not be caught by the CI gate. Worth a follow-up to extend the workflow to cover all Edge Functions in supabase/functions/ (the list has grown — create-checkout-session, create-billing-portal-session, me-entitlements, image-upload, stripe-webhook, plus _shared/).
  • create-checkout-session customer self-heal — observability + unverified param assumption (SUR-501): create-checkout-session/index.ts now recreates a stale stripe_customer_id and retries the Checkout Session once on a Stripe resource_missing for the customer (motivated by the SUR-500 test↔live mode-mismatch incident). Two residual risks. (a) Mode-mismatch masking: if a test key is wrongly deployed to prod, every live customer 404s and self-heal would recreate them all as test customers — hiding the misconfiguration and divorcing users from billing history. The single retry (no loop) bounds the blast per request, but the only fleet-level signal is the [create-checkout-session] customer self-heal: <old> -> <new> log; there is no alert on its rate, so a spike should be wired to page someone. (b) param assumption: the heal-vs-surface decision (recreate the customer, but let a bad price surface unchanged) depends on the pinned stripe@^22 setting param: 'customer' vs line_items[0][price] on the error — asserted only against unit mocks and must be confirmed by the Stripe test-mode E2E (the open billing-reviewer HOLD on the code PR). Bounds: the clear is conditional on the observed stale id so the SUR-351 concurrent-clobber race does not reopen, and self-heal deliberately does not reconcile entitlements (a stale test-mode pro from SUR-500 step 4 is tracked separately). Like SUR-351, this Edge Function is still outside the CI gate.
  • Catch-all unauth <Navigate> must preserve search (SUR-370 invariant): src/App.jsx’s /* catch-all route now redirects unauthenticated visitors to /signin while preserving window.location.search. After SUR-711 retired ?intent=signup, this is load-bearing for ?open_email=1 (the UpgradeAuthGate deep-link) and UTM/click-ID attribution — without the search preservation, the redirect drops the query and AuthScreen never sees them. Future refactors of the route table that revert to plain <Navigate to="/signin" replace /> will silently break those paths with no test signal until a regression catches it in the marketing-side smoke; an explicit route-table test would be more durable. Marketing CTAs themselves now point at /signin directly (deep-link past the redirect) so the search-preservation is belt-and-braces for stray callers.
  • device_label is immutable after enrolment (SUR-233): wrapped_key_blobs.device_label is set once by getDeviceLabel() at passkey-enrolment time and is never updated. If a user renames their device at the OS level, or if the UA changes (e.g. browser update changes its user-agent string), the stored label stays stale. This is cosmetic-only (display in LinkedDevicesModal) — the underlying crypto is unaffected. A future edit path would require an authenticated PATCH to wrapped_key_blobs scoped by user_id.
  • Add-idea Free-tier cap not yet enforced (SUR-235 TODO): The “Create” CTA in AddIdeaSheet (src/components/AddIdeaSheet.jsx) has a TODO(SUR-235) comment noting that it should be gated by useEntitlements() for Free-tier custom-idea cap + Pro upsell. Until SUR-235 ships, all users can create unlimited custom ideas from both Profile and the note-form sheet.
  • In-app help articles bundled at build time (SUR-303, 2026-05-04): src/help/manifest.js reads docs/getting-started/*.md via import.meta.glob({ eager: true }). Changes to help article content require a full Vite rebuild and redeploy before they appear in the app. This creates a two-step publish process: edit the markdown file → commit → trigger a new build (same constraint as the surfc-web/ blog). There is no hot-update or CMS path. The FORWARD_REFERENCES set in manifest.js must be kept in sync with docs/.vitepress/config.mjs’s ignoreDeadLinks list when new articles are added.
  • Approved waitlist rows with user_id IS NULL are stranded for email-OTP (SUR-261): supabase/migrations/0007_waitlist_requests.sql:74-89’s match_waitlist_on_signup trigger only back-fills user_id on auth.users INSERT. A waitlist row approved before the user ever signed up has no matching auth.users row, and email-OTP’s shouldCreateUser:false (in requestEmailOtp) refuses to create one. These users must accept a Supabase Auth invite (dashboard or /auth/v1/invite) before email-OTP signin works for them. Audit query: SELECT count(*) FROM public.waitlist_requests WHERE status='approved' AND user_id IS NULL;. No automated alert.
  • Brand migration is partial — name is Braird, identity/infra still surfc.app (SUR-675): The app now presents as Braird — manifest name/short_name, <title>, apple-mobile-web-app-title, icons, the design palette (amber → forest/shoot-green), the home/sign-in wordmark lockup, and the encryption-gate wordmarks + WebAuthn display strings (PRs #323 surface / #324 crypto-adjacent). But the underlying identity is unchanged: the WebAuthn RP_ID, the app.surfc.app origin, the auth-email templates (supabase/email-templates/{magic-link,confirm}.html — still “Surfc” + the old amber #9a6a3a), and many surfc code identifiers. A user therefore sees mixed branding (e.g. a Surfc-branded sign-in email landing them in a Braird app) until the domain cutover (SUR-692) and the string/env purge (SUR-680) complete. Cosmetic note: the wordmark renders in EB Garamond (--font-serif), not the brand Lora 500 — no self-hosted Lora upright yet. No functional risk; brand-coherence gap for the migration window.
  • Live Privacy/Terms are stock Termly with material legal inaccuracies (SUR-618/619): The published policies are Termly-generated and contradict how Surfc works — they claim “we do not process sensitive personal information” (users photograph notes that can carry special-category data), grant Surfc a broad perpetual UGC licence (Surfc cannot read E2EE note text and there is no public surface), and assert US data-location / “consent to transfer to the US” (primary data is EU/Supabase, controller Dipeolu Innovations is Swiss, governing law is Switzerland). In-house rewrites are in progress — SUR-618 (Privacy) and SUR-619 (Terms) — each spine + lawyer-gated (legal-copy-reviewer persona + Zac Kuyinu + founder sign-off, no unilateral merge). The current iframe is also client-rendered, noindex, not crawlable, with CLS — owning the text in-repo fixes that too.
  • Termly is a load-bearing dependency with sequenced removal (SUR-618 → 621): Termly supplies both the policy text (embed IDs 3269e493… privacy / f893c672… terms) and the consent banner / resource-blocker (135b750f…) that gates PostHog across both surfc/index.html and surfc-web/src/layouts/BaseLayout.astro. Removal is deliberately ordered so there is never a window with no published policy or no consent banner: internalize policy text (SUR-618/619, reuses the HelpArticleBody markdown renderer) → migrate consent to self-hosted Klaro (SUR-620, cross-repo lockstep) → decommission Termly (SUR-621, last). The consent banner — and the SUR-516 “Turnstile blocked by Termly until cookies accepted → email sign-in stalls” bug — stay live until SUR-620.
  • SUR-209 (P1) — broken in-app help links: Fully resolved (SUR-223 Phase 1 + SUR-303 Phase 2, 2026-05-04). Phase 1 (SUR-223, 2026-04-25) added https://help.surfc.app cross-domain links in ProfileScreen and SettingsModal. Phase 2 (SUR-303, 2026-05-04) shipped the in-app renderer: /helpHelpCenterScreen, /help/:slugHelpArticle. Both read docs/getting-started/*.md at build time via src/help/manifest.js. Cross-domain fallback links remain for any future slugs not yet in the manifest’s ARTICLE_ORDER.
  • Direct Anthropic exposure (BYOK): Resolved (SUR-91). All AI calls now go through the managed Edge Function via api.jsinvokeAnthropicProxy. The only remaining BYOK trace is a one-time apiKey meta cleanup in db.js:156.
  • Schema probe only runs once: Resolved (SUR-61). useAuth.js:166-176 now sets schemaProbed.current = true only after a successful probe; failures leave the flag false so the next sync attempt re-checks.
  • Full-table sync only: Resolved (SUR-62). fetchAllCloud(since) (supabase.js:99-106) is wired through lastSyncRef.current, with a backfill pass for missing books in useAuth.js:189-210.
  • No OCR regression coverage: Resolved. src/test/api.test.js, src/ingest/__tests__/photoAdapter.test.js, and src/test/capture.test.jsx cover transcription happy/error paths and case-1/2/3 image-attribution branches.
  • How-It-Works placeholders: Resolved — HowItWorksPage.jsx and LandingPage.jsx were removed by SUR-215 (2026-04-23). Marketing content and the waitlist live entirely in surfc-web/.

Key assumptions to validate

  • Dexie ↔ Supabase lockstep is human-driven. scripts/check-schema.js verifies that columns/policies/buckets exist on Postgres, but Dexie’s v1→v9 migration chain in db.js is not cross-checked against the contract. A new column in notes requires both a Supabase migration and a Dexie version(n+1) block plus the mergeCloudRecords mapping. We assume the PR author remembers all three.
  • Free-tier monthly cap is per-user via user_profiles.month_limit (SUR-92), default 50. The Edge Function resolves it via getResolvedMonthlyLimit on every call; an additive allocation_override (SUR-230 admin tool) raises the cap when valid. getMonthlyUsage sums request_count across all action types — transcribe and discover draw from the same shared monthly cap (cross-action enforcement fix, 2026-04-29). Per-token cost is not yet correlated with ai_usage_daily.input_tokens/output_tokens in any dashboard.
  • ANTHROPIC_API_KEY rotation is a manual operator task — there is no client-side hint of expiry; a rotated-then-misconfigured key surfaces as a blanket 500.
  • VitePWA autoUpdate is acceptable — registered without an explicit refresh prompt (vite.config.js:21). We assume users will hit a hard reload before encountering a stale-cache bug from a deploy mid-session.
  • Hard-deleting a book + cascading note tombstones is OK even when image upload races fail. handleDeleteBook in useNoteForm.js:110-116 only filters local React state for child notes; the cascade tombstone happens inside the Dexie transaction in db.js:185-192. We assume orphaned storage objects are tolerable until a future cleanup sweep.
  • The Supabase Edge runtime is on a single Anthropic API key with no per-org isolation and no project-level spend cap. We assume project-level usage from posthog-node plus the per-user ai_usage_daily table is enough early-warning for cost spikes; no automated alert exists.
  • Azure Content Safety severity threshold ≥5 is calibrated for Surfc content (SUR-242): The moderate() harm classifier in guardrail.ts blocks on severity ≥5 for Hate/Violence/Sexual/SelfHarm. This threshold was selected in the SUR-242 spike against a representative Surfc note corpus (book annotations, philosophical passages). It may be too permissive for user-generated content at scale (severity 4 harms pass through) or too aggressive for literary/historical passages that trigger moderate harm scores out of context. Validate against real-world false-positive rates once managed usage exceeds ~500 calls/month and adjust before v2.0.
  • Future ingest adapters (Readwise/Kindle) will reuse the existing ingest interface without backend changes (src/ingest/index.js, photoAdapter.js, manualAdapter.js). The Dexie source enum ('manual' | 'image' | 'readwise' (future) | 'kindle' (future)) is documented in db.js:43.
  • The CaptureFabMenu speed-dial (SUR-238) provides sufficient navigation coverage on the capture/note views now that the bottom nav is hidden on those screens. If user testing reveals confusion, a dedicated back-button or persistent minimal nav will be needed.
  • The in-app help renderer (SUR-303) is the primary help surface. The cross-domain https://help.surfc.app (VitePress, Cloudflare Pages) remains a fallback for slugs not yet in the in-app manifest’s ARTICLE_ORDER. If a user follows a help.surfc.app link from the VitePress site, they land on the public site; if they follow /help/<slug> from inside the app, they get the in-app renderer. Both surfaces read the same docs/getting-started/ markdown sources.
  • session.access_token expiry is handled by supabase.auth.getSession() calling _callRefreshToken immediately before each Edge Function invoke (supabase.js:268-277, 351-360). This is the documented mitigation for backgrounded-PWA “Invalid JWT” errors and is assumed to cover all Functions-gateway expiry races.
  • Email-OTP error-string mapping is best-effort (SUR-261): src/AuthScreen.jsx matches Supabase Auth’s Signups not allowed for otp substring to render a friendly waitlist CTA when shouldCreateUser:false rejects an unknown email. We assume that exact wording stays stable across Supabase Auth versions; if it ever changes, the user falls back to the generic “Sign-in failed” copy — a UX regression, not a security regression. No automated test pings the live Supabase Auth response shape.
  • Email signups stay disabled at the Supabase platform level: Per the migration playbook (0007_waitlist_requests.sql:26-28) and confirmed 2026-04-26. SUR-261’s requestEmailOtp pins shouldCreateUser:false as defence-in-depth, but if both protections were removed simultaneously the trigger would still leave new users without a waitlist_requests row — they would be invisible to the admin queue and stuck in PendingApprovalScreen. We assume the dashboard toggle is not flipped without a deliberate SQL playbook change.
  • Email-OTP code length and expiry are Supabase defaults (6 digits / 1 hour): requestEmailOtp uses signInWithOtp without overriding token settings. Adequate for personal-scale usage; revisit if abuse signals appear.
  • isStandaloneOrTwa() decides OTP-code vs magic-link delivery once at component mount (SUR-261): src/lib/platform.js checks display-mode: standalone and document.referrer once when the email step renders. We assume the platform identity does not change inside a session (a user does not move from a TWA-installed surface to a normal browser tab mid-flow). True for all current install paths; revisit if iOS or Android ever expose hot-swapping.
  • OAuth /authorize is captcha-exempt by design (SUR-361): Cloudflare Turnstile is wired in EmailSignInFlow only, NOT at the AuthScreen top level. Two reinforcing reasons: (1) GoTrue only enforces [auth.captcha] on dispatch endpoints (signup / password / OTP send / recover) — /authorize is a redirect to the OAuth provider and is not gated; (2) supabase-js v2.100.0’s signInWithOAuth silently drops options.captchaToken (forwards only redirectTo / scopes / queryParams / skipBrowserRedirect to _handleProviderSignIn — see node_modules/@supabase/auth-js/dist/main/GoTrueClient.js:669-677 and :2030-2042). We assume both invariants hold across supabase-js minor bumps; if a future supabase-js release does serialise captchaToken into the OAuth flow, the OAuth path silently becomes captcha-gated and the AuthScreen UX needs to react. Pin/audit on supabase-js bumps.
  • Resend SMTP sender hello@braird.app (SUR-673) needs DKIM/SPF/DMARC on braird.app: supabase/config.toml’s [auth.email.smtp] block lists admin_email = "hello@braird.app" as the production sender (was hello@surfc.app pre-SUR-673). We assume the Resend → DNS records are configured and warmed for the braird.app domain (SUR-669) so confirmation/magic-link emails do not land in spam or bounce. The branded templates reach prod only via the manual dashboard paste, and the From identity is owned by SUR-674. Verify Resend dashboard → Domains shows green DKIM + SPF + DMARC before the first prod test send. No automated test pings deliverability.
  • WebAuthn RP_ID = 'surfc.app' survives the Braird rebrand (SUR-675 / SUR-692): Existing passkeys are scoped to the registrable domain surfc.app. The rebrand changed only display-layer strings — rp.name and the OS credential label became “Braird” (PR #324, crypto-reviewer PASS) — while RP_ID and the surfc-* HKDF/PRF info constants were left untouched. We assume RP_ID is not changed before the SUR-692 domain cutover; doing so would orphan every enrolled passkey and lock users out of their E2EE master key. A future move to app.braird.app must be a deliberate, migration-planned change, not a find-replace.
  • The in-house policy rewrites will be lawyer-reviewed before publish (SUR-618/619): We assume no policy markdown is merged or rendered to users without legal-copy-reviewer + Zac Kuyinu + founder sign-off. The drafts encode how the app actually works (E2EE; primary data EU/Supabase; named sub-processors — Anthropic, Azure AI Content Safety, Supabase, Stripe, PostHog, Resend, Cloudflare), but several positions are explicitly deferred to the lawyer: the EU digital-consent age floor, whether an Art. 27 EU representative is needed (controller is in CH), arbitration-clause suitability under EU consumer protection, and whether the “~1 month of account termination” retention claim holds including Supabase backups / soft-deletes.

Unknowns / open questions

  • How are Supabase credentials and ANTHROPIC_API_KEY managed in Netlify and Supabase Edge environments — manual env vars, secrets manager, or a vault — and what is the rotation cadence?
  • Is there any monitoring or alerting for sync failures or Edge Function errors? syncStatus is client-side only; the server-side console.log-based Edge Function logging is not piped to PagerDuty/Slack/email.
  • Is there a project-level Anthropic spend cap, or only the per-user ai_usage_daily counters? What happens if a single account hits the per-user 50/month default (or a higher allocation_override) while many others ramp simultaneously? Per-token cost is not yet correlated with ai_usage_daily.input_tokens/output_tokens in any dashboard.
  • Should exports include Supabase storage paths or binary blobs to guarantee a fully portable backup? Today buildExport (db.js:301-303) emits text-only books/notes/customIdeas with imagePath but no embedded image data, so an exported JSON cannot reproduce attachments after account deletion.
  • What is the SLA for clearing orphaned note-images objects on accounts that are still active (deleted notes, replaced images)? deleteCloudData only fires on account deletion.
  • Does multi-device editing need conflict UI beyond last-write-wins? mergeCloudRecords (db.js:253-295) silently picks the higher updated_at and there is no surfaced “your edit was overwritten” hint.
  • Should background sync (service worker sync events) exist so queued writes flush without opening the app? Today the outbox only flushes on the online event or a manual sync trigger.
  • How do we recover an account whose passkey was destroyed and which never created a transfer code? The current copy points to “the device where you originally enabled encryption” but offers no support-side reset; data becomes unrecoverable by design.
  • Is there a deletion-success audit trail beyond the in-flight delete-account 200 response? If auth.admin.deleteUser succeeds but the client crashes before finalizeAccountDeletion, the user sees a stale session with no Supabase row to re-authenticate against.