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(added91b796ef) — 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(addedc36b5111, six commits later, same file, sameclubDuesobject) — 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/confirmMarkPaidMessageto the same 확인 phrasing the bulk sibling already uses six lines below. - Prevention: add a
check-i18n-house-style.mjsrule banning the verb phrase "납부 완료" (as opposed to the noun 완납) outsidestatusBadges, 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.mdwith 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, fromsupportChannels()). - 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:165—sheet.outcomeWillComplete(t().common.won(outcome.confirmedTotalIfAccepted)). packages/app/src/config/i18n/ko/common.ts:63—won: (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'scount/amountfrom that same array, then sendsduesIds: markAllPaidTargets.map(d => d.id)verbatim tomarkDuesPaidBulk.mutate(...).- Server (
supabase/migrations/00536_payment_self_confirm_guard.sql:118-123, still live):dues_updateRLS requiresis_club_admin(club_id) AND dues.user_id <> auth.uid()— the acting treasurer's own row fails this and is silently excluded from theUPDATEinsidemark_dues_paid_bulk(00648), which returns the trueROW_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 tofeeLocked(see AD-05/verified-sound below). - Prevention: a unit test on
useClubDuesDataassertingmarkAllPaidTargetsexcludes the currentuserIdwhen 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 existingfeeLocked-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-78—for (const asset of result.assets) { const resized = await resizeImageForUpload(...); images.push(...) }— sequential, no per-itemtry/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-44—const result = await pickImagesFromLibrary(options);with no surroundingtry/catch; the rejection propagates unhandled out of the hook's returned callback.packages/features/clubs/src/settings/use-club-image-pickers.ts(pickAndUploadGallery) callsawait pickImages({...})with notry/catcheither — 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'srenderAsync/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
resizeImageForUploadcall in its owntry/catchinsidepickImagesFromLibrary, returning the images that succeeded plus a count of failures (mirroring the shapepickAndUploadGallery's upload loop already uses); haveusePickImagessurface a friendly toast when 1+ items failed but others succeeded, and the existing generic error toast when all fail. - Prevention: a unit test on
pickImagesFromLibraryasserting 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.sql—member_rows/members_j/member_profiles_j(lines 51-66, unchanged from00638) resolve profiles only for active members (m.is_active = true);recentMediaUploads'suploaded_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 viaget_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,40—uploader: Profile | undefined(resolved only from the bundle's members-only-scopedprofileByIdmap);const name = uploader ? profileDisplayName(uploader) : '...'. The comment there assumesundefinedis 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 waypost_author_idsalready does — unionuploaded_byinto a profile-id set resolved viaget_public_profile_views(...)independent ofmember_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_bulkcross-club / non-admin / self-confirm safety —dues_updateRLS (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 ownclub_id/user_id;is_club_admin(00066_security_foundation.sql:44-58) checksclub_members.role IN ('admin','owner')for that sameclub_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_bulkdouble-confirm / idempotency / audit-trail parity — theUPDATE ... 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. TheBEFORE UPDATEtriggerguard_manual_dues_authority(00629_manual_dues_authority.sql:81-84, still live, unchanged) unconditionally re-stampscleared_by/cleared_at/paid_atfrom the actualauth.uid()/now()regardless of what either write path'sUPDATEsupplied — both the bulk and single-row paths are stamped identically by the same trigger, and neither touchespayment_history(verified: no INSERT into that table anywhere in00648, matching the single-rowdues.supabase.tspath).- Session fee editable-until-first-payment (
00649) — clientfeeLocked(packages/features/sessions/src/edit-session/config-form.tsx:141-145: session not open, payments loading/offline-cold, orpayments.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, HINTfee_locked_by_holds→sessionDetail.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 (feeDirtygate,config-form.tsx:268,288), so unrelated saves never even risk tripping the trigger. - Courts-derived club regions (
00650) —court_venues_insert/_update/_deleteRLS (00014_court_venues.sql:49-86, unchanged by anything since — confirmed via a full-repo grep of everycourt_venues-touching migration) all requireclub_members.role IN ('owner','admin'). A plain member or guest cannot INSERT/UPDATE/DELETE acourt_venuesrow, so the SECURITY DEFINERtrg_sync_club_regionstrigger's escalation to rewriteclubs.adm1_slug/adm2_slug/club_play_regionsonly ever fires as the consequence of an already admin-gated action — no privilege crossing. - Photo feed row visibility (
00651) —get_club_detail_bundleisSECURITY INVOKER, soclub_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 onlyapproval_status='approved' AND visibility='public'on a discoverable club) is enforced live per caller even though the aggregatingmedia_upload_rowsCTE only restatesapproval_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'::jsonbwholesale before any sub-CTE runs, gated by theclubstable's own RLS on theclub_rowCTE. - Quick-start
guestsAllowed: true(747cc80f) — the review step labels every quick-start-seeded field with the honest "기본값"/"Default value" badge (common.tsnewdefaultValueleaf,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) andguest_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 valuesapplyQuickStartDefaults(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 predicate —packages/features/clubs/src/club-detail-screen.tsx:181-182andpackages/features/clubs/src/club-admin-screen.tsx:37-42computeisAnyAdminwith the byte-identical OR-chain (canManageClub || canManageMembers || canManageDues || canManageSchedule || canManageMatches) off the sameuseClubContext/roleobject.ClubAdminScreenindependently re-derives and re-checks it (bounce-away effect) as defense in depth against a direct deep link, and every mutation insideAdminTabis 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 undersupabase/migrations/for the literalCREATE OR REPLACE FUNCTION private.recalculate_trust_tiermarker and takes the last (highest-numbered) match. Confirmed only three migrations ever define this function (00086,00125,00444), nothing in00645-00651touches it, so the script correctly resolves to00444_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 + returnsnull, row omitted fromsupportChannels()) anyEXPO_PUBLIC_SUPPORT_KAKAO_CHANNEL_URLthat doesn't start with the literalhttps://pf.kakao.com/;readSupportPhone()requires a strict^\+82\d{8,11}$match before building atel:URI. Both are validated before ever reachingopenExternalUrl(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, sinceopenExternalUrlintentionally doesn't provide one (per its own header comment, scoped to today's vetted callers only). - 인터클럽 → 대항전 rename, all three surfaces —
packages/app/src/config/i18n/ko/signals.ts:65-71(chip),packages/app/src/adapters/expo/notification.expo.ts:141-144(Android channelname), andsupabase/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, andsessions.deuceHint's newnoAdsibling 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
.tsxfile 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 throught(). - 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.