Skip to content

Copy and adversarial audit — 2026-09-12 → 2026-09-17 delta

Status: Accepted · Date: 2026-09-17 · Scope: commits 56fc2132..HEAD

Resolution: CP-01–CP-04 and AD-01–AD-03 all built 2026-09-17 (납부 확인 / 플랜 / parallel EN labels / josa helpers with a house-style gate for particles glued to interpolations; bulk mark-paid excludes the actor; pickImagesFromLibrary isolates per-asset failures; migration 00654 resolves uploader profiles independent of membership). Commit aee4d359 · preview OTA b8186450.

Method

Read-only. git log/git diff 56fc2132..HEAD scoped the delta (30 commits; biggest waves c36b5111 usability tier 2 and 747cc80f live anchor/quick-start/courts-regions/ photo-row). Copy lane read the full packages/app/src/config/i18n/{ko,en} diff plus the isolated diffs for c36b5111, 91b796ef (usability batch 1), 747cc80f, and a14b755a, cross-checked against docs/design/terminology-guide.md, docs/canon/korean-market.md, and scripts/check-i18n-house-style.mjs (read to avoid re-reporting what that gate already catches). Every flagged template was traced to its real call site to check the actual runtime value (e.g. what common.won/common.matchCount actually produce) before being kept as a finding — several initial suspects were ruled out this way. New/changed components from both waves were grep-swept for literal Korean/English UI strings and accessibilityLabel="..." literals.

