Summary
Every signed-in, non-guest account gets a 6-character invite code on first
request. Every link and image Scout shares carries it (since 2026-10-13), and a
friend who enters it on Invite a friend › "Have a code?" (pre-filled on Android
when they installed from an invite link) is attributed to the referrer.
When the friend becomes a real account and collects at least one patch, they
count toward the referrer — never the friend. "Patch on us": when ten
friends have counted, the referrer gets a free patch — free_earned_patch,
a single-use store code for $8 off any patch they have earned, and with the
store's standing free US shipping the order comes to $0. It is paid once:
friends after the tenth still count and still list, and nothing more is
promised. That is the whole default ladder — one rung (see "What changed
2026-10-19"). The code is minted by RewardsService.grant() (see
rewards.md) and shows on the Rewards screen. The invite screen is
a tracker: ten slots, one per friend, filling as friends count.
Shipped 2026-08-25, revised 2026-08-27 to reward the referrer alone and close a code-trading exploit; rebuilt 2026-09-01 to pay in merchandise instead of cloud-album time; repriced 2026-09-03 from 1/3/5/10 to 3/5/10; repriced again 2026-09-22 to a 1/3/5/10 store-promo ladder; repriced 2026-10-19 to the current single "10 friends → patch on us" rung, and given a runtime admin editor in the 2026-09-03 change (see "What changed" below). Nothing about the feature is behind a feature flag — it is fully live in production for every non-guest account.
What changed 2026-10-25 — referrals no longer grant album access at all
Cloud-album access is granted separately from referrals now. The ladder's album grant is removed end to end:
LadderMilestone.grant,grantPermanent/extendAccess(referral-rewards.rules.ts, deleted) and theAlbumAccessupsert inReferralQualificationService.evaluateRefereeare gone. A rung now pays its storerewardand nothing else.- A stored ladder that still says
grant: 'permanent' | 'none'still loads.parseLadderignores the unknown key and rebuilds each rung as{ friends, reward? }, so it never reaches the app; the strict admin validator likewise accepts and drops it. A legacy rung with agrantbut norewardloads as an inert rung (it pays nothing; the app would call it "a reward") — the admin editor refuses to save such a rung. GET /api/referral/meno longer returnsaccess.Referral.rewardedAtkeeps its meaning: stamped only once a store-rewardgrant()lands (granted/waitlisted). It used to also be stamped when an album grant landed; that path no longer exists, so it is now stamped for store rewards alone.- Existing
album_accessrows withsource: 'referral'are left untouched.
What changed 2026-10-19 — one goal: ten friends, a patch on us
Alan, 2026-10-19: "if you get ten people to join with your user code, you get a free patch… just say patch on us." Two changes, one release:
- The ladder is one rung.
DEFAULT_LADDER.milestonesis[{ friends: 10, reward: 'free_earned_patch' }](backend/src/referral/referral-ladder.ts). The 1/3/5 discount rungs (pct_10_off,pct_20_off,patch_bogo) and the 10 → permanent cloud album rung are gone; no rung grants cloud-album access any more. Hard cutover like the ones before it: a code someone already earned keeps working (UserRewardrows are per user and untouched). A referrer already at 10+ qualified friends is NOT paid on deploy.evaluateRefereeonly runs when one of their referees qualifies (referral-qualification.service.ts); the next such qualification back-fills rung 10 — until then the new screen shows "Your patch is on us" with no code on Rewards. And a referrer who reached 10 under the 2026-09-03 ladder already holds areferral:milestone:10reward (the Snapback);RewardsServicededupes on that key regardless of kind, so their free patch would come backduplicate. Anyone in either group needs a hand grant from Admin → Store → Rewards (or a conscious decision not to).free_earned_patchis a free good, which the 2026-09-03 anti-farming rule guards against at low rungs; ten real-looking accounts that each collect a patch is well above that price. The kind has no issuance cap. - The invite screen is a punch card (
mobile/research/invite-tracker-lab.html, variant C · Punch card — Alan first picked A · Ten slots, shipped in6ddbe96f, and switched to C the same evening):ReferralTrackerdraws a "Patch on us" card with one punch per friend toward the goal rung (ten to a row); counted friends fill brass with a check, pending ones (signed up, no patch yet) are dashed, the scout's most recently collected patch sits in the corner (a gift icon if they have collected none), greyed until earned, and the foot reads "4 OF 10 FRIENDS · 6 TO GO". Copy: headline "Patch on us" · "Get 10 friends to join Scout with your code and your next patch is on us." · "6 more friends and your next patch is on us." · fine print "Any patch you've earned · free shipping". Earned: "Your patch is on us" · "Pick any patch you've earned. Shipping's free too.", the card stamps "Earned · On us", and a gold Get my patch button opens Rewards (where the code is); Share steps back to a secondary button. All of it comes frombuildTrackerView(mobile/src/domain/referralTracker.ts), which reads the goal from the ladder: "Patch on us" and "Get my patch" are said only when the goal rung paysfree_earned_patch; any other reward falls back to that reward's noun and "See my reward", and a ladder with no reward rung draws no card at all. The four-rungReferralProgress/ReferralLaddercomponents were deleted. - "Who's joined" names each friend (owner decision, 2026-10-19 — reverses
the earlier "no names" rule):
GET /api/referral/menow sends each friend's byline identity,nameandavatarUrl, fromprofileIdentityMap(the same rule every board byline uses: handle first, else the email's local part, else the display name). A row leads with their profile photo (UserAvatar; their initial without one), the status check/clock on its corner, their name, then "Joined Aug 12 · Collected their first patch". A friend whose account is gone (a referral is not a relation) reads "A friend". Rationale: entering someone's code is the friend choosing to credit them. - Same day, the cloud-album upsell left the app — the locked strip and the
share icons on the Album, so nothing in the app links to the cloud-album
wall any more (see album-and-cloud-backup.md).
The wall itself (
/cloud-album, which said "invite friends for the album") was deleted on 2026-10-25; the route no longer exists, not even by deep link. - Old builds read the new ladder from the server the moment the backend deploys, and some of their copy goes false: under Share, with nobody pending, the old hint reads "You get a free patch once a friend collects a patch." (implies one friend); at 10+ friends the old screen says "Keep inviting friends to earn more rewards." (there are none); and the Album's old locked strip still offers the cloud album "permanently — free" for invites, which no rung pays. These last until users take the new build — so deploying the backend together with the app release, not before, avoids the gap.
- After deploying, check Admin → Referral Ladder reads "default". A
custom
referral_ladderrow in prod shadowsDEFAULT_LADDERforever; if one exists, Reset it.
What changed 2026-10-13 — every share carries the code; Android pre-fills it
Boards ce3991e9 ("Every share carries the sharer's invite code") and
2261d84d ("Invite code survives the install"), as Alan scoped them on
2026-10-13:
- Every link Scout shares carries the sharer's code and the surface, by one
rule,
inviteUrlFor(code, src, page?)(mobile/src/config/invite.ts): a surface that shares its own page adds?ref=<code>&src=<surface>to it (the celebration's/celebrate/<patchId>, the public profile's/u/<slug>); an image share and the invite screen link/i/<code>?src=<surface>. No code (a guest, or a failed first-share mint) never produces aref: the page keeps only itssrc, and an image share falls back to/get?src=<surface>. The surfaces areinvite,celebrate,celebrate-video,celebrate-image,states-map,parks-map,trip-finale,profile. - Every image Scout shares prints "Invite code <CODE>" and
scout-patches.com/i/<code>(InviteCodeLines,mobile/src/components/referral/InviteCodeLines.tsx): the celebration image, the 50 States and National Parks map cards, the trip finale card. A guest's image prints no code line. Instagram, TikTok and every Android share drop the text beside an image, so the printed code is the only thing a viewer of the picture gets. - The code is minted on the account's first share if it has none
(
useInviteCode().ensureCode,mobile/src/hooks/useShareLink.ts, through the sameGET /api/referral/memint the invite screen uses). A slow (2.5s) or failed mint shares without a code rather than holding the sheet. The code is resolved ONCE per share and both the image and its text are built from that one answer (useShareCardhandsprepare's result tomessage; the celebration image keeps it in a ref), so a share waits at most one 2.5s budget and a mint landing after it can never put/i/<CODE>in the text of an image printed without it.useInviteCode().codestays pinned to that answer until the next share asks again. - Android carries the code across the install.
/get?ref=and/i/:codeputref=<CODE>into Play's install referrer (withStoreAttribution,backend/src/public-profile/smart-link.ts). After sign-up, the invite screen's existing "Have a code?" field opens PRE-FILLED with it (readInviteCodeFromInstall→parseInviteReferrer), one tap to confirm. It is forced open once per code (offeredInviteCodes, persisted): collapse it and later visits leave it collapsed, still pre-filled when opened. - iOS carries it on the clipboard.
/i/:codegets a primary "Copy code & get the app" button (copy, then that phone's store); the friend pastes it into "Have a code?" after signing in. - Rejected (Alan, 2026-10-13): any new place to enter a code. No onboarding
step (
REFERRAL_CAPTURE_IN_ONBOARDINGstaysfalse), no sheet or card on Home, no "Got an invite code?" prompt on iOS. The design options are kept inmobile/research/invite-attribution-lab.html.
What changed 2026-09-22 — store promos, and the Scout patch drops off the ladder
The 2026-09-03 ladder (3 → album, 5 → Scout patch, 10 → Snapback) paid real
merchandise it had to give away for free — a growing liability as the
programme scales. The fix changes what the lower rungs pay, not just their
price: 1, 3 and 5 friends now pay store discounts (pct_10_off,
pct_20_off, patch_bogo — see rewards.md) rather than free
goods. A discount pays nothing unless someone buys, so a one-friend rung —
which would have been a farmable coupon under the old free-merchandise rule
("no merchandise below the price of farming a throwaway account," the
2026-09-03 rule) — is safe now: referral-ladder.ts's header comment states
this explicitly. The 10-friend rung was amended the same day (owner
decision, 2026-09-22) to pay only permanent cloud-album access — no
reward at all — rather than the Snapback the plan originally specified.
The consequences worth knowing:
- The Scout patch (
free_scout_patch) is on NO rung of the shipped ladder, and neither is the Snapback (free_snapback) or its buy-one-get-one fallback (snapback_bogo). All three kinds — plusfree_earned_patch— stay fully defined inreward-kinds.tsand remain grantable by hand from Admin → Store → Rewards, purely so an already-issued code keeps rendering and an admin can still hand one out; nothing a scout does earns any of them today.REWARD_NOUNSinmobile/src/domain/referralLadder.tsstill maps every one of them, deliberately, so an admin grant or a restored config rung still renders with real copy. - Cloud album moved from the THIRD rung to the TENTH, and lost its
companion reward. Under 2026-09-03 the album unlocked at three friends
with nothing else attached; under the current ladder it is the only thing
ten friends earns — no
rewardrides alongside it. A rung MAY carry both agrantand areward(they are independent fields,ReferralQualificationServiceevaluates them separately, and the mobile copy layer —milestoneNoun,milestoneMeta— names both payouts when one does), but no shipped rung does today. - The permanent album grant is no longer the lowest rung, which restored a
test's teeth. Under the 2026-09-03 ladder the bottom rung was the
permanent one, so the old bug where entitlement was computed as "any rung
reached" rather than "a
grant: 'permanent'rung reached" was indistinguishable from correct behaviour on the shipped config alone. Moving the permanent rung back to the top meansDEFAULT_LADDERitself once again has a store-reward rung underneath the permanent one, so this is covered directly;referral-qualification.service.spec.tsadditionally keeps an injectedLEGACY_LADDERfixture for the tests written against the 2026-09-03 shape (see Tests below). - This is a hard cutover, like the two rebuilds before it. No migration,
no back-fill, no grandfathering. A scout who had already been paid a 3- or
5-friend merchandise rung under an earlier ladder keeps that
UserRewardrow — nothing is revoked — but those exact rungs (with those exact reward kinds) will never fire again for anyone.
What changed 2026-09-03 — the first repricing
The ladder used to open at one qualified friend (a free earned patch) and pay the Scout patch at three. Both rungs were removed at the time. The qualification bar is a non-anonymous account plus one collected patch, which a determined person can produce for a throwaway account in a few minutes — so a rung that pays $8 of free merchandise for a single friend was a coupon with an extra step, not a referral programme, and it paid out to farming rather than to word of mouth. (The 2026-09-22 repricing above reopens the low rungs, but only because they now pay discounts rather than free goods — see that section for why a discount doesn't reintroduce the exploit.)
The fix split the ladder by what a prize actually costs, at the time: cloud album access has no marginal cost, so it moved down to three friends; everything physical moved up, to five (Scout patch) and ten (Snapback). That specific arrangement is now superseded by the 2026-09-22 repricing above — kept here as history, not as the current shape.
The ladder is editable at runtime — Admin → Referral Ladder
Added 2026-09-03 alongside the repricing, because the repricing itself was a code change and a deploy for what has always been a runtime value.
GET/PUT/DELETE /api/admin/referral/ladder(backend/src/referral/referral-ladder-admin.controller.ts,AdminGuard).GETreturns the effective ladder (post-parseLadder, not the raw row), the codedefaults, anisCustomflag, and every mintable reward kind with its price and cap so the editor can render a dropdown.- The write path is STRICT where the read path is lenient.
validateLadderInput(referral-ladder.ts) is the mirror ofparseLadder:parseLaddersilently drops a rung it cannot understand, which is right when reading config an older build wrote and catastrophic at write time — a typo'd reward slug would make a rung quietly stop paying. So the PUT returns a 400 with a per-rung reason instead, and refuses a duplicate friend count, a rung that pays nothing at all, and an unknown reward kind. - Reset is a
DELETE, never a write of the current defaults. A stored row that happens to equalDEFAULT_LADDERshadows it forever, so the next repricing in code would never reach production.AppConfigService.unset()was added for exactly this. - The admin page is
backend/admin-ui/src/pages/ReferralLadderPage.tsx(sidebar → Referral Ladder). It edits rungs, not raw JSON — dropdowns of real reward kinds make the silent-drop class of mistake unreachable. /referral/ladderis deliberately not inCONTENT_WRITE_PREFIXES(backend/admin-ui/src/lib/content-routes.ts), so it stays editable in the read-only production admin, like feature flags and app config. Pinned by a test — if it were treated as content, "change the rungs on the fly" would only work locally.- Changes reach a scout on their next
GET /api/referral/me; the ladder is cached server-side for 30s (AppConfigService), and no mobile build, release or OTA is involved for a change of friend counts or of which known kind a rung pays. Nothing inmobile/hardcodes a friend count. Reward nouns are the server's (rewardNoun, fromreward-kinds.ts); the app falls back to its ownREWARD_NOUNStable (mobile/src/domain/referralLadder.ts) only when the server sends none. A new reward kind is not free to ship, though: an app build that predatesrewardNounreads neither the server's noun nor any kind added toREWARD_NOUNSafter it was built, so it shows such a rung as "a reward" until the user installs a newer build. That includes today'spct_10_off/pct_20_off/patch_bogoon builds older than this change.
What changed 2026-09-01 — the merchandise ladder
The ladder used to pay perFriendDays: 30 of cloud-album access per
qualified friend, permanent at 5 friends, with a 10-friend milestone whose
bonus ("mystery gift") was deliberately unresolved copy that nothing branched
on. All three of those are gone. This was a hard cutover: no
migration, no backfill, no grandfathering. It was verified safe before
shipping — no active user had referred anyone at the time. Existing
AlbumAccess rows were not revoked; they simply stop being extended by
new referrals (an admin-granted or already-permanent row is untouched).
perFriendDays and refereeDays are still parsed from AppConfig for
back-compat (so an old config row does not error), but neither has a
production reader any more — setting perFriendDays today does nothing.
Status
The 2026-10-13 invite attribution (codes on every share, the Android
pre-fill, "Copy code & get the app", ab93bfd3) is live: it shipped in
v1.1.108, and by 2026-10-21 every new iOS and Android install on the admin
dashboard was on 1.1.110. The backend carrying it is deployed (prod runs
d2f24f03, which contains ab93bfd3). Nothing about it is flagged.
Fully shipped, not flagged. No feature-flag check gates any referral code
path in backend/src/referral/ or the mobile screens — confirmed by grepping
the feature-flag registries and finding no referral reference. The only
runtime-tunable piece is the reward ladder itself (AppConfig key
referral_ladder, parsed by parseLadder in
backend/src/referral/referral-ladder.ts:110), which is a values config, not
an on/off flag, and falls back to hardcoded defaults
(DEFAULT_LADDER, referral-ladder.ts:79-98) if unset or malformed:
perFriendDays: 0(inert — parsed and validated forAppConfigback-compat only; nothing reads it in production, see "What changed" above)refereeDays: 0(inert — the referee has never received a grant since the 2026-08-27 revision; this field never had a production reader even before the merchandise ladder)claimWindowDays: 7milestones: [{friends: 10, reward: 'free_earned_patch'}]— one rung, since 2026-10-19: ten friends, a patch on us. No rung grants cloud-album access.
It is edited from Admin → Referral Ladder, not by shipping a build — see "The ladder is editable at runtime" above.
User-facing surfaces
scout://invite(mobile/app/invite.tsx) — the referrer's screen: "Patch on us" and the punch card (ReferralTracker; a Get my patch button to Rewards once earned), the 6-character code with Copy, a native Share sheet button, a "Who's joined" list (each friend's photo and name, status and join date), and a collapsible "Have a code?" late-entry field — the only place a friend's code is entered. On Android, when the install came from an invite link, the field opens pre-filled with that code (from Play's install referrer), one tap to confirm; it never claims on its own (app/invite.tsx, the effect besidetoggleLateEntry). The field is forced OPEN only the first time a given code is offered — the store's persistedofferedInviteCodes(markInviteCodeOffered, MMKV via the app store'spartialize). Every later visit still pre-fills the field but leaves it as the scout left it, so a scout who collapsed it is not shown it again on each visit (PR #234 review). A different code (a reinstall from another invite) is offered afresh.- Entry point: drawer → COMMUNITY & SHOP → "Invite a friend" row,
route: 'invite-beta'(mobile/src/components/navigation/drawerSections.ts:251), always visible/enabled — it shares/get, which always resolves to a real store link. - The cloud-album wall used to carry a second "Invite a friend" CTA here. Nothing opened the wall after 2026-10-19, and the wall screen itself was deleted on 2026-10-25, so the drawer row is the only entry point.
- Entry point: drawer → COMMUNITY & SHOP → "Invite a friend" row,
scout://onboarding-referral(mobile/app/onboarding-referral.tsx→ReferralCaptureScreen) — "How did you hear about us?" DORMANT since 2026-09-21: no user is routed here any more. The step was removed from onboarding by one switch,REFERRAL_CAPTURE_IN_ONBOARDING = false(mobile/src/components/referral/referralCapture.ts), which both routing paths read throughreferralCaptureOwed: the first-run resolver (mobile/src/components/permissions/permissionRouting.ts) and the standing condition that would otherwise re-route any already-onboarded signed-in account (mobile/app/_layout.tsx'sreferralOwed,mobile/src/providers/OnboardingProvider.tsx). The screen, its screen test and this deep link are all deliberately kept — it is reachable by deep link for testing and is switched back on by flipping that one constant. The gating rules described below (once per real non-guest account viareferralAsked, never shown to guests) are what applies when it is on; todayreferralAskedsimply stays false for everyone.GET /i/:code— the public, unauthenticated invite landing page (backend/src/referral/invite-page.controller.ts), server-rendered (backend/views/invite/page.hbs). Shows the referrer's first name, the visible code with a Copy button, a primary "Copy code & get the app" button (copies the code, then goes to that phone's store whether or not the copy worked; shown on iPhone and Android, not on desktop), and both store badges. The HTML is the same bytes for every device: the button shipshidden, and the page's own inline script aims it at#store-iosor#store-androidfromnavigator.userAgent(the server'splatformGuessrule) and shows it. Without JS, or on desktop, it stays hidden and the two badges are the way to a store. (The backend sets no Content-Security-Policy, so the inline script needs no nonce —/a/:slugand/brandship inline scripts the same way.)noindex. Copy (2026-10-13): the lede promises nothing to the friend ("Get the free app, sign in, then add this code in the app soon after you sign up") and the hint names the one entry point ("After you sign in: open the menu, tap Invite a friend, then Have a code? and enter it there. Installing on Android from this page fills it in for you."). It used to promise "a free month of cloud album" and point at the switched-off "how you heard about us" screen. It carriesog:title,og:description,og:image(https://scout-patches.com/og-card.png) andtwitter:cardso a pasted invite shows a preview.GET /get— the UA-sniffing smart app link (backend/src/public-profile/public-profile.controller.ts:35), the link actually baked intoINVITE_LINK/every share payload (mobile/src/config/invite.ts:20) and the drawer row's fallback share. Since 2026-09-30 it accepts allow-listed campaign tags —?src=<channel>&c=<campaign>&m=<medium>(lowercase, digits, dashes, ≤ 64 each; anything else is dropped) — and turns them into store attribution (withStoreAttribution,smart-link.ts): Play gets an encodedreferrer=utm_…that the app reads on first open, and the App Store getspt+ctonceAPP_STORE_PROVIDER_TOKENis set (aggregate rows in App Store Connect only). Every tap is logged tosmart_link_click(path, platform guess, tags — no IP, UA or code), best-effort./i/:codelogs its views to the same table (path='i', never the code) withutm_source= the sharing surface from its?src=(defaultinvite; before 2026-10-13 it was alwaysinvite— filter onpath='i'to count invite-page views), and tags its store buttons with thatsrcand — Play only — the code asref./getalso acceptsref=<code>and passes it to Play the same way (arefwith nosrcis taggedinvite); it never logs it.GET /card— the permanent business-card redirect (same controller,:53), unrelated to invite attribution; included in scope only because it shares the smart-link resolver (resolveStoreUrl) with/getand/i/:code.
How it works
-
Code issuance —
ReferralService.getOrCreateCode(backend/src/referral/referral.service.ts:47) is called lazily fromGET /api/referral/me. It never reissues once aprofile.referralCodeexists. Guests (profile.isAnonymous) are refused with a 403 ("Sign in to get an invite code") so the ~150 guest profiles never burn a code that would be orphaned if the guest never converts. -
Sharing — every share carries the code (see "What changed 2026-10-13"): the invite screen's Share sends
inviteUrlFor(code)=${apiBaseUrl}/i/${code}?src=invite; the celebration, map cards, trip finale and profile build theirs with the same function, and their images print the code. Opening/i/:codedoes not open the app or hand it the code by deep link (universal links do not cover/i/yet); it shows the code, copies it on "Copy code & get the app", and on Android puts it in Play's install referrer. The app never reads the clipboard by itself. -
Entry — the friend opens Invite a friend › "Have a code?" and types or pastes the code, or, on Android after installing from an invite link, finds it already filled in and taps to confirm.
POST /api/referral/claimis called withsource: 'settings'. (ReferralCaptureScreenwould send'onboarding', but it is switched off.) -
Claim validation —
ReferralService.claim(referral.service.ts:87) runs, in order: format check (normalizeReferralCode), self-lookup of the referee profile, already-referred check (Referral.refereeIdis unique — one claim per account, ever), the claim window (claimWindowDaysfrom the referee'sprofile.createdAt), code-owner lookup, self-referral check, then the reciprocal-referral check (see below). On success, oneReferralrow is inserted (referrerId,refereeId,code,source). A unique-constraint race onrefereeIdis caught and reported asalready_referredrather than a 500. -
Qualification —
ReferralQualificationService.evaluateReferee(backend/src/referral/referral-qualification.service.ts:26) is called from the sync read path,SyncService.getUserData(backend/src/sync/sync.service.ts:334), on every sync — not from any of the three code paths that createuser_patchesrows. It is best-effort: a failure is caught and logged, never fails the sync. It finds the referee's one unqualifiedReferralrow, requires the referee to be non-anonymous AND to have collected at least one patch (UserPatchcount ≥ 1), then computes the referrer's qualified-friend count (existing qualified rows + this one) and looks up the applicable milestone.Referral.qualifiedAtis stamped either way (keeps the count honest even if the grant can't be written, e.g. an admin-revokedAlbumAccessrow);Referral.rewardedAtonly if a grant was actually written. -
Reward grant, for the referrer only. Every rung the referrer's qualified-friend count has reached is evaluated —
ladder.milestones.filter(m => qualifiedCount >= m.friends)— not just the highest one.That is deliberate and load-bearing.
milestoneFor()(which returns only the top rung reached, and is still used by the mobile ladder view) was what this granted, and a jumped rung was then never paid at all. Rungs get jumped easily:qualifiedCountis read outside any transaction andqualifiedAtis stamped afterwards, so three referees syncing at once all computequalifiedCount = 1, all fire rung 1 (two dedupe on the unique index), all stampqualifiedAt— and the referrer ends with three qualified friends having never been paid rung 3, which could then only fire if a fourth friend ever qualified. The same shape applies at rung 5 (buy one patch, get one free) and rung 10 (permanent album access), and any bulk import that stampsqualifiedAtskips every rung it jumps over, permanently.Re-granting an already-paid rung is free: the
sourceRefis keyed by the rung, so@@unique([userId, sourceRef])collapses it to aduplicateno-op.Together with
grant()'s self-heal that is also the recovery path for a rung whose Shopify mint failed. The back-fill is what callsgrant()for the old rung again;grant()then reads the existing row rather than reportingduplicateblind, and re-attempts the mint if it is stillpendingwith no Shopify node id (see rewards.md, How it works #2). So the trigger is the referrer's next qualifying friend, not a timer — there is no background retry job, and a referrer who never brings anyone else in still needs an admin to finish a stuck rung.Each reached rung pays only its store reward; referrals no longer write
AlbumAccess(removed 2026-10-25, see "What changed" above):reward: <RewardKind>(the one 10-friend rung today —free_earned_patch) — callsRewardsService.grant(referrerId, m.reward, 'referral:milestone:<friends>')once per reached rung that names a reward (referral-qualification.service.ts, see rewards.md for the full grant mechanics: Shopify discount minting, caps, idempotency). Each rung is caught separately, so an outage on one does not cost the scout the rungs either side of it. None of the three currently-shipped reward kinds carries a cap, so thewaitlisted/fallbackKindmachinery described in rewards.md (the snapback's "buy one, get one once the free ones run out" behaviour) does not fire on the default ladder today — it remains real mechanics for a capped kind, reachable again the moment an admin ladder names one.Referral.rewardedAtis stamped oncegrant()actually succeeds — not merely because a milestone existed — so a secondevaluateRefereepass (e.g. a retried sync) never double-grants. It is stamped forgrantedandwaitlistedoutcomes alike, and leftnullforrefused_guest/mint_failed. It is a timestamp, not a counter: one write however many rungs the back-fill just paid.
Data model
Three models, all deliberately not foreign-keyed to profiles (a
deleted account must not cascade away referral history or an access grant,
and the looseness avoids write failures racing account creation):
Referral(backend/prisma/schema.prisma:763) — one row per referee, ever (refereeId String @unique). Fields:referrerId,refereeId(unique — the whole anti-farm primitive),code(literal string entered, audit only),source('onboarding' | 'settings'),enteredAt,qualifiedAt(set once referee is real + has ≥1 patch),rewardedAt(set only if the referrer's grant was actually written — idempotency guard so a secondevaluateRefereepass never double-grants). Indexed on(referrerId, qualifiedAt)for the invite screen's summary query.AlbumAccess(schema.prisma:740) — one row per user (userId String @id), shared with the (out-of-scope) cloud-album feature.sourceis'admin_grant'or'referral'.expiresAt: nullmeans permanent.revokedAtis an admin override that outranks any automated grant. Referral no longer writes this row at all (removed 2026-10-25). Rows it wrote earlier (source: 'referral') are left alone.UserReward— what the 10-friend rung pays in (free_earned_patch); not part of this feature's own schema, see rewards.md for its full model. No referral rung touchesAlbumAccess(removed 2026-10-25).SignupSource(schema.prisma:789) — one row per account (userId String @id, upserted, not appended), the answer to "How did you hear about us?":'friend' | 'social' | 'search' | 'other' | 'skipped'. Analytics only; unrelated to the reward mechanism, but the 'friend' answer is recorded on the same screen and independently of whether the paired code claim actually succeeds.profiles.referral_code— a nullable, uniquely-indexedTEXTcolumn added by the referral migration (not its own model).
API surface
All under JwtAuthGuard (backend/src/referral/referral.controller.ts) —
guests get a token but are still refused inside the service layer where
guest-specific:
GET /api/referral/me— returns{ code, qualifiedCount, pendingCount, ladder, friends: [{qualified, enteredAt, name, avatarUrl}], ownReferral, claimWindowOpen }. Issues a code as a side effect if the caller has none (403 if the caller is a guest).POST /api/referral/claim— body{ code, source };sourceis coerced server-side to'settings'only on an exact match, defaulting to'onboarding'otherwise — the client's claimed source is not trusted as-is. Returns{ ok: true }or{ ok: false, reason }(never throws for a rejection — six distinct reasons (invalid_code,unknown_code,self_referral,reciprocal_referral,already_referred,window_closed) drive six distinct UI messages).POST /api/referral/signup-source— body{ source }, validated against the fixedSIGNUP_SOURCESlist server-side (400 on anything else).
Under AdminGuard (referral-ladder-admin.controller.ts):
GET /api/admin/referral/ladder—{ ladder, defaults, isCustom, rewardKinds }.ladderis the EFFECTIVE, post-parseLadderconfig, not the rawAppConfigrow.PUT /api/admin/referral/ladder— body is aLadderConfig. Validated byvalidateLadderInput; a 400 carries{ message, errors: string[] }with a reason per bad rung. Stores the sorted, normalized ladder.DELETE /api/admin/referral/ladder— removes theAppConfigrow so the build'sDEFAULT_LADDERis live again. Not the same as saving the defaults.
Unauthenticated, no guard:
GET /i/:code— renders the invite landing page or 404s (malformed code never touches the DB; well-formed-but-unknown code also 404s).GET /get,GET /card— smart links, not referral-specific but shared infrastructure (resolveStoreUrl,resolveCardTarget).
Key files
backend/src/referral/referral-code.ts— code alphabet (31 chars, no0/O/1/I/L), 6-char length, generation and normalization.backend/src/referral/referral.service.ts— code issuance, claim validation, reciprocal-referral guard, signup-source recording,summaryFor(invite screen payload).backend/src/referral/referral-qualification.service.ts— the qualify- and-grant path, called from sync; callsRewardsService.grant()for a reward rung (the 10-friend free patch). It writes noAlbumAccess.referral-rewards.rules.ts(grantPermanent/extendAccess) was deleted 2026-10-25 along with its spec.backend/src/referral/referral-ladder.ts—LadderConfigshape,DEFAULT_LADDER,parseLadder(lenient, fails closed on a malformedmilestonesarray),milestoneFor, andvalidateLadderInput— the STRICT mirror ofparseLadderused only by the admin write path.backend/src/referral/referral-ladder-admin.controller.ts—GET/PUT/DELETE /api/admin/referral/ladder, the runtime rung editor's API.backend/admin-ui/src/pages/ReferralLadderPage.tsx— the rung editor (sidebar → Referral Ladder);backend/admin-ui/src/lib/api.ts'sreferralLadderclient.backend/src/app-config/app-config.service.ts—unset(key)exists for this feature's "Reset to defaults", which must DELETE the row rather than store a copy of the current defaults.backend/src/referral/referral.controller.ts/invite-page.controller.ts— the two app-facing HTTP surfaces.backend/src/referral/referral.module.ts— exportsReferralServiceandReferralQualificationService;SyncModuleimports it to callevaluateReferee. It importsAdminModulebecause the ladder admin controller isAdminGuard-protected — without that the app fails to BOOT, a DI error no unit test catches.backend/src/sync/sync.service.ts:334— the one call site ofevaluateReferee, insidegetUserData, best-effort/non-blocking.backend/src/achievements/feature-context.ts:47— feedsreferral_qualified_count(qualified referrals where this user is the referrer) into the achievement system; theringleaderachievement fires atreferral_qualified_count>=5(mirrors the milestone threshold but is a separate, independent system — no shared code).backend/prisma/migrations/20260825120000_referral/migration.sql— thereferralstable andprofiles.referral_codecolumn.backend/prisma/migrations/20260825130000_signup_source/migration.sql— thesignup_sourcestable.mobile/src/api/referral.ts— typed client (referralApi.me/claim/ recordSignupSource).mobile/src/config/invite.ts—INVITE_LINK(/get),inviteUrlFor(THE rule for every shared link:ref+src),inviteFooterFor(what a share image prints),SHARE_LINE(each surface's share text), share payload/copy building.mobile/src/hooks/useShareLink.ts—useInviteCode(the code to print, andensureCode, the first-share mint) anduseShareLink.mobile/src/domain/inviteAttribution.ts—normalizeInviteCode(mirror of the server's),parseInviteReferrer(missing / malformed / self), andresolveShareCode(the bounded mint).mobile/src/services/installReferrer.ts— the one Play Install Referrer reader (analytics'first_app_openuses it too) andreadInviteCodeFromInstall, which feeds "Have a code?" on Android.mobile/src/components/referral/InviteCodeLines.tsx— the printed "Invite code" line on every share image.backend/src/public-profile/smart-link.ts—withStoreAttribution'sref.landing/src/lib/store-links.ts—celebrateInstall:/celebrate's badges go to/i/<code>?src=with a code, else straight to the stores (R-010).inviteCodeFromreads therefby the backend's exactnormalizeReferralCoderule (upper-case, strip every space and dash, six alphabet characters), so?ref=k7m-2qxforwards to/i/K7M2QX. Until the PR #234 review it only trimmed and upper-cased, and dropped such codes.invite-code-cases.json— ONE table of code-normalisation cases, in three byte-identical copies (backend/src/referral/__tests__/,landing/src/lib/,mobile/src/domain/__tests__/), because the three projects cannot import one another. Each runs it against its own rule;referral-code.spec.tsfails if the copies differ. Change all three together.mobile/src/domain/referralTracker.ts—buildTrackerView, the invite screen's tracker since 2026-10-19: the goal rung (the lowest reward rung not yet reached, else the highest, earned), one slot per friend (counted, then pending, then empty — never more than the goal), and every line of copy. Says "Patch on us" only forfree_earned_patch. Pure and unit-tested (__tests__/referralTracker.test.ts).mobile/src/components/referral/ReferralTracker.tsxdraws it.mobile/src/domain/referralLadder.ts— the older ladder view, no longer drawn on the invite screen (2026-10-19). Its last screens, the cloud-album wall and the Album's locked strip, are gone (the wall was deleted 2026-10-25), sobuildLadderViewhas no caller in the app; what is still read ismilestoneNoun(by the tracker,referralTracker.ts:15) andorderFriends(byReferralFriends.tsx:22). It turns the server summary into: one rung per milestone (one, under the 2026-10-19 default), plus an optional "every friend" per-friend rung that only appears ifperFriendDays > 0— which it is not, by default, since the 2026-09-01 rebuild.REWARD_NOUNS(referralLadder.ts) is the one place aRewardKindis mapped to display copy for a store-reward rung, and now includespct_10_off/pct_20_off/patch_bogo; an unrecognized kind falls back to "a reward" rather than an empty rung. Pure and unit-tested.milestoneNoun/milestoneMetaname both payouts on a rung that carries both agrantand areward— legal config, but the shipped ladder's one rung carries arewardalone.mobile/app/invite.tsx— the whole invite screen, in the house view-model shape:InviteScreenViewModel(an explicitstatusofloading | signed-out | error | ready, never a compound of "is the summary null"),InviteScreenViewModelImpl(every hook), the pureInviteScreenLayout, and two exported pure helpers the DEV screen mock reuses rather than re-deriving —buildInviteReady(summary → everything the ready screen draws, viabuildTrackerView, plus the goal slot's patch art). The view model picks that art: the scout's most recently collected patch, else none (a gift icon). It used to bemobile/src/components/referral/InviteScreen.tsx, a 506-line component the route did nothing but render; that file was deleted in3970a1fa(2026-09-04) —mobile/src/components/referral/holds onlyInviteCodeLines.tsx,ReferralCaptureScreen.tsx,ReferralFriends.tsx,ReferralTracker.tsxandreferralCapture.tstoday (verified 2026-10-29) — and the route is the screen's only home.mobile/src/components/referral/ReferralCaptureScreen.tsx— the onboarding capture screen, in the house view-model shape:ReferralCaptureScreenViewModel,ReferralCaptureScreenViewModelImpl(every hook), the pureReferralCaptureScreenLayout, and two exported pure helpers the DEV screen mock reuses rather than re-deriving —claimMessage(rejection reason → copy, with the unknown-reason fallback) andreferralCaptureForm(selection + window + code →showCodeField,continueDisabled). It is also the home ofCLAIM_MESSAGE, which the invite screen's late-entry row imports; that second consumer is why the split stayed in this file instead of being pulled up intoapp/onboarding-referral.tsx, which remains a 12-line route. Alongside it:ReferralFriends.tsx,ReferralTracker.tsx(ReferralLadder.tsxwas deleted 2026-10-19).mobile/src/dev/mocks/invite.tsx— ten screen-mock states for the invite screen (scout://dev-screen-mock/invite): loading, error, guest, nobody yet, part way (4 + 2 pending), one to go, earned, past the goal (13), no patches collected (gift in the goal slot) and the live "Have a code?" field, on the real one-rungDEFAULT_LADDER. User counts are synthetic; the goal slot's art is a real catalog row (Acadia). See screen-mocks.md.mobile/src/dev/mocks/referral-capture.tsx— ten screen-mock states for the onboarding capture screen (scout://dev-screen-mock/referral-capture; the slug comes from the component's filename, not the route's). Every rejection state seeds a realClaimRejectiondiscriminant and lets the screen's ownCLAIM_MESSAGErender it; the codes are synthetic. There is deliberately no success state — a successful claim draws nothing, it callsfinish()and the screen unmounts.mobile/src/domain/store.ts—referralSummary,referralAsked(one-time onboarding gate),refreshReferral()(swallows all errors, setsreferralSummary: nullon any failure — including a genuine outage).mobile/src/components/permissions/permissionRouting.ts— would route a real (non-guest) account to/onboarding-referralexactly once; currently short-circuited by the switch below.mobile/src/components/referral/referralCapture.ts— the one switch,REFERRAL_CAPTURE_IN_ONBOARDING+referralCaptureOwed, read by both routing paths.
Configuration and flags
- No server feature flag. Code issuance, claim, qualification,
/invite, the late-entry "Have a code?" row and/i/:codeare all unconditionally live. REFERRAL_CAPTURE_IN_ONBOARDING(source constant, currentlyfalse) — the onboarding capture screen only. While it is off, the app never asks "How did you hear about us?", nothing records a signup source during onboarding, and no code can be claimed from the onboarding path; codes are still claimable from/invite's late-entry row. Flipping the constant and rebuilding restores the step exactly as documented here.AppConfig.referral_ladder(JSON blob) is the only tunable, read viaAppConfigService.getAll()(notgetNumber, deliberately — a structured value needs field-by-field validation, not a scalar's positive-number fallback). Every field validates independently; an invalid single field falls back to the matchingDEFAULT_LADDERfield rather than voiding the whole config.milestonesfails closed: a non-empty array that loses every entry to validation falls back to the default milestones (one rung, 10 friends, since 2026-10-19), but an explicit[]is honored as "no milestones." A milestone naming arewardthis build does not recognize (isRewardKind()fails) is dropped entirely rather than kept with a dead reward — a config typo is not a milestone that silently pays nothing.ladder.perFriendDaysandladder.refereeDaysstill parse and are still returned inGET /api/referral/me'sladderobject, but neither has a production reader any more as of the 2026-09-01 merchandise-ladder rebuild (refereeDayshas had none since 2026-08-27). Both are kept parseable only so an existingAppConfigrow with either key doesn't error — settingperFriendDaysto a nonzero value today changes nothing a scout sees except a client-side "every friend" ladder rung that reappears in the mobile ladder view (mobile/src/domain/referralLadder.ts) with no server-side grant behind it.
Edge cases and known limits
-
Android image and video shares carry no link text.
expo-sharinghas no text field (shareFile,mobile/src/lib/shareFile.ts), so on Android the printed code on the image is the only carrier; iOS hands the text to the sheet beside the file (Messages and Mail keep it; Instagram and TikTok drop it). Changing that needs a native share module and a store build. -
The celebration VIDEO does not print the code. There is one video per patch, rendered once on the server and shared by everyone who collects it (celebration-share-video.md); a per-user code would make it one video per sharer per patch, each a 45–75s render on the 2-vCPU box, and defeat the cache. Its share text carries the code-bearing link on iOS.
-
The Android pre-fill needs a Play install from a tagged link. A friend who installs from a plain store search, or sideloads, has no
ref; they type the code. A code equal to the reader's own is never offered, and the field is pre-filled only while it is offered at all (window open, no prior claim).offeredInviteCodesis device-local and survives sign-out, so a second account on the same install is not forced open with the same code (it is still pre-filled when they open the field). -
A well-formed code that belongs to nobody sends
/celebrate's badges to a 404. The landing page can only check aref's format;/i/:code404s an unknown code (a forged link, or a sharer who has since deleted their account), so that visitor has no store button. Rare, and left as is. -
/i/:codeis one page for every device, so its 5-minute public cache (s-maxage=300) is safe even behind a CDN that ignoresVary. It once chose the button's store on the server from the User-Agent and sentVary: User-Agent(PR #234 review); the choice is now the page's own script. The server still reads the User-Agent, but only for the click log'splatformGuess— and a cached view never reaches the log at all. -
The prompt names no inviter. "Have a code?" cannot say "Did Maya invite you?": nothing on the app side can look a code's owner up without a new public endpoint, which is a privacy call not taken.
-
Invite shares and claims are measured (2026-09-30): the invite share goes through
analytics.trackShare('invite', 'invite_link', …)(alongside the existingreferral_code_sharedfeature event), and both claim entry points sendreferral_claim_submittedwith the server'sreason(orok/error) — never the code (analytics-events.md). -
A rung is keyed by friend count, not by what it pays. A milestone's
UserReward.sourceRefisreferral:milestone:<friends>, unique per user. If a rung is repriced (a differentrewardat the same friend count) after rows already exist for it — including rows leftpendingbecause minting was blocked —RewardsService.grant()finds the existing row (finishOrDuplicate,backend/src/rewards/rewards.service.ts) and finishes or duplicates that row's kind. The user is paid the OLD reward and the new one never. Before repricing a live rung, queryuser_rewardsforsource_ref LIKE 'referral:milestone:%'and revoke or rewrite the rows that should not be paid as they stand. -
Claim window: 7 days by default, measured from the referee's
profile.createdAt, inclusive at the boundary (ageMs > windowrejects, so exactlywindowdays old still succeeds). A repeat claimant whose window has also closed getsalready_referred, notwindow_closed— the more specific reason is checked first. -
reciprocal_referral's copy (CLAIM_MESSAGE.reciprocal_referral,mobile/src/components/referral/ReferralCaptureScreen.tsx) reads "You invited them, so their code can't credit you. Invite someone new." It promised "your own month" until 2026-09-30, a reward the repriced ladder (perFriendDays: 0) no longer pays. Both screens that render a claim rejection import the same map. -
A
window_closedrejection IS reachable on the onboarding screen, even though it hides the code field once the window has closed. The screen'sclaimWindowOpendefault is permissive (summary ? summary.claimWindowOpen : true), so an account whoseGET /api/referral/mehas not resolved — or failed outright — is offered the field and learns from the server instead. -
Guests:
getOrCreateCoderefuses with 403 forprofile.isAnonymous. A guest who later converts to a real account is not covered by any special handling documented here — see Open questions. -
Concurrent double-claim (e.g. a double-tap on onboarding's Continue): the pre-check for an existing
Referralrow can race two calls; therefereeIdunique constraint on the DB is the actual guarantee, and a P2002 on insert is caught and returned asalready_referredrather than a 500. -
Code collision on issuance: retried up to 5 times on a Prisma P2002 before throwing (887M possible codes across the 31-char alphabet × 6 positions, so this is described in-code as "vanishingly rare").
-
Revoked
AlbumAccess: an admin'srevokedAtalways wins. A qualifying referral still stampsqualifiedAt(so counts/analytics stay accurate) but writes no grant and logs a warning server-side. -
Landing page code display:
/i/:codeshows the code as plain visible text with a client-side clipboard-copy button; ifnavigator.clipboardis blocked (e.g. non-HTTPS context) the copy button silently no-ops — the code is still visible and can be typed by hand. -
No maestro/E2E coverage found for either
/inviteor/onboarding-referral(searchedmobile/maestro/tests/*.yamlforinvite/referral— none reference this feature). Coverage is unit/ screen-test only (see Tests below).
What this feature does NOT do
- Does not ask a new user how they heard about Scout. The onboarding
capture screen is switched off (see Configuration and flags), so no
first-run user is offered a code field, no signup source is recorded
during onboarding, and an existing account that was never asked is not
caught later either — both routing paths to it are closed by one switch.
A friend's code can still be entered, but only from
/invite's late-entry "Have a code?" row, and only inside the 7-day claim window. - Does not claim a code automatically, ever, and asks for one nowhere but
Invite a friend. There is no prompt in onboarding (the capture screen stays
switched off) and none on Home, and the app never reads the clipboard by
itself. The one place a code is entered is Invite a friend › "Have a code?".
On Android that field arrives pre-filled from Play's install referrer when
the app was installed from an invite link (or
/get?ref=); the friend still taps to claim it. On iOS the friend types or pastes the code there;/i/:code's "Copy code & get the app" puts it on their clipboard. Openingscout://inviteor/i/:codenever fills in or submits anything, and an iOS install cannot be tied to the link it came from. - Does not reward the referee anymore. As of 2026-08-27, only the referrer is ever rewarded — at any rung. The friend who enters a code gets no merchandise and no cloud-album time for doing so; they get the app and their own code to share.
- Does not pay anything before the tenth friend, or anything after it. Since 2026-10-19 the default ladder is one rung: ten qualified friends, one free earned patch, once. There are no discounts on the way up and no second patch at twenty. (The rungs are admin-editable — see Status — so check the live ladder before quoting a number.)
- Does not unlock the cloud album any more. No rung grants
AlbumAccesssince 2026-10-19, and the cloud-album upsell is off the app. The 2026-09-01 rebuild had already removed per-friend album time (perFriendDays). - The free patch is not any patch.
free_earned_patchis $8 off a patch from theall-patchescollection that the scout has earned (the store's earned-not-bought gate), one per order, 90-day expiry. Free shipping is the store's standing US free shipping, not part of the code. - Codes never combine, and percentages never apply to a set bundle or
apparel. (No default rung pays a percentage since 2026-10-19; this holds
for codes already earned and any an admin ladder adds.)
pct_10_off/pct_20_offare scoped to thepatches-and-patch-hatscollection only (single patches and patch hats — never a completed-set bundle product, a tee, or a hat that isn't a patch hat), and every reward code — an older free-item code or a store discount — is mintedcombinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }, so it can never stack with another code or an automatic discount. See rewards.md. - The Scout patch cannot currently be earned by any user action.
free_scout_patch(the compass patch specifically) is admin-grant-only, as isfree_snapback. The 10-friend reward isfree_earned_patch— any earned patch, not the Scout patch. Do not describe the programme as "refer friends for the Scout patch". - The 10-friend rung carries no "mystery gift." That bonus was always
unresolved copy that nothing branched on, and the field it lived in
(
LadderMilestone.bonus) no longer exists. - Does not let two people trade codes for mutual reward. The
reciprocal-referral guard (
referral.service.ts:118) refuses a claim whenever the code being claimed belongs to someone the claimant already referred — i.e. A refers B, then A tries to enter B's code from Settings. This is keyed on who specifically referred the code's owner, not merely "was the code's owner referred by anyone" (that looser check would wrongly refuse a legitimate claim on a code whose owner happens to have been referred by a third, unrelated person). - Does not allow more than one claim per account, ever —
refereeIdis unique at the database level; this is unconditional and outlives the 7-day window (a repeat claimant is rejected asalready_referredregardless of how long ago their first claim was). - Does not issue a code to a guest account, and does not retroactively attribute or repair a code that would have been issued had the account been real from the start.
- Does not show anything about a friend beyond their byline name, photo,
join date and whether they have collected a patch — and nobody who did not
enter the referrer's own code. The name is exactly what boards print
(
displayNameFor): their handle, or for an account with no handle the part of their email before the @, then their display name. Before 2026-10-19 the list showed no identity at all. - Does not gate cloud album on referrals exclusively —
AlbumAccessalso has an'admin_grant'source, entirely independent of this feature (out of scope here; see the cloud-album feature's own doc).
Tests that cover it
Backend (backend/src/referral/):
referral-code.spec.ts— alphabet, generation, normalization.referral-ladder.spec.ts—parseLadderfallback/fail-closed behavior,milestoneForboundaries pinned adjacently on both sides of a rung, dropping a milestone whoserewardthis build does not recognize, and a dedicateddescribe('the patch-on-us ladder (2026-10-19)')block pinned directly againstDEFAULT_LADDER: exactly one rung,free_earned_patchat 10, and the boundary pinned at 9 / 10 / 40.referral-qualification.service.spec.ts(not under__tests__/— a pre-existing doc/code path mismatch fixed here) — qualification gating (zero patches, anonymous referee). Most of the "reward grants" and "back-filling jumped rungs" assertions run against an injectedLEGACY_LADDERfixture (the pre-2026-09-22 3/5/10 shape, in its STORED form including the oldgrantkey: a reward-less 3-friend rung, 5 →free_scout_patch, 10 →free_snapback) rather than the currentDEFAULT_LADDER, so they exercise the general grant/back-fill mechanism — every rung the count has reached is granted and no rung above it is;rewardedAtis stamped for both agrantedand awaitlistedoutcome and written exactly once across a multi-rung back-fill; a throw on one rung doesn't cost the others. A dedicated test pins the 2026-10-25 removal: a stored ladder whose rung still saysgrant: 'permanent'loads, still pays itsreward, and never callsalbumAccess.upsert.makeServicealso takes ad hoc inline ladders for "a failed mint leavesrewardedAtnull". A separatedescribe('the patch-on-us default ladder (2026-10-19)', ...)block in this same file callsmakeServicewith noladderoverride at all, which is what runs the real, liveDEFAULT_LADDER(getAll()then returns{}, soparseLadderfalls back to the real default) throughevaluateReferee()end to end: nothing is paid on the first friend or at nine; at ten,rewards.grantis called exactly once, for the referrer, withfree_earned_patch/referral:milestone:10, and noAlbumAccessrow is written.referral.service.spec.tspins the summary's one rung and its noun ("a free patch").referral-ladder-admin.controller.spec.ts—validateLadderInputrefusing exactly whatparseLaddersilently repairs (unknown reward kind, duplicate friend count, a rung that pays nothing, a zero claim window), that an invalid ladder writes NOTHING, thatGETreturns the effective ladder rather than echoing a bad stored row, and that reset DELETES the row instead of storing a copy of the current defaults.__tests__/referral-claim.spec.ts— the fullclaim()decision tree, including a dedicateddescribe('reciprocal referrals')block asserting the exploit is closed (refuses a code belonging to someone this user already referred) and is NOT over-broad (allows the claim when the code owner was referred by someone ELSE).referral.service.spec.ts—getOrCreateCode,summaryFor(includingclaimWindowOpenboundary),recordSignupSourceupsert behavior.referral.controller.spec.ts— HTTP-level source coercion, pass-through of claim rejections.backend/admin-ui/src/lib/content-routes.test.ts—/referral/ladderwrites are NOT content writes, so the rungs stay editable in the read-only production admin.__tests__/invite-page.spec.ts—/i/:coderendering, 404s, and an SPA fallback assertion that/idoesn't swallow other/i*marketing paths, plus template-text guards: no "cloud album", no "free month", no "heard about us", the real "Have a code?" path, and the link-preview tags.
Since 2026-10-13 (invite attribution):
backend/src/public-profile/smart-link.spec.ts—refin the Play referrer: canonicalised, a malformed code dropped, a lone code taggedinvite, never on the App Store link.get-app.controller.spec.ts—/get?ref=reaches Play and never the click log.__tests__/invite-page.spec.ts— the surface logged per?src=, a hostilesrcfalling back toinvite, the code in the Android referrer, the page byte-identical for an iPhone, an Android phone and a desktop with both store links in it and the button shipped hidden, and the page's inline script run against it: aimed at the App Store on iPhone, at Play on Android, hidden on desktop; the hint naming Invite a friend › Have a code? and Android's pre-fill.api-tests/invite-page.api.spec.ts— the same over real HTTP, including identical iPhone/Android bodies and noVary: User-Agent(both confirmed failing against the UA-chosen page).public-profile.page.spec.ts—/u/:slug's CTA forwards itssrc.landing/src/pages/CelebratePage.test.tsx—/celebrate's badges: a code forwards to/i/<code>?src=, no code keeps the direct store links (R-010).- The shared
invite-code-cases.jsontable:backend/src/referral/referral-code.spec.ts(and the byte-identity check across the three copies, falsified by editing one),landing/src/lib/store-links.test.ts(plus a dashedrefstill forwarding; both confirmed failing on the old trim-only rule) andmobile/src/domain/__tests__/inviteAttribution.test.ts. - Mobile:
src/domain/__tests__/inviteAttribution.test.ts(missing, malformed, self-referral, the bounded mint),src/services/__tests__/installReferrer.test.ts,src/config/__tests__/invite.test.ts(every surface's URL, a null code never producingref,inviteFooterFor),src/lib/__tests__/shareFile.test.ts,src/hooks/__tests__/useShareCard.test.tsx(the capture waits for the card to re-render with a freshly minted code; the text is built fromprepare's one answer),states-mapandcelebration§ "one answer per share" (a mint slower than the 2.5s budget shares after ONE budget with no code on the image or in the text, a faster one puts the same code on both; confirmed failing against the double-resolve, which stalled to ~4s and put/i/HB4ZTWbeside a code-less image), and screen tests asserting the shared URL and the printed code per surface, each paired with a no-code case:celebration,states-map,national-parks-map,trip-map,profile,invite— the last also pins the Android pre-fill (confirmed failing without it), that it never claims before the tap, and that it is forced open once per code: collapsed and remounted it stays closed, still pre-filled when opened by hand (confirmed failing on the every-mount version), while a different code is offered afresh.src/domain/__tests__/store.offeredInviteCodes.test.tspins the record and its persistence.
Also see rewards.md's own Tests section for RewardsService,
RewardsShopifyService and the admin/webhook coverage the store-reward rungs
depend on.
Mobile:
mobile/screen-tests/onboarding-referral.test.tsx— code field show/hide by selection and window state, the rejection messages (the reciprocal one is covered since 2026-09-30, and must never mention a month), paste-uppercasing, and the "don't overwrite 'friend' with 'skipped'" signup-source guard. All 11 tests passed unchanged through the view-model refactor ofReferralCaptureScreen.tsx, which is the evidence that split preserved behaviour — as didinvite.test.tsx, which importsCLAIM_MESSAGEfrom it.mobile/screen-tests/invite.test.tsx— guest vs signed-in vs loading/error states, late-entry visibility rules (window open AND no prior claim), the tracker (slots by state at 4 + 2 pending, the pending friend in the last slot at 9, earned at 10 with Get my patch opening/rewards, the goal slot's patch art vs the gift when nothing is collected, no ladder), the friends list, share-sheet completion vs dismissal recordingreferral_code_sharedcorrectly (dismissal records nothing).mobile/src/domain/__tests__/referralTracker.test.ts—buildTrackerView: every slot state, singular/plural copy, more pending than room, earned and past the goal, a goal read from config (5, not 10), a non-patch reward dropping "Patch on us", and a ladder with no reward rung.mobile/screen-tests/screen-mocks.test.tsx— renders all ten invite mock states and asserts per-state content, and enforces that the mock renders the screen's own layout rather than one it declared.mobile/src/domain/__tests__/referralLadder.test.ts—buildLadderView(no longer drawn on the invite screen) against generic/admin-tuned fixtures plus adescribe('the shipped store-reward ladder, 0 friends')block on a local copy of the 2026-09-22 ladder (superseded 2026-10-19; the block still exercises the multi-rung view, which no screen draws since the cloud-album wall was deleted on 2026-10-25): the discount ladder opens at 1 friend (no per-friend rung to bank first), all four rungs render naming their store reward (the 10-friend rung is the Snapback in that local copy) and none names cloud album, the 3-friend rung names its store reward rather than a duration, and the 5-friend rung names the buy-one-get-one offer.isPermanentand the permanent/bonus copy were removed 2026-10-25.store.refreshReferral.test.ts.
Open questions
- What happens to a pending
Referralrow (or a referee's eligibility to be referred) if a guest converts to a real account mid-flow — could not verify from code alone whether guest-to-real conversion interacts withclaimWindowDays(measured fromprofile.createdAt, which may predate conversion) without a live DB check, which is out of scope for this doc. Flagging rather than guessing. - Whether the
perFriendDays/refereeDaysAppConfigfields should be deleted outright now that neither has a production reader, or kept as documented dead code in case a future change reintroduces per-friend stacking — a product/cleanup call, not something to guess at here. - Whether product intends to add Maestro E2E coverage for this feature per the "one E2E per feature" repo convention — none exists today for either the referral/invite surfaces or the store rewards surface (see rewards.md's own Open questions).