An operator names one place — "Kenosha, Wisconsin" — and this answers two
questions: which local service niche can I win here, and can an exact-match
.com rank inside a month?
Locality-first. Every keyword tool answers the inverse question (niche-first, sweeping thousands of cities for one niche) and so cannot answer the question an operator actually has. Retrofitting locality-first onto a niche-first schema is a migration, so it is built this way from the start.
| Output | Meaning |
|---|---|
| SERP Difficulty 0–100 | Competition only. Higher = harder. The primary sort. |
| EMD verdict | likely_30d / likely_90d / likely_6m / not_winnable / unknown |
| Demand | Monthly searches — modelled from population |
| Modelled rent | $/month the site could rent for |
A brutal SERP worth $2,400/mo and an empty one worth nothing are different facts. One combined "opportunity score" destroys the ability to filter for either.
Never let an unmeasured signal read as a good one.
Null is not zero. Silence is not availability. An unchecked domain is not a free one. A rate-limited registry is not a yes. Every place that rule is broken, the failure is silent and points toward spending money.
Concretely:
- Unmeasured scoring components are omitted and the weights renormalise —
never defaulted to 0. Zero referring domains is the strongest "beatable" signal
there is, so a missing measurement rendered as
0turns every unknown domain into a jackpot.weightCoveredis surfaced next to every difficulty score. difficultyis nullable and sorts LAST in an easiest-first table. A 0 would sort first and read as the best opportunity in the locality.outcomes.positionis nullable,checked_atis not. The row existing is the measurement; a null position means checked and nowhere. Treating it as missing data drops every failed build from the denominator.- One deliberate inconsistency: the
likely_30dband, alone, treats missing evidence as a failure. Everywhere else leniency is right; here it is not, because this is the only output that says "go buy this domain".
Volume is estimated. Rent is modelled. The 30-day verdict is a prior until outcome data says otherwise. That is on screen in a permanent banner, not in a docstring — this is a tool people buy domains from.
pnpm install
cp .env.example .env # then fill in DATABASE_URL
pnpm db:push # create the schema
pnpm seed:niches # 41 local service niches
pnpm ingest:geo # Census corpus + provider location resolutionTwo processes:
pnpm dev # Next.js
pnpm worker # the ONLY consumer of the scan_runs queueA queued scan only starts when the worker is running. There is no Redis: the
scan_runs table is the queue, so there is no second system to forget to
poll — which is what made the previous build's "Start scan" button silently do
nothing forever.
LIVE_CALLS_ENABLED must be the exact string true to spend money. Anything
else — unset, empty, TRUE, 1, a typo — routes every provider call to the
deterministic fixture implementations. The polarity is deliberate: a
misconfigured env var should fail toward $0, not toward live spend.
pnpm test # unit + contract, no network, no database
pnpm e2e # full pipeline on fixtures; ASSERTS spend === 0
pnpm scan kenosha-wi --cap-cents 25 # one locality, explicit cap, full log| Endpoint | Cost |
|---|---|
/serp/google/organic/live/advanced |
$0.002 |
/serp/google/maps/live/advanced |
$0.002 |
/backlinks/bulk_*/live (×3) |
$0.024/req + $0.000036/row |
/serp/google/locations · /appendix/user_data |
free |
| US Census bulk files · RDAP | free |
~$0.24 per cold locality (41 niches × [SERP + map pack] + one batched link
lookup). Budget is integer micros in a bigint, checked before each
purchase, and every purchase writes a spend_ledger row so the total is
reconcilable rather than merely tracked.
There is deliberately no two-stage cheap/expensive funnel. At $0.24 for full fidelity, a funnel spent real complexity plus a score gate that silently dropped most rows in order to save about twenty cents, and made the output worse.
Each of these is guarded by a test, and each one failed silently in the previous build.
It does not return referring_domains, despite the name and every instinct.
Reading them off it yields null for every domain forever — the 0.40-weight
authorityWall component vanishes from every score, the model renormalises
around the hole exactly as designed, and the only symptom is a coverage
percentage nobody reads. This survived for months.
All three backlinks endpoints are required, fanned out in parallel and merged
on target. Guarded by a negative contract test asserting the field is
absent — a test written against our own beliefs would agree with whatever we
already believe, which is how the bug survived.
It is /serp/google/locations, not /serp/google/organic/locations. Guessing
produces "zero locations exist" rather than an error.
/dataforseo_labs/locations_and_languages returns 94 rows and stops at
Country — it enumerates keyword databases, not queryable places. Volume is
modelled from population and flagged estimated: true as a literal type, so
it cannot be constructed unflagged.
A real capture, in __contracts__/error_ip_not_whitelisted.json:
HTTP 200 · status_code: 20000 · status_message: "Ok." · tasks_error: 1
tasks[0].status_code: 40207
tasks[0].status_message: "Access denied. Your IP is not whitelisted."
tasks[0].result: null
The outer envelope claims success twice. Only tasks[0].status_code says
otherwise. Read as "no results", this scores every SERP in the corpus as a
wide-open jackpot — and the credentials were perfectly valid, so nothing else
looked wrong. Every response passes three gates: HTTP status → body shape →
tasks[0].status_code === 20000. An account issue throws and aborts the run;
it is never catchable into an empty result set.
api.census.gov returns an 8,529-byte HTML "Missing Key" page under HTTP 200
— at national, state, and single-place scope alike. Verified 2026-08-02.
The whole geography corpus therefore comes from keyless www2.census.gov
bulk files, and median household income (API-only) was cut from the rent
model rather than made a prerequisite for ingest. The general rule it produced:
the fetch layer asserts response shape, never just status.
Two of the briefed paths were also wrong:
- sub-county population is at
.../cities/**totals**/sub-est2024.csv .../metro/totals/cbsa-est2024-alldata.csvgives real CBSA population, which retires the metro-undercount problem at source rather than tuning around it (Milwaukee: 1.57M actual vs ~700k from summing places)
/serp/google/locations is free but sits behind the account's IP whitelist —
and this machine's egress IP rotates within a NAT pool, so a single-address
whitelist works intermittently (one ingest got all 267,107 rows; the next got
40207). Without a location index, every locality fails to resolve and the tool
has nothing to scan. That is what "Tucson, AZ has no provider location code"
looks like from a perfectly healthy ingest.
So there is a fallback: Google Ads geo target constants — free, keyless,
16,407 active US cities plus 3,098 counties. DataForSEO documents its
location_code as the Google Ads criterion ID, and the canonical-name forms
match exactly, including both odd ones:
Orange,Orange,California,United States <- county, no word "County"
Brentwood,Contra Costa County,California,United States <- county with the word
McKinney,Texas,United States <- no county at all
But that equivalence is unverified, so localities.location_source records
which source produced each code, and runScan refuses to spend money on
anything but 'dataforseo':
Tucson, AZ has location code 1013509 from "google_geotargets", not from
DataForSEO. Refusing to spend money on an unverified code -- it could return a
well-formed SERP for the wrong city.
Fixture scans work fine on either source. Verification is free, so the fix is to
re-run ingest:geo with a reachable API, not to trust the fallback.
404 = available · 200 = registered · anything else = null. A 429 is not
a yes. DNS fallback is asymmetric: nameservers present proves registered;
their absence proves nothing.
Each silently loses real cities.
- Never widen. "Kenosha, Wisconsin" happily matches the Wisconsin Region
row, returning a well-formed statewide SERP that is completely wrong. Explicit
acceptTypesper kind; unmatched beats substituted. - Case-SENSITIVE suffix stripping.
Richmond city→Richmond, butKansas CitystaysKansas City. Blind stripping sends Kansas City to the state of Kansas. - State-scoped consolidation aliases.
Boise City|ID→ Boise. Keyed on name and state because there is also a Boise City in Oklahoma (pop. ~1,100), and a name-only table sends it to Idaho. - Three county-qualification forms, because the provider is inconsistent:
Kenosha,Wisconsin,US·McKinney,Collin County,Texas,US·Orange,Orange,California,US. McKinney is 227,000 people and was missing entirely until form 2. - The 95% rule. Skip a county rollup when one member place is ≥95% of its
population. Catches all 23 VA/MD/MO/DC independent cities generically — they
are simultaneously places and county-equivalents, and their county rows can
never resolve because the provider types them as
City. - Slug is the only natural key.
(kind, state, name)is not unique — two Wilmingtons in Illinois, three Oakwoods in Ohio. FIPS disambiguation is applied to every member of a collision, so slugs don't depend on row order.
pnpm ingest:geo fails loudly below 100% coverage of cities >250k and 99% of
the 25k–250k band, and prints a sample of unmatched localities with reasons. An
unresolved locality is silently excluded from scanning, so a bad rule must
surface as a failed ingest rather than as "no data for half the country".
CTR-weighted, never 1/position:
[0.276, 0.151, 0.100, 0.070, 0.052, 0.040, 0.031, 0.025, 0.021, 0.018]
| Component | Weight |
|---|---|
authorityWall — CTR-weighted defender link strength |
0.40 |
slotDefence — what kind of result holds each slot |
0.30 |
intentLock — is there a city+niche-dedicated asset, and how high? |
0.15 |
linkQuality — dofollow ratio + spam, scaled by link mass |
0.15 |
Platform domains (Yelp, Angi, Facebook, BBB, Thumbtack, …) contribute a fixed 0.12, not their real link profile. Yelp has ~4M referring domains; through the same curve as a local plumber it scores ~1.0 and walls off the page. But a Yelp "Best Plumbers in Kenosha" page at #2 ranks on generic domain power applied to a template — nobody there is defending that query, and for a local operator it is the easiest slot to take.
Without this discount, every directory-stuffed SERP — which is to say every genuinely winnable market, which is to say the entire reason this tool exists — scores as unwinnable. It looks like a bug and it is the most important constant in the model.
| SERP | This model | Brief's estimate |
|---|---|---|
| 8 directory slots + 2 thin locals | 16 → likely_30d |
~10 |
| 2 committed operators + directories | 32 → likely_6m |
~33 |
| 5 exact-match operators, 150–420 refdomains | 81 → not_winnable |
~72 |
Ordering and all three bands match exactly. The two magnitude gaps are left in
place rather than tuned away: reaching ~72 on the brutal page needs an authority
saturation ceiling around 23,000 referring domains — a national-scale ceiling,
and a local-scale one is precisely why this reads local SERPs better than Ahrefs
KD does. Both differences are conservative (harder, not easier), so they cost
missed opportunities rather than wasted purchases. See
packages/core/src/scoring/__fixtures__/archetypes.ts.
authorityWall initially reported itself measured whenever any result was
evaluable — but platforms always are, since they use a constant. On a page where
five exact-match operators hold 1–5 and five directories hold 6–10, losing the
operators' link data still left the directories, and the component happily
reported 0.12: a page walled by five committed operators with hundreds of
referring domains, scored as having almost nothing defending it. Fixed with
AUTHORITY_WALL_MIN_CTR_COVERAGE — the evaluated results must account for ≥50%
of the page's click weight, or the component is not a weak reading of the
defenders, it is no reading of them.
Every threshold in priors.ts is annotated PRIOR — awaiting calibration with
its source (Ahrefs' 2M-page study: 1.74% of new pages reach the top 10 within a
year; ~40.8% of those that do arrive within a month). Emitting "72% chance" from
that would be invention dressed as measurement, so the model emits bands with
named blockers.
- Hit rate per band is reported with n always visible — "100%" off 3 builds is not the same claim as off 300.
isOrderingSound()checks30d ≥ 90d ≥ 6m. Answerable at far smaller n than any absolute rate, and it fails for a different reason: a violation means the model is misreading SERPs, not that a constant needs nudging.- Rank re-checks at day 7/14/30/60/90 are not cache-first. The cached snapshot predates the site by definition, so serving it would record every build as never having ranked.
difficulty_at_save/verdict_at_saveare frozen on the shortlist item. Joining to a live score would let today's thresholds judge yesterday's build, and every band would validate itself.
scan_runs -> scan_targets -> shortlist_items -> sites -> calls -> leads -> lead_outcomes
\-> serp_keywords -> serp_targets -> serp_checks
A locality+niche pair is one market, addressed by /markets/{locality}/{niche} rather
than by a site id -- research exists before a site does, so an id-keyed URL could never show a
cell you had merely scanned. One market has one website: sites_active_cell_uq is a partial
unique index on (locality_id, niche_id) WHERE status <> 'dropped', so a cell can be
re-targeted after being dropped without losing its history. sites.domain is nullable --
you start watching keywords before you register a domain, and a placeholder domain would be
worse than none because the column is unique.
/sites, /sites/:id and /shortlist permanently redirect here. Keeping them apart is what
put the frozen prediction on one page and the realised result on another, so the one comparison
this system exists to make was never on screen together.
You post comments on Reddit threads that rank for a market's keywords. Two things are watched: whether the thread still ranks, and whether your comment held its place.
- Keywords arrive as a Semrush CSV -- UI headers (
Keyword,Search Volume) and API codes (Ph,Nq) both, any column order,,/;/tab, BOM tolerated. Only a keyword column is required and every skipped row is reported with its line number. - The thread's rank comes from one live SERP ($0.002), which also yields your own domain's position for free from the same response.
- The comment ordinal is read from
old.reddit.commarkup fetched through DataForSEO. Reddit answers 403 to server IPs -- verified againstwww,oldandapi, all three returning a "blocked / bot / network security" page -- and self-service OAuth registration closed in 2026, so there is no token to go get. DataForSEO crawls for a living, and it is already the vendor whose credentials and spend ledger this repo understands. serp_targets.next_check_atis the queue, claimed withFOR UPDATE SKIP LOCKEDlikescan_runs. Daily, capped bySERP_MONITOR_DAILY_CAP_CENTSbefore each purchase.
serp_checks.comment_present is the column this feature can get dangerously wrong:
| Value | Meaning | Alerts? |
|---|---|---|
true |
found, with an ordinal | no |
false |
a complete thread was loaded and the comment is not in it | yes |
NULL |
could not measure -- blocked, or the tree was truncated | never |
Telling you your comment was deleted because Reddit rate-limited a crawler is the worst thing
this feature could do, so a 403, a layout change, or a "load more comments" node all write
NULL. serp_position is the opposite: there NULL is the measurement -- we ran the search and
the thread was not in the top 100, which is the "post is not showing up" signal itself.
The research half answers which domain. This half answers what it produced.
scan_runs → scan_targets → shortlist_items → sites → calls → leads
(research) (the decision) (the asset and its revenue)
Ranking was never the outcome. Calls are. shortlist_items already freezes
what the model predicted at decision time, and the only outcome data was
outcomes.position — did it rank. A site at position 3 producing two calls a
month falsifies the modelled rent just as loudly as one that never ranked, and
nothing could see that. sites carries locality_id, niche_id and an optional
shortlist_item_id, so real call volume finally tests priors.ts.
A Twilio number rings, a Retell agent answers, triages for genuine emergencies, qualifies the caller, and the lead lands in Postgres before they hang up.
Retell's inbound call webhook fires before the call connects. We resolve the
dialled number to a site and return its name, hours, service area and fee as
dynamic variables, plus metadata.site_id. One prompt, one agent, every site
correct — adding a site is an INSERT plus a number.
metadata.site_id is then the only way a call learns its site. Resolving by
to_number at report time would silently reattribute every historical call the
moment a number moved between sites — the same discipline as difficulty_at_save.
A number whose webhook URL was never configured produces real calls, sends nothing, and renders as
0 calls.
Zero calls and never-connected are the same pixels, and the second one means
customers are ringing through and vanishing. That is the Start-scan button that
silently did nothing, again. So sites.first_webhook_at is nullable and drives a
permanent banner, and every count on an unconnected site renders as an em dash
rather than a zero. Provisioning also sets the webhook URL in the same API call
that imports the number, so the banner is a backstop rather than the only defence.
leads.is_emergencyis nullable, notfalse. Never asked ≠ caller said no. Afalsehere is how a no-heat call at 11pm in January gets queued for Tuesday. The tool parser coerces"unknown"/"maybe"/absent to null, and deterministic triage over the transcript can only ever promote to true — a keyword scan finding no hazard is absence of evidence, not evidence of absence.in_service_areais nullable. An unvalidated zip is not an in-area zip.qualifiedis nullable and sorts LAST, exactly likedifficulty.callsrows are created oncall_started. A caller who hangs up at four seconds is a measurement — abandon rate is how you learn the greeting is too slow or too obviously synthetic. Create the row at end-of-call and every abandoned call vanishes and the funnel looks perfect.captured_fieldsis stored separately from the values. "Address is null" and "address was never asked" are different bugs.
The mid-call save_lead tool is authoritative — it is what makes a lead
survive a hang-up, and the caller who gives a name and "my furnace is dead" then
hangs up is still worth $50–200. Retell's post-call analysis only fills nulls;
where it disagrees the tool wins and the conflict is written to
reconcile_conflict, because a recurring conflict on one field is a prompt bug and
that is the only place it becomes visible.
Retell's recording_url is an S3 link with no documented lifetime — and a hard
ten minutes when the PII opt-out is enabled. A queued job copies the bytes to
RECORDINGS_DIR; the UI reads recording_path, and a null renders the reason it
is missing rather than a play button that 404s.
pnpm db:push && pnpm db:extras # extras adds the partial indexes push cannot express
pnpm voice:agent-config # the prompt + tool schema to paste into Retell
pnpm voice:simulate <domain> # a full signed fake call, no phone needed
pnpm sites:provision <domain> --number +1XXXXXXXXXX [--confirm]voice:simulate exists so the dashboard is built without phoning the number and
squinting at logs. It signs its payloads with RETELL_API_KEY rather than
bypassing verification — there is no SKIP_SIGNATURE flag, because that flag
eventually ships and turns the tool endpoint into an open write endpoint.
A simulated call never texts anyone. calls.simulated is set at ingest from the
call-id prefix (sim_, test_, e2e_ — Retell's own ids are call_…, so there is
no overlap), and with live providers the delivery job records suppressed instead of
sending. Found the hard way: the first simulated call after LIVE_CALLS_ENABLED=true
queued a real SMS to a real phone about a caller who did not exist. Suppression is
gated on the provider being live, so the fixture path still tests delivery end to end.
Relatedly, both vitest configs force LIVE_CALLS_ENABLED=false. They load the
real .env, so flipping that flag silently turned pnpm e2e into a suite that buys
SERP data and sends texts — and expect(spend).toBe(0) fires after the requests, so
it reports the loss rather than preventing it.
sites:provision is a dry run without --confirm: attaching a number to the
trunk silently removes it from Programmable Voice, and these are working business
lines. It also refuses outright when the trunk has no Disaster Recovery URL —
without one, a Retell outage is a dead phone line. Full runbook in
docs/telephony.md; architecture in
docs/crm-plan.md and
docs/voice-agent-plan.md.
packages/core pure: scoring, EMD, calibration, geography, money,
and the voice agent's prompt/triage/lead parsing.
ZERO dependencies. Safe to import from a client component.
packages/data providers + Drizzle schema + pipeline + voice ingest. Imports
'server-only', so a client component reaching in fails AT BUILD TIME.
apps/web Next.js App Router, incl. the Retell/Twilio webhook routes.
The package boundary is load-bearing, not decorative: core cannot see DB or
network types, so "scoring is pure" is enforced by the compiler rather than by
discipline.
- SERP + map pack per niche, cache-first,
p-limit(6) - BARRIER — collect every unique domain across all niches, buy link data in one batched call set. The single most cost-sensitive line in the codebase: the bulk endpoints charge per request plus per row, so 41 per-niche lookups pay the request fee 41 times for identical rows (~10×).
- Score → assess EMD → check availability → persist
Caches: SERP 45d · link profiles 90d · availability 7d · negative cache for unresolved domains 14d — small local sites with no measurable link profile are the common case, and without it they are re-requested and re-paid for forever.
Verified end to end against Supabase (ca-central-1, Postgres 17.6):
pnpm typecheck |
clean across all three packages |
pnpm test |
147 passing, 1 failing by design (see below) |
pnpm e2e |
13/13, including SPENDS NOTHING |
pnpm ingest:geo |
19,479 cities · 3,074 counties · 921 metros = 23,471 localities |
pnpm build |
all 5 routes compile; the server-only boundary holds |
| Kenosha fixture scan | 41/41 niches, $0.0000, difficulty 14–75, stdev 17.4, 24 distinct values |
Coverage, both assertion bars cleared:
kind/band resolved / total
city/250k+ 93 / 93 100.0% <- assertion: 100%
city/25k-250k 1505 / 1513 99.5% <- assertion: >= 99%
city/under 25k 9890 / 17873 55.3% (below the viable band)
county/25k-250k 1297 / 1302 99.6%
metro/25k-250k 656 / 665 98.6%
The 19,479 incorporated places matches the briefed ~19,475. The remaining mid-band misses are places newer than the free geo-target vintage — South Fulton GA (2017), Jurupa Valley CA (2011), Stonecrest GA (2017), Peachtree Corners GA (2012) — not rule failures.
Zero metros. The CBSA gazetteer puts the code in GEOID, not CBSA.
Looking for CBSA gave index -1 and therefore zero rows — with no error,
because an empty result from a file that downloaded fine looks exactly like
"there are no metros". Now 935 CBSAs → 921 metros, and the parser throws if the
column is missing rather than returning an empty list.
185 counties silently deleted, most of them real. Dothan city spans Dale,
Henry and Houston County, Alabama, and the place→county file gives no
population split. Attributing Dothan's full 71k to Dale County (pop 49k) read as
143% of the county and tripped the 95% independent-city rule. The rule was
right; its input was wrong. Now: an exact name test first (Census marks these
county-equivalents with a trailing lowercase " city"), and a share above 1.0 is
treated as proof of misattribution rather than evidence of an independent city.
150 skipped, 35 real counties recovered.
Every accented place name was corrupted. The gazetteer files are UTF-8 —
"Doña Ana County" is stored as 44 6F C3 B1 61 — and were being decoded as
Latin-1, producing "Doña Ana County". That does not surface as an encoding bug;
it surfaces as a county that will not resolve.
Multi-word legal suffixes left debris. The suffix list matched shortest-first,
so "Juneau city and borough" hit " borough" and became "Juneau city and",
and Utah's "Kearns metro township" became "Kearns metro". Longest form first.
Eleven Massachusetts cities over 25k were unreachable. Census writes
"Weymouth Town city"; stripping the lowercase " city" leaves "Weymouth Town", but the provider carries "Weymouth". Now stripped — New England
only, because a blanket rule turns Boys Town, Nebraska into "Boys".
St. Paul (307,465) was the only US city over 250k that failed. Census writes
"St. Paul city", the provider carries "Saint Paul". Both spellings are now
generated, in both directions, plus Mount/Mt. and Fort/Ft. This alone took
cities-over-250k from 98.9% to 100%.
Every metro failed (0 of 921). The resolver was fed the full CBSA name
("Kenosha-Racine, WI") rather than an anchor. Fixed by trying progressively
shorter hyphen prefixes longest first — "Winston-Salem" before
"Winston", since splitting on the hyphen and taking the first segment is the
obvious implementation and it breaks Winston-Salem.
Upsert conflicted on the wrong key. slug is derived from the name and
legitimately changes when a naming rule improves; (kind, fips) is the immutable
identity. Matching on slug meant an improved metro name inserted a second row and
then died on the (kind, fips) index.
contracts.test.ts › fixture provenance › CAPTURED FROM THE LIVE API.
The failing test is contracts.test.ts › fixture provenance › CAPTURED FROM THE LIVE API. The payloads in __contracts__/ were transcribed from documentation,
so the tests asserting against them confirm what we already believe — and a wrong
belief about which fields an endpoint returns is Trap 1. A green suite there
would claim Trap 1 is guarded when it is not, so it fails until real payloads are
captured:
pnpm probe:dfs # ~$0.08, gated on explicit confirmationThree fixtures are already verified: the two error captures and the real 40207. If a field assertion breaks after the probe, the mechanism worked — fix the adapter, not the fixture.
Blocked on the IP whitelist. The credentials are valid; this machine's IP
(167.220.102.1) is not whitelisted, which is what produced the captured 40207.
Add it at https://app.dataforseo.com/api-access, then:
pnpm probe:dfs # captures real payloads
pnpm ingest:geo # LIVE_CALLS_ENABLED=true for the real 267k location dumppnpm dev # http://localhost:3000
pnpm worker # in a second terminalpnpm worker is now the only consumer of two queues: scan_runs and
voice_jobs. Voice drains first — a scan takes minutes and a lead alert is meant
to reach the contractor in seconds, so putting voice second would park an
emergency text behind a 40-niche SERP sweep. Recordings and lead texts only move
while it is running.
DATABASE_SCHEMA=some_schema pnpm dev runs the whole app against an isolated copy
of the schema, which is how the webhook routes get smoke-tested without a single
write landing in real data.
Note for monorepo readers: every Node entry point runs with
--conditions=react-server, and both vitest configs declare the same resolve
condition. @rnr/data imports server-only, whose default export throws by
design so a client component importing it fails loudly — but plain Node trips the
same guard, and that condition resolves the package to an empty module. The guard
still fires where it should: in a browser bundle.