Skip to content

PS9 — Venues & Discovery

Status: Archived Draws from: docs/audits/blocks/B6-venues-weather-backend.md (findings #3–#8, the venue data-quality + weather tail — #1/#2 are PS1's ACL fixes, cited not repeated), docs/audits/blocks/U6-discovery.md (findings #2–#6 — #1 is PS3's RLS fix, cited not repeated). Canonical server fix shapes for the backend half are pre-negotiated in ../wiring-plans-2026-07-backend.md (V2–V6, provisional migrations 0041500417); this document cites that plan for shape and adds the precedence/runtime research it assumes but doesn't spell out, and designs the discovery-UX half the wiring plan doesn't cover at all. Venue canon: migrations 00347 (provenance), 00348 (fee parser), 00355/00356/00357/00358 (user corrections + trust gate), 00370/00379 (amenity facts, latest resolve_venue_facts).

Boundary with sibling docs (do not duplicate):

  • PS1 (ps1-access-control.md, P3/P4) owns the venue_corrections RLS/GRANT lockdown and the ingest-venues fail-open token fix — the security mechanism. PS1's slice 5 also already bundles the per-row BEGIN...EXCEPTION isolation for both resolve_venue_facts and enrich_venues_batch loops (B6 #2/#8) in the same migration as the RLS fix, since the batch-abort DoS and the RLS bypass share one root cause (a raw table write reaching the resolver unvalidated). This document does not re-derive that fix — §2.1 below independently verifies it's still live and necessary (grounding, not duplication), and every downstream slice here assumes it lands first.
  • PS3 (ps3-sessions-rsvp.md, SS1) owns the sessions_select_public RLS policy that lets PublicPickupPreviewScreen load a public pickup's row for a non-member/anon caller — the data-visibility half of U6 #1. This document does not redesign that RLS. §2.9 below designs the small remaining client-side gap U6 #1's own reproduction trace surfaces (the post-signup redirect losing the pickup) — a resolveOnboardingDestination bug that is independent of the RLS fix and cheap enough to include for completeness.

PS9 owns: venue data quality (manual/host observation trust precedence, reopen path), ingest-time correctness (fee parser, admin-region linkage), weather-warning re-notify, and discovery UX (confidence-badge honesty, filter encoding unification, dormant-data surfacing, cause-aware empty states, the club-discovery localeCompare regression).


1. Problem statement

#DefectSourceSeverity
V2Owner/host facts written as venue_source_observations(provider='manual'|'host') get zero trust weighting in resolve_venue_facts — they're one anonymous vote in the same majority/OR-monotonic aggregate as any scraper, and 5 of 11 resolved facts (has_lighting, amenities, has_parking, has_shower, has_shop) have no human-override path at all (not even via the venue_corrections ledger — the field isn't in its CHECK domain)B6 #3HIGH
V3A venue wrongly marked closed_at (2-agreement bar, no real-identity verification behind submitted_by) has no reopen path anywhere in the codebase — the client doesn't even expose closedAt on the read entity, so there's no UI surface to build a fix on top ofB6 #4HIGH
V4private.parse_tennis_fee (00348) fixed a real multi-sport-fee-blob bug but is wired into a one-time backfill only — any future re-ingest of gov fee data reintroduces itB6 #5MEDIUM
V5adm1_id/adm2_id region linkage is populated by a standalone Kakao-reverse-geocode script, never by ingest_venues_batch itself — new venues silently sit region-less until someone remembers to re-run it, and district-filtered directory views drop them with no signalB6 #6MEDIUM
V6signals_on_weather_warning_activated only fires on inactive→active — a warning escalating severity (주의보→경보) while staying active never re-notifies already-warned usersB6 #7MEDIUM
U6-2The "미확인 포함" toggle widens the directory query to admit corroborated-but-unofficial and gov-only/unverified venues, but DirectoryVenueCard renders a trust badge only for official/host_verified — every admitted venue looks identical regardless of actual confidenceU6 #2HIGH
U6-6naver_place's deep scrape fields (business_hours per-day, review_keywords) are written to venue_source_observations.payload by design ("stored... for future venue-detail UI") but zero product code reads them — dormant since the day they were scrapedU6 #6LOW (dead data)
U6-5Three independently hand-rolled region→district filter bars (pickup, venue directory, club discovery) — region:district colon vs region-district hyphen vs no encoding at all (single-select) — a live single-source-of-truth violation, not a hypothetical oneU6 #5MEDIUM (canon)
U6-4The directory empty state renders one static string regardless of cause — a sparse region gated out by the default verification='corroborated' filter reads identically to "zero courts exist," with no path back to the toggle that would fix itU6 #4MEDIUM
U6-3Club discovery's sort (sortClubCardModelsForDiscovery, club-card-summary.ts) reintroduces the exact localeCompare ICU-freeze anti-pattern the sibling venue-directory screen explicitly documents and works aroundU6 #3MEDIUM

Cited, not redesigned: B6 #1 (ingest-venues fail-open) and B6 #2's RLS half → PS1. U6 #1's RLS half → PS3. B6 #8 (enrich_venues_batch exception isolation) → PS1 (bundled with #2, verified below).

