Summary
Every real-place Scout patch carries one of four authored tiers — Common, Rare, Epic, Legendary — describing how hard the place is to physically reach.
The tier is Scout's own editorial judgement, not a measured statistic. It is not how many people have collected the patch, not how famous the place is, not how good it is, and not how nice the art is.
THE CRITERIA
These are the rules. Everything else in the rarity system — the judging rubric the tiering agents are given, the admin dropdown, the badge — derives from this section. If a tier and this section disagree, this section wins and the tier is a bug.
The question
What is the single hardest thing standing between an average traveler and standing on that spot?
Not the last mile. Not the total distance. The one obstacle you cannot avoid.
The four tiers
| Tier | The obstacle | Canonical example |
|---|---|---|
| Common | None. It is on the drivable road network, you can go today, and you book nothing. | St. Louis · Gateway Arch · Yellowstone |
| Rare | One real obstacle. A mandatory flight to somewhere not road-connected; a scheduled ferry; a seasonal-only road; a long unpaved drive; a serious hike; or a reservation that sells out. | Isle Royale (ferry) · Denali (you cannot drive to Alaska in a day) · Alcatraz (ferry, books out) |
| Epic | An obstacle with no scheduled service. A chartered boat or plane, or a permit plus genuine backcountry effort. | Dry Tortugas (boat or seaplane) · Glacier Bay · Buck Island Reef |
| Legendary | No scheduled service exists at all. Bush plane, air taxi, or an expedition. | Kobuk Valley (bush plane) · Gates of the Arctic · Noatak |
Rules that follow from the question
-
A long drive is not an obstacle — but isolation is. Distance alone never lifts a tier. What lifts it is a trip an ordinary person would call a hassle rather than a drive: hours from any city of size, no real services near the destination, and planning needed around fuel, water or daylight. The test: is this somewhere you just go, or somewhere you organise?
- Yellowstone is Common. Long drive, but there are gateway towns with fuel and beds at the entrances, and four million people a year simply turn up.
- Big Bend is Rare. Paved the whole way, and still five hours from any city, with almost nothing between. You plan that trip.
The remoteness number is actively misleading here and must be ignored. Big Bend reads 18.6 mi from the nearest populated place; Yellowstone reads 30.2. The gazetteer counts Study Butte the same way it counts Gardiner. Judge the character of what is nearby — does it have fuel and beds — not the mileage.
-
A mandatory flight IS an obstacle. Anywhere not connected to the contiguous road network — Hawaii, the territories — is at least Rare, because no amount of driving gets you there. (Alaska is road-connected via the Alaska Highway, but only as a multi-day international drive, which is itself the obstacle. Alaska is at least Rare.)
-
Water without a bridge is an obstacle. An island reached by scheduled ferry is Rare no matter how close it is to a city. Alcatraz sits 4.2 miles from downtown San Francisco and is Rare, for Isle Royale's exact reason.
-
Scheduled beats chartered. If you can book a seat on something that runs to a timetable — a ferry, an airline, a concession boat — that is Rare. If you must charter the whole vessel or aircraft, that is Epic. If nothing scheduled serves the place at all, that is Legendary.
-
Count the hardest obstacle, not how many there are. Two Rare obstacles do not make an Epic. A place needing a flight and a ferry is still Rare.
-
Judge the place, not the visitor. A Fairbanks local finds Denali easy; the tier is not relative to where anyone lives. Use the average American traveler.
-
Judge the patch's boundary, not its deepest corner. You earn a patch by standing anywhere inside its geofence. Denali's park road past mile 15 needs a shuttle, but the entrance does not — and the entrance is inside the boundary. Tier the easiest legitimate way to earn it.
-
Reputation is not an obstacle. Fame, beauty, danger, and "bucket-list" marketing copy in a description are all irrelevant. Ask only about access.
Worked precedents
These are settled. Any new judgement should be consistent with them.
| Place | Tier | Why |
|---|---|---|
| Yellowstone | Common | Gateway towns at five entrances; you just turn up |
| Big Bend | Rare | Paved all the way, and still five hours from any city with nothing in between |
| Great Basin | Rare | Four hours from Salt Lake or Vegas, and Baker is a hamlet |
| Rogers Centre | Common | Downtown Toronto — the 32-mile remoteness reading is a gazetteer artifact |
| Shoshone National Forest | Common | The 32-mile reading is its centroid; trailheads are on the highway |
| Hoover Dam | Common | Park in the garage off US 93 |
| Denali National Park | Rare | Reaching Alaska at all is the obstacle; the entrance itself is drive-up |
| Haleakalā | Rare | Paved road to the summit, but Maui needs a flight |
| Guam · Saipan | Rare | Ocean flight mandatory, then you drive |
| Alcatraz Island | Rare | Ferry only, and it sells out |
| Cumberland Island | Rare | Passenger ferry from St. Marys |
| Grand Canyon-Parashant | Rare | No paved roads; high-clearance 4WD and your own water |
| Death Valley | Common | Two hours from Las Vegas, with fuel and lodging inside the park |
| Baxter State Park | Rare | Gravel roads, day-use reservation, gates fill by dawn |
| American Samoa | Epic | Twice-weekly flight from Honolulu, and the Ofu unit needs a small plane |
| Dry Tortugas | Epic | Boat or seaplane from Key West |
| Glacier Bay | Epic | No road in; fly to Gustavus or arrive by ship |
| Kobuk Valley | Legendary | No roads, no scheduled service, bush plane |
| Aniakchak | Legendary | Floatplane, weather permitting; among the least-visited units in the system |
Expected shape of the catalog
Revised 2026-09-24 against the 204-patch pilot; the earlier estimate was written before these criteria were tightened and predicted far more Rare than they produce.
Projected over 1,989 real-place patches: roughly 10–15 Legendary, 5–10 Epic, 150–250 Rare, and ~1,700 Common (≈85%).
The basis, measured rather than guessed:
| Patches by remoteness rank | Legendary | Epic | Rare | Common |
|---|---|---|---|---|
| 1–100 (the most remote in the catalog) | 11 | 5 | 17 | 67 |
| 101–200 | 0 | 0 | 10 | 89 |
Remoteness stops predicting difficulty past about 35 miles. Every Legendary and every Epic in the catalog is in the top 100, because those tiers require the absence of scheduled service — which in practice means roadless Alaska and a handful of islands. Below that, tiers are driven entirely by mode (a ferry, a mandatory flight, a seasonal closure, a permit), not by distance, and the great majority of American places are simply drive-up.
A Common-heavy catalog is the correct outcome, not a failure. Most places really are easy to reach, and a ladder where a third of the rungs are "hard" would not be believed. The tiers earn their meaning from being rare.
This is a calibration guide, not a quota. If a batch comes back far outside it, the bar has moved — say so and recalibrate, rather than forcing patches up or down a tier to hit a number.
When in doubt
Return no tier. A missing tier renders no badge and is a safe, deliberate state. A wrong Legendary is not safe — it is the one claim a user will challenge.
Status
Built and complete, but not yet live on prod. scout_prod carries zero rows
for rarity/rarity_note/rarity_source and has never run the migration that
adds them — the tier data exists only in local scout_dev databases so far.
Publishing content to prod is the content owner's job, not part of shipping the
code (see CLAUDE.md § Content publishing) — once the code merges, the backend
deploys, and the patches are published, this feature goes live with no further
code change.
No feature flag. rarity is a nullable column
(backend/prisma/schema.prisma:154), and NULL renders no badge on any
surface — that nullability is the rollout control in place of a flag, which is
what let the whole 1,989-patch real-place catalog be tiered in one pass without
gating anything behind a toggle.
All 1,989 real-place patches in the local catalog are tiered:
| Tier | Count | Share |
|---|---|---|
| Common | 1,888 | 94.9% |
| Rare | 83 | 4.2% |
| Epic | 7 | 0.4% |
| Legendary | 11 | 0.6% |
All 11 Legendary patches are in Alaska. The other 172 rows in the catalog
(2,161 total patches) are store-only merch — Animals, Japan, Dinosaurs and
similar product-only sets, collectionType: null at coordinates exactly
(0,0) — and carry no tier; see Edge cases.
User-facing surfaces
| Surface | Shows Common? | File |
|---|---|---|
| Patch detail hero (Overview tab) | Yes | mobile/src/components/patch-detail-v2/tabs/OverviewTabV2.tsx:45-48 — RarityBadge at size="hero", gated only on patch.rarity !== null |
Unlock celebration (app/celebration.tsx, CelebrationStage) |
No — rare+ only | mobile/app/celebration.tsx:548,751-756 — showRarityBadge = patchRarity !== null && patchRarity !== 'common' |
Onboarding first-unlock reveal (PatchUnlockReveal, mounted only by app/onboarding-reveal.tsx) |
No — rare+ only, shown for the rarest patch in the unlock batch | mobile/src/components/celebration/PatchUnlockReveal.tsx:177-179,310-312 |
Browse grid card pip (PatchCard.tsx) |
No — rare+ only | mobile/src/components/patch-browser-v2/PatchCard.tsx:43,72-73 — top-left corner, pointerEvents="none" |
| Campaign-collection stop grid card pip (art view) | No — rare+ only | mobile/src/components/campaign/CampaignCollectionScreen.tsx:84,523 — same PatchCard as the browse grid, fed by CampaignCollectionPatch.rarity |
| Trophy Case badge pip | No — rare+ only | mobile/app/collected-patches.tsx:262-264 |
| Profile "Your rarest" card | Yes, plus the full tier-count breakdown | mobile/app/(drawer)/profile.tsx:363-416 (viewModel), :799-834 (render) |
| Trophy Case "Your rarest" card | Yes, plus the full tier-count breakdown | mobile/app/collected-patches.tsx:376-416 (viewModel), :480-495 (render) |
| Admin patch editor — Rarity panel (select, note, source pill, Confirm button) | n/a (internal) | backend/admin-ui/src/pages/PatchDetailPage.tsx:1376-1439 |
| Admin patches list — Rarity column + filter (the review queue) | n/a (internal) | backend/admin-ui/src/pages/PatchesPage.tsx:736-761,1139,1168-1171,1267-1290 |
No surface has its own scout:// deep link — the badge is an embellishment on
existing routes (patch-modal/<id>, celebration, onboarding-reveal,
collected-patches, profile), so nothing new was added to
mobile/src/dev/deepLinkRoutes.ts.
How it works
Authoring (no server-side computation). No rule or model assigns a tier —
every one is judged by a human or an LLM agent reading the place against the
criteria above. backend/scripts/enrichment/rarity.ts builds a
RarityBatchRecord per unjudged patch (buildRarityBatchRecord, lines 32-53)
carrying name, type, city/state, description excerpt, coordinates, and
nearestTownMi (from backend/src/locations/remoteness.ts's
nearestPopulatedPlaceMi, which returns null — never Infinity — outside the
US or over open ocean, so an unmeasurable distance can't be misread as
"remote"). rarity-batch/rarity-apply (backend/scripts/patch-enrich.ts,
wired at lines 2034-2049) emit a batch most-remote-first and write back
verdicts; parseRarityVerdicts rejects any row with an unknown tier by name
rather than writing it (enrichment/rarity.ts:86-89), and cmdRarityApply
refuses to apply the whole file at all if parsing produced any errors
(enrichment/rarity.ts:168-173). Per-row, it also never overwrites a patch
whose raritySource is already 'confirmed' — a confirmed tier is a human
decision that nothing automated overrides (enrichment/rarity.ts:186-191).
The one-time 1,989-patch pass
used 18 independent judging agents plus a 13-anchor gate that rejects a whole
batch on one wrong anchor answer — mechanics documented in
docs/superpowers/plans/rarity-audit/, not part of the shipped runtime.
New patches get judged too. enrich-full (backend/scripts/patch-enrich.ts:1541-1599)
emits a patch's RarityBatchRecord whenever its rarity is still null, so a
patch created after the initial audit doesn't ship un-judged forever — the
verdict is written back through rarity-apply, landing raritySource: 'suggested' for a human to confirm later. verifyOne reports a missing rarity
as a WARNING, never a hard error (patch-enrich.ts:1719-1724) — null is a
deliberate, safe state, not a defect.
Delivery. mapSyncPatch (backend/src/sync/sync.service.ts:110-114)
copies rarity/rarity_note onto every synced patch and deliberately omits
rarity_source — authoring bookkeeping the app has no use for. On the client,
transformPatch (mobile/src/domain/store.ts:337-338) re-validates the tier
through isPatchRarity before trusting it, so an unrecognized string from a
newer backend renders null (no badge) instead of crashing or falling back to
a default.
Rendering. RarityBadge (mobile/src/components/rarity/RarityBadge.tsx)
draws 1–4 amber diamond pips (rarityPips, one per tier step) plus the tier
word, and at size="hero" the note — it never renders a default, so every
caller gates on patch.rarity !== null itself. buildRarityBreakdown
(mobile/src/domain/rarityBreakdown.ts:29-49) walks a batch of patches,
returning zero-filled per-tier counts and the single rarest patch held (by
rarityRank), explicitly skipping any patch whose rarity is null rather
than counting it as Common — used by the celebration/reveal surfaces (rarest of
a nested unlock) and by the profile/Trophy Case breakdown (rarest of the whole
collection).
Admin editing. The Rarity panel's plain Save button posts
buildPatchSavePayload (backend/admin-ui/src/lib/patchRarity.ts:90-…), which
never includes a raritySource key — so an unrelated field edit can never
silently flip a confirmed tier back to suggested. Only the dedicated "Confirm
this tier" button sends buildConfirmRarityPayload
(patchRarity.ts:49-55), which always sets raritySource: 'confirmed'. On the
server, UpdatePatchDto validates rarity against the four tiers plus null,
rarityNote against a 120-char bound, and raritySource against the two
source values (backend/src/admin/dto/index.ts:323-352) — raritySource
uses @ValidateIf, not @IsOptional(), so an explicit raritySource: null
is rejected with a 400 instead of reaching Prisma as a NOT NULL violation;
the controller (backend/src/admin/api/patches-api.controller.ts:432-448)
spreads only the DTO keys actually present in the request body into the
Prisma data object, which is the mechanism that lets a save that never
mentions rarity leave a confirmed row untouched. The patches list's Rarity
filter (backend/admin-ui/src/pages/PatchesPage.tsx:445-451) — All / Not
judged / Common / Rare / Epic / Legendary / Suggested only — is the
review queue: "Suggested only" surfaces every auto-judged tier still waiting
on a human to confirm it.
CreatePatchDto (backend/src/admin/dto/index.ts:215-230) also validates
rarity/rarityNote — a new patch's Rarity panel value used to be silently
stripped by the global ValidationPipe's whitelist: true (the DTO had no
rarity fields at all) and never reached the row. It deliberately has no
raritySource field: a created patch always starts at the column default
('suggested'), and only the dedicated Confirm action may ever stamp
'confirmed'. The create handler
(backend/src/admin/api/patches-api.controller.ts:368-426) passes
dto.rarity/dto.rarityNote straight into the same explicit Prisma data
object as its other authored fields.
Data model
backend/prisma/schema.prisma, on Patch (table patches):
rarity—String? @map("rarity")(schema.prisma:154) — one of'common' | 'rare' | 'epic' | 'legendary', ornullfor "not yet judged."rarityNote—String? @map("rarity_note")(schema.prisma:156) — one short line in the app's voice ("Takes a ferry."), shown only at hero badge size.raritySource—String @default("suggested") @map("rarity_source")(schema.prisma:159) —'suggested'(an automated pass wrote it) or'confirmed'(a human agreed). Never leaves the server.
Migration: backend/prisma/migrations/20260923120000_patch_rarity/migration.sql.
Content-publish classification. All three columns are publishedColumns on
the patch content-publish entity
(backend/src/content-publish/content-entities.ts:286-291) — authored content,
published from local to prod exactly like short_description.
rarity_source publishes too, deliberately: a tier confirmed locally must stay
confirmed on prod, the same way wikipedia_url_manually_set travels with the
URL it describes, even though the field itself never reaches the mobile app.
Vocabulary is hand-mirrored, not shared. This repository is not a
monorepo, so the four-tier vocabulary exists as two independent copies with
identical shape: backend/src/patches/rarity.ts (PATCH_RARITIES,
isPatchRarity, rarityRank) and mobile/src/domain/rarity.ts (the same
three exports, plus display-only rarityLabel/rarityPips).
API surface
| Endpoint | Auth | Notes |
|---|---|---|
GET /api/sync/content |
None (optional bearer unlocks admin-only rows) | Every patch object carries rarity/rarity_note; rarity_source is never included (sync.service.ts:110-114) |
GET /api/sync/patch/:id |
None | Same shape, single patch — deep-link fallback |
PATCH /api/admin/patches/:id |
AdminGuard + ContentWriteGuard |
Accepts rarity/rarityNote/raritySource; 400 on an unknown tier/source or an over-length note (backend/src/admin/dto/index.ts:323-352, backend/src/admin/api/patches-api.controller.ts:432-448). Read-only in prod like every content-write route (see admin-and-content-ops) |
POST /api/admin/patches |
AdminGuard + ContentWriteGuard |
Accepts rarity/rarityNote (no raritySource — a created patch always starts 'suggested') (backend/src/admin/dto/index.ts:215-230, backend/src/admin/api/patches-api.controller.ts:368-426). Read-only in prod like every content-write route |
Key files
Vocabulary and judging
backend/src/patches/rarity.ts/mobile/src/domain/rarity.ts— the tier vocabulary, hand-mirrored on each side.backend/src/locations/remoteness.ts—nearestPopulatedPlaceMi, the "how isolated is this" signal fed to judges (a signal, never the rule).backend/scripts/enrichment/rarity.ts—buildRarityBatchRecord,REAL_PLACE_WHERE(merch exclusion), verdict parsing/apply, confirmed-row protection.backend/scripts/patch-enrich.ts:1541-1599,1719-1724,2034-2049—rarity-scan/rarity-batch/rarity-applyCLI commands and theenrich-fullrarity step.docs/superpowers/plans/rarity-audit/— the one-time 1,989-patch audit's rubric, anchor gate, and per-batch verdict files; not runtime code.
Delivery
backend/src/sync/sync.service.ts:110-114—mapSyncPatch's rarity fields.mobile/src/domain/store.ts:337-338—transformPatch's guarded tier read.mobile/src/domain/types.ts:764,766— thePatchinterface'srarity/rarityNotefields.
Rendering
mobile/src/components/rarity/RarityBadge.tsx— the shared badge component (pip/inline/hero sizes).mobile/src/domain/rarityBreakdown.ts—buildRarityBreakdown(per-tier counts + rarest patch).mobile/src/theme/narrative.ts:50-54— therarity.tierspalette's leading comment and object key (Common has no glow, on purpose; the four tier entries themselves continue past line 54).mobile/src/components/patch-detail-v2/tabs/OverviewTabV2.tsx:45-48— hero badge.mobile/app/celebration.tsx:548,751-756— celebration badge (rare+ only, one patch per modal).mobile/src/components/celebration/PatchUnlockReveal.tsx:177-179,310-312— onboarding reveal badge (rare+ only, rarest of the batch).mobile/src/components/patch-browser-v2/PatchCard.tsx:43,72-73— browse grid pip (rare+ only).mobile/src/components/campaign/CampaignCollectionScreen.tsx:84,523— campaign-collection stop grid pip (rare+ only, samePatchCard).mobile/app/collected-patches.tsx:262-264— Trophy Case pip (rare+ only).:376-416,480-495— "your rarest" card viewModel and render (shows Common).mobile/app/(drawer)/profile.tsx:363-416,799-834— profile "your rarest" card viewModel and render (shows Common).
Admin
backend/admin-ui/src/lib/patchRarity.ts—RARITY_LABEL(the one copy of the display labels;PatchesPage.tsxandPatchDetailPage.tsxboth import it rather than each keeping their own),rarityToWire,buildConfirmRarityPayload,buildPatchSavePayload.backend/admin-ui/src/pages/PatchDetailPage.tsx:1376-1439— the Rarity panel and Confirm action.backend/admin-ui/src/pages/PatchesPage.tsx:445-451,736-761,1139,1267-1290— the Rarity column, filter, and cell.backend/src/admin/dto/index.ts:323-352—UpdatePatchDto's rarity fields.backend/src/admin/api/patches-api.controller.ts:432-448— thePATCH :idhandler's keys-present-onlydatabuild.backend/src/admin/dto/index.ts:215-230—CreatePatchDto's rarity fields (noraritySource).backend/src/admin/api/patches-api.controller.ts:368-426— thePOSTcreate handler, which now passesrarity/rarityNoteinto its Prismadatabuild the same as its other authored fields.backend/src/content-publish/content-entities.ts:286-291— thepatchentity'spublishedColumnsfor rarity.
Configuration and flags
- No feature flag exists or is needed.
rarity's nullability is the rollout control: anulltier renders no badge anywhere in the app, so the catalog can be tiered (and re-tiered, and extended to new patches) in waves with no toggle to flip. - No environment variable or remote config gates any part of this feature.
Edge cases and known limits
Common shows on two surfaces and is suppressed on two others — deliberately, not inconsistently. This is the single most important thing to know before "fixing" it:
| Surface | Common shown? | Why |
|---|---|---|
| Patch detail hero | Shows | You opened this one specific place; "Common · Drive in, park downtown." is useful information about it |
| Unlock celebration + onboarding reveal | Suppressed | A moment, one patch (or the rarest of a batch) per modal — at 94.9% Common, badging every ordinary unlock would fire on ~19 out of 20 |
| Grid cards + Trophy Case badges | Suppressed | 1,888 cards all saying "unremarkable" is noise; a pip means "this one is special," and showing it on almost every card would defeat that |
| Profile + Trophy Case breakdown | Shows | A summary of a whole collection; an honestly-Common "your rarest" is real information, not noise |
The rule: show Common when the user is looking at one specific patch or asking for statistics; suppress it when the badge is competing for attention against many other things on screen. The justification is the measured 94.9% share above — a number the code itself does not contain — so a future edit that "fixes" this asymmetry without re-deriving that number would be undoing a deliberate decision.
- The 172 store-only merch patches never get a tier.
REAL_PLACE_WHERE(backend/scripts/enrichment/rarity.ts:108-110) excludes any patch sitting exactly at(0, 0)— Animals, Japan, Dinosaurs and the other product-only sets — from every rarity command. "How hard is it to get there" has no meaning for a product, and none of them carry a tier or ever will unless the exclusion is removed on purpose. - A patch created outside
enrich-fullcan ship withrarity: nullindefinitely. Nothing else assigns a tier automatically; a patch that skips enrichment, or is hand-inserted via SQL, simply shows no badge until someone runsrarity-batch/rarity-applyorenrich-fullagainst it. - Not runnable against prod yet.
mobile/maestro/tests/patch-rarity.yamlis taggedlocal-backendand deliberately excluded from the routine Maestro suite (per its own header comment) —scout_prodhas zero rows for the three rarity columns, so the flow can only run against a local backend pointed at a DB that has been tiered. - The catalog being 94.9% Common is the intended shape, not a bug — see the "Expected shape of the catalog" section above. A future batch that comes back far outside that range means the bar moved, not that patches need to be forced up or down a tier to hit a number.
What this feature does NOT do
- It does not count collectors. Scout does not show how many people have earned a patch, anywhere. No percentages, no drop rates, no "you're the first", no leaderboards. A tier is an editorial judgement about a place, never a statistic about other users.
- It does not change what you can do. Rarity is a label. Nothing is gated on it, nothing costs more because of it, and no reward depends on it.
- It does not sort or filter the catalog. You cannot browse by tier in the app. The rarity filter exists only in the internal admin.
- It does not appear on map markers, and it plays no part in achievements.
- It does not cover store-only merch patches (Animals, Japan, After Dark and the other product-only sets). Those are products, not places, so "how hard is it to get there" has no meaning for them.
- It is not computed. No rule assigns a tier. Every one was judged by reading the place. See the design spec for the auto-assignment rules that were tried and rejected, and why.
Tests that cover it
Backend
backend/src/patches/__tests__/rarity.spec.ts— the vocabulary: four tiers in ascending order, nothing else accepted, ranks in the right direction.backend/src/sync/__tests__/sync.service.rarity.spec.ts—mapSyncPatchsendsrarity/rarity_note, never sendsrarity_source, and sendsnullfor an unjudged patch.backend/src/locations/__tests__/remoteness.spec.ts—nearestPopulatedPlaceMifinds a real distance for a genuinely remote place and returnsnull(never a number, neverInfinity) outside the US or over open ocean.backend/scripts/__tests__/rarity.spec.ts—buildRarityBatchRecordcarries what a judge needs and degrades safely on missing fields;parseRarityVerdictsrejects an unknown tier by name rather than writing it.backend/scripts/__tests__/patch-enrich-rarity.db.spec.ts—enrich-fullemits aRarityBatchRecordfor an unjudged patch, passes remoteness through asnullrather than a number when unmeasurable, does not re-flag an already-tiered patch, andverifyreports a missing tier as a WARNING, never an error (falsified by reverting the warning to an error and confirming the test fails before restoring it).backend/api-tests/patches-rarity.api.spec.ts— real HTTP boundary against the fixture Postgres DB: 400 on an unknown tier/source or an over-length note; persists all three fields together; a save that never mentions rarity leaves a CONFIRMED row untouched (the guarantee the whole Save/Confirm split exists for); an explicitrarity: nullun-judges a patch; an unauthenticated caller is rejected before validation runs;POST /api/admin/patches(create, not update) persistsrarity/rarityNoteand defaultsraritySourceto'suggested'rather than silently stripping the tier (confirmed to fail —rarity: nullin the response — against the unfixedCreatePatchDto/controller and pass after); an explicitraritySource: nullis rejected with a clean 400 rather than reaching Prisma as a NOT NULL violation (confirmed to fail as a real 500 against the unfixed@IsOptional()and pass after).backend/src/admin/api/__tests__/patches-api.rarity.spec.ts—UpdatePatchDtovalidation mechanics against a mocked Prisma (unknown tier/source rejected, explicitnulland an omitted field both accepted, note length bound).backend/admin-ui/src/lib/patchRarity.test.ts—rarityToWire;buildConfirmRarityPayloadalways setsconfirmedand ignores anyraritySourcesmuggled onto its input;buildPatchSavePayloadnever carries araritySourcekey at all, asserted withnot.toHavePropertyand falsified against a version of the function that adds the key back (see thec8d6daaffix commit).
Mobile
mobile/src/domain/__tests__/rarity-transform.test.ts—transformPatchcarries tier+note through, staysnull(never defaults to'common') for an unjudged patch, and rejects a tier the backend should never send rather than passing it through.mobile/src/domain/__tests__/rarityBreakdown.test.ts— per-tier counts, rarest picked by rank rather than array position, unjudged patches excluded from both the counts and the rarest search, empty-batch and all-unjudged-batch both returnrarest: null.mobile/src/components/rarity/__tests__/RarityBadge.test.tsx— one pip per tier step (four for legendary, one for common), pip size shows pips only (no word or note), hero/inline sizes show the word, a missing note renders no note text.mobile/screen-tests/patch-modal-id.test.tsx— the hero badge renders for a judged patch and is absent (no badge testID, no "Common" text) for an unjudged one — a paired falsification.mobile/screen-tests/celebration.test.tsx— the badge shows for a patch above Common and is suppressed for both a Common patch and an unjudged one.mobile/screen-tests/patch-unlock-reveal.test.tsx— the badge names the rarest patch across a mixed batch, not the first or the common one; confirmed to fail against the unfixed component and pass after.mobile/screen-tests/browse-patches.test.tsx,mobile/screen-tests/collected-patches.test.tsx— the grid/Trophy-Case pip renders with the right count for a tiered patch and renders neither a pip nor the tier word for a Common-tier patch.mobile/screen-tests/campaign-collection-id.test.tsx— the campaign-collection stop grid pip renders (positive anchor on the card first) for a Legendary patch and renders no pip for a Common one, the same rare+-only rule as the browse grid.mobile/screen-tests/profile.test.tsx,mobile/screen-tests/collected-patches.test.tsx— the "your rarest" card renders the full tier-count breakdown, including a Common "rarest" shown honestly rather than suppressed, for an all-Common collection: the rarest row still renders with the Common tier, and at least one real count number from the breakdown is asserted (not just presence/absence). Confirmed to fail when the surface is edited to suppress Common like the celebration/grid surfaces do, and to pass again once reverted.
End-to-end
mobile/maestro/tests/patch-rarity.yaml— deep-links a real judged catalog patch (American Samoa National Park, Epic, "Twice-weekly flight from Honolulu.") and asserts the pip count, tier word, and note through the real sync/detail path. Taggedlocal-backend; see Edge cases for why it does not run against prod yet.