The plumbing is healthy, the judgment layer has real defects. Every property gets a fresh discovery result daily, the data payloads are intact, and today's new Texas stay-mix engine ran and is live in the UI. But the audit found 1 critical infrastructure hole (the app compiles to production with zero type checking), 9 major defects, and a tail of minor ones. The two worst product defects share one theme: hotels with missing data outscore hotels with honest unfavorable data, which is the exact opposite of the engine's own written rule, and errors and unscored hotels are displayed as if they were real zeros.
The build command runs tsc --noEmit, but the root tsconfig.json is a solution-style file with "files": [], so that command checks zero files and always passes. The real check, tsc -p tsconfig.app.json --noEmit, currently reports 235 errors on the working branch, including 2 in DiscoveryTab.tsx and 14 in useCompsets.ts. The same no-op config is on deployed main, so CI has been green while type errors ship. A comment inside DiscoveryTab.tsx documents two production crashes (ReferenceErrors) that this exact hole already let through.
tsc --build (or -p tsconfig.app.json), then burn down the 235 errors. Until then every "biome passed, tsc passed" claim in CI is theater.The scoring library's own header states the invariant: missing data must never outscore honest unfavorable data on the same brick. It is violated twice. Under the lender persona, a candidate with an unknown chain scale scores 5 points while an honest one-tier-off comp scores 4. On the new stay-mix brick, a candidate with no stay data scores 10 points while an honest large-mismatch operator (0.28 non-tax-share gap) scores 8. Both were confirmed by executing the deployed scoring functions directly. Effect: hotels with data holes rank above honestly mismatched hotels and can flip who makes the recommended core. Roughly 4.6% of the candidate pool (193 of 4,232 STR summary rows) rides the missing-data default today.
supabase/functions/_shared/compset_scoring.mjs:435 vs 443-444 (chain), :66,71,360 (stay-mix); invariant at :26-27; executed via node import 2026-08-14 (5 vs 4 pts, 10 vs 8 pts); pool counts from str_hotel_summary REST queries 2026-08-14 (4,039 of 4,232 pass the stay filter)On the live HOUZN page, current member Staybridge Suites Houston Willowbrook shows score 0/100, no rank, 0.00 mi, and the new stay strip counts it as "1 unknown". The engine never scored it: its STR summary row is marked status: inactive, which excludes it from the candidate universe, even though it filed 12 consecutive months of receipts through 2026-06 totaling $2,108,158 TTM. The wrong status is almost certainly an ownership-transition artifact (its merged hotel rows carry two different taxpayer numbers). Three separate lies land on screen from one root cause: a fake zero score, a fake 0.00 mi distance, and a footer count ("7 currently in compset") that contradicts the members table ("8 peers").
Today's stay-mix commit changed the batch pipeline to classify product type from brand_name || canonical_name, but the deal-compset edge function (which imports the same "single source of truth" library and carries a "keep in sync" comment) still passes brand_name only. A hotel with a null brand but a brand-bearing name (e.g. "Homewood Suites ...") scores the full 15-point product brick in the nightly batch and 0 in the deal path.
The lender persona can append up to 2 members to push RGI into the 105-125 band. Those additions are checked for tract, size, correlation, value, and tier, but not for the STR concentration rules (single property ≤ 50%, brand ≤ 50%, parent ≤ 70%). Worse, the cached compliance_by_persona is snapshotted before band-seek runs, so the UI can show a green "all five rules pass" badge on a lender set that violates rule 3.
The STR upload reconcile writes the subject hotel into its own compset as an active member. The dashboard drift metric counts all active members, but the engine excludes the subject from its universe, so the subject can never be a "top-7 pick" and every self-including compset takes an automatic drift hit. Recomputed both ways from live data: HOURP reads 13% drift but is truly 0%; CFDBR and CLLUD read 40% vs a true 25%; CLLSP 33% vs 20%; HOUZN 33% vs 25%. Four properties wear a drift badge one severity band too alarming.
src/hooks/useDiscovery.ts:254-304 (useCompsetDrift); subject exclusion at scripts/precompute-discovery.mjs:994; self-membership count and drift recompute from live compset_members + compset_discovery_cache (audit scripts in session scratchpad, run 2026-08-14); self-row acknowledged in useCompsets.ts:1463-1469useCompsetDrift and in the list page member counts (one filter each), then decide whether reconcile should write it at all.The Discovery tab has loading and empty branches but no error branch. Any RLS, network, or PostgREST failure renders "Discovery has not been computed for X yet ... appears within a day", a false factual claim. The compset list page does the same: a metrics query error silently renders every RGI/MPI/ARI as a dash, indistinguishable from genuinely missing data.
origin/main DiscoveryTab.tsx (empty-state copy at line 697, no isError branch); Compsets.tsx:240-244 equivalent on branch, metrics error path verified by frontend audit agentisError branch to both surfaces that says a load failed and offers retry.The Add and Watchlist mutations do bare INSERTs into compset_members, which has a unique constraint on (compset_id, hotel_id). Hotels dropped by an earlier STR reconcile still have a row (status "watch", reason "dropped from STR upload"), and they render as suggested cards with live Add buttons. One click surfaces a raw Postgres duplicate-key error in the toast. Freshly staged adds have the same trap: the overlay only fetches active and watch rows, so a hotel just staged with status "add" keeps offering "+ Add", and the second click hits the same crash.
onConflict: (compset_id, hotel_id) updating status, and include staged statuses in the overlay so buttons reflect reality.On the live HOUZN view, the persona pills read "RGI 108", which is the recommended set's implied RGI, with no qualifier on screen. The swap line says "your index vs the set 112 → 108 (tax-receipt basis)", the current set. The list page says RGI 112.3, from the Jun 2026 STAR report. Same word, three bases, only one of them labeled. An owner glancing at the pills reads their RGI as 4 points lower than it is.
live page 2026-08-14 (pills 119/108/108, swap 112 → 108, list 112.3 from "Jun 2026 STAR Report · uploaded Jul 19"); origin/main DiscoveryTab.tsx:728 (impliedRgi = cache.implied_rgi_by_persona), PersonaToggle.tsx:63-68 (no on-screen qualifier)The Discovery tab receives the viewed compset's id but ignores it; mutations resolve the target as the most recent compset for the property code, while the membership badges read the viewed compset. Today no subject has two compsets so it works by coincidence; the day one does (e.g. a deal compset alongside the operating one), adds land in the wrong set with no error.
src/hooks/useDiscovery.ts:29-49,323; DiscoveryTab.tsx:118-134 and unused compsetId at :889-892; live probe: 0 subjects currently have multiple compsets (frontend audit agent, 2026-08-14)compsetId into the mutations. Two-line change now, silent data corruption later.| Defect | Detail and source |
|---|---|
| Verdict band ignores the persona toggle | Recall ("6 of 8 STR peers in the top 7") is computed once on the balanced ranking; under Owner or Lender the band still claims it against a different visible list. Also latent: with more than 10 STR peers the 0.7 recall@7 threshold is mathematically unreachable, and the hits number is reconstructed as round(fraction × peers) with no guard against pre-migration-253 integer rows. DiscoveryTab.tsx:491-501; precompute-discovery.mjs:1282; migration 253 |
| Exactly-100 RGI renders as good but is not counted as good | The color threshold is ≥ 100 on the raw value while "Above Fair Share" counts > 100, and formatting rounds: 99.95 displays "100.0" in amber. origin/main Compsets.tsx:25-27,451 |
| List-page Scan Now inserts an empty job and swallows errors | Legacy call path creates a rate_scan_jobs row with hotel_ids [] and total_targets 0, and the rejection ("another scan running") is discarded, so the button gives no feedback. Compsets.tsx:164; useCompsets.ts:631-657 |
| Detail-page scans pay for non-active members | Scan dispatch builds hotel ids from ALL member statuses (watch/add/evaluate) while displaying actives only: paid DataForSEO scans for watchlisted hotels. Doc comment says nights capped at 30, code caps at 90. CompsetDetail.tsx:97,143; RateCalendarTab.tsx:301-305; useCompsets.ts:627,632 |
| Stay-mix market profiles include dead hotels | The tract profile pool filters only coverage and span, not status or recency: 47 closed and 24 inactive hotels plus 78 rows whose TTM ended before 2025-08 feed the market bands (the 71 dead-status rows alone are 1.8% of the 4,039 eligible rows; a concentrated tract could tilt further). precompute-discovery.mjs:838-898 (loadTexasStayContext); str_hotel_summary REST counts 2026-08-14 |
| Shared-filing stay data keeps full weight | Medium-confidence (shared taxpayer filing) stay shares score identically to high-confidence ones, flag only; and shared-filing detection misses dual-brand pairs where one tower lacks a complete TTM row (suspected, not traced live). compset_scoring.mjs:369-373; precompute-discovery.mjs:858-869; is_dual_brand exists (mig 098) and is not consulted |
| Role gating is default-open | chief_engineer, comptroller, bookkeeper, analyst, and staff fall through to the default tab set and see MORE than a GM sees (Discovery, Parity, Alerts). Policy question rather than bug, but worth an explicit decision. CompsetDetail.tsx:47-64; roles in nav-config.ts:20-32 |
| Generated DB types are stale | src/types/database.ts says compset_members.status_reason and hotel_tax_summary.tract do not exist; live probes confirm both exist. Last regenerated at commit ffa393c0. Several of the 235 type errors trace here. frontend audit agent live probes 2026-08-14; database.ts:402-432 |
| Discovery cache grows without bound | Insert-only table, 1,728 rows at +15/day, large JSON payloads, and the UI only ever reads the newest row per property. No pruning exists. compset_discovery_cache full scan 2026-08-14; write path precompute-discovery.mjs:1771 |
| Test residue live in prod | "RLS Test Compset Alpha" (null subject, 1 member) is an active compset counted by any unfiltered query. compsets scan 2026-08-14 (data audit agent) |
| HOUZV is blind on STR recall | str_peer_count is null in its latest cache row: no STR upload exists for it, so the recall scoreboard and STR-peer filter are empty for that property. compset_discovery_cache HOUZV latest row; str_uploads (data audit agent 2026-08-14) |
| Dead code in the discovery surface | DiscoveryCandidatesTable, CompsetDiffView, PerformancePanel, useStrSummary, and the "Dropped YYYY-MM" pill logic are imported or computed by nothing. discovery-v4.mjs is dead legacy that writes no tables (verified: zero insert/update calls, no scheduler references). import graph + scheduler sweep (both audit agents, 2026-08-14) |
| INNRLY folder override map is dead config, flipped differently on each branch | Live property codes are DALDL, DFWZY, DFWXF and the Drive folders carry the same names, so no override ever fires; the branch and main carry mutually inverted key/value pairs of codes that do not exist. The two audit agents disagreed on direction; live hotels query plus a Drive folder listing settled it as harmless on both. hotels REST query + ls of "STAR Reports for Innrly Config" 2026-08-14; precompute-discovery.mjs:195 branch vs main |
| Process: today's live results were computed from uncommitted code | The 15 live v6.3 rows were written 10:15-10:16 CDT; the engine commit landed 10:17:47 CDT. Content matches what was committed, but for two minutes production data had no provable source revision. First cron-produced v6.3 rows arrive with the 2026-08-15 05:30 UTC run. compset_discovery_cache computed_at vs git log 93d32d2c |
scripts/precompute-discovery.mjs from main for all 15 properties.compset_discovery_cache.Nothing here was changed yet: this audit is read-only, per the ask. Say the word and I will start on the fix list.