Why these are one problem space: every finding here is either (a) the resolver/ingest pipeline computing or admitting data the product then can't represent honestly (V2, V4, V5, U6-2, U6-4), or (b) the discovery surfaces duplicating logic instead of sharing it (U6-5, U6-3) — both are "the platform knows more than it shows, or shows it three different ways," the discovery half of the same provenance system PS1 secures and PS3's RLS half unlocks.


2. Research

2.1 Live-reverifying the batch-abort DoS (grounding for PS1's fix, not a redesign)

Traced resolve_venue_facts through its full lineage (00347→00355→00356→ 00357→00358→00370→00379, the last redefinition — 00379_derive_amenity_flags.sql postdates 00370 and is the function actually live today; the wiring plan and B6 audit both cite 00370 as "latest," which is stale by one migration). Confirmed via pg_proc.prosrc hash against a fresh local reset (HEAD 00382) that 00379's body is what's running.

Live repro (local DB, BEGIN...SAVEPOINT...ROLLBACK, no persisted state):

sql
-- seeded a throwaway auth.users row + a malformed venue_corrections row
-- (field='court_count', proposed_value='"not-a-number"') among 3 real venues
SELECT public.resolve_venue_facts(ARRAY(SELECT id FROM public.venues ORDER BY id LIMIT 3));
-- ERROR:  invalid input syntax for type integer: "not-a-number"
-- CONTEXT: PL/pgSQL function public.resolve_venue_facts(uuid[]) line 253 at assignment

The whole 3-venue call aborts on one poisoned row — confirms B6 #2/#8 are still live today, exactly as PS1 slice 5 assumes. This document does not re-fix it — every slice below is sequenced after PS1 slice 5, and V2's own migration (§3.1) is additive to the same function body PS1 patches, so it must land second, not first (stated explicitly in the slices table).

2.2 Precedence-consistent human authority — what "top-trust" actually requires

The resolver has two disjoint authority mechanisms today, and only one of them is actually protected. Reading 00379's live body end to end:

  1. The venue_corrections ledger (status='applied' rows) — the "HUMAN CORRECTIONS WIN" block, covering exactly 6 of 11 resolved facts: telephone, court_count, has_indoor, capabilities, operating_hours, fee_schedule. This path is validated end-to-end by submit_venue_correction (rate limit, jsonb_typeof bounds, the reputation-weighted trust gate, PS1's forthcoming RLS lockdown) before a row can ever reach status='applied'.
  2. venue_source_observations(provider IN ('manual','host')) — a raw per-source snapshot row, written only by service-role migrations (record_venue_observations, e.g. 00382's owner-QA fixes) or ingest scripts. This is aggregated through the exact same majority-vote / bool_or / union logic as gov_kspo, kakao, naver, etc. — zero trust weighting, despite 00382's own migration comment calling manual "the top-trust source in resolve_venue_facts." That claim is false as written today; verified by reading 00379's HUMAN CORRECTIONS WIN block, which reads exclusively from venue_corrections, never venue_source_observations.

A second, independent defect compounds #2, specific to the boolean facts:has_indoor/has_lighting/has_parking/has_shower/has_shop all resolve via bool_or(...) — an OR-monotonic aggregate that can represent "at least one source confirms X" but structurally cannot represent an authoritative "no, X does not exist." A manual observation asserting has_indoor=false only "wins" today because it happens to be the sole voter; the instant any other source (even a wrong one) asserts true, bool_or flips the resolved value regardless of the manual claim's intent — exactly the mechanism 00382 finding #2 describes fixing by hand (매헌시민의숲, manual has_indoor=false pinned outdoor-only, corrected by editing the manual row, not by protecting it). Fixing precedence alone (giving manual/host a trust tier) is necessary but not sufficient for booleans — the boolean facts additionally need a straight-override read (like the ledger's own r_indoor := (hv #>> '{}')::boolean assignment, not another fold into bool_or) so an authoritative false can actually stick.

Design: a second precedence layer, applied uniformly to all 11 facts, slotted between the multi-source compute and the existing ledger override (so the validated ledger — which has been through rate-limiting, bounds-checking, and the reputation gate — still wins when both a ledger row and a raw manual observation exist for the same field; a raw table write should never outrank a validated RPC write, the same lesson PS1's §2.7 draws for venue_corrections itself):

Layer 0 (unconditional, unchanged): facility_kind_source = 'host' sticky pin
Layer 1 (unchanged): venue_corrections.status = 'applied'   — HUMAN CORRECTIONS WIN
Layer 2 (NEW):       venue_source_observations WHERE provider IN ('host','manual')
                      — HUMAN OBSERVATION WINS (straight override, not another vote)
Layer 3 (unchanged): multi-source computed (majority vote / bool_or / union)

Because venue_source_observations carries a UNIQUE(venue_id, provider) constraint (verified: venue_source_observations_provider_uniq), there is at most one manual row and one host row per venue — no "most agreed" tie-break needed, just host outranking manual when both assert the same field (mirrors facility_kind_source='host''s own sticky-pin precedent).

Scope decision, stated explicitly: this closes the precedence gap for facts a manual/host observation can already carry (all 11, since venue_source_observations.payload has no field allowlist). It does not widen venue_corrections_field_check to let end-users submit corrections for the 5 currently-ledger-unreachable facts (has_lighting, amenities, has_parking, has_shower, has_shop) — that needs new VenueCorrectionModal per-field editors and validation branches, a real UI scope, not a resolver change. Flagged as a follow-up, not designed here (same discipline PS1 uses for nationality_code's deferred immutability fix).

2.3 Reopen path — reusing the trust gate instead of inventing a second one

The wiring plan's fix shape (§V3) proposes a standalone reopen_venue(p_venue_id, p_reason) RPC with its own authority model to decide. Tracing submit_venue_correction's existing 'closed' branch shows a cleaner fit:

sql
-- current (00358, unchanged since):
ELSIF p_field = 'closed' THEN
  IF jsonb_typeof(p_value) <> 'boolean' OR p_value <> 'true'::jsonb THEN
    RAISE EXCEPTION 'invalid closed';
  END IF;

closed is already modeled as a boolean fact in the ledger — it's just artificially restricted to true only, with its own bespoke 2-distinct-agreement threshold (no trust-tier bypass, unlike every other fact). Reopening a wrongly-closed venue is exactly "submit closed=false, contradicting a previously-applied closed=true fact" — the identical shape submit_venue_correction's reputation-weighted trust gate (00357) was built to arbitrate for every other fact. Recommendation: extend the existing branch to accept false, and route it through the same trust gate the fact-correction ELSE branch already implements, rather than a parallel RPC with a from-scratch authority decision. This is a single-source-of-truth call, not just economy: a second bespoke authority model for the inverse of an already-gated action is exactly the kind of drift PS1 §2.1 found four independent instances of (a rule understood correctly once, then reinvented slightly wrong nearby).

Reopening is asymmetric-by-design relative to closing (closing is a nuisance, wrongly leaving a real court "closed" or wrongly reopening a genuinely-shut one both have real cost) — the reputation trust gate already produces exactly this asymmetry: a sprout/active user's reopen vote needs a 2nd independent agreement (same as today's close path), while a trusted/veteran user's reopen applies instantly (a capability the close path doesn't have today either — extending it uniformly is a strict improvement, not a new risk surface, since the same trust tier already governs every other fact).

A prerequisite this reveals, not present in either audit: closed_at/ closure_reason are not mapped onto DirectoryVenue at all — grep of directory-venue.entity.ts and the adapter's mapper returns zero hits; they exist purely as a server-side .or('closed_at.is.null,...') list-filter predicate (venue-directory.supabase.ts:87). findById (the detail-screen read) does not filter on them, so a closed venue's detail page is already reachable today (via a saved link, browser history, or a stale share) and renders with zero indication it's closed — no banner, no disabled state, nothing. Any reopen UI needs this plumbed onto the entity first; noted in §3.2, not designed in full (the exact banner treatment is a small, genuinely new UI composition — flag for a quick wireframe check-in per the project's UI/UX process, not blocking the RPC-level fix).

2.4 Fee-parser + adm-lookup — verifying what can actually run where

V4 (fee parser) is a clean, low-risk wiring gap. private.parse_tennis_fee(note TEXT) RETURNS JSONB (00348) is pure SQL/plpgsql with no external dependencies — it can be called directly from record_venue_observations (00347) wherever a gov_% payload's fee_schedule entry is note-only (unparsed). No further research needed; the wiring plan's fix shape is accurate as written.

V5 (adm-lookup) needed independent verification — the wiring plan's fix shape as literally worded would not work. Its proposed fix is "fold an adm-lookup... into ingest_venues_batch at write time — a private. lookup_adm_ids(lat, lng) helper." Tracing the ONLY existing implementation (scripts/backfill-venue-adm.mjs) shows this reverse-geocode is not a pure-SQL polygon/bbox lookup against administrative_divisions — it's an external HTTP call to Kakao's coord2regioncode REST API, whose result (시/도 + 시/군/구 Korean names) is then matched against administrative_divisions's slug/names.ko columns client-side. A plpgsql function inside ingest_venues_batch has no HTTP client available (no pg_net usage found anywhere in this schema) — a private.lookup_adm_ids(lat, lng) helper as literally described cannot be written in pure SQL.

Corrected design, grounded in the real mechanism: the Kakao lookup has to run where HTTP is already available — the Deno-based ingest-venues edge function (confirmed zero existing Kakao/administrative_divisions references there today) — reusing backfill-venue-adm.mjs's exact coord2region() + slug-matching logic, called inline for each row before the batch upsert. Separately, ingest_venues_batch (00334) itself has zero adm1_id/ adm2_id references in its SET list today — unlike enrich_venues_batch, which already accepts and COALESCEs them from its JSONB rows (00370:403-404). So this is two changes, not one: (a) a small additive SQL change teaching ingest_venues_batch to accept + write adm1_id/adm2_id the same way enrich_venues_batch already does, and (b) the edge-function change that actually computes them via Kakao before calling it. This is the kind of "verify what actually runs, not the obvious-named fix" catch the project's research methodology exists to force — the wiring plan's one-liner would have sent an implementer looking for an HTTP-capable Postgres extension that isn't there.

2.5 Confidence-tier badge — reusing the existing vocabulary, not inventing a new one

venue-confidence.tsx already has the exact vocabulary this needs: TIER_COLOR (high:$success / medium:$warning / low:$error), ConfidenceBars, VenueTrustRing, SourceChips — all driven off fieldConfidence/ coordConfidence, "never hand-set." DirectoryVenueCard's own doc comment confirms the current one-badge design was a deliberate redesign choice ("No trust-tier chip / meta-line... here"), so the fix must add exactly one signal, not reintroduce the meta-line prose the redesign removed.

The corroboration gate itself is already computed server-side and exposed on the domain entity with no extra fetch needed: DirectoryVenue.sourceCount and .verificationStatus are both already selected in VENUE_BASE_COLUMNS/VENUE_LIST_COLUMNS (venue-directory.supabase.ts:28,35) and mirror the adapter's own filter predicate exactly (venue-directory.supabase.ts:136,138: corroborated = source_count >= 2 AND verification_status != 'unverified'). This means the card can compute "would this venue survive the corroborated filter" locally, from props it already has — no new query, no new prop, and critically no threat to DirectoryVenueCard's React.memo stability contract (adding filters as a prop would re-render the whole list on every filter tick, exactly what the memo exists to prevent). The badge is a pure function of the venue itself, not of the currently-active filter — which also means it naturally never renders under the default verification='corroborated' filter (those venues are already excluded server-side), so it only starts appearing the moment "미확인 포함" is toggled — exactly the surface where the audit says the decision needs to be made.

2.6 Deep-data surfacing — grounded in the real scrape payload shape

Traced the exact write path (scripts/scrape-naver-place.mjs:126-168,330-334, scripts/lib/providers.mjs:14-26) rather than trusting the audit's field names alone:

payload.business_hours = dt.businessHours
  // dt.businessHours: Array<{ day: '월'|'화'|…, start: string, end: string }>
  // sourced from ROOT_QUERY['placeDetail(...)'].newBusinessHours[0].businessHours[]
  // — PER-DAY granularity, richer than the resolved `operating_hours` fact's
  // coarse weekday/weekend/holiday buckets (which naver_place's OWN
  // normalized shape collapses into, when present).
payload.review_keywords = dt.reviewKeywords
  // Array<{ code: string, label: string, count: number }>, top 10 by count,
  // from VisitorReviewStatsResult.analysis.votedKeyword.details
payload.booking_url = dt.bookingUrl  // string | undefined — same dormancy, not audit-named but same mechanism

The scrape script's own comment settles the "why isn't this in the resolver" question definitively: "Deep-parse extras — stored on the per-source payload for future venue-detail UI; not (yet) resolver-known fields, so they can't clobber facts." This is a deliberate architecture choice, not an oversight — Naver-only review/hours data has no cross-source corroboration, so folding it into the multi-source-agreement model (resolve_venue_facts) would be category-wrong. The correct read path is the raw per-source observation, not the resolved venues row.

That raw read path already exists and needs zero new query. venue_source_ observations already grants unconditional SELECT to authenticated (USING (true), verified live), and getSourceValues(id) (the adapter method backing useVenueSourceValues) already fetches every provider's full payload for a venue in one query — it just only projects SOURCE_VALUE_FACTS (the 7 resolver-known keys) out of each row. directory-venue-detail-screen.tsx already calls useVenueSourceValues(venueId) unconditionally on mount (line 342, feeding getCoordSources) — so business_hours/review_keywords are already sitting in memory on the detail screen the moment it loads; they just aren't read out of the response. This is a pure additive-projection + render fix, no migration, no new network round-trip.

2.7 Filter unification — what's actually shared vs. what's a product decision

Diffed all three filter modules line-by-line rather than assuming "same option source = same behavior" (the audit's own method, re-verified here):

SurfaceCardinalityEncodingFile
Pickupmulti-selectregion:district (colon, indexOf-based parse with defensive bounds)pickup-filter-values.ts
Venue directorymulti-selectregion-district (hyphen, startsWith-based, relies on the undocumented-to-callers invariant "region slugs never contain a hyphen")venue-filter-bar.tsx:46-56
Club discoverysingle-selectnone — plain region: Region | null / district: string | null fieldsdiscover-filter-model.ts

All three already share the same option source (REGIONS/getSigunguOptions from @/config/regions) — that part is correctly centralized (confirmed, no drift). What's duplicated is (a) the value-encoding scheme for the two multi-select surfaces, and (b) the region→district cascade-reset logic (clearing districts that no longer belong to a de-selected region) — both pickup and venue hand-roll their own version of the same 6-line reset.

Two separable fixes, deliberately scoped differently:

  1. Encoding module — fully specified, zero product-behavior change. Pickup's region:district scheme is the better-engineered of the two (defensive indexOf bounds vs. venue's bare startsWith, and Korean district names never contain a colon or a hyphen, so either is technically safe — colon is adopted because it already has the more defensive parser and predates the venue module). Promote buildPickupDistrictFilterValue/parsePickupDistrictFilterValue out of pickup-filter-values.ts into a shared location, add the one function venue's module has that pickup's doesn't (districtBelongsToRegion), and point both pickup-filters.tsx and venue-filter-bar.tsx at the one module. This alone closes the "live drift, not hypothetical" MEDIUM finding completely — it's a like-for-like internal refactor with identical rendered output, so it does not need a wireframe pass.
  2. Full component unification across all three cardinalities — direction proposed, not fully specified. A single RegionDistrictFilter primitive parameterized by multi: boolean (mirroring DropdownChip's own existing multi prop) could back all three, with club discovery's single-value DiscoverFiltersState.region/.district fields adapted at the call site (regions={filters.region ? [filters.region] : []}, onRegionsChange={(next) => set('region', next[0] ?? null)}). This is not fully designed here — changing club discovery's underlying selection cardinality touches user-visible filter-panel composition, which CLAUDE.md's wireframe-first rule requires iterating with the user on before code, not something this doc should silently prescribe. Flagged as the next decision point, same discipline PS1 uses for the profiles view-vs-RPC choice it explicitly evaluated and deferred alternatives for.

2.8 Cause-aware empty state — using the primitive that already supports it

EmptyState (packages/ui/src/empty-state.tsx:16-25) already has a first-class actionLabel/onAction CTA slot — this isn't a new pattern or a "CTA in a card" violation (EmptyState is its own primitive, not a Card). The fix isn't just better copy, it's a one-tap recovery: when the empty result is plausibly caused by the default verification='corroborated' gate, the empty state itself should flip the toggle, not just describe it. This requires no new query or diagnostic — filters.verification === 'corroborated' at the point the list renders empty is a sufficient (if imprecise) trigger: if toggling truly wouldn't help (region has zero venues even unconfirmed), the action is a harmless one-tap no-op; if it would help, it directly resolves the empty state in place. This mirrors the audit's own suggested fix ("mention the toggle") but goes one step further using a primitive already in the design system.

2.9 The onboarding funnel gap PS3's RLS fix doesn't close

PS3's SS1 (sessions_select_public RLS policy) fixes the data half of U6 #1 — a non-member/anon caller can now read a public pickup's row. It does not touch packages/app/src/navigation/onboarding-intent.ts, which is the actual mechanism U6 #1's own reproduction trace blames for the symptom ("new user signs up, completes onboarding, lands on Home instead of the pickup they came for"): inferIntentFromPath has no rule for routes.sessionPreview(id) (/sessions/{id}/preview) or routes.clubPreview(id) (/clubs/{id}/preview, same shape, same gap), so resolveOnboardingDestination falls through to its final default (new_player → Home). Both routes already exist in routes.ts; isPublicSharePath's regex-match-then-preserve-pathname pattern (onboarding-intent.ts:69-71,141-143) is the exact template.

Type-contract detail that shapes the fix: inferIntentFromPath returns ActionIntent | null, and ActionIntent explicitly excludes'return_to_link' — the function cannot return it today, and INTENT_DESTINATIONS[pathIntent] (indexed by FirstLaunchIntent, which also excludes 'return_to_link') would break if it could. Rather than widening ActionIntent's contract (which every other caller of inferIntentFromPath assumes maps to a fixed destination), the fix adds a new top-level guard in resolveOnboardingDestination itself, before inferIntentFromPath is called — the same shape the next-param branch already uses (intent: 'return_to_link', destination: <the actual pathname>), just triggered by the current path instead of an explicit next param. Zero type changes, zero risk to the 5 existing path rules.


3. Solution design

3.1 V2 — resolve_venue_facts HUMAN OBSERVATION WIN layer (00415, part 1)

Reproduced from 00379 (the confirmed-live definition, §2.1) with one new block inserted after the multi-source computation for each fact and before the existing HUMAN CORRECTIONS WIN (venue_corrections) block. Shown for one scalar fact and one boolean fact — the remaining 9 follow the identical shape (straight override on presence, boolean facts read raw rather than folded into another bool_or):

sql
-- === HUMAN OBSERVATION WINS (venue_source_observations provider IN
--     ('host','manual'), 00415) — a straight override, not another vote.
--     UNIQUE(venue_id, provider) guarantees at most one row per provider;
--     'host' outranks 'manual' when both assert the same field (mirrors
--     facility_kind_source='host' sticky-pin precedent). Runs BEFORE the
--     venue_corrections ledger override so a validated ledger row (which
--     passed rate-limit/bounds/trust-gate) still wins over a raw table
--     write when both exist for the same field. ===
SELECT payload->>'telephone' INTO ov_tel
  FROM public.venue_source_observations
  WHERE venue_id = vid AND provider IN ('host','manual') AND payload ? 'telephone'
  ORDER BY CASE provider WHEN 'host' THEN 1 ELSE 2 END LIMIT 1;
IF ov_tel IS NOT NULL THEN
  r_tel := ov_tel;
  fc := fc || jsonb_build_object('telephone',
    jsonb_build_object('confidence', 'high', 'sources', jsonb_build_array('host')));
END IF;

-- has_indoor (boolean fact — READ RAW, never bool_or'd in with the multi-
-- source aggregate, so an authoritative manual "false" can't be flipped by
-- a single lower-trust "true" the way bool_or alone would allow — closes
-- the 00382-finding-#2 class of bug structurally, not by hand-editing rows):
SELECT (payload->>'has_indoor')::boolean INTO ov_indoor
  FROM public.venue_source_observations
  WHERE venue_id = vid AND provider IN ('host','manual') AND payload ? 'has_indoor'
  ORDER BY CASE provider WHEN 'host' THEN 1 ELSE 2 END LIMIT 1;
IF ov_indoor IS NOT NULL THEN
  r_indoor := ov_indoor;
  fc := fc || jsonb_build_object('has_indoor',
    jsonb_build_object('confidence', 'high', 'sources', jsonb_build_array('host')));
END IF;
-- ... identical shape for has_lighting, has_parking, has_shower, has_shop
-- (booleans, straight override) and court_count, capabilities, amenities,
-- operating_hours, fee_schedule (mirrors telephone's shape per type: numeric
-- override, array replace, jsonb replace respectively).

New DECLAREs: ov_tel TEXT; ov_indoor BOOLEAN; (and one variable per additional fact, same pattern). No change to the amenity-string-derivation block (00379's own addition) — it still runs on whatever r_amen this layer or the multi-source compute produced, unchanged.

3.2 V3 — submit_venue_correction's closed branch accepts false (00415, part 2)

sql
ELSIF p_field = 'closed' THEN
  IF jsonb_typeof(p_value) <> 'boolean' THEN
    RAISE EXCEPTION 'invalid closed';
  END IF;
  v_value := p_value;
  -- (validation now accepts true OR false — was `<> 'true'::jsonb` only)

And the apply branch, reusing the exact reputation-weighted trust-gate shape the fact-correction ELSE branch already implements (§2.3), rather than the old bespoke 2-agreement-only rule:

sql
ELSIF p_field = 'closed' THEN
  SELECT trust_tier INTO v_tier FROM public.profiles WHERE id = uid;
  agree := (SELECT count(DISTINCT submitted_by) FROM (
    SELECT submitted_by FROM public.venue_corrections
      WHERE venue_id = p_venue_id AND field = 'closed' AND proposed_value = v_value
        AND status IN ('applied', 'pending_agreement')
    UNION SELECT uid) u);
  do_apply := COALESCE(v_tier, 'sprout') IN ('trusted', 'veteran') OR agree >= 2;
  INSERT INTO public.venue_corrections (venue_id, submitted_by, field, proposed_value, status)
    VALUES (p_venue_id, uid, 'closed', v_value, CASE WHEN do_apply THEN 'applied' ELSE 'pending_agreement' END);
  IF do_apply THEN
    UPDATE public.venues SET
      closed_at = CASE WHEN v_value = 'true'::jsonb THEN now() ELSE NULL END,
      closure_reason = CASE WHEN v_value = 'false'::jsonb THEN NULL ELSE closure_reason END,
      updated_at = now()
    WHERE id = p_venue_id
      AND (v_value = 'true'::jsonb AND closed_at IS NULL
        OR v_value = 'false'::jsonb AND closed_at IS NOT NULL);
    RETURN jsonb_build_object('applied', true);
  END IF;
  RETURN jsonb_build_object('applied', false, 'reason', 'needs_second_agreement');

The WHERE ... AND (...) guard prevents a no-op reopen-of-an-open-venue or close-of-an-already-closed-venue from creating a false "applied" state. closure_reason is cleared on reopen (a stale 'temporary'/'permanent' tag shouldn't survive a reopen). Client plumbing prerequisite (§2.3):closedAt/closureReason need to land on DirectoryVenue (directory-venue.entity.ts + the mapper) before any reopen UI can exist — ship as part of this slice's client half even though the detail-screen banner/CTA composition itself is deferred to a quick wireframe check-in.

3.3 V4 — fee parser wired into live ingest (00416, part 1)

sql
-- inside record_venue_observations, before the INSERT ... ON CONFLICT:
IF r->>'provider' LIKE 'gov_%' AND (r->'payload'->'fee_schedule') IS NOT NULL THEN
  r := jsonb_set(r, '{payload,fee_schedule}',
    (SELECT jsonb_agg(
       CASE WHEN elem ? 'note' AND NOT (elem ? 'amount')
            THEN private.parse_tennis_fee(elem->>'note')
            ELSE elem END)
     FROM jsonb_array_elements(r->'payload'->'fee_schedule') elem));
END IF;

Matches the wiring plan's fix shape exactly (§2.4 — no correction needed here, only V5 required re-grounding).

3.4 V5 — adm-lookup at ingest time (00416, part 2 — corrected mechanism, §2.4)

Migration half — teach ingest_venues_batch to accept + write region linkage, mirroring enrich_venues_batch's existing pattern:

sql
-- added to ingest_venues_batch's INSERT/UPDATE column list:
adm1_id = COALESCE(v.adm1_id, (r->>'adm1_id')::uuid),
adm2_id = COALESCE(v.adm2_id, (r->>'adm2_id')::uuid),

Edge-function half (not a migration — supabase/functions/ingest-venues/, ships together with the migration since neither alone closes the gap): before calling upsertVenues(), resolve each row's adm1_id/adm2_id via the identical Kakao coord2regioncode + administrative_divisions slug/name match backfill-venue-adm.mjs already implements (port the function verbatim, don't re-derive), attach to the row JSONB, then call ingest_venues_batch as today. The standalone backfill script stays as the one-time closer for the historical NULL-adm backlog; this only prevents future drift.

3.5 V6 — weather severity re-notify (00417)

Exactly as specified in the wiring plan — no additional research needed (single-table trigger change, well-scoped):

sql
IF TG_OP = 'UPDATE' AND OLD.is_active = true AND NEW.warning_level = OLD.warning_level THEN
  RETURN NEW;  -- only skip when nothing materially changed
END IF;
-- dedup key extended: session_weather_warning:{userId}:{warning_type}:{warning_level}:{session_date}

3.6 U6-2 — confidence-tier badge on DirectoryVenueCard

ts
// directory-venue-card.tsx — no new prop, computed from venue fields already present
const UNCONFIRMED_BADGE_VARIANT: BadgeVariant = 'warning'; // named const, matches VERIFIED_BADGE_VARIANT's own pattern (COMP-2)
const isUnconfirmed = !isVerified && (venue.sourceCount < 2 || venue.verificationStatus === 'unverified');
// in the name row, mutually exclusive with the verified badge:
{isVerified ? (
  <Badge label={i.verified} variant={VERIFIED_BADGE_VARIANT} size="xs" />
) : isUnconfirmed ? (
  <Badge label={i.unconfirmedBadge} variant={UNCONFIRMED_BADGE_VARIANT} size="xs" />
) : null}

New i18n key courts.unconfirmedBadge: '미확인' (ko/venues.ts, sibling to verified: '인증됨' at line 127). No filter prop threading — the badge only starts appearing once "미확인 포함" is toggled and such venues enter the list, by construction (§2.5).

3.7 U6-6 — deep-data surfacing (zero migration)

Domain type (directory-venue.entity.ts):

ts
export interface NaverBusinessHourEntry {
  day: string;
  start: string;
  end: string;
}
export interface NaverReviewKeyword {
  code: string;
  label: string;
  count: number;
}
export type VenueSourceValues = Partial<Record<VenueSourceValueKey, VenueSourceValue[]>> & {
  naverBusinessHours?: NaverBusinessHourEntry[];
  naverReviewKeywords?: NaverReviewKeyword[];
};

Adapter (venue-directory.supabase.ts, inside getSourceValues's existing per-row loop — same query, no new fetch):

ts
if (row.provider === 'naver_place') {
  const bh = payload.business_hours;
  if (Array.isArray(bh) && bh.length > 0) out.naverBusinessHours = bh as NaverBusinessHourEntry[];
  const rk = payload.review_keywords;
  if (Array.isArray(rk) && rk.length > 0) out.naverReviewKeywords = rk as NaverReviewKeyword[];
}

Render (directory-venue-detail-screen.tsx): a new "리뷰 키워드" SectionBlock (Badge chip strip, ${label} (${count}), top 5 by count) placed after the existing "요금" block; widen the existing "운영 시간" block's render guard from hasHours to hasHours || sourceValues?. naverBusinessHours != null and append the per-day rows as additional SpecList items when present — richer than the coarse weekday/weekend/rest buckets the resolved operating_hours fact carries, sourced from sourceValues which is already fetched unconditionally on this screen (useVenueSourceValues(venueId), line 342 — confirmed no enabled gate here).

3.8 U6-5 — shared region/district encoding module (encoding half only, §2.7)

New module (e.g. packages/app/src/domain/utils/region-district-filter.ts), lifted from pickup-filter-values.ts verbatim plus the one missing helper:

ts
export const REGION_DISTRICT_SEPARATOR = ':';
export function buildRegionDistrictValue(region: string, district: string): string;
export function parseRegionDistrictValue(
  value: string
): { region: string; district: string } | null;
export function districtBelongsToRegion(value: string, region: string): boolean {
  return value.startsWith(`${region}${REGION_DISTRICT_SEPARATOR}`);
}

pickup-filter-values.ts re-exports from here (no call-site churn in activity). venue-filter-bar.tsx drops its local buildDistrictValue/ districtBelongsToRegion and imports the shared module instead — the ${regionSlug}-${구} hyphen format changes to colon, a pure internal value-encoding change with zero effect on rendered options/labels. Club discovery is not touched by this slice (§2.7 — different cardinality, deferred pending a wireframe decision).

3.9 U6-4 — cause-aware empty state with a one-tap fix

tsx
// venue-directory-content.tsx, emptyState (non-fetching branch)
<EmptyState
  icon={MapPin}
  title={i.empty}
  subtitle={
    filters.verification === 'corroborated' ? i.emptyDescriptionTrustGated : i.emptyDescription
  }
  actionLabel={filters.verification === 'corroborated' ? i.filterUnconfirmedToggle : undefined}
  onAction={
    filters.verification === 'corroborated'
      ? () => setFilters({ ...filters, verification: 'all' })
      : undefined
  }
  variant="compact"
/>

New i18n key courts.emptyDescriptionTrustGated, e.g. "미확인 코트까지 함께 찾아볼까요?" (exact copy is a voice/tone call, not a data-modeling one).

3.10 U6-3 — plain-string-compare sweep (club discovery)

ts
// packages/app/src/presentation/utils/plain-string-compare.ts (new, shared —
// venue-directory-content.tsx's own inline ternary at line 117 migrates to
// call this too, so the ICU-avoidance pattern has exactly one implementation)
export function plainStringCompare(a: string, b: string): number {
  return a < b ? -1 : a > b ? 1 : 0;
}

Replace all 5 call sites: club-card-model.ts:396,399 and club-card-summary.ts:30,55,59. Explicitly scoped to these 5 — the repo-wide grep found 6 more localeCompare call sites outside this block (records-history-screen.tsx, record-detail-screen.tsx, scorecard/draws-tab.tsx, session-match-detail.tsx, signal-resolver.ts, user-state.rules.ts, use-club-attendance-summary.ts); each sorts a different (possibly small/bounded) list and belongs to PS10/PS4's domains, not audited here — flagged as a follow-up sweep, not silently expanded into.

ts
// onboarding-intent.ts, new top-level guard in resolveOnboardingDestination,
// before the pathIntent branch (mirrors the next-param branch's own shape):
function isPreviewPath(pathname: string): boolean {
  return /^\/(?:sessions|clubs)\/[^/?#]+\/preview$/.test(pathname);
}
// ...
if (isPreviewPath(pathname)) {
  return { intent: 'return_to_link', destination: pathname, source: 'current_path' };
}
const pathIntent = inferIntentFromPath(pathname, params);

Zero change to inferIntentFromPath's return type or the 5 existing path rules (§2.9 — the type-contract reason this is a standalone guard, not an ActionIntent addition).


4. Slices

#SliceFindingTypeBlast radiusVerificationDepends on
1resolve_venue_facts HUMAN OBSERVATION WIN layer (00415a)V2migrationresolve_venue_facts body only (additive block)Seed a venue with manual has_indoor=true + 2 agreeing scraped has_indoor=false observations, call resolve_venue_facts — assert resolved value stays true (§ wiring plan's own verification, now boolean-override-safe not just precedence-safe). Repeat for telephone (scalar override) and confirm venue_corrections-ledger values still win when both exist for the same field.PS1 slice 5 (must land first — same function body)
2closed/reopen trust-gate extension (00415b)V3migrationsubmit_venue_correction's closed branch onlyClose a venue via the existing 2-agreement path; a trusted-tier user submits closed=false — assert instant apply, closed_at clears, closure_reason clears, venue reappears in the default directory query. A sprout-tier single reopen vote — assert pending_agreement, no state change.none
3closedAt/closureReason on DirectoryVenue (client)V3 (prereq)client OTAentity + mapper only, no new queryyarn typecheck; confirm a closed venue's detail-screen data now carries closedAt (UI banner/CTA composition explicitly deferred — flag for a wireframe pass before building it)none
4Fee parser wired into record_venue_observations (00416a)V4migrationrecord_venue_observations only, additiveFeed a synthetic multi-sport gov fee blob through record_venue_observations (not the backfill migration) — assert the tennis-specific price is extractednone
5ingest_venues_batch accepts adm1_id/adm2_id (00416b)V5 (SQL half)migrationingest_venues_batch SET list only, additiveSELECT public.ingest_venues_batch(...) with a row carrying adm1_id — assert the column populates (today it's silently dropped)none
6ingest-venues Kakao adm-lookup (edge-fn)V5 (compute half)edge-fnsupabase/functions/ingest-venues/ onlyIngest a synthetic new-region venue through the deployed function — assert adm1_id/adm2_id populate without a separate script runSlice 5 (the column must exist to receive the value)
7Weather severity re-notify (00417)V6migrationweather_warning_cache UPDATE trigger onlyActivate 주의보, update to 경보 without deactivating — assert a new/updated signal fires (previously silent)none
8Confidence-tier badge on DirectoryVenueCardU6-2client OTAdirectory-venue-card.tsx, 1 new i18n keyToggle "미확인 포함" on a region with unconfirmed venues — assert 미확인-badged and 인증됨-badged cards are now visually distinguishable; confirm default (corroborated) view renders identically to today (badge never appears)none
9Deep-data surfacing (business hours + review keywords)U6-6client OTAdomain type, adapter (getSourceValues), detail screenOpen a venue detail page for a venue with a naver_place observation carrying business_hours/review_keywords — assert both render; a venue with neither renders unchanged (no empty section)none
10Shared region/district encoding moduleU6-5 (encoding half)client OTAnew shared module, pickup-filter-values.ts (re-export), venue-filter-bar.tsxyarn check; manually confirm pickup + venue directory filter panels render identical option lists/selection behavior pre/post (pure internal encoding swap)none
11Cause-aware empty stateU6-4client OTAvenue-directory-content.tsx, 1 new i18n keyFilter to a sparse region with only unconfirmed venues under the default filter — assert the empty state now shows the trust-gated copy + CTA; tap the CTA — assert verification flips to 'all' and results appear in placenone
12localeCompareplainStringCompare sweepU6-3client OTAnew shared util, club-card-model.ts, club-card-summary.ts (5 call sites)yarn test (club discovery sort tests, if any, must still pass with identical ordering for ASCII/Korean test fixtures); manual smoke of club discovery sort order unchanged for a representative fixture setnone
13Onboarding return-to-link for preview routes§2.9client OTAonboarding-intent.ts onlyUnit-test resolveOnboardingDestination({ pathname: '/sessions/abc/preview' }){ intent: 'return_to_link', destination: '/sessions/abc/preview', source: 'current_path' }; manual: unauthenticated → pickup preview → sign up → onboarding → lands back on the same preview, not Homenone (independent of PS3's SS1 — this fixes the redirect destination, SS1 fixes whether the data loads there)

Total: 13 slices — 5 migrations (00415×2, 00416×2, 00417), 2 edge-fn/migration pairs (V5 spans both), 8 client-OTA slices. Slices 1–2 are hard-sequenced after PS1 slice 5 (same resolve_venue_facts/submit_venue_correction function bodies). Slices 8–13 are fully independent of the migrations and of each other — ship in any order, in parallel, as OTA-only changes (no native surface, yarn check + the scenario/manual probes above are sufficient verification per the project's efficient-pipelines rule; no hosted Maestro run needed for any slice in this doc).

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