Skip to content

U7 — Auth/Profile/Settings + DM: Adversarial UI/UX Audit

Status: Archived Date: 2026-07-11 Scope: onboarding/login (packages/app/src/presentation/screens/auth/login-screen.tsx, packages/features/onboarding/src/**, packages/app/src/adapters/supabase/auth.supabase.ts, session-provider.tsx), profile + settings + consents + account lifecycle (packages/features/profile/src/**), DM (packages/features/messaging/src/**, packages/app/src/presentation/hooks/{queries/use-dm.ts,mutations/use-dm-mutations.ts,mutations/use-safety-mutations.ts}), push-permission soft prompt (packages/features/sessions/src/push-permission-soft-prompt-sheet.tsx). Method: 3 parallel evidence sweeps (auth/onboarding, profile/settings/consents/lifecycle, DM) — direct code reads + repo-wide greps + migration cross-reference (00107, 00220, 00226, 00228, 00294, 00184/00186) + canon diff (docs/canon/networking.md, docs/canon/notifications.md, docs/canon/korean-market.md) + docs cross-check (docs/external-blockers.md, docs/current-project-state.md). No code modified.


Executive-severity ranking

#FindingSeverity
1OAuth is unreachable from the UI. LoginScreen is email/password-only with a "Kakao/Google 로그인은 곧 추가돼요" disclaimer; Kakao is code-complete but has no button; Google/Apple adapters are stubs that unconditionally throwCRITICAL (launch gate)
2signal_preferences (per-category in-app/push/minSeverity/quietHours) has ZERO settings UI. Route '/settings/signal-preferences' is defined but dead (no screen file, no push call); quiet-hours i18n keys scaffolded but unused; adapter updatePreference() has zero feature consumers. Alert-plan Phase D deferral confirmed accurateCRITICAL (feature gap, deferred by design — now documented precisely)
3The generic "푸시 알림" toggle silently grants/revokes the marketing_push PIPA consent purpose — a legal consent action hidden behind an unlabeled general toggle, inconsistent with the explicit consent-management screen writing the same purpose keyHIGH (compliance ambiguity)
4OAuth cancel is silently swallowed and OAuth failure renders no UI — cancel resolves null → bare return; failure sets ctx.error which no screen reads (email path re-throws for surfacing; OAuth path never got the same treatment)HIGH (latent — regression trap for Phase 8c)
56 of 9 user_preferences notify booleans have no toggle anywhere (notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifyBoardPosts, notifyAchievements — entity + i18n labels exist, zero Switch rows)HIGH (settings gap)
6DM inbox 15s-poll doc/code drift — canon + screen header + a hook docstring all claim a 15s inbox refetchInterval that does not exist in useDmThreads/useDmThreadsByStatusMEDIUM (canon drift)
7Login screen has raw inline-styled TextInputs + SafeAreaView — full manual style objects on the single most-trafficked screen, no justification commentsMEDIUM (canon)
8Empty DM inbox has no actionable path to a 1:1 conversation — empty-state copy says "message a club member" but passes no onAction CTA, and the header "+" only creates group chatsMEDIUM (UX)
9Deleted/unresolvable DM partner shows header "메시지" (generic inbox title) instead of the existing unknownUser ("알 수 없음") string used elsewhere in the same file; no "deactivated account" indicator during the 30-day soft-delete grace windowMEDIUM (edge case)
10Change-password "current password" field is cosmetic — collected and required for the submit gate but, per the screen's own header comment, "not validated against current" — false-assurance patternMEDIUM (UX/security perception)
11Expired session lands the user on the login screen with no "session expired" messaging — the screen has no state reflecting why it's showingLOW-MEDIUM
12No profile-completeness nudge — missing avatar/nickname get passive placeholders only, no "add a photo" CTA anywhereLOW (UX)
13Phone number: entity + UpdateProfileInput.phone + korean-phone.ts (libphonenumber-js wrapper) all exist, but there is zero phone-capture UI in onboarding/profile/settings — the KR-1 canon has nothing to apply to in this blockLOW (informational)
14Verified-identity signup (KR 본인인증) is not implemented anywhere — nationality_code is a hardcoded 'KR' default with a forward-looking migration comment; production Kakao IdP itself is blocked on EXT-11 credentialsLOW (roadmap reality check)
15AuthServicePort docstring claims "Universal OAuth sign-in via browser redirect" but the adapter uses per-provider native SDKs + signInWithIdToken (OIDC token exchange), not the PKCE browser-redirect flow — stale/aspirational port docLOW (doc drift)
16DM: no typing indicators, no message editing (delete-only, 60s window, DB+client enforced) — absent features, not bugs; noting for expectation-settingLOW (informational)

What works well (evidence):

  • Push-permission soft-prompt canon claim VERIFIED accurate. All three trigger sites are RSVP-mutation onSuccess callbacks in session-detail-screen.tsx:677-681,740-746,805-809, gated on notifPermStatus === 'undetermined'; the OS prompt (Notifications.requestPermissionsAsync, notification.expo.ts:250) only fires from the sheet's accept CTA. Repo-wide grep confirms no earlier firing point (not onboarding, not _layout.tsx). And despite its filename, push-permission-soft-prompt-sheet.tsx:56-58 uses ModalPanel, not Sheet.
  • User blocking is fully implemented, not a gap: user_blocks table + RLS (00294_dm_block_and_report.sql:26-43), block_user/unblock_user/get_blocked_users/ report_content RPCs (00294:91-162), bidirectional initiate_dm_thread guard (00294:186-189), trg_dm_block_guard BEFORE INSERT send guard (00294:227-255), useBlockUser/useUnblockUser (use-safety-mutations.ts:22-44), thread-header block/report menu (dm-thread-screen.tsx:1404-1457), inbox filtering (dm-inbox-screen.tsx:243-255,323-335), group-thread message hiding (dm-thread-screen.tsx:1004-1008), plus a dedicated settings-blocked-users-screen.tsx with unblock.
  • dm_push_preview_enabled is fully wired end-to-end (NOT dead): column (00226_dm_message_push.sql:53-59, default false) → mapper (preferences.mapper.ts:28-31,54-55) → Switch row (settings-screen.tsx:533-543) → mutation (settings-screen.tsx:313-321) → edge-function gate (send-push/index.ts:198-226: preview truncated to 50 chars when true, generic '메시지를 보냈어요' when false — PII-safe default).
  • No Supabase Realtime channels anywhere in DM (grep for .channel(/postgres_changes = zero hits); thread-screen 5s poll correctly focus-gated (use-dm.ts:142-143: refetchInterval: isFocused ? 5_000 : false). Registry discipline clean (use-dm.ts:4, use-dm-mutations.ts:15 import from @/registry; zero direct supabase imports in packages/features/messaging).
  • Consent architecture is genuinely strong: onboarding consent step renders per-jurisdiction purposes with required/optional prefixes + policy links (onboarding-step-consent.tsx:44-61,139); settings-consent-management-screen.tsx gives PIPA §37 revocation (optional+granted only, confirm dialog, required-consent → delete-account pointer); consent-history-screen.tsx is a PIPA §39-7 audit trail. Logout uses useConfirm (ModalPanel-based confirm-sheet.tsx) at both entry points (profile-screen.tsx:127-140, settings-screen.tsx:323-336). Account deletion exists with real friction: warning card → typed "탈퇴합니다" phrase gate → double-confirm → soft-delete + 30-day anonymization (delete-account-screen.tsx:1-12,52-79,89-109,153-158).
  • i18n coverage is clean across the whole block — zero hardcoded UI copy in onboarding, login, profile, settings, or DM JSX (only comments and the intentionally-hardcoded OWNER-owned legal pages terms-screen.tsx/privacy-screen.tsx). Pressable used throughout DM (zero TouchableOpacity). DM message deletion is defense-in-depth (client 60s check dm-thread-screen.tsx:1347-1349 + RLS 00220_dm_threads.sql:166-170).
  • Onboarding wizard styling is clean (only documented STYLE-1 SafeAreaView/pager escape hatches); all copy through t().onboarding.*.

1. CRITICAL — OAuth is unreachable from the UI; Google/Apple are throw-stubs

Evidence:

  • packages/app/src/presentation/screens/auth/login-screen.tsx is the ONLY login screen (apps/mobile/app/_layout.tsx:178 renders it for authState === 'unauthenticated'). It is email/password-only; no Kakao/Google/Apple buttons exist in its JSX. Header comment (login-screen.tsx:4-8): "Phase 8b MVP … Future (Phase 8c): OAuth providers." The screen shows the disclaimer oauthComingSoon = 'Kakao/Google 로그인은 곧 추가돼요' (login-screen.tsx:155-159, i18n/ko/common.ts:97).
  • Adapter status (packages/app/src/adapters/supabase/auth.supabase.ts): Kakao (50-77) implemented via native SDK → signInWithIdToken but has no UI entry point AND production credentials are blocked (EXT-11, docs/external-blockers.md:33). Google (87-100): throw new DomainError('Google 로그인은 아직 준비 중이에요', 'AUTH_GOOGLE_NOT_IMPLEMENTED'). Apple (110-132): throws AUTH_APPLE_NOT_IMPLEMENTED (or AUTH_APPLE_IOS_ONLY first).
  • Dev email login is env-gated (login-screen.tsx:37-39,118-139, EXPO_PUBLIC_DEV_EMAIL/_PASSWORD).

Impact: the live sign-in surface is exactly email/password + dev login. This directly contradicts the "Unified OAuth via Supabase" memory framing for the live product, and Apple's App Store guideline 4.8 (Sign in with Apple required when offering third-party login) becomes a hard gate the moment Kakao ships without Apple. Launch-blocking work, correctly staged as Phase 8c — but the audit must record that today's UI has no social login at all.

2. CRITICAL — signal_preferences per-category notification preferences have ZERO UI

Evidence:

  • DB: supabase/migrations/00107_signals_system.sql:213-311public.signal_preferences (user_id, category ×13, in_app_enabled, push_enabled, min_severity, quiet_hours, allow_critical_bypass), per-user defaults seeded via signal_preferences_seed_defaults() trigger (line 272, default INSERT line 311).
  • Adapter methods exist: signal.supabase.ts:167-186 (findPreferences, updatePreference).
  • Exhaustive grep (useSignalPreferences|SignalPreferences|signalPreferences across packages apps): the ONLY feature consumer is notification-center-screen.tsx:350,366 (block U1) — read-only, used to filter the feed. No updatePreference() call exists in any packages/features/** file.
  • Dead scaffolding: routes.ts:111-112 defines signalPreferences: '/settings/signal-preferences' but no screen file exists in apps/mobile/app/settings or apps/web/app/settings, and no appRouter.push(routes.signalPreferences) exists anywhere. Quiet-hours i18n keys (ko/settings.ts:271-274, en/settings.ts:270-273) are defined and unused.

Verdict: the audit-plan line 22 deferral ("Phase D … deferred by design") is accurate — but the deferral's precise shape is: schema + resolver + read-path shipped; the write-path UI (screen, mutation wiring, route mount) is 100% absent while a route constant and i18n strings imply it was started. When Phase D lands it should claim the existing route + i18n scaffolding rather than minting new ones.

Evidence: settings-screen.tsx:273-301 (handleTogglePush, bound to the row labeled rowPushNotifications: '푸시 알림', ko/settings.ts:766):

ts
logConsent.mutate({ userId, category: 'system', channel: 'push', granted: next });
if (next) {
  grantConsent.mutate({
    purposeKey: 'marketing_push',
    jurisdiction: 'KR',
    channel: 'push',
    collectionMethod: 'settings_toggle',
    userId,
  });
} else {
  revokeConsent.mutate({ purposeKey: 'marketing_push', jurisdiction: 'KR', userId });
}

The row's label says nothing about marketing/광고, yet toggling it ON grants the PIPA marketing_push purpose (and OFF revokes it). Meanwhile settings-consent-management-screen.tsx (lines 108-126, 147-167) manages the same purpose key with explicit "마케팅 푸시 알림" labeling and a confirmed 철회 flow.

Impact: (a) a user enabling general service pushes unknowingly opts into marketing — a consent captured without informed specificity is a PIPA §22 problem, not just a UX one; (b) two surfaces write the same purpose with inconsistent framing, so the consent-history audit trail records grants the user never knowingly made. The general toggle should control service push only (§50-exempt per canon) and never touch the marketing_push purpose; marketing consent belongs exclusively to the explicitly-labeled surfaces.

4. HIGH (latent) — OAuth cancel/failure produce zero user feedback

Evidence:

  • Cancel: auth.supabase.ts:150-173 catches code === 'CANCELLED' || 'AuthFailure' → returns null; session-provider.tsx:390-394 then bare-returns — no state change, no toast.
  • Failure: any other error is re-thrown, caught at session-provider.tsx:402-409, which sets ctx.error + logs to Sentry — but no consumer in the auth scope reads error from useAuth() (use-auth.ts:20,35). login-screen.tsx only tracks its own local errorMessage from the email path.
  • The asymmetry is deliberate-looking but one-sided: signInWithEmail (session-provider.tsx:430-439) explicitly re-throws "so LoginScreen's catch can surface the error to the user" — the OAuth path was never given the same treatment.

Impact: currently dead code (no OAuth buttons — finding #1), which is exactly why it's dangerous: the moment Phase 8c wires a Kakao button, cancel = the screen sits inert, and real failures (no idToken because OIDC unchecked in Kakao Console — auth.supabase.ts:56-61 — network errors, SDK errors) are Sentry-only. Fix must ship WITH the button, not after.

5. HIGH — 6 of 9 notify booleans on user_preferences have no toggle

Evidence: preferences.entity.ts:44-54 defines notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifyBoardPosts, notifyAchievements with i18n labels (ko/settings.ts:49-62 + en). settings-screen.tsx wires exactly 3 Switch rows: notificationsEnabled (509-519), notifySessionReminders (521-531), dmPushPreviewEnabled (533-543). The other 6 exist in DB/schema/i18n with zero UI.

Impact: independent of (and older than) the signal_preferences gap — two parallel half-finished preference systems (user_preferences booleans vs signal_preferences categories) now both lack UI, and any Phase D design must decide which one is canonical rather than adding a third. A user who wants dues reminders but not board-post pushes has no lever anywhere.

6. MEDIUM — DM inbox "15s poll" exists in three comments but not in code

Evidence:

  • Claim ×3: docs/canon/networking.md:25 ("thread screen 5s, inbox 15s"), dm-inbox-screen.tsx:8 ("useDmThreads (15 s native refetchInterval)"), use-dm.ts:152 ("covered by useDmThreads' 15 s poll").
  • Reality: useDmThreads (use-dm.ts:26-33) and useDmThreadsByStatus (use-dm.ts:43-64) have no refetchInterval — and their own docstrings say so deliberately (use-dm.ts:23-24: "intentionally does not poll"; 40-41: infinite-page refetch trap). Inbox freshness = push invalidation + focus/mount refetch.
  • Thread screen is correct: use-dm.ts:142-143 refetchInterval: isFocused ? 5_000 : false.

Impact: the code's no-poll rationale is sound (refetching every loaded infinite page per tick is a real perf trap) — so this is a documentation fix: canon doc + screen comment + the useDmUnreadCount docstring must be rewritten to describe the actual freshness model, or an unread-count-only lightweight poll added if inbox staleness proves real on device. Never "fix" this by adding the interval to the infinite-page hooks as documented.

7. MEDIUM — Login screen raw inline-styled inputs (Tamagui canon)

Evidence: login-screen.tsx:65 (<SafeAreaView style={{ flex: 1, backgroundColor: bg }}> — variable in style), and login-screen.tsx:82-91,99-108 — two raw TextInputs with full manual style objects (backgroundColor/borderColor/borderWidth/borderRadius/padding/fontSize/color), no justification comments. Contrast: delete-account-screen.tsx:14,168-178 and change-password-screen.tsx:13,240-242 use raw RNTextInput + StyleSheet.create with inline justification ("Tamagui's compiler cannot extract styles applied directly to react-native TextInput") — the sanctioned pattern the login screen predates.

Impact: highest-traffic screen sits outside the theming system; also the three inputs across these screens should share one blessed RNTextInput wrapper rather than three hand-rolled style blocks (single-source-of-truth).

8. MEDIUM — Empty DM inbox: copy promises an action the UI doesn't offer

Evidence: dm-inbox-screen.tsx:290<EmptyState icon={MessageSquare} title={strings.noMessages} variant="compact" /> where noMessages = '아직 대화가 없어요. 클럽원이나 픽업 호스트에게 먼저 메시지를 보내보세요.' EmptyState supports actionLabel/onAction (packages/ui/src/empty-state.tsx:21-23,147-160) — not passed. The header "+" (dm-inbox-screen.tsx:417-419,427-432) routes to routes.dmCreateGroup() only; 1:1 threads are created exclusively from other surfaces (profile pages via useInitiateDmThread).

Impact: first-run DM user is told to message someone with no path to do it from where they're standing. Toss filter: the empty state should carry the task (e.g. CTA into club members list or a recipient picker).

9. MEDIUM — Deleted-partner thread header falls back to the wrong string

Evidence: dm-thread-screen.tsx:1151-1153:

ts
const headerTitle = isGroup ? (thread?.title ?? strings.newGroup) : otherName || strings.inboxTitle;

When otherParticipant is unresolvable (mapper null-safety: dm.mapper.ts:122-133 yields undefined), otherName is '' → header shows inboxTitle ('메시지'). The correct string unknownUser ('알 수 없음') exists in ko/dm.ts and is already used for message-sender fallback in the same file (dm-thread-screen.tsx:951). Additionally, during the 30-day soft-delete grace window (00186:67-72 sets deleted_at; anonymization to '탈퇴한 회원' runs later, 00184:78-93) the partner appears fully normal with no deactivated indicator.

10. MEDIUM — Change-password current-password field is decorative

Evidence: change-password-screen.tsx:5 (header comment): "현재 비밀번호 (shown for user reassurance; not validated against current)". The field gates isValid (line 96) and is passed to changePassword.mutate({ currentPassword, newPassword }) but nothing verifies it (Supabase updateUser doesn't take the old password). A wrong current password still succeeds — false-assurance UX; either verify via signInWithPassword re-auth before update, or drop the field and say "logged-in session required."

11-16. Lower-severity items (compact)

  • #11 Expired session, no messaging: proactive refresh (60s interval, 5-min threshold, 3-failure force-signout, session-provider.tsx:297-345) + reactive guard (351-378) both land on <LoginScreen /> via authState with zero "세션이 만료되었어요" context.
  • #12 No completeness nudge: repo-wide grep for profile-completion prompts = zero. Missing nickname → '이름 없음' fallback (profile-hero-card.tsx:163-164, ko/settings.ts:693; near-unreachable since display name is a hard onboarding gate, 2–20 chars, onboarding-step2-display-name.tsx:4-5,18-21). Missing avatar → initial-letter AvatarBubble (avatar-placeholder.tsx:8-10), no CTA.
  • #13 Phone: Profile.phone + UpdateProfileInput.phone exist; korean-phone.ts + no-direct-libphonenumber ESLint rule enforce KR-1 at tooling level; zero capture UI in onboarding (no phone step in packages/features/onboarding/src/) or profile/settings. Daum postcode: N/A — no address field exists in this block (sole "Daum" hit is legal text, privacy-screen.tsx:9,136).
  • #14 Verified identity: 00228_profiles_nationality.sql:18-22nationality_code NOT NULL DEFAULT 'KR', comment defers population to future verified-IdP onboarding; no picker by design (UpdateProfileInput has no nationalityCode). Club affiliation as a profile attribute exists nowhere (UI or DB) — matches "flat nationality now, groups held" (docs/architecture/foundational-schema-decisions.md:66).
  • #15 Port doc drift: auth.service.port.ts:20-21 claims browser-redirect universal OAuth; adapter (auth.supabase.ts:150-173) dispatches to native SDKs + signInWithIdToken (OIDC token exchange). PKCE flowType on the client (client.ts:81, client.web.ts:57) governs email/magic-link/web flows, not this path.
  • #16 DM absent features: no typing indicators (grep-confirmed), no message editing — delete-only within 60s, enforced client (dm-thread-screen.tsx:1347-1349) + RLS (00220:166-170). Reactions exist (6-emoji quick row, dm-thread-screen.tsx:100,111-137). Read receipts: per-message 읽음/전송됨/전송 중 on sender's last message (dm-thread-screen.tsx:1019-1026) + unread dot per row (dm-inbox-screen.tsx:161-170).
  • Onboarding friction note (not filed as broken): optional 3-card value-prop pager (skippable, onboarding-value-props.tsx:230-232; showValueProps is unpersisted local state so it reappears on every pre-completion mount) + 6 hard-gated wizard steps (onboarding-wizard-screen.tsx:62-68): welcome → birth year → consent (under-14 gate has no forward path by design) → display name (mandatory) → play style (mandatory) → done. Six mandatory steps is heavy by Toss standards, but each gate is load-bearing (age/PIPA/identity); candidate trim: fold play-style into a post-onboarding prompt.
  • Near-miss (not a violation): use-dm-mutations.ts:20 imports isAlreadyAcceptedError from @/adapters/supabase/idempotency inside presentation — a pure error classifier, not a client instance, but it is an adapter-layer import in presentation worth normalizing (move to a shared util or re-export via registry).

Adversarial probes run

  • Traced the full OAuth call chain UI → useAuthsession-providerAuthServicePortauth.supabase.ts per provider, specifically hunting the cancel/failure feedback paths; diffed the email path's deliberate re-throw against the OAuth path's swallow-into-context.
  • Grepped requestPermissionsAsync/PushPermissionSoftPromptSheet repo-wide to verify the "fires only after first RSVP success" canon claim — confirmed exactly 3 trigger sites, all RSVP onSuccess, no earlier firing point (onboarding, layout, app start all clean).
  • Exhaustive grep for signal_preferences consumers across packages apps (hooks, screens, mutations, routes, i18n) to prove the zero-UI claim definitively rather than assert deferral from the plan doc; separately diffed user_preferences entity fields against rendered Switch rows to find the 6 orphaned booleans.
  • Cross-read both writers of the marketing_push purpose key (settings-screen.tsx generic toggle vs settings-consent-management-screen.tsx) and checked the row label i18n for any marketing disclosure — none.
  • Diffed the DM freshness canon (docs/canon/networking.md:25) against actual refetchInterval options in every use-dm.ts hook; grepped for .channel(/postgres_changes in all DM files; verified the thread-screen poll's focus gate.
  • Traced dm_push_preview_enabled end-to-end (migration → mapper → toggle → mutation → send-push edge function body gating) to rule out a dead toggle.
  • Simulated a deleted-account DM partner through the soft-delete/anonymization migrations (00184/00186) → dm mapper LEFT-JOIN null path → thread-screen header fallback expression.
  • Checked blocked-users existence beyond DM (table, RLS, RPCs, trigger guard, settings screen) before accepting "blocking exists" as a works-well.
  • Swept style={{/StyleSheet/className/TouchableOpacity/raw-Korean-literals across all three sub-scopes, classifying every hit as sanctioned escape hatch (justified comment, static RN-primitive literal) vs violation.
  • Verified the verified-identity/nationality claims against docs + migrations rather than project-memory assertions (external-blockers.md EXT-11, current-project-state.md:47, 00228 comments, foundational-schema-decisions.md).

Canon violations (summary)

  • Tamagui-only (STYLE)login-screen.tsx:65,82-91,99-108: variable-bearing inline styles
    • two fully hand-styled raw TextInputs, no exemption comments (violation). All other raw-style hits in the block are sanctioned: static flex:1 on RN primitives, commented palette-hex swatches (settings-screen.tsx:155-171), justified RNTextInput StyleSheets (delete-account/change-password).
  • Networking canon driftdocs/canon/networking.md:25 describes a DM inbox 15s poll that code intentionally does not implement (use-dm.ts:23-24,40-41); two in-code comments repeat the phantom claim. Realtime-channel ban: fully honored.
  • PIPA / marketing-consent canon — the generic 푸시 알림 toggle writing marketing_push (finding #3) undercuts the "active, informed marketing consent" requirement (docs/canon/notifications.md, docs/canon/korean-market.md); the (광고) title prefix itself is a server-side send-push concern (out of U7 scope, flag to B7/U1).
  • Single source of truthmarketing_push written from two inconsistently-framed surfaces; three divergent hand-styled password/text inputs; two parallel preference systems (user_preferences booleans vs signal_preferences) both half-surfaced.
  • ModalPanel-not-Sheet, Pressable, i18n, registry ports — all clean across the block (verified, not assumed).

Improvement candidates (design-spec seeds for the synthesis phase)

  1. Phase 8c login screen (with #1/#4/#7): Kakao-primary button + Apple (App Store 4.8) + Google, error/cancel states designed in from day one (cancel = stay put silently is fine IF intentional; failure = inline error band using the same surface as the email path), inputs on a blessed RNTextInput wrapper. Wireframe before build per wireframe-first rule.
  2. Notification preferences screen (Phase D, with #2/#5): one canonical surface claiming the existing /settings/signal-preferences route + quiet-hours i18n keys; decide signal_preferences (13 categories × in-app/push/severity/quiet-hours) as the model and migrate/retire the 6 orphaned user_preferences booleans rather than surfacing both.
  3. Consent hygiene (with #3): strip marketing_push writes from the generic push toggle; add a separately-labeled 마케팅 알림 row (광고 framing) whose grant flows through the consent ledger with collectionMethod: 'settings_toggle'.
  4. DM inbox first-run (with #8): EmptyState onAction → recipient picker (club members) or route to 클럽 멤버 list; header "+" gains a 1:1 path (or the create-group screen generalizes to "new message" with 1 recipient = 1:1).
  5. Small fixes batch: #9 header fallback to strings.unknownUser + deactivated-partner indicator; #10 re-auth or drop the cosmetic field; #11 session-expired reason banner on LoginScreen; #6 rewrite the three phantom-poll comments + canon line.

Markdown remains the source of truth. Run yarn docs:check before handoff.