Adversarial lane read docs/canon/payments.md, docs/canon/data-and-hooks.md, and the two precedent audits, then re-derived every predicate from the live SQL: RLS policies and helper functions were traced to their last CREATE OR REPLACE/CREATE POLICY across all of supabase/migrations/*.sql (not just the migration that introduced the feature), per the project's own "\sf the live function" lesson. Client-side gates were compared line-for-line against the server predicate they claim to mirror.


Copy findings

CP-01 — Dues single-row "mark paid" confirm dialog uses 완료, contradicting the same-wave 확인 ruling its own bulk sibling follows (MEDIUM)

  • Evidence:
    • docs/design/terminology-guide.md:32 (2026-09-17 ruling, part of this delta): "Dues: 완납 / 미납 / 일부 납부 for the ledger state, 납부 확인 for the 총무's action."
    • packages/app/src/config/i18n/ko/status.ts:36 — the canonical badge (DuesStatusBadge) correctly renders the ledger state as 완납.
    • packages/app/src/config/i18n/ko/settings.ts:259-261 (added 91b796ef) — the single-row manual mark-paid confirm dialog: confirmMarkPaidTitle: '납부 완료로 표시할까요?', confirmMarkPaidMessage: (name, amount) => \${name}님의 ${amount}원 회비를 납부 완료로 표시해요.``. Neither the ledger-state noun (완납) nor the ruled action verb (확인) — a third, leftover phrasing.
    • packages/app/src/config/i18n/ko/settings.ts:270-271 (added c36b5111, six commits later, same file, same clubDues object) — the new bulk sibling gets it right: confirmMarkAllPaid: (count, amount) => \${count}명, 총 ${amount}을 납부 확인으로 표시할까요?``.
    • packages/app/src/config/i18n/ko/settings.ts:238 (bulk toast) and :231 (pre-existing self-attest-confirm toast) both correctly say 납부 확인(했어요/됐어요).
    • EN does not show the drift ('Mark this paid?' is register-neutral), which is exactly why a ko/en pair-diff wouldn't catch this — it takes comparing multiple ko leaves for the same concept.
  • Scenario: a treasurer confirming one member's dues sees "납부 완료로 표시할까요?"; confirming several via the brand-new bulk action one tap later, for the identical action, sees "납부 확인으로 표시할까요?".
  • Canonical fix: reword confirmMarkPaidTitle/confirmMarkPaidMessage to the same 확인 phrasing the bulk sibling already uses six lines below.
  • Prevention: add a check-i18n-house-style.mjs rule banning the verb phrase "납부 완료" (as opposed to the noun 완납) outside statusBadges, mirroring the file's existing 결제/참가 allowlist-rule shape.

CP-02 — galleryUnavailableOnPlan names the subscription-plan gate "상위 등급", colliding with the app's already-loaded 등급 (rating tier) vocabulary; its own sibling six lines up says "플랜" (LOW-MEDIUM)

  • Evidence:
    • packages/app/src/config/i18n/ko/clubs.ts:1235 (pre-existing, unchanged) — galleryLimitReachedBody: '기존 사진을 보관하거나 클럽 플랜을 업그레이드해 주세요.'.
    • packages/app/src/config/i18n/ko/clubs.ts:1241 (new, 747cc80f) — galleryUnavailableOnPlan: '사진첩은 상위 등급에서 이용할 수 있어요'.
    • Both leaves gate the exact same concept (a club subscription plan's photo-album entitlement) six lines apart in the same object, with different Korean nouns for "plan" — a grep of every ko i18n file for "등급" used this way (상위 등급, 요금제, 플랜을 업그레이드) turns up no other precedent, so "플랜" is the established word and "등급" is a one-off.
    • "등급" is also the exact word this codebase uses for player-facing rating tiers (실력 등급, 신뢰 등급, EloTier) — a 총무 reading "상위 등급" out of context could plausibly read it as a member/club rating requirement rather than a paid-plan upsell.
    • EN mirrors are internally consistent with their own ko sibling ("upgrade the club plan" / "available on a higher plan"), so this is a Korean-only drift the near-duplicate gate's string-similarity check would not catch (the two sentences are structurally unrelated, not simple wording variants).
  • Canonical fix: galleryUnavailableOnPlan'사진첩은 상위 플랜에서 이용할 수 있어요' (or reuse "클럽 플랜").
  • Prevention: extend the money/plan vocabulary section of terminology-guide.md with an explicit "클럽 플랜, never 등급" ruling, gated the same way the other one-word-per-concept rulings are.

CP-03 — New EN support-channel row labels break the parallel construction their KO siblings hold perfectly (LOW)

  • Evidence: packages/app/src/config/i18n/ko/settings.ts (new, c36b5111) — contactKakao: '카카오톡 채널로 문의', contactPhone: '전화 문의', contactEmail: '이메일 문의' (pre-existing) — all three end in the same "…문의" (inquiry via X) pattern. The EN mirror breaks it: contactKakao: 'Message us on KakaoTalk' (subject-verb-object-preposition), contactPhone: 'Call support' (verb-object), contactEmail: 'Email support' (verb-object) — three different grammatical shapes for what renders as a parallel list of three rows (contact-screen.tsx, from supportChannels()).
  • Canonical fix: align the three, e.g. 'Message us on KakaoTalk' / 'Call us' / 'Email us'.
  • Prevention: none needed beyond reviewer eye — this class (parallel-list grammatical agreement across sibling leaves) isn't mechanically checkable without over-fitting a rule to one screen.

CP-04 — Real particle error: "로" should be "으로" after a money value, in the dues self-attest outcome preview (MEDIUM)

  • Evidence:
    • packages/app/src/config/i18n/ko/settings.ts:226 (rewritten this delta, c36b5111, replacing an arrow-glyph version): outcomeWillComplete: (total: string): string => \확인되면 누적 ${total}로 완납 처리돼요``.
    • Call site: packages/features/clubs/src/dues/dues-attest-modal.tsx:165sheet.outcomeWillComplete(t().common.won(outcome.confirmedTotalIfAccepted)).
    • packages/app/src/config/i18n/ko/common.ts:63won: (amount) => \${amount.toLocaleString('ko-KR')}원`total` is therefore always consonant-final ("원", batchim ㄴ). Korean's directional/instrumental particle after a consonant-final noun is 으로, not (로 attaches only after a vowel-final or ㄹ-final syllable) — so the rendered sentence is grammatically "…30,000원로 완납 처리돼요", which any Korean reader parses as wrong.
    • The previous version of this exact string used an arrow (확인되면 누적 ${total} → 완납 처리돼요, no particle needed); this delta's near-duplicate-copy cleanup turned it into a real sentence and introduced the error in the same edit. The EN sibling (\Once confirmed, ${total} in total marks it fully paid``) has no particle system and reads fine, so the bug is invisible to a ko/en pair check.
    • I checked the sibling risk I initially suspected in recordsScreen.scopeEmptySubtitle (${matchCount}를, matchCount = t().common.matchCount(n)"${n}경기") — "경기" is vowel-final (기), so 를 is correct there; not a bug.
  • Scenario: any member on the club-dues self-attest sheet who types a partial amount that would bring their cumulative total to fully-paid sees this sentence in the outcome preview.
  • Canonical fix: `확인되면 누적 ${total}으로 완납 처리돼요` (insert 으).
  • Prevention: no existing gate checks particle agreement. A lightweight targeted test — render every t() template whose only argument is money/count-formatted (common.won, common.wonAmount, counter helpers) with a real formatted sample and assert the output never contains a known wrong-particle substring (원로 , 원는 , 원가 , 명로 etc.) — would have caught this specific class without a full grammar engine.

Adversarial findings

AD-01 — mark_dues_paid_bulk's client-side target list includes the acting treasurer's own row, so the confirm dialog systematically over-promises versus what the server (correctly) applies (MEDIUM)

  • Reproduced predicate:
    • packages/features/clubs/src/club-dues/use-club-dues-data.ts:156-159: markAllPaidTargets = dues.filter(d => d.status === 'unpaid' || d.status === 'partial') — filters by status only, from the admin view's full roster (useClubDues(canManageDues ? clubId : '', ...), i.e. every member's row including the admin's own).
    • packages/features/clubs/src/club-dues/use-club-dues-actions.ts:242-268 (handleMarkAllPaid) builds the confirm dialog's count/amount from that same array, then sends duesIds: markAllPaidTargets.map(d => d.id) verbatim to markDuesPaidBulk.mutate(...).
    • Server (supabase/migrations/00536_payment_self_confirm_guard.sql:118-123, still live): dues_update RLS requires is_club_admin(club_id) AND dues.user_id <> auth.uid() — the acting treasurer's own row fails this and is silently excluded from the UPDATE inside mark_dues_paid_bulk (00648), which returns the true ROW_COUNT (necessarily one row short whenever the treasurer's own current-period dues are outstanding).
    • handleGenerateDues (use-club-dues-actions.ts:283-295) creates dues rows for every club member/profile, admins included, with no exemption — so an admin who is also a paying member (the common case for a 총무) routinely has an outstanding row in the exact roster this feature bulk-acts on.
  • Scenario: the confirm dialog promises "12명, 총 360,000원을 납부 확인으로 표시할까요?"; the treasurer taps confirm; the toast (correctly, v_count-driven) reports "11명 납부 확인했어요" — a silent, unexplained shortfall the treasurer has no way to anticipate from the confirm copy, in a screen whose entire premise (U-15) was reducing per-row confirms. No money-safety issue (00536's self-confirm guard is doing exactly its job), but the promised-vs-actual mismatch is a genuine "confused treasurer" moment in a fresh feature.
  • Severity: MEDIUM (financial-confirmation UI must not promise a number it can't deliver; the underlying data stays safe).
  • Canonical fix: markAllPaidTargets = dues.filter(d => (d.status === 'unpaid' || d.status === 'partial') && d.userId !== userId) — mirror the RLS self-exclusion client-side, the same discipline already correctly applied to feeLocked (see AD-05/verified-sound below).
  • Prevention: a unit test on useClubDuesData asserting markAllPaidTargets excludes the current userId when present in the roster; more generally, treat "client-side target list for a self-touch-guarded bulk RPC" as its own checklist item alongside the existing feeLocked-style parity discipline.

AD-02 — usePickImages/pickImagesFromLibrary has no partial-failure isolation: one bad photo in a multi-select batch silently drops the whole picker action with no toast (MEDIUM)

  • Reproduced code path:
    • packages/app/src/lib/pick-images.ts:70-78for (const asset of result.assets) { const resized = await resizeImageForUpload(...); images.push(...) } — sequential, no per-item try/catch; one throw (a corrupt file, an unsupported HEIC variant, a 0-byte asset) rejects the whole function, discarding every image already resized in that same batch.
    • packages/app/src/presentation/hooks/use-pick-images.ts:29-44const result = await pickImagesFromLibrary(options); with no surrounding try/catch; the rejection propagates unhandled out of the hook's returned callback.
    • packages/features/clubs/src/settings/use-club-image-pickers.ts (pickAndUploadGallery) calls await pickImages({...}) with no try/catch either — so the same unhandled rejection reaches a fire-and-forget event handler.
    • Contrast: the very same function's upload stage (further down in use-club-image-pickers.ts, gallery multi-select loop) explicitly isolates per-asset upload failures and reports "successes and failures separately once the loop finishes" — proving the team already applies the right pattern one stage later, just not at the pick/resize stage.
    • fileName (pick-images.ts:45,77) is captured but never referenced by any adapter (grep -rn fileName packages/app/src/adapters — zero hits): it is not used to build any storage path, so a crafted filename has no path-traversal surface.
    • EXIF/GPS: the resize step re-encodes via expo-image-manipulator's renderAsync/saveAsync (packages/app/src/lib/image-resize.ts:59-60) rather than copying the source bytes, which — per that library's documented behavior — does not carry the source asset's EXIF block into the output. This audit could not independently confirm the installed version's actual output (no device/runtime access; read-only), so this is noted as a plausible but unverified mitigation, not a proven one.
  • Scenario: a member multi-selecting 6 gallery photos to post, where photo #4 is an unusual color profile or a Live Photo variant the manipulator can't decode, gets nothing uploaded and no error message — the picker just appears to do nothing, the exact "silent no-op" openExternalUrl's own header comment (written in this same delta) explicitly calls out as the failure mode to avoid.
  • Canonical fix: wrap each resizeImageForUpload call in its own try/catch inside pickImagesFromLibrary, returning the images that succeeded plus a count of failures (mirroring the shape pickAndUploadGallery's upload loop already uses); have usePickImages surface a friendly toast when 1+ items failed but others succeeded, and the existing generic error toast when all fail.
  • Prevention: a unit test on pickImagesFromLibrary asserting a batch with one throwing asset still returns the other N-1 successfully-resized images rather than rejecting.

AD-03 — Photo feed row shows a bare, permanent "..." for a former/removed member's uploads, unlike post authorship in the same bundle (LOW)

  • Evidence:
    • supabase/migrations/00651_club_detail_bundle_media_uploads.sqlmember_rows/members_j/member_profiles_j (lines 51-66, unchanged from 00638) resolve profiles only for active members (m.is_active = true); recentMediaUploads's uploaded_by (lines 205-241) is never unioned into that profile-resolution set.
    • The same migration file already solves this exact problem correctly for a different actor: post_author_ids/post_author_profiles_j (lines 135-142) resolve any post author via get_public_profile_views(...) regardless of current active membership — a removed member's old posts still show their name.
    • Client: packages/features/clubs/src/club-detail/home/auto-media-row.tsx:26,40uploader: Profile | undefined (resolved only from the bundle's members-only-scoped profileById map); const name = uploader ? profileDisplayName(uploader) : '...'. The comment there assumes undefined is a transient loading state ("effectively never on a warm cache") — it is not: for a since-removed uploader, the map will never contain them, on any cache state.
  • Scenario: a member uploads photos, is later removed from (or leaves) the club; the home feed's photo row for that historical upload permanently renders "...님이 사진 3장을 올렸어요" instead of their name. No data leak (if anything, under-exposure) — a display/consistency bug, not a security issue.
  • Canonical fix: extend recentMediaUploads's profile resolution the same way post_author_ids already does — union uploaded_by into a profile-id set resolved via get_public_profile_views(...) independent of member_ids, or add a translated "탈퇴한 멤버"/"Former member" fallback client-side instead of a bare non-localized ellipsis.
  • Prevention: none beyond noting the precedent — the fix is to reuse the pattern already sitting in the same file.

Verified sound

Each item below was actively attacked (not merely read) — the predicate cited is the live one.

Adversarial

  • mark_dues_paid_bulk cross-club / non-admin / self-confirm safetydues_update RLS (00536_payment_self_confirm_guard.sql:118-122, is_club_admin(club_id) AND user_id <> auth.uid()) is evaluated per-row against the row's own club_id/user_id; is_club_admin (00066_security_foundation.sql:44-58) checks club_members.role IN ('admin','owner') for that same club_id. A caller admin of club A cannot touch club B's rows even if their ids are mixed into the same array — RLS simply excludes them, no error. mark_dues_paid_bulk (00648) is SECURITY INVOKER, so it has no elevated privilege beyond what the caller's own RLS grants.
  • mark_dues_paid_bulk double-confirm / idempotency / audit-trail parity — the UPDATE ... WHERE status IN ('unpaid','partial') clause (00648_dues_mark_paid_bulk.sql:64-65) excludes already-paid/waived rows outright (no double-confirm), and a retry (manual, or from any future offline replay) after a partial or full prior success matches zero rows for the already-updated ones — safe no-op. The BEFORE UPDATE trigger guard_manual_dues_authority (00629_manual_dues_authority.sql:81-84, still live, unchanged) unconditionally re-stamps cleared_by/cleared_at/paid_at from the actual auth.uid()/now() regardless of what either write path's UPDATE supplied — both the bulk and single-row paths are stamped identically by the same trigger, and neither touches payment_history (verified: no INSERT into that table anywhere in 00648, matching the single-row dues.supabase.ts path).
  • Session fee editable-until-first-payment (00649) — client feeLocked (packages/features/sessions/src/edit-session/config-form.tsx:141-145: session not open, payments loading/offline-cold, or payments.length > 0) matches the server trigger's predicate exactly (private.block_fee_edit_with_existing_holds, 00649_session_fee_edit_until_payment.sql:52-53: EXISTS(SELECT 1 FROM session_payments WHERE session_id = NEW.id)). A stale client that races a hold being created gets the friendly mapped message (use-update-session.ts:42-45, HINT fee_locked_by_holdssessionDetail.feeLockedByPayments) instead of a raw Postgres error. Because the reserve-then-pay model (00299) auto-creates a hold on the first RSVP, the fee is locked well before any transfer is "mid-flight" — a host cannot lower the fee once RSVPs exist. An untouched fee field is never sent (feeDirty gate, config-form.tsx:268,288), so unrelated saves never even risk tripping the trigger.
  • Courts-derived club regions (00650)court_venues_insert/_update/_delete RLS (00014_court_venues.sql:49-86, unchanged by anything since — confirmed via a full-repo grep of every court_venues-touching migration) all require club_members.role IN ('owner','admin'). A plain member or guest cannot INSERT/UPDATE/DELETE a court_venues row, so the SECURITY DEFINER trg_sync_club_regions trigger's escalation to rewrite clubs.adm1_slug/adm2_slug/club_play_regions only ever fires as the consequence of an already admin-gated action — no privilege crossing.
  • Photo feed row visibility (00651)get_club_detail_bundle is SECURITY INVOKER, so club_media's own RLS (00255_club_growth_profile_media.sql:87-104: a member sees all statuses/visibilities of their club; a non-member sees only approval_status='approved' AND visibility='public' on a discoverable club) is enforced live per caller even though the aggregating media_upload_rows CTE only restates approval_status='approved' AND archived_at IS NULL. Net effect verified by composing the two: members-only photos never reach non-members; pending/rejected/archived reach no one. A fully private/non-discoverable club returns 'null'::jsonb wholesale before any sub-CTE runs, gated by the clubs table's own RLS on the club_row CTE.
  • Quick-start guestsAllowed: true (747cc80f) — the review step labels every quick-start-seeded field with the honest "기본값"/"Default value" badge (common.ts new defaultValue leaf, create-club-step8.tsx), never implying the club actively chose it; the quick-start caption itself says "starts with defaults... change anytime." default_guest_fee (00040_club_discovery_fields.sql:22, NOT NULL DEFAULT 0, no further CHECK) and guest_visit_limit (00479_club_guest_policy.sql:33, CHECK (guest_visit_limit IS NULL OR guest_visit_limit > 0)) both accept the exact 0/null values applyQuickStartDefaults (quick-start.ts:86-96) leaves them at — the same values a manually-tapped 허용 tile would leave. No CHECK-constraint failure is reachable.
  • HeaderTextAction 관리 role predicatepackages/features/clubs/src/club-detail-screen.tsx:181-182 and packages/features/clubs/src/club-admin-screen.tsx:37-42 compute isAnyAdmin with the byte-identical OR-chain (canManageClub || canManageMembers || canManageDues || canManageSchedule || canManageMatches) off the same useClubContext/role object. ClubAdminScreen independently re-derives and re-checks it (bounce-away effect) as defense in depth against a direct deep link, and every mutation inside AdminTab is separately RLS-gated server-side — a client-side bypass of the button's visibility would still hit a real wall.
  • TrustTierInfoSheet/check-trust-tier-parity.mjs — the script (scripts/check-trust-tier-parity.mjs:53-70) does not hardcode a filename; it scans every file under supabase/migrations/ for the literal CREATE OR REPLACE FUNCTION private.recalculate_trust_tier marker and takes the last (highest-numbered) match. Confirmed only three migrations ever define this function (00086, 00125, 00444), nothing in 00645-00651 touches it, so the script correctly resolves to 00444_information_flow_wave2.sql — the true live definition, not a stale snapshot.
  • Support config / Kakao URL (support.ts)readSupportKakaoChannelUrl() (packages/app/src/config/support.ts:24-35) rejects (logs + returns null, row omitted from supportChannels()) any EXPO_PUBLIC_SUPPORT_KAKAO_CHANNEL_URL that doesn't start with the literal https://pf.kakao.com/; readSupportPhone() requires a strict ^\+82\d{8,11}$ match before building a tel: URI. Both are validated before ever reaching openExternalUrl (packages/app/src/lib/open-external-url.ts), which has no allowlist of its own but also, today, no untrusted caller — note for later: if a future caller (e.g. a club's free-text external link) is routed through this same helper, it will need its own validation, since openExternalUrl intentionally doesn't provide one (per its own header comment, scoped to today's vetted callers only).
  • 인터클럽 → 대항전 rename, all three surfacespackages/app/src/config/i18n/ko/signals.ts:65-71 (chip), packages/app/src/adapters/expo/notification.expo.ts:141-144 (Android channel name), and supabase/functions/send-push/notification-copy.ts:49 (edge-function mirror) all say 대항전; the stable engineering key (id: 'interclub') correctly stays English. No drift across the three surfaces the terminology guide requires to move together.

Copy

  • glossary.composition, InfoHintButton, a11yMaxMembersDecrease/Increase, and sessions.deuceHint's new noAd sibling are all wired to real call sites (create-club-step4.tsx:90,143; create-session-step3.tsx:212,261; club-planning-section.tsx:164-165; round-rules-strip.tsx:64) — none are dead keys, and each matches its documented intent exactly.
  • A grep sweep of every new/materially-changed .tsx file in both focus waves (live anchor, quick-start wizard, courts regions, photo row, tier-range restructure, fee-edit modal, dues row, contact screen, decor section) for raw Hangul outside comments, and for literal (non-t()) accessibilityLabel="..." values, found none — every new UI string, including accessibility labels, routes through t().
  • Confirm/toast grammar is applied correctly everywhere checked in the new copy: every confirm dialog title ends in "~할까요?", every success toast in "~했어요/됐어요" (bulk mark-paid, generate, waive, mark-paid all checked).
  • recordsScreen.scopeEmptySubtitle's ${matchCount}를 (matchCount = t().common.matchCount(n)"${n}경기", vowel-final) is grammatically correct — flagged during the particle sweep, verified clean via the real call site rather than assumed.

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