Ad Placement Economics โ
Status: Working draft โ open for iteration Owner: Mechelle (Cofounder 1) Last updated: 2026-05-10 Companion docs:
- Business Plan (ยง3.2 placements, ยง5 pricing, ยง6 GTM)
- Cofounder Agreement (ยง6.1 profit formula, ยง6.3 phase triggers)
- ECONOMICS.md (archived)
- docs/economics/CALCULATOR.md
What this doc is for โ
Two questions need a defensible answer before Phase 2 launch:
- Are we delivering enough value at $150/mo to keep merchants paying? โ i.e. fair rotation across the three placement surfaces, and impression numbers a merchant can defend internally.
- Are we spending so much per impression that the $150 doesn't actually fund the company? โ i.e. infrastructure costs, processor fees, and what's left to flow into profit-sharing per the ยง6.1 formula.
Everything below works backward from those two questions.
TL;DR โ
Update (2026-06-15): The chat-assist pill placement has been removed. Lantern's encrypted 1:1 chat is deliberately ad-free (see Privacy-Preserving Advertising). The model now spans 3 placements: hero, inline, and feed. Figures below that bundled chat have been updated where it was broken out cleanly; combined assumptions that mixed chat with another surface are flagged inline for re-baselining, not silently rescaled.
- Each $150 campaign runs across 3 placements: hero rail (dashboard), inline venue card, and feed insertion, with up to 4 audience-targeted variants (Nearby, Active Lantern Holders, Frequent Visitors, New Users).
- Fairness mechanism: minimum impression-share guarantee with pacing on the hero rail (the only contended slot), relevance-first on the other two.
- Cost-control mechanism: Cloudflare Workers + KV cache + Durable Object counters โ BigQuery batch flush, with client-side frequency capping to honor the privacy commitment.
- Architecture decision (cached vs naรฏve) is worth ~2.4% of revenue at $75K MRR โ about $3.8K/cofounder/year through the ยง6.1 profit-sharing lens.
- Pre-Phase-2, the architecture choice maps almost 1:1 to per-cofounder annual distribution. Get it right the first time.
Part 1 โ The simple version โ
What the merchant gets for $150/mo โ
| Surface | Description | Contention |
|---|---|---|
| Hero rail | Top sponsored slot on the user dashboard | High (1 slot, market-wide) |
| Inline venue card | Sponsored card embedded on the merchant's own venue page | Low (1 merchant per venue) |
| Feed insertion | Sponsored card mixed into the main offer feed at a random position | Medium (multiple slots, but bounded by min-spacing rule) |
Each campaign also supports up to 4 audience variants โ Nearby Users, Active Lantern Holders, Frequent Visitors, New Users. Same campaign, different copy/CTA per audience.
What we spend serving it (rough order of magnitude) โ
| Cost line | Per merchant/month at scale | % of $150 |
|---|---|---|
| Stripe processing (~2.9% + $0.30) | ~$4.65 | 3.1% |
| Infrastructure (cached architecture) | ~$1.40 | 0.9% |
| Help-desk + ops tooling | ~$0.40 | 0.3% |
| Total cost of revenue | ~$6.45 | ~4.3% |
Everything else ($143.55) flows into the ยง6.1 profit formula: opex first, then salaries (Phase 2 only), then 70% to cofounders / 30% to reserves.
The simple promise โ
A merchant paying $150 should get a floor of monthly impressions and a defensible attribution number โ not a "best efforts" pitch. Pacing is what makes the floor real.
ELI5 โ Fair rotation, in playground terms โ
Imagine a playground.
There are 3 places kids can play:
- ๐ The big slide โ only one kid can go at a time. Everyone wants a turn. (Hero rail.)
- ๐๏ธ The sandbox โ lots of room, but if everyone dumps sand at once nobody can play. (Feed insertion.)
- ๐ณ The swing in your own backyard โ only your kid plays here. No fighting. (Inline card on your venue page.) Now imagine 30 kids each paid $1 to play, and they all want a turn on the big slide.
The unfair way โ
Whoever runs to the slide fastest goes down. The kid who lives right next to the slide wins every single time. The kid from across the street never gets a turn โ even though they paid the same dollar.
That's what "nearest merchant wins" looks like. Same $150 ticket, wildly different number of slide rides.
The fair way โ
A teacher keeps a clipboard. Every time a kid slides, their name goes to the back of the list. The kid who's been waiting longest goes next. Distance only matters as a tiebreaker between two kids who've waited the same amount of time.
That's pacing. The clipboard is the Durable Object counter. The teacher is the Cloudflare Worker.
What if 100 kids show up at once? โ
The playground gets popular. At 7pm on Friday, 100 kids walk through the gate in the same instant, each wanting to know who's next on the slide.
The naive way: hand every kid their own copy of the clipboard. They all look at it, all see "Tommy is most behind," all yell "Tommy!" Tommy slides 100 times while everyone else waits. The clipboard is also a mess because everyone wrote on their own copy.
The right way: there's only one clipboard, and one teacher holds it. When a kid asks "who's next?" the teacher reads the clipboard, picks a name, writes the new tally, and only then answers the next kid. The second kid asking sees the updated state, so the teacher picks someone else. Then the third. Then the fourth.
The teacher is quick โ handles dozens of questions per second. At our size, no single merchant ever sees their name come up more than once a second, so the teacher is never overwhelmed.
Your pocket vs. the teacher's clipboard โ
- ๐ The teacher's clipboard lives in one place and tracks everyone's tally. Shared. The source of truth.
- ๐ชช Your pocket has a tiny private note: "I already saw the lemonade stand today." That note never leaves your pocket. If the teacher tries to send you to a sticker you've already seen, you whisper "give me a different one" and the teacher picks again.
That pocket note is the only thing that lives on your phone. Everything else lives with the teacher. The pocket is private by design โ that's how we keep our promise that no one, not even us, can build a profile of what you've seen.
What if some kids didn't get a turn today? โ
Natural follow-up: what about the kids who didn't slide today? Do they get extra turns tomorrow?
No carryover into tomorrow's slide rotation. At the end of the day, the teacher tears off today's tally and starts a fresh clipboard at midnight. Yesterday's gaps don't push anyone to the front of tomorrow's line.
Why not? Because "make-up days" warp the playground for everyone. If Tommy didn't slide yesterday, a make-up rule would shove him to the front today โ meaning every other kid notices "Tommy is going a lot today." Multiply that across 30 merchants and a few low-traffic blips, and users start seeing the same merchant over and over instead of a healthy mix.
But there's a separate notebook for the month. The teacher keeps a second book โ not the daily clipboard โ that tracks each kid's monthly slide count. The playground's promise is "your $1 buys you a fair share of slides this month," not "your $1 guarantees you slide twice today." At month-end, if Tommy's monthly count came in well below his fair share โ because the playground was sparsely attended, or the system glitched, or his neighborhood was quiet โ Tommy is owed something. Either we extend his pass into next month or refund part of his $1.
In merchant terms:
- Daily pacing is local and resets at midnight. No catch-up days, no warped UX.
- Monthly delivery is the actual product promise, tracked separately, accounted for at month-end.
- If a merchant's monthly count meaningfully misses their fair share, they get a credit toward next month or a pro-rated refund.
The exact threshold ("meaningfully below" โ 80% of fair share? a flat per-placement floor?) is a working policy decision โ see Part 6.
What if Tommy is always at the back of the line? โ
A sharper worry: what if every month, Tommy ends up with fewer slides than the other kids? Do we just keep bumping him to the front next month?
Bumping forever doesn't work. If we bump Tommy to the front next month to make up for last month, someone else gets pushed back. Now they need a bump next month, which pushes Tommy back again. The teacher is playing whack-a-mole and never solving the actual problem.
The teacher's real job is to figure out why Tommy's at the back:
- Maybe Tommy only plays in the far corner of the playground and most kids never go there โ widen Tommy's geofence
- Maybe Tommy only wants to play with one specific group โ broaden the audience targets
- Maybe Tommy's sticker is boring and kids skip past it โ coach Tommy on better creative
- Maybe Tommy's corner of the playground genuinely has fewer kids โ that's a market problem, not a pacing problem
Then the teacher tells Tommy what's wrong โ on his merchant dashboard, with specific suggestions. Most of the time, Tommy can fix the issue himself once he can see it.
If Tommy fixes things, the rotation evens out naturally. No special boost needed.
If the problem isn't Tommy's fault and the playground genuinely can't deliver his fair share, we owe Tommy a refund โ not a permanent boost, a refund. He paid $1 for slides we couldn't give him, so we credit him toward next month or give part of his $1 back.
If even refunds aren't fixing it after two months, we have an honest conversation: "Tommy, this corner of the playground isn't working for you. You can move to a busier corner, pause until more kids show up, or end your campaign with a final refund."
The trap to avoid: using permanent priority boosts to hide a market-fit problem. That's worse for Tommy (he keeps paying for slides that don't materialize) and worse for the playground (we generate a growing pile of "we owe Tommy" debt that masks the real issue).
Pacing's job is fairness within the slides we have. When there aren't enough slides for everyone's fair share, no rotation rule fixes it โ only diagnosis, coaching, refund, or an honest conversation does.
Is the kid's pocket notebook actually safe? โ
Reasonable thing to worry about: the notebook has all kinds of secrets in it โ what stickers were dismissed, what kinds the kid likes, what corner of the playground they spend time in. If the playground gets robbed, or the teacher gets nosy, what stops that notebook from leaking?
๐ Lock 1 โ Sealed with a key only the kid has. The notebook is encrypted with a password the kid knows but never tells anyone โ not even the playground. If you find a sealed notebook on the ground, it's just scribbles. Lantern itself can't open it. A subpoena demanding "show us this kid's notebook" returns ciphertext and nothing else.
In tech: encrypted at rest with a key derived from the user's passphrase. Same model as the existing profile-data architecture in Business Plan ยง3.1 (PBKDF2 with 600K iterations โ AES-256-GCM).
๐ Lock 2 โ The notebook never leaves the kid's pocket. Even if the lock could be picked, it doesn't matter โ the notebook physically never leaves. The kid checks it, the kid acts on it, the kid puts it back. The teacher never sees it, never receives a copy, never asks for one.
In tech: personalization happens on-device. The server is blind to user preferences.
๐ Lock 3 โ When the kid talks to the teacher, they only say generic things. The kid does have to tell the teacher something to get a sticker. The trick is what they say. They say "I'm in the north corner of the playground" (a geo cell broad enough that hundreds of kids share it) and "I'm in the Active-Lantern bucket" (one of four generic categories). They DO NOT say "I'm Tommy Smith and I dismissed sticker #47 last week." The teacher hands back a short list of 5 candidates, and the kid picks one privately from their pocket.
In tech: server returns top-N candidates by pacing. Client re-ranks locally. Request payloads contain only the categorical audience tags + geo cell.
What if the kid loses their notebook? โ
This is the trickier question. How can the notebook be "tethered to the kid" so it survives across days, but still be unreadable to the playground?
Each kid has a badge with a number on it โ say, "Kid #7382." The badge doesn't say their name. It's just an opaque sticker that the playground office uses to recognize them when they come back through the gate tomorrow.
Two ways the notebook can persist:
Option A โ Notebook stays in the pocket, period. If the pocket gets ripped (phone lost, app uninstalled), the notebook is gone. New kid, new notebook. Tomorrow's playground experience starts cold.
Option B โ Sealed envelope with the office. The kid hands the office a sealed envelope with their notebook inside, labeled with their badge number. The office files it under "Kid #7382." The office can't open the envelope โ they don't have the key. The playground director can't open it. A subpoena returns the sealed envelope still sealed. When the kid comes back tomorrow with the same badge, the office hands the envelope back. The kid opens it with their password.
The notebook IS tethered to the badge (so it survives). The playground genuinely can't read it (no key). The two properties don't contradict each other.
In tech: encrypted preference blob keyed by user_id, stored server-side. Server has
(user_id_opaque, ciphertext). Decryption key never leaves the device. Same pattern as Business Plan ยง3.1 for profile data.
The honest limit (this is important). The playground does know the badge came in and out โ what time the kid arrived, when they left, which corners of the playground they lit lanterns at. That's not in the sealed notebook; that's just the metadata of the kid being in the playground. A subpoena can reach that. The Business Plan ยง12 calls this out explicitly as a known limit of the privacy model.
So the rule is:
| Type of data | Where it lives | Can Lantern read it? |
|---|---|---|
| Sticker preferences, dismissals, likes | Sealed in the kid's notebook (encrypted) | โ No |
| Frequency-cap "seen today" set | Phone, encrypted, never synced | โ No |
| Account creation, login times, IP at login | Plaintext server-side | โ Yes โ known limit |
| Lantern-lit events (venue + timestamp) | Plaintext server-side | โ Yes โ known limit |
| Aggregate impression counts per offer | Plaintext server-side | โ Yes โ but not per-user |
| Phone number (for SMS login) | HMAC-SHA-256 with server pepper โ stored as hash only | Strong โ server can verify a candidate, cannot browse the database. See "Phone numbers โ hashing, not encrypting" in Part 5. |
Recommendation for v1: Option A (phone-only). Don't ship the encrypted-blob sync until there's clear demand. Less attack surface, simpler implementation, stronger privacy story. Most preference data is non-critical (dismissals fade, frequency caps reset daily) โ losing it on phone reset is acceptable. Revisit at Phase 2+ if cross-device sync becomes a frequent merchant or user request.
How does the badge get tied to the kid without the playground knowing the kid's name? โ
A natural follow-up: the kid's badge (Kid #7382) has to be tied to something about the real kid โ otherwise how do they get the same badge each day? In the real world, the "real kid" is identified by their phone number for SMS login.
So the question becomes: what does the playground actually do with that phone number?
The scrambler at the gate. Imagine the playground has a scrambler machine right at the entrance. When a kid arrives the first time, they say their phone number out loud. The scrambler converts it into a long random-looking string (phone_hash). The playground writes the string in their book next to badge #7382 โ and forgets the actual phone number. They never wrote it down.
When the kid comes back tomorrow and says their number again, the scrambler produces the same string (because the same input always produces the same scrambled output). The playground looks the string up, finds badge #7382, hands it to the kid. They never knew the phone number.
This is hashing, not encryption. A locked box (encryption) would mean the playground could open it later if they wanted. The scrambler (hash) is one-way โ the original number can't be recovered, only confirmed if you say it again.
Counterintuitive but important: for phone numbers specifically, hashing is stronger than encryption. Encryption only helps if you need to read the original later. For phone numbers, we don't โ the user says it again every time they log in, so we never need to recover it from storage.
What about the other places? โ
- ๐ณ Backyard swing (inline card): No teacher needed. It's your kid's swing.
- ๐๏ธ Sandbox (feed insertion): The teacher still keeps a list, but also enforces a "no piling on" rule: no more than 1 sponsored sand-castle per 5 regular ones, so the sandbox doesn't turn into an ad billboard.
The one exception: birthday parties โ
If a kid is having their birthday at the playground right now, they get to go down the slide next, regardless of the list. That's the live-event boost โ a venue with an active live event gets priority on the hero rail because that's a moment users actually want to see.
Why this is the whole game โ
The clipboard is what makes the $150 ticket fair. Without it, we're just selling slide rides to whoever lives closest, and the kids on the wrong side of the street churn out before month two.
Everything in Part 2 below is the grown-up version of "the teacher keeps a list."
Part 2 โ Fair delivery (the rotation problem) โ
What goes wrong with naive selection โ
The hero rail can only show one offer per dashboard load. Naive "nearest merchant wins" means a merchant in a dense neighborhood gets crushed by 30 closer competitors, while a merchant in a quiet area dominates for free. Same $150, wildly different outcomes. That's the churn risk ยง10 of the business plan flags as the #1 missing assumption.
The rule (per placement) โ
| Placement | Selection rule | Why |
|---|---|---|
| Hero rail | Pacing-first: pick the eligible merchant furthest below their daily fair-share, with live-event boost and distance as tiebreakers. | One slot, market-wide contention. Pacing is the only way the $150 floor holds. |
| Inline venue card | Relevance-first: the merchant owns their own venue page, no contention. | 1 merchant = 1 venue. |
| Feed insertion | Pacing-first within an ad-density cap: at most 1 sponsored card per N organic cards (working default: N=5), pick the eligible merchant furthest below their daily fair-share. | Multiple slots per session, so contention is real but bounded; pacing keeps share fair, min-spacing keeps the feed from feeling like an ad reel. |
Fair share per merchant per day = total daily hero impressions / N eligible merchants for this user. Track delivered impressions per merchant per day in aggregate (no per-user history). Selection picks the eligible offer with the largest deficit-to-share ratio.
What rotation does NOT do โ
- โ Sell priority. We do not sell "premium" or "boosted" placement at higher price tiers. The $150 flat-fee positioning falls apart the moment we do.
- โ Track per-user impression histories on the server. Frequency capping is client-side (encrypted device-local "seen today" set, server reselects on duplicate). This preserves the ยง3.2 k-anonymity threshold and the Immutable Right #6 commitment.
- โ Ship pacing on day 1. With <30 merchants per market, contention is theoretical. Build the impression-counting plumbing correctly; turn pacing on once a market crosses ~30 paying merchants.
Part 3 โ The cost problem โ
Where money leaks โ
Five trap-doors that can quietly turn the $150 from healthy margin into break-even:
- Per-impression Firestore writes. Naive counter increments at scale = millions of writes/day + hot-document contention. Fix: Cloudflare Workers + Durable Object counters, hourly batch flush to BigQuery, mirror daily totals to Firestore for the merchant dashboard.
- Per-user impression history. The most expensive trap and a privacy violation. Don't build it. Fix: client-side frequency cap, server-side aggregate counters only.
- Hot-offer contention under bursty traffic. Friday 7pm in SF. Fix: cache eligible-offers-per-geo-cell in Cloudflare KV (S2/geohash, 30โ60s TTL). Pacing math runs against the cache.
- BigQuery streaming volume. Click/claim/redemption events at 1:1 are fine; impressions in dense markets at 1:1 are not. Fix: sample impressions at 1:10 or 1:100 in dense markets, extrapolate.
- (Retired risk.) Per-redemption SKU floored too low. Moot as of 2026-08-30: per-redemption pricing is retired outright (the rate is flat per merchant, period), which also removes the Stripe fixed-fee floor problem this row described.
Cost projection โ cached vs naive โ
Assumed DAU ramp (mine, not the business plan's): 500 โ 15,000 per city over 24 months.
| Month | Combined MRR | Infra cost (cached) | % rev | Infra cost (naive) | % rev |
|---|---|---|---|---|---|
| 3 | $4,500 | ~$50 | 1.1% | ~$80 | 1.8% |
| 6 | $12,000 | ~$80 | 0.7% | ~$220 | 1.8% |
| 12 | $30,000 | ~$200 | 0.7% | ~$650 | 2.2% |
| 18 | $52,500 | ~$400 | 0.8% | ~$1,400 | 2.7% |
| 24 | $75,000 | ~$700 | 0.9% | ~$2,500 | 3.3% |
The architectural decision is worth ~2.4% of revenue at maturity. That number does meaningful work in Part 4.
Part 4 โ The cofounder-agreement lens โ
The ยง6.1 formula is:
Revenue
โ Operating costs (Stripe, infra, help-desk, sales, BigQuery, etc.)
โ Salaries + benefits (Phase 2 only โ $100K MRR or seed funding)
= Profit
โโ 30% โ company reserves (permanent โ never redirected)
โโ 70% โ split equally among employee-ownersWith 6 cofounders as the default founding team, and Cofounder 1 (Mechelle) redirecting 50% of her share during the pre-incorporation window per Exhibit D (default destination: reserves, until a unanimous addendum says otherwise).
Phase 1 distribution table (no salaries) โ
Working assumption on opex: scales from $1.2K/mo at $4.5K MRR to $11.2K/mo at $75K MRR (cached architecture, modest sales spend, includes Stripe).
| Combined MRR | Opex | Profit/mo | 30% reserves/mo | 70% pool/mo | Per cofounder/mo (N=6) | Mechelle's actual draw* | Annualized per cofounder (5 of 6) |
|---|---|---|---|---|---|---|---|
| $4,500 | $1,240 | $3,260 | $978 | $2,282 | $380 | $190 | $4.6K |
| $12,000 | $2,552 | $9,448 | $2,834 | $6,614 | $1,102 | $551 | $13.2K |
| $30,000 | $5,330 | $24,670 | $7,401 | $17,269 | $2,878 | $1,439 | $34.5K |
| $52,500 | $8,228 | $44,272 | $13,282 | $30,990 | $5,165 | $2,583 | $62.0K |
| $75,000 | $11,225 | $63,775 | $19,133 | $44,643 | $7,440 | $3,720 | $89.3K |
* Mechelle's standard share, halved per Exhibit D. The redirected half flows to reserves under the Deadlock Fallback unless an allocation addendum is in force.
What to read from this: Phase 1 cash distribution per cofounder doesn't cross a livable annualized number until the team is well past the $30K combined MRR mark. This is consistent with ยง6.3 of the agreement ("Phase 1 will run thinโฆ accepted by design") but it's worth eyes-open: the rotation/cost work below is what makes Phase 1 livable for the team, not just for merchants.
What the architecture decision is worth, per cofounder โ
A 2.4% revenue swing at $75K MRR = $1,800/mo flowing through the ยง6.1 formula:
- $540/mo lost to reserves (slows the cushion build for Phase 2 cliff)
- $1,260/mo lost to the 70% distributable pool
- รท 6 cofounders = $210/cofounder/mo = ~$2,520/yr per cofounder, in the cached vs. naive delta, growing with revenue
The cached architecture is not free engineering polish. It's directly visible in everyone's Phase 1 draw.
Phase 2 trigger ($100K MRR) โ
ยง6.3 opens W-2 salaries within the 3ร cap. Salaries layer on top of profit-sharing โ Immutable Right #4 keeps profit-sharing flowing in parallel. Working illustration at the trigger, 6 cofounders all at the SF living-wage floor (~$75K base, ~$90K total comp):
| Line | $/mo |
|---|---|
| Revenue | $100,000 |
| Opex | ~$15,000 |
| Salaries + benefits (6 ร ~$90K total comp) | ~$45,000 |
| Profit | ~$40,000 |
| โ 30% reserves | $12,000 |
| โ 70% distributed (split 6 ways) | $28,000 โ $4,667/cofounder/mo |
| Total per cofounder (salary + share) | ~$7,500 + ~$4,667 = ~$12,167/mo (~$146K/yr) |
The ยง6.1 example uses higher revenue/lower team comp; this one uses the ยง6.3 "thin by design" caveat literally. Both are defensible. The point: at the Phase 2 trigger the company is sustainable, not generous, and every dollar of infra waste comes directly out of either the salary line or the distribution line.
Part 5 โ Technical specifics โ
Data flow (cached architecture) โ
[user opens dashboard]
โ
[Cloudflare Worker]
โ
1. Fetch eligible offers from KV cache (key: geo cell + audience target)
โ 30s TTL, refreshed by offer mutation hooks
2. Run pacing math against Durable Object counters (per-offer daily delivered)
3. Pick winner: max(deficit_to_fair_share) โ live_event_boost โ distance_tiebreak
4. Return offer ID + tracking pixel URL
โ
[client renders]
โ
Client checks local "seen today" set (encrypted, device-local)
If duplicate within frequency cap: request next-best offer
โ
[impression recorded]
โ
Worker increments Durable Object counter (in-memory, batched)
โ
Hourly cron: flush DO counters โ BigQuery streaming insert
Daily rollup: BigQuery โ Firestore merchant dashboard mirrorPrimitives we need โ
- Cloudflare KV keyed on
s2_cell_id + audience_targetโ list of eligible offer IDs. Refreshed on offer create/update via Firebase Function hook. - Durable Object per offer (or per offer-shard) โ daily delivered impression count. In-memory increments, durable on flush.
- BigQuery table
ad_impressionswith sampling rate โ 1:1 for clicks/claims/redemptions, 1:10 or 1:100 for raw impressions in dense markets. - Firestore offer doc gets a daily-rollup
delivered_todayfield for the merchant dashboard. Updated once/day by the rollup cron, not on every render. - Client-side frequency cap โ encrypted IndexedDB record of
[offer_id, ts]pairs, expired at 24h, never sent to server. Frequency check happens before render.
Selection algorithm (hero rail, single eligible offer pick) โ
def pick_hero_offer(user_geo, user_audience_tags) -> OfferID:
# 1. Get candidate set from cache (cheap)
candidates = kv_get(f"hero:{s2_cell(user_geo)}:{user_audience_tags}")
if not candidates:
return None # no eligible offers โ show organic content
# 2. Pull pacing state for each candidate (cached, also cheap)
pacing = {
offer_id: durable_object_read(f"pacing:{offer_id}")
for offer_id in candidates
}
# 3. Score each candidate
def score(offer):
fair_share = TOTAL_DAILY_HERO_IMPS / len(candidates_in_market)
delivered = pacing[offer.id].delivered_today
deficit_ratio = (fair_share - delivered) / fair_share # negative if over-delivered
live_event_boost = 0.5 if offer.has_active_live_event else 0
distance_score = 1 / (1 + offer.distance_meters / 1000) # tiebreak only
return (deficit_ratio, live_event_boost, distance_score)
# 4. Pick max โ pacing dominates, live-event tiebreaks, distance tiebreaks last
return max(candidates, key=score)Implemented baseline โ selection module + first-paint flicker fix (shipped) โ
Status: the pseudocode above is the target (it assumes the pacing/rotation engine exists). What's actually shipped today is the selection-without-rotation baseline plus a client fix for the layout shift. Ad rotation / pacing / merchant-fairness is NOT implemented anywhere in code yet โ it lives only in this doc (Part 2 + the Durable Object design below), gated on the sealed-identity migration. The two diagrams here are the real, current wiring.
Where selection lives โ
Selection logic is centralized in @lantern/shared/ads (selectOffers / getSponsoredOffer), imported in-process by every surface so there is no network hop on the placement path. merchants-api's /offers/active route is now a thin caller; future surfaces (feed) import the same module. Rotation, when built, slots in as a step inside selectOffers and no caller changes.
flowchart TB
subgraph brain["@lantern/shared/ads โ one selection brain"]
SO["selectOffers({ db, surface, context })<br/>1. active offers<br/>2. drop expired<br/>3. hydrate venue + distance<br/>4. geofence (within radius?)<br/>5. audience (nearby / lantern / new โฆ)<br/>6. placement filter (hero / inline / โฆ)<br/>โ offers[] or []"]
GSO["getSponsoredOffer(โฆ) โ closest, or null"]
ROT{{"rotation / pacing / fairness<br/>NOT built โ slots in here later,<br/>gated on sealed-identity"}}
GSO --> SO
ROT -.future.-> SO
end
MA["merchants-api<br/>/offers/active<br/>(thin caller, today)"] -->|in-process| brain FEED["future: feed / other placements"] -.same contract.-> brain
brain --> FS[("Firestore<br/>offers ยท venues ยท users ยท lanterns")]First-paint flicker fix (client) โ
The "Sponsored Nearby" hero resolves on a separate /offers/active HTTP call โ and that call is geofenced, so it cannot even start until a location is known. The venue list, by contrast, paints near-instantly from the localStorage cache before live geolocation lands (userLocation is null at mount and set asynchronously by the getLocation callback). So the two tracks are not really parallel: the venue track short-circuits to an instant cached paint while the offer track is chained behind location. If the hero arrived a beat later it inserted above the painted list and shoved it down โ the layout shift.
Why the original gate-only fix (PR #553) was insufficient. The first version held the first commit until the offer settled, bounded by a 250ms timeout โ assuming a location was known at first paint. On the returning-user cached path it never was: the cached list painted heroless, then geolocation landing re-closed the gate (the "Finding venuesโฆ" loader flashed back over the already-painted list), and the late hero inserted above it. Two visible jolts on the most common load. And even when it didn't re-gate, the hero only appeared if the geofenced offer call happened to beat the 250ms window โ so the ad showed intermittently, a coin-flip per load. (Both paths are now regression-covered in HomeView.flicker.test.jsx.)
The shipped fix layers three cooperating mechanisms (all in HomeView):
- Gate hardening (
offerWaitArmed). The offer-wait is armed only during a load cycle โ the very first paint and any explicit venue reload (loadingVenues). The moment the gate opens it disarms, so a late location or offer change on an already-painted screen can never re-close the gate. Only a real venue reload re-gates. This kills the returning-user loader-flash outright, even with no cache. (A late offer that has no seed and missed the window is suppressed, never retro-inserted โ no shift; it appears next load from the cache below.) - Hero cache seed (
offerCache, stale-while-revalidate). The resolved hero โ or the literal absence of one โ is persisted to localStorage keyed by area bucket, with a TTL aligned to the venue cache. On the next loadHomeViewseeds the committed hero synchronously at mount, so venues + hero paint together in one frame with no fetch race. "No ad" is a first-class cached value (hero: nullโ flush list), so there is never a reserved gap or collapsing placeholder. The background revalidation usesfetchActiveOffersResult({ ok, offers })so a failed call can never evict a good cached hero, and persists the fresh pick for the next load's seed. The fetch is also seeded by the last-known (cached) location so it can start before live geolocation resolves. - In-place swap. If a hero is already on screen but the user has moved to a new area bucket, the now-stale hero is corrected by swapping the slot's content (never insert/remove โ only absentโpresent transitions shift), so the stale "ยท 476 ft" distance fixes with no layout shift. Same-area revalidation never swaps the visible hero (it only updates the cache for next load).
Crucially, rotation/pacing is not decided on the client โ it stays in the server-side ad brain (@lantern/shared/ads). The cache only replays the brain's last answer, so the winning offer advances at most once per app-open / area-change (show cached pick โ revalidate โ new pick used next load) โ a sane cadence for a glanceable hero that keeps impression-based pacing honest.
flowchart TB
A["HomeView mounts (Places screen)"] --> S{"usable hero seed in offerCache?<br/>(fresh, same area, not expired)"}
S -- "yes (warm return)" --> P1["paint venues + seeded hero TOGETHER<br/>(flush list if seed = 'no ad')"]
S -- "no (cold / past TTL)" --> G{"gate armed: venues loading,<br/>or have a (cached) location but<br/>offers unsettled within 250ms?"}
G -- "holding" --> L["'Finding venuesโฆ' loader (bounded 250ms)"]
L -.settle / timeout.-> G
G -- "ready" --> P2["commit venues + hero (if any) atomically"]
P1 --> RV["background: /offers/active (seeded by last-known location)"]
P2 --> RV
RV --> U["persist resolved hero โ offerCache (NEXT load's seed)<br/>failed fetch never evicts a good cache"]
RV --> M{"committed hero's area still current?"}
M -- "yes (same area)" --> K["keep what's shown โ
no shift"]
M -- "no (moved buckets)" --> SW["in-place content swap to the new hero โ
no shift"]After the first paint, the full-screen gate never re-closes for offers; only an explicit venue reload (a genuine area move that refetches venues) re-gates and re-resolves, exactly like a fresh first paint.
Dev tooling. The hero pipeline is inspectable from the console via the window.lantern.ads.* namespace (dev/test only โ register() no-ops in prod): status() snapshots the gate/latch/offer state, reinit() replays the first-paint gate (re-arm + refetch โ faithfully reproduces a late-offer suppression), and forceShow() commits the current hero regardless of timing for visual testing.
Rotation-ready scaffolding (shipped, no-op today) โ
So the loading phase can absorb the rotation engine without a redesign, three behavior-preserving seams are already in place:
- Pick seam โ
getSponsoredOfferdelegates the winner choice to a pluggableRotationStrategy(defaultproximityStrategy= closest-wins, unchanged). The fairness engine ships as a new strategy; no caller changes. - Pacing store โ a
createNoopPacingStore()(reportsdeliveredToday: 0, records nothing) is threaded into the strategy. The Durable Object counter drops in behind this interface. - Honest delivery data โ
sponsored_offer_filled(selection committed a hero) andsponsored_ad_impression(card entered the viewport, viauseInViewOnce) are emitted as distinct events. Pacing counts impressions, not fills โ capturing both now is what avoids a retrofit later.
Follow-up โ when it becomes relevant: impression tracking currently covers only the hero slot. The hero is above the fold, so its fill and impression nearly coincide; the gap only widens for inline OfferPills further down the list, which can be filled but never scrolled into view. When pacing extends to inline placements (or any below-the-fold surface), wrap those cards with the same useInViewOnce impression emit. Until rotation actually paces inline inventory, hero-only is sufficient and not worth the extra observers.
Follow-up โ dedupe fill/impression across remounts (observed 2026-06-05). The sponsored_offer_filled / sponsored_ad_impression dedupe guards in HomeView are per-mount useRef Sets, so they reset whenever the component remounts. The login/auth transition remounts Dashboard/HomeView several times in one page load (visible in the console as repeated "Starting initial venue load" + gate re-opens), and the cache-seed fix now commits the hero on every warm mount โ so each remount re-fires fill + impression for the same page session, inflating the counts the pacing/billing layer depends on. Fix: lift the "already fired" sets to module scope (or sessionStorage keyed by offer id) so a remount within one page load doesn't re-count, while a genuine full reload still starts a fresh impression window. Low effort; do this before impressions feed real pacing/billing. (Some of the observed doubling is dev-only React StrictMode, but the login-flow remounts are real and would fire in prod too.)
Follow-up โ venue-detail inline-offer flicker (observed 2026-06-05). The same first-paint flicker exists on the venue detail screen (VenueView): it fetches its offer async and gates the card on venueOffer &&, so the card inserts a beat after paint and shoves "Lit Lanterns" down. The hero-ad path is fixed (VenueView seeds synchronously from the hero cache โ the venue you land on by clicking the ad is the cached hero's venue). Still open: a non-hero venue reached from the list that has an inline OfferPill โ those offers aren't cached, so its detail card can still insert late. General fix: thread the clicked offer through navigation (onSelectVenue(venue, offer)) so any list entry seeds the detail view directly (more general than caching inline offers per-venue). No VenueView test harness exists yet, so a regression test should land with the fix.
Distributed pacing under load โ keeping the counter honest across phones โ
The pseudocode above looks fine in isolation. The hard part: many phones run that same code concurrently, and without coordination, multiple Workers could all read the same delivered_today value, all decide merchant A is most-behind, and all show A. Pacing locally violated, fair-share broken.
The race condition โ
7:00:00 PM, Friday, North Park. Three users open Lantern in the same second. Merchant A is most-behind (delivered=60, fair_share=100). Without coordination:
- Phone 1's Worker reads
delivered=60, picks A, writesdelivered=61 - Phone 2's Worker also reads
delivered=60(before phone 1's write landed), picks A, writesdelivered=61(clobbering) - Phone 3 same
Three impressions of A in three seconds. Counter ends up wrong. Pacing violated.
The fix: one Durable Object per merchant โ
Each merchant has a per-day Durable Object that owns their pacing counter. DOs are Cloudflare's single-threaded, strongly consistent edge primitives โ every Worker that wants to read or write merchant A's counter routes through the same DO. The DO serializes operations atomically.
Updated selection flow:
- Worker fetches eligible merchants from KV cache (30s TTL โ staleness is fine here)
- For each candidate, ask its DO for
(delivered_today, fair_share_for_this_user) - Compute pacing scores, pick winner
- Atomically increment that merchant's DO counter via
state.storage.transaction() - Return offer ID to phone
Steps 2 and 4 are the only DO hops. They're ~10โ30ms each. Serialized โ no race possible.
What lives where (state table) โ
| State | Where it lives | Consistency | Why this choice |
|---|---|---|---|
| Eligible offers per geo cell | Cloudflare KV | Eventual (30s TTL) | Cheap reads; eligibility doesn't change second-to-second |
| Daily delivered counter | Durable Object | Strong / atomic | Source of truth โ must be exact under concurrency |
| Hourly impression rollup | BigQuery | Eventual (hourly batch) | Reporting / analytics, not selection |
| Frequency-cap "seen today" | Phone (encrypted IndexedDB) | Local-only | Privacy โ no per-user impression history server-side |
| User location | Request payload (transient) | N/A | Computed server-side, never stored |
What the phone actually does โ
The phone is dumb on the pacing side:
- Opens dashboard
- Sends geo cell + audience tags + (optionally) frequency-cap exclusion list
- Receives offer ID
- Renders
The only per-phone state that matters for ad selection is the frequency-cap record โ encrypted, IndexedDB-local, never sent to the server. This honors the ยง3.2 / Immutable Right #6 privacy commitment and keeps each user's experience non-repetitive.
Two phones literally cannot race their way into double-paying merchant A's pacing budget โ they're talking to the same DO.
Cost at Lantern scale โ
Month 24 combined SD+SF: ~675K hero impressions/day = ~20M/month. Each impression triggers:
| Operation | Unit cost | Monthly cost (20M imps) |
|---|---|---|
| KV read (eligible offer set) | $0.50/M | ~$10 |
| DO call (read + atomic increment) | $0.20/M | ~$4 |
| Total pacing-layer cost | ~$14/month |
DOs scale to ~20M ops/month each before throughput limits. At Lantern's scale, individual merchants see <1 op/sec even at peak โ nowhere near the limit. No DO sharding needed for years.
Failure modes (graceful degradation) โ
| What breaks | How we handle it |
|---|---|
| DO temporarily unavailable | Fall back to distance-only selection. Counter syncs when DO returns. |
| KV cache stale by >30s | Small pacing drift on next refresh. No race because writes still go through DO. |
| Phone offline / weak network | Renders stale candidates from local cache. No write โ no inconsistency. |
| Multi-region failover | All DOs pinned to US-East for now. Acceptable for SD+SF; revisit at Phase 4 expansion. |
Counter reset and monthly accountability โ
Daily pacing counters reset at market-local midnight. The reset is intentional and serves two goals:
- Operational simplicity. DO state stays bounded, no risk of counter drift accumulating across days, debugging is straightforward (today's counter only reflects today).
- UX protection. Prevents "catch-up days" โ a merchant under-delivered yesterday shouldn't suddenly dominate today's impression budget to balance the books. Pacing's job is to make today fair; yesterday is yesterday's problem.
The $150 product promise is monthly, not daily. Monthly accountability lives in BigQuery, separate from the per-day DO state:
- At campaign start, each merchant has a monthly fair-share floor computed from expected market volume ร merchant's share of eligible geos. Specific threshold (e.g., 80% of pro-rated fair share, or a flat per-placement floor) is open โ see Part 6.
- A daily cron rolls up DO state into BigQuery monthly totals.
- At month-end, if
delivered_month < monthly_floor, the merchant is auto-credited toward the next billing cycle (or, optionally, refunded pro-rata โ policy decision pending).
Two-tier accounting โ
| Time horizon | System of record | Used for |
|---|---|---|
| Today | Durable Object | Real-time pacing decisions |
| Month | BigQuery rollup | Merchant dashboard, monthly true-up, credit/refund |
The merchant dashboard surfaces both: today's delivered count (live), and month-to-date count with projected end-of-month total. If projected total falls below the monthly floor with N days remaining, the dashboard flags it proactively โ surface the problem before the credit conversation.
Why this beats "rolling-window pacing" โ
A rolling window (e.g., "fair share over the last 7 days") is mathematically tidier but operationally heavier โ counters never reset, every selection has to query a longer window, and a low-traffic Tuesday quietly biases pacing for the next 6 days. Daily reset + monthly true-up gets ~95% of the fairness benefit with cleaner mechanics and a promise that maps directly to the way the merchant pays (monthly).
Privacy architecture for personalization โ
When we add interest signals (dismissals, blocks, affinities), the architecture has to honor Immutable Right #6 (no data sales, ever) and the ยง3.2 k-anonymity threshold by design, not by promise. Encryption helps, but the real protection is architectural โ the data we never collect is the data we can never leak.
The core distinction: tethered vs. readable โ
The two properties are independent. We can give every user a stable identity (so preferences persist across sessions) without ever being able to read those preferences:
| Property | Achieved by |
|---|---|
| Tethered (data follows the user) | Opaque user_id assigned at account creation. Stable, but unconnected to real-world identity. |
| Readable (Lantern can decrypt and use the data) | Possession of the encryption key. The key is derived from the user's passphrase, never sent to the server, never recoverable by Lantern. |
Tethering uses identifiers. Reading requires keys. We hold the first; only the user holds the second.
The three-lock model โ
Three independent layers of protection, each with a clear scope:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Lock 1 โ At-rest encryption with passphrase-derived key โ
โ PBKDF2(passphrase, 600K iter) โ AES-256-GCM key โ
โ Protects against: phone theft, forensic extraction, OS compromise โ
โ Matches existing profile-data model (Business Plan ยง3.1) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Lock 2 โ Architectural separation: preference data stays on-device โ
โ No server-side preference storage in plaintext, ever โ
โ Protects against: server compromise, rogue insider, subpoena โ
โ Pattern: top-N candidates from server, client re-ranks locally โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Lock 3 โ Request payload minimization โ
โ Each request contains only what's needed for the selection โ
โ Protects against: fingerprinting, request-log analysis โ
โ Implementation: categorical audience tags + S2 geo cell, no IDs โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโRequest payload audit (what crosses the wire) โ
For an ad-selection request:
| Field | Sent? | Privacy mitigation |
|---|---|---|
| Geo cell | โ | S2 cell at level 13 (~1km granularity). Cell membership is shared by hundreds-to-thousands of users in a dense city. |
| Audience target tags | โ | Reduced to four boolean buckets (Nearby / Active Lantern Holder / Frequent Visitor / New User). Multi-bit value โ but coarse enough to be non-unique. |
| Frequency-cap exclusion list | โ | NOT sent. Client requests top-N, then filters locally. |
| User dismissals | โ | NOT sent. Same pattern. |
| Affinity / interest profile | โ | NOT sent. Computed and applied locally. |
| User ID | โ | NOT sent on selection requests. Auth happens separately for billable events (claims, redemptions). |
| Device fingerprint | โ | NOT collected. |
The server's view of a request is essentially: "Someone in geo cell X with audience tags Y, give them 5 candidates." The "someone" is intentionally not pinned to a user_id at this layer.
Persistence options for preference state โ
| Option | What it is | Tradeoffs |
|---|---|---|
| A โ Phone-local only (recommended for v1) | Encrypted IndexedDB on the device. Lost on app uninstall / phone reset. New device = cold start. | Strongest privacy story (no server-side blob to attack). Simplest to implement. Cost: cross-device users start fresh on each device. |
| B โ Encrypted blob sync | Phone encrypts preference blob with passphrase-derived key, syncs to server keyed by user_id. Server cannot decrypt. On new device, user logs in, phone fetches blob, decrypts locally. | Better UX for cross-device users. Adds attack surface (server has the blob, even if encrypted). Requires careful blob versioning, conflict resolution, and rotation. |
v1 ships with Option A. Revisit Option B if cross-device demand becomes meaningful in Phase 2+. The encryption pattern for B is identical to existing profile-data sync โ no new cryptographic primitives needed.
Threat model โ
| Threat | Protection | Residual risk |
|---|---|---|
| Phone stolen / lost | Lock 1 (passphrase-derived encryption) | If passphrase is weak, brute-force is possible. Mitigation: enforce passphrase strength + 600K PBKDF2 iterations. |
| Network sniffer | TLS in transit | None significant. |
| Server compromise (full database leak) | Lock 2 (no preference plaintext server-side) | Aggregate impression counts and plaintext metadata (login times, lit-lantern events) can leak โ known limit per Business Plan ยง12. |
| Rogue Lantern insider | Lock 2 + audit logging | Same as above โ preference data unrecoverable, metadata reachable. |
| Subpoena to Lantern | Lock 1 + Lock 2 | Returns aggregate counts + ciphertext for synced blobs. Plaintext metadata is reachable โ known limit. |
| Fingerprinting via request metadata | Lock 3 (S2 cell granularity, audience-tag bucket coarseness) | At very low traffic densities, requests could in principle be linked. Mitigation: enforce minimum cell population thresholds before serving. |
| Court order against the user (compelled disclosure of passphrase) | Outside our scope | Not a technical protection. The user controls their own key. |
Phone numbers โ hashing, not encrypting โ
This is counterintuitive enough to be worth its own section: for phone numbers specifically, hashing is a stronger privacy design than encryption. The instinct is "encryption is more secure," but encryption helps only when you need to recover the plaintext. For phone numbers, we don't.
The flow โ
flowchart TD
A["๐ค User types phone number<br/>at signup or login"] --> B["๐ก Server normalizes to e.164<br/>e.g., +14155551234"]
B --> C["๐ HMAC-SHA-256<br/>pepper (from KMS) + normalized<br/>= phone_hash"]
C --> D{"Account with this<br/>phone_hash exists?"}
D -- "No (signup)" --> E["Create user_id (UUID)<br/>Store: (user_id, phone_hash)<br/>Plaintext NOT persisted"]
D -- "Yes (login)" --> F["Resolve to user_id"]
E --> G["๐ฒ Send SMS verification code<br/>using in-memory plaintext"]
F --> G
G --> H["๐๏ธ Plaintext freed when<br/>request handler returns"]
H --> I["๐ค User enters SMS code"]
I --> J["๐ User enters passphrase<br/>(derives encryption key client-side,<br/>never sent to server)"]
J --> K["โ
Authenticated session<br/>profile blob decrypted locally"]
L[("๐พ Storage contains:<br/>(user_id, phone_hash) only<br/>NEVER plaintext phone numbers")] -.-> C
style C fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style E fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style H fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style J fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style K fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style L fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2pxThe plaintext phone number exists only in transient request memory long enough to call Twilio, then is freed. Storage holds the hash and the user_id, never the plaintext. The pepper lives in KMS, separate from the database, so a DB leak alone cannot reveal phone numbers.
Why we don't need stored plaintext โ
| What we need to do with the phone number | Need plaintext from storage? |
|---|---|
| Send SMS at login | No โ user types it on each login. Server has plaintext transiently in request memory long enough to call Twilio, then discards. |
| Verify an account exists for a number | No โ equality lookup against the hash works. |
| Account recovery without passphrase | Not supported by design (Business Plan ยง3.1 โ "no passphrase recovery"). |
| Customer support lookup | No โ support asks user for the number, hashes it, finds the account. |
| Subpoena compliance | "We hold a hash. Provide a candidate number and we can confirm or deny." Stronger response than producing a decrypted list. |
Why encryption-with-server-key would be weaker โ
If we stored encrypt(phone, server_key) instead of hash(phone, pepper):
- Lantern itself could decrypt the database any time it had key access.
- A rogue insider with key + DB access could browse all phone numbers.
- A compelled disclosure could be answered with the full plaintext list.
- A leaked KMS access token = phone number leak.
Hashing closes those doors entirely. Lantern cannot browse the phone-number database โ only confirm a candidate.
The implementation โ
phone_hash = HMAC-SHA-256(server_pepper, normalized_phone_number)Where normalized_phone_number is the e.164 format (+14155551234), so different formattings of the same number produce the same hash.
The pepper:
- Lives in a KMS/HSM (Cloudflare Secrets / GCP KMS), not in source code or the database.
- Is read on-demand by the auth service, never logged.
- Is rotated periodically (working cadence: yearly + on any suspected exposure). Rotation re-hashes on next login per account.
- Has audit logging on every access.
A database leak alone can't reveal phone numbers. An attacker would also need the pepper, and the pepper has its own security boundary.
Account creation flow โ
1. User types phone number (plaintext in request memory only)
2. Server normalizes to e.164
3. Server computes phone_hash = HMAC-SHA-256(pepper, normalized)
4. Server checks: does any account have this phone_hash already?
โ if yes: send "log in instead" flow
โ if no: create user_id (random UUID), store (user_id, phone_hash)
5. Server uses in-memory plaintext to send SMS verification code via Twilio
6. Plaintext is freed when the request handler returns. Not persisted.Login flow โ
1. User types phone number
2. Server normalizes + hashes (same way)
3. Server looks up account by phone_hash โ resolves user_id
4. Server sends SMS code; user enters code
5. User enters passphrase โ derives encryption key client-side
6. Phone fetches encrypted profile blob (and optionally preference blob, if Option B)
7. Phone decrypts locallyThe server never holds or stores the plaintext phone number beyond the duration of the SMS-send call.
When encryption IS the right answer โ
Different data, different patterns. The decision rule:
| Property needed | Pattern | Examples |
|---|---|---|
| User reads, server doesn't | Client-side encryption with passphrase-derived key | DOB, profile fields, preference blob (Option B sync) โ Business Plan ยง3.1 |
| Server verifies equality, neither stores plaintext | Keyed hash (HMAC with server pepper) | Phone numbers. Any other identifier we look up by equality. |
| Server reads | โ ๏ธ Plaintext, or encryption with server-held key (marginally better) | Minimize what falls here. Currently: account creation timestamp, login records, lit-lantern events, IP at login. |
The third pattern is the weakest. We minimize it by design โ anything that can live in pattern 1 or 2 belongs there.
What the server stores plaintext (the honest list) โ
Per Business Plan ยง12, these exist plaintext server-side because the platform needs them:
- Account creation timestamps
- Login records and IP addresses (rotated/aged out per retention policy)
- Hashed phone numbers (HMAC-SHA-256 with server pepper โ verifiable, not browseable; see "Phone numbers โ hashing, not encrypting" above)
- Lantern-lit events (venue + timestamp + lantern_id)
- Aggregate impression / click / claim / redemption counts per offer
- Aggregate dismissal counts per offer (with k-anonymity floor โ only surface if N โฅ 3 distinct sessions)
What's not on this list (and never should be):
- Per-user impression history
- Per-user dismissal history
- Per-user click/claim/redemption sequences mapped to interests
- Cross-device behavior linkage in plaintext
Anything that would let us answer "what did user_id 7382 see today" must be impossible by architecture, not just by policy. That's the line.
"But wait โ are we collecting user_ids?" โ
A fair pressure-test on the privacy story. Yes โ every user has a stable opaque user_id. This is consistent with Business Plan ยง3.1 and ยง12, and worth being explicit about because the flowchart in the next section shows user_id flowing through the pipeline.
What the user_id is, and what it isn't โ
The user_id is a random opaque UUID generated at account creation. It is NOT:
- The user's phone number (that's hashed separately, never stored plaintext)
- The user's name, photo, email, or any traditional PII (we don't collect these)
- Linkable to a real-world identity by Lantern alone (the user's passphrase is required to decrypt profile data)
- Exposed to merchants in any API endpoint or dashboard view
It IS a stable identifier Lantern uses internally for these specific purposes:
| Internal use | Why we need it |
|---|---|
| Account state | Login, profile blob, encrypted preference blob if Option B ships |
| De-duplication in aggregates | "12 unique users lit lanterns at venue X" requires knowing which lightings came from the same person |
| Server-side frequency-cap fallback | If the phone's local cap state is unavailable, server can refuse a duplicate render |
| Fraud / abuse detection | Detecting an account creating 50 fake claims in 5 minutes requires per-account aggregation |
| Pacing accounting | Daily delivered counts per merchant need user uniqueness to avoid double-counting one user's multiple impressions |
The honest distinction โ
An opaque user_id is not PII on its own. It's a UUID. Standalone, it's a random string with no real-world meaning. But it becomes a privacy concern when it indexes rich behavioral metadata over time โ the ยง12 "plaintext metadata" honest limit. A subpoena demanding "show us everything tied to user_id 7382" returns:
- Account creation timestamp (plaintext)
- Login records and IP addresses (plaintext, retention-capped)
- Lit-lantern events: venue + timestamp + mood (plaintext, retention-capped)
- Aggregate impression / click / claim counts indexed by user_id (plaintext)
- Encrypted profile blob (returns ciphertext only)
- Encrypted preference blob if Option B (returns ciphertext only)
The first four are real, reachable data. The last two are sealed.
What mitigates the risk โ
| Mitigation | What it does |
|---|---|
| Opaque UUID (not phone, not name) | A leaked user_id alone tells an attacker nothing |
| No merchant API endpoint takes/returns user_id | Merchant-side leaks can't include it |
| Retention caps on row-level events | Raw event tables aged out at 90 days (working number); only k-anonymous monthly rollups retained indefinitely |
| Audit logging on internal access | Any human query against user_id-indexed data is logged |
| K-anonymity gate on anything externally surfaced | Merchant view never exposes user_id-level data |
| Hashed phone number | The link from "real-world person" to user_id requires the pepper, which lives in KMS |
| Encrypted profile/preference blobs | Server-stored data cannot be decrypted by Lantern |
The principle to internalize โ
Opaque IDs aren't PII, but they ARE a vector for inference if the metadata they index is rich. The discipline:
- Keep metadata minimal โ collect only what we operationally need
- Keep metadata time-bounded โ retention caps on raw events
- Keep metadata access audit-logged โ every internal query is recorded
- Keep metadata externally inaccessible โ k-anonymity on anything that leaves Lantern toward a merchant
The user_id stays. The architectural discipline is what makes it safe.
Banning users โ without storing plaintext phone numbers โ
A common operational pressure-test on the phone-hashing design: "If we don't store the phone number, how do we ban someone who's abusive?"
Answer: hashing supports equality lookup, which is exactly what a ban check needs.
The flow โ
flowchart TD
A["๐ค User submits phone number<br/>at signup or login"] --> B["๐ก Normalize + Hash<br/>HMAC-SHA-256 with pepper"]
B --> C{"๐ซ phone_hash IN<br/>banned_phone_hashes?"}
C -- "No" --> D["โ
Proceed to standard<br/>SMS verification + auth"]
C -- "Yes" --> E["โ Reject login<br/>show ban message"]
E --> F["๐ Optional appeal<br/>via separate non-auth endpoint:<br/>user submits phone + reason"]
F --> G["๐ค T&S reviews evidence_link<br/>+ appeal narrative"]
G --> H{"Decision?"}
H -- "Lift" --> I["โ
Remove from banned_phone_hashes<br/>user can re-register"]
H -- "Uphold" --> J["โ Mark appealed=true<br/>ban remains"]
H -- "Downgrade to warning" --> K["โ ๏ธ Remove from ban list<br/>log warning"]
L[("๐พ banned_phone_hashes contains:<br/>phone_hash ยท banned_at ยท reason ยท<br/>evidence_link ยท expires_at ยท appealed ยท<br/>reviewer_id<br/>NEVER plaintext phone numbers")] -.-> C
style B fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style C fill:#4d4d0d,color:#fff,stroke:#8d8d2d,stroke-width:2px
style D fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style E fill:#4d0d0d,color:#fff,stroke:#8d2d2d,stroke-width:2px
style I fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style L fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2pxHow it works โ
Trust & Safety maintains a banned_phone_hashes table containing only hashes โ no plaintext. Every login attempt runs through a ban check:
1. User submits phone number (plaintext, transient)
2. Server normalizes (e.164) + hashes:
phone_hash = HMAC-SHA-256(server_pepper, normalized)
3. Server checks: phone_hash IN banned_phone_hashes?
โ if yes: reject login with appropriate message
โ if no: proceed to SMS verification + standard auth flowThe ban list contains only hashes. We never need plaintext to enforce a ban. We never decrypt anything โ the hash is one-way and the check is just equality.
What a ban record holds โ
Each entry stores only the bare administrative record:
| Field | Purpose |
|---|---|
phone_hash | The lookup key (HMAC-SHA-256) |
banned_at | Timestamp |
reason | Internal taxonomy: spam, abuse, ToS violation, etc. |
evidence_link | Reference to the original event that triggered the ban |
expires_at (optional) | For temporary / time-limited bans |
appealed (boolean) | Has the user submitted an appeal |
reviewer_id (optional) | Who issued the ban; for audit |
No plaintext phone number. No name. No personal details beyond what's required to track the administrative action.
What ban can and can't do โ
| Can do | Cannot do |
|---|---|
| Block the same phone number from creating a new account or logging back in | Block a user from creating a new account with a different phone number |
| Combine ban with other signals (phone hash + IP-rate-limit + behavioral pattern) | Identify the banned user in the real world without external information |
| Soft-ban / shadowban for spam (account exists, features silently disabled) | Track a banned user across devices or phone-number changes (no fingerprinting) |
| Process appeals via a separate workflow with audit trail | Reach into the user's data to "verify identity" beyond what the user provides at login |
The last "cannot" is structural and intentional. A determined bad actor with a fresh phone number can create a new account. We accept this as a limit because the alternative (device fingerprinting, cross-account behavioral linkage) would directly conflict with the ยง3.2 anonymity commitments.
Appeals โ
A banned user can appeal through a separate non-authenticated endpoint:
- User submits phone number + reason for appeal
- Server hashes phone, looks up ban record
- T&S reviews
evidence_linkand the appeal narrative - Decision: lift, downgrade to warning, or uphold (with reviewer_id and reason logged)
The appeals mechanism doesn't require relaxing the privacy architecture โ appeals are just human review of an existing audit trail.
Layered enforcement (when ban-by-hash isn't enough) โ
For sophisticated abuse (e.g., repeat fraudsters cycling phone numbers), additional signals can be applied at the aggregate level:
- IP rate-limiting at signup (transient, not stored long-term)
- Pattern detection on aggregate signup velocity from a region
- Manual review of anomalous account-creation bursts
What we don't do: build per-account behavioral profiles that survive across phone numbers. That's the line. Above that line is anti-fraud; below it would be tracking infrastructure that conflicts with the mission.
What a subpoena can actually compel us to disclose โ
Business Plan ยง12 explicitly acknowledges this: "a subpoena or warrant compelling disclosure of plaintext metadata is a real, foreseeable scenario โ and 'we won't' is not a defensible legal posture." So: what can we actually be compelled to produce?
The architecture is the protection. We can only produce what we have. Here's the honest inventory.
The flow โ
flowchart TD
A["โ๏ธ Legal process arrives<br/>subpoena ยท 2703(d) court order ยท warrant<br/>specifies an identifier"] --> B{"What identifier<br/>is provided?"}
B -- "Phone number" --> C["๐ Hash with pepper<br/>look up account"]
B -- "user_id directly" --> D["Direct lookup<br/>(rare โ requires user_id<br/>obtained elsewhere)"]
B -- "IP address" --> E["Match against login records<br/>within retention window<br/>(may resolve to multiple user_ids)"]
B -- "Real name / email / device ID" --> F["โ NOT collected<br/>cannot resolve"]
C --> G{"Account exists?"}
G -- "No" --> H["๐ญ Return: 'no record matches'"]
G -- "Yes" --> I["Resolve to user_id"]
I --> J["๐ Build subpoena response packet"]
D --> J
E --> J
J --> K["โ
INCLUDED in packet<br/>(reachable plaintext + ciphertext):<br/>โข Account creation timestamp<br/>โข Login records: timestamps + IPs<br/>(retention-capped, ~90 days)<br/>โข Lit-lantern events<br/>(retention-capped)<br/>โข Aggregate event counts per user_id<br/>โข Ban status (if applicable)<br/>โข Ciphertext blobs<br/>+ written statement we cannot decrypt"]
J --> M["โ NOT in packet<br/>(architecturally cannot produce):<br/>โข Decrypted profile data<br/>โข User preferences / dismissals / interests<br/>โข Per-user impression history<br/>โข Cross-merchant behavior linkage<br/>โข Frequency-cap state<br/>โข Real-time GPS location<br/>โข Device identifiers / fingerprints<br/>โข Message content<br/>(governed by separate retention policy)"]
J --> N["๐จ User notification<br/>(where legally permitted โ<br/>not gagged by the order)<br/>+ counsel-governed timing"]
style C fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style F fill:#4d0d0d,color:#fff,stroke:#8d2d2d
style H fill:#4d4d0d,color:#fff,stroke:#8d8d2d
style K fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style M fill:#4d0d0d,color:#fff,stroke:#8d2d2d,stroke-width:2px
style N fill:#0d4d0d,color:#fff,stroke:#2d8d2dHow legal process arrives โ
Law enforcement (or a civil litigant) presents a subpoena, court order, or warrant specifying an identifier โ usually a phone number, sometimes a user_id if obtained elsewhere, occasionally an IP address. The legal process demands records associated with that identifier.
The actual response is governed by counsel review (different legal instruments compel different categories of data โ basic subscriber info via subpoena, non-content records via 2703(d) court order under the SCA, content via warrant). This doc doesn't dictate legal posture; it documents what data is technically reachable so counsel knows the bounded universe.
Step 1 โ Resolving an identifier to a user_id โ
| Identifier provided | Lookup mechanism | Result |
|---|---|---|
| Phone number | HMAC-SHA-256(pepper, normalized) โ match against accounts | Confirms or denies an account exists; returns user_id if matched |
| User_id directly | Direct lookup | Proceeds to step 2 |
| IP address | Match against login records (retention-capped) | May resolve to one or more user_ids whose login attempts came from that IP within the retention window |
| Real name / email | Not applicable | We don't collect these. Cannot resolve. |
Step 2 โ Reachable data for a known user_id โ
| Data | What we can produce |
|---|---|
| Account creation timestamp | Plaintext, exact |
| Login records | Plaintext: timestamps + IP addresses, subject to retention cap (working: 90 days for raw login events) |
| Lit-lantern events | Plaintext: venue_id + timestamp + mood, subject to retention cap |
| Aggregate impression / claim / redemption counts indexed to user_id | Plaintext counts |
| Ban status (if applicable) | Plaintext |
| Encrypted profile blob | Ciphertext only โ we cannot decrypt |
| Encrypted preference blob (if Option B is ever shipped) | Ciphertext only โ we cannot decrypt |
Step 3 โ Genuinely NOT reachable โ
These are not "we won't produce them" โ they're "we cannot, because the architecture doesn't have them":
| Data | Why we can't produce it |
|---|---|
| Decrypted profile data (DOB, age verification fields) | Encrypted with the user's passphrase-derived key. Lantern doesn't have the key. |
| Decrypted preferences / dismissals / interests | Lives on the phone, encrypted. Even if synced via Option B, the blob is encrypted with a passphrase-derived key. |
| Per-user impression history mapped to "what they actually saw" | We don't store per-user impression sequences. Aggregate counters per offer don't reverse into per-user histories. |
| Cross-merchant behavior linkage | Aggregate counts don't surface user_id-level cross-references in any pipeline that survives k-anonymity. |
| Frequency-cap "seen today" history | Phone-local only. Never sent to the server. |
| Real-time location | Not tracked. We have S2 geo cell (~1km, level 13) at request time, not GPS coordinates. |
| Device identifiers / fingerprints | Not collected. |
| Private user-to-user messages | Subject to a separate retention policy (Business Plan ยง3.1: "chats expire after 30 days"). Encryption status at rest is governed by a separate document โ confirm before responding to a content request. |
What a complete subpoena response actually looks like โ
In practice, a packet handed over to law enforcement for a single user_id would contain:
- Account confirmation โ yes/no, based on phone hash lookup
- Login metadata โ timestamps + IPs, capped at the retention window
- Lit-lantern events โ venue + time + mood, capped at the retention window
- Aggregate event counts โ impressions, claims, redemptions tied to the user_id
- Ban status โ if applicable
- Ciphertext blobs โ for any encrypted data, with a written statement that Lantern does not hold the decryption key
What's NOT in the packet (because we don't have it):
- Plaintext profile data (DOB, etc.)
- User preferences, interests, or dismissals
- Cross-merchant behavior linking
- User communications (governed by separate retention policy + counsel review)
- Real-time location
- Device identifiers
User notification โ
Where legally permitted (i.e., not gagged by the order), Lantern will notify the affected user that their data has been requested before disclosure. This is standard for privacy-respecting platforms โ Apple, Signal, and others publish similar policies. Specific notification policy is governed by counsel review at incorporation, not by this doc.
Why the architecture is the protection โ
A platform that holds less data has less to disclose. That's the whole point of the ยง3.2 anonymity model and the ยง6 privacy commitment. Subpoena response isn't a place we resist โ it's a place where the architectural choices we made years earlier determine the bounded universe of what's even conceivable to produce.
The data we never collected, we cannot be compelled to produce. The data we encrypted with user-only keys, we cannot decrypt regardless of the order. The data we do hold (plaintext metadata) is acknowledged as the ยง12 honest limit, retention-capped, and audit-logged.
Could we sever the phone โ user_id link entirely? โ
A sharper version of the subpoena question: under the current architecture, when law enforcement provides a phone number, we can resolve it to a user_id and produce user_id-indexed metadata. The link phone_hash โ user_id is in our database. Could we architect this so even WITH the phone number, we cannot produce user_id-indexed data without the user's cooperation?
Yes โ this is called a sealed identity or indirect link architecture. It's a Phase 3+ option, not a v1 capability, but it's worth documenting because it's the strongest version of the privacy story and we should know what direction the architecture can evolve.
v1 architecture vs. sealed architecture โ side by side โ
flowchart TB
subgraph V1["๐ v1 ARCHITECTURE (current โ server-resolvable link)"]
direction TB
A1["โ๏ธ Subpoena: phone number"] --> B1["๐ Hash with pepper"]
B1 --> C1["๐ Look up auth_table:<br/>(phone_hash, user_id)"]
C1 --> D1["๐ค Returns user_id directly<br/>server can resolve"]
D1 --> E1["๐ Pull all user_id-indexed metadata:<br/>login records, lit-lantern events,<br/>aggregate counts, etc."]
E1 --> F1["๐ฆ Full subpoena response packet"]
end
subgraph V2["๐ SEALED ARCHITECTURE (Phase 3+ option โ passphrase-sealed link)"]
direction TB
A2["โ๏ธ Subpoena: phone number"] --> B2["๐ Hash with pepper"]
B2 --> C2["๐ Look up auth_table:<br/>(phone_hash, encrypted_user_id_blob)"]
C2 --> D2["๐ค Returns ciphertext only<br/>blob = encrypt(user_id,<br/>key = passphrase-derived)"]
D2 --> E2{"๐ Who has the<br/>decryption key?"}
E2 -- "Lantern: โ NO" --> F2["๐ Cannot resolve to user_id<br/>Cannot pull user_id-indexed data"]
E2 -- "User: โ
YES" --> G2["User could decrypt locally<br/>(would require their cooperation)"]
F2 --> H2["๐ฆ Bounded response:<br/>account exists yes/no,<br/>ciphertext blob,<br/>statement we cannot decrypt"]
end
style D1 fill:#4d4d0d,color:#fff,stroke:#8d8d2d
style F1 fill:#4d4d0d,color:#fff,stroke:#8d8d2d,stroke-width:2px
style D2 fill:#0d4d0d,color:#fff,stroke:#2d8d2d
style F2 fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style H2 fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2pxThe data model difference โ
v1 (current):
auth_table: (phone_hash, user_id) โ link is server-resolvable
user_table: (user_id, encrypted_blobs, ...)
events: (event_id, user_id, action, ts, ...)
login_events: (ts, ip, user_id, phone_hash) โ link reinforced at every loginSealed (Phase 3+ option):
auth_table: (phone_hash, encrypted_user_id_blob)
blob = AES-256-GCM(user_id, passphrase_derived_key)
server has ciphertext, no key
user_table: (user_id, encrypted_blobs, ...)
events: (event_id, user_id, action, ts, ...)
login_events: (ts, ip, user_id) โ logged against user_id ONLY
phone_hash NOT recorded hereLogin flow under the sealed architecture โ
- User types phone + passphrase on the phone.
- Phone normalizes phone, computes phone_hash with public salt portion.
- Phone sends phone_hash to server, receives
encrypted_user_id_blob(ciphertext). - Phone derives the encryption key from the user's passphrase (PBKDF2, client-side).
- Phone decrypts the blob locally to extract user_id.
- Phone makes subsequent API calls with a session token bound to user_id.
The server never observes the user_id during the auth lookup. The login_events table records "user_id Y had a login at time T from IP X" but never records "phone_hash Z was used to look up user_id Y."
What it buys us in subpoena response โ
| Question | v1 response | Sealed-architecture response |
|---|---|---|
| "Does an account exist for this phone?" | โ Yes/No | โ Yes/No |
| "What's the user_id for this phone?" | โ Returns user_id | ๐ Returns ciphertext, cannot decrypt |
| "What did this phone's user do?" | โ Full user_id-indexed history | ๐ Cannot link phone to behavior |
| "What did user_id Y do?" (if they have user_id from elsewhere) | โ Full history | โ Full history (no protection here) |
| "Decrypt this user's blob" | N/A | ๐ Cannot โ no key |
The protection is specifically on the phone-number-as-input subpoena pattern, which is the most common law-enforcement entry point.
What it costs us operationally โ
Real tradeoffs that matter for shipping:
| Capability | Impact |
|---|---|
| Customer support lookup by phone | โ ๏ธ Harder. Support can confirm an account exists by phone hash but cannot view its history without the user's session. Workaround: user-initiated support flows where the authenticated user shares their session for support to assist. |
| Cross-session fraud detection on a single phone | โ ๏ธ Reduced. We'd see "logins from phone_hash X" without being able to link them to user_id activity. Behavioral anti-fraud needs to operate on user_id-only signals. |
| Ban enforcement by phone hash | โ
Unaffected โ banned_phone_hashes is an independent index. |
| Pacing & merchant reporting | โ Unaffected โ pacing operates on user_id, which the phone provides at request time. K-anonymity rolls up user_id-level events without needing phone linkage. |
| Account recovery if user loses passphrase | โ Worse than current โ already unrecoverable per Business Plan ยง3.1 ("no passphrase recovery"). The sealed model adds another layer of "can't recover from server side" on top of that. |
| Engineering complexity | โ ๏ธ Higher. Every authenticated request flow involves passphrase-derived key handling. Session token rotation needs careful design. |
Why this isn't v1 โ
Three reasons:
- Engineering scope. Every login flow becomes more involved. Session management, key derivation, error paths all expand. The merchant transaction loop is the Phase 1 critical-path build (Business Plan ยง9), and sealed identity is a deepening, not a launch blocker.
- Operational maturity. Customer support and anti-fraud workflows that don't rely on server-side phone-to-user_id resolution have to be designed and trained. That's months of operational work before the first deployment.
- Marginal benefit at current scale. With low subpoena volume in Phase 1/2, the additional protection is mostly defensive against future scenarios. The current architecture (hashed phone + encrypted profile + retention caps + k-anonymity) is already a strong privacy posture, well above the platform-industry median.
When we'd ship it โ
Reasonable triggers for migrating to the sealed architecture:
- A material privacy incident at a comparable platform that highlights server-resolvable phoneโuser_id as the specific leak vector
- Cumulative subpoena volume reaches a level where reducing reachable data per-request becomes operationally important
- A merchant, governmental partner, or pilot partner specifically requires demonstrating sealed identity as a contracting condition
- We move into a jurisdiction with stricter compelled-disclosure rules where the additional protection has tangible legal value
What we'd want from counsel before shipping โ
Before committing to the sealed architecture, counsel review on:
- Whether "we cannot decrypt without user cooperation" is a defensible response under the relevant frameworks (US ECPA / SCA, GDPR Art. 6 / 32, CCPA)
- Whether a court can compel us to modify the architecture to recover the link (probably not โ but worth confirming)
- Whether the user-notification policy still applies when we technically can't identify the affected user from the subpoena's identifier
- Whether retention of the encrypted_user_id_blob alongside the phone_hash creates any obligation we wouldn't have under a hashed-only model
Bottom line โ
The sealed architecture is possible, designed, and documented as a Phase 3+ direction. It's not a launch dependency. The current architecture already meets the privacy commitments in ยง3.2 and Immutable Right #6. The sealed version is the deepening โ the next zoom level of "we cannot leak what we cannot resolve."
For now: ship v1, instrument it correctly, monitor subpoena patterns, revisit the migration question explicitly at the Phase 3 boundary.
Merchant data sharing โ what flows where โ
The whole architecture funnels through one principle: the merchant sees aggregates; we never share an individual user's actions, preferences, or identity. This applies to both ad performance (impressions, clicks, claims, redemptions, dismissals) and venue intelligence (lit lanterns, mood mix, bonfire formation rate).
The flow โ
flowchart TD
U["๐ค User action<br/>light lantern ยท view ad ยท click<br/>claim ยท redeem ยท dismiss"]
U --> P["๐ฑ Stays on the phone<br/>encrypted, never sent:<br/>โข preferences & affinities<br/>โข dismissed offers list<br/>โข 'seen today' frequency-cap<br/>โข interest profile"]
U --> S["๐ก Sent to server<br/>(only what's needed):<br/>โข event type<br/>โข offer_id / venue_id<br/>โข timestamp<br/>โข user_id (internal only)"]
S --> A[("โ๏ธ BigQuery aggregate counters<br/>per offer / per venue<br/>user_id used for de-dup,<br/>NEVER surfaced to merchant")]
A --> K{"๐ K-anonymity gate<br/>โฅ 3 unique users<br/>per reported cell?"}
K -- No --> H["๐ Suppressed<br/>shown as 'โ' on dashboard<br/>'not enough data yet'"]
K -- Yes --> M["๐ Merchant dashboard"]
M --> V["โ
Merchant SEES (aggregates only):<br/>๐ Counts: impressions ยท clicks ยท<br/>claims ยท redemptions ยท dismissals<br/>๐ Rates: conversion ยท CTR ยท dismissal<br/>โฐ Time patterns: peak hours, day-of-week<br/>๐ญ Mood mix % at venue<br/>๐ฏ Audience composition by target<br/>๐ฅ Bonfire formation rate<br/>๐ Performance vs platform median"]
M --> X["โ Merchant NEVER sees:<br/>โข any individual user (no user_id, ever)<br/>โข per-user actions, moods, or sequences<br/>โข preferences, interests, or affinities<br/>โข cross-merchant or cross-venue behavior<br/>โข phone number, IP, device, or any PII<br/>โข any cell with fewer than 3 unique users"]
style P fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style V fill:#0d4d0d,color:#fff,stroke:#2d8d2d,stroke-width:2px
style X fill:#4d0d0d,color:#fff,stroke:#8d2d2d,stroke-width:2px
style K fill:#4d4d0d,color:#fff,stroke:#8d8d2d,stroke-width:2px
style H fill:#4d4d0d,color:#fff,stroke:#8d8d2d,stroke-width:2pxThree checkpoints, three different leak vectors closed โ
๐ข Phone-side encrypted state (top-left branch). Preferences, dismissals, and frequency-cap state never leave the device. No server-side leak surface for this data because the server never has it.
๐ก K-anonymity gate (middle). Even after aggregation, no metric is exposed to the merchant unless at least 3 unique users contributed. Below threshold = suppressed and rendered as
โ. This closes the small-cell leak: the classic case where a merchant who sees "1 redemption today, 11am, by a Frequent Visitor" can identify which specific regular walked in alone.๐ข Aggregate-only merchant view (bottom-right green). What the merchant sees is intentional, scoped, and capped at the aggregate level. There is no per-user drill-down endpoint โ not for merchants, not for Lantern customer support, not for the founders. The capability simply isn't built.
What's still in the system (the honest part) โ
The middle of the diagram (BigQuery layer) does retain user_id at row level for two operational purposes:
- De-duplication โ counting "12 unique lit lanterns at venue X this evening" requires knowing which lightings came from the same user.
- Fraud / abuse signals โ detecting an account creating 50 fake claims in 5 minutes requires per-account aggregation internally.
This is the "plaintext metadata" trade-off called out in Business Plan ยง12. Mitigations baked into the architecture:
user_idis opaque (UUID, not phone number, not name).- Raw events have a retention cap (working number: 90 days for row-level events, indefinite for k-anonymous monthly rollups).
- The merchant API has no endpoint that takes
user_idas a parameter or returns it in any response. - Internal access to plaintext metadata (e.g., fraud investigation) is audit-logged.
What this flow looks like for specific merchant questions โ
Same architecture, different surface:
| Merchant question | Source data | K-anonymity check | What the merchant sees |
|---|---|---|---|
| "How did my offer perform this week?" | Aggregate impression / click / claim / redemption counters | โฅ 3 distinct users in period | Total counts + rates |
| "What hours are my lanterns lit?" | Aggregate lit-lantern events at the venue | โฅ 3 distinct users per hour bucket | Hourly chart; hours below threshold rendered as โ |
| "What's the mood mix at my venue?" | Aggregate mood selection per lit lantern | โฅ 3 distinct users per mood bucket | Pie chart; low-count moods grouped as "Other" |
| "How often does my offer get dismissed?" | Aggregate dismissal counter | โฅ 3 distinct sessions | Dismissal rate % with platform-median comparison |
| "Who claimed my offer?" | โ | โ | โ Question rejected โ the API has no endpoint for this. |
The honest answer to the last question is "We cannot tell you and we cannot ourselves know โ that's the design." Pre-loading that answer into merchant onboarding prevents the question from becoming a recurring support ticket.
Aggregate tags & vibes โ what the merchant legitimately sees โ
A point worth being explicit about: merchants don't just get raw counts. They get composition data. A venue owner deciding whether Tuesday afternoons need more staff, or whether their Conversation-mood crowd skews toward New Users vs. Frequent Visitors, has a legitimate need for distributional signals about their own venue and their own offers. The architecture supports this โ through aggregates only, never per-user attributes.
The line: aggregate is fine, per-user is not.
| โ Merchant CAN see (aggregate distribution) | โ Merchant CANNOT see (per-user attribute) |
|---|---|
| "Your venue: 60% Conversation, 25% Quiet Company, 15% Activity/Game" | "User #7382 is in Conversation mood right now" |
| "Active Lantern Holders were 32% of your eligible audience" | "User #7382 is a Frequent Visitor" |
| "Frequent Visitors redeemed at 18%; New Users at 6%" | "User #7382 has redeemed 4 of your offers" |
| "Tuesday afternoons skew Quiet Company" | "User #7382's mood progression over 2 weeks" |
| "Bonfire formation rate: 14% of nights" | "Which users formed which bonfires" |
| "Your offer's CTR is 6.2% โ platform median is 4.1%" | "User X clicked your offer at 7:14pm" |
| "Genre distribution among lit lanterns at your venue: 40% Jazz, 30% Hip-Hop, 30% Rock" | "User Y prefers Jazz" |
How tags & vibes flow through the same plumbing โ
There's no special pipeline for tags/vibes. Same architecture, just one more dimension:
Raw event: (offer_id, venue_id, action, user_id, timestamp, mood, audience_target)
Aggregation: GROUP BY (offer_id, venue_id, mood, hour_bucket)
COUNT(DISTINCT user_id) โ unique_users
COUNT(*) โ events
K-anon gate: WHERE unique_users >= 3
Merchant: "60% Conversation, 25% Quiet, 15% Activity"
^
The user_id was used internally for the COUNT(DISTINCT) operation,
never surfaced in the result.The user_id appears in the GROUP BY pipeline only as a way to count unique users. It does not appear in the rendered merchant output. The k-anonymity threshold is what makes this safe: any cell with fewer than 3 distinct users gets suppressed before the dashboard sees it.
What tags can a merchant use to target? โ
Merchant-side targeting (deciding who sees an offer) is also aggregate-driven and audience-bucket-based, not per-user:
- Audience targets (per Business Plan ยง5): Nearby Users / Active Lantern Holders / Frequent Visitors / New Users โ four broad buckets
- Geo radius (default 1.5 mi for Nearby Users)
- Optional venue-affinity (e.g., "users who have lit lanterns at this venue โฅ 3 times" โ applied as a bucket-level filter, not per-user)
- Optional time-of-day windowing (e.g., "show only during evening hours")
What merchants cannot do at the targeting layer:
- Target a specific named user
- Target users with specific past behaviors ("user clicked my last 3 offers")
- Upload a list of user identifiers and target against it
- Build a custom segment outside the four broad audience buckets
Targeting that requires per-user attribute access is architecturally unavailable, not just policy-blocked.
Chronic under-delivery โ diagnose, don't compensate โ
The carryover policy above handles "yesterday was a bad day" cleanly. The harder question: what about a merchant who systematically under-delivers month after month? Two natural-but-wrong instincts:
- "Just give them monthly priority for the next round."
- "Build an algorithm that auto-compensates over time."
Both are traps. Three things go wrong:
- Compounding distortion. Boosting Merchant A pushes Merchant B into under-delivery, which earns B a boost next month, which pushes A back into under-delivery. The algorithm chases its own tail.
- Hides the root cause. Most chronic under-delivery is fixable by the merchant (geo too narrow, audience too narrow, weak creative). Auto-boost papers over the symptom and delays the fix.
- Permanent distortion for structural problems. A merchant in a low-volume neighborhood gets a permanent boost that never solves the underlying inventory shortage. They keep paying $150 for impressions the market can't supply.
The honest response is layered:
Layer 1 โ Detect and classify (must build) โ
A daily cron flags merchants whose projected end-of-month delivery is below floor. Pseudocode:
def under_delivery_risk(merchant, day_of_month):
delivered = bigquery_sum(merchant.id, "delivered", "this_month")
projected_total = (delivered / day_of_month) * days_in_month(today)
return projected_total < merchant.monthly_floor
def classify_cause(merchant):
if merchant.geofence_radius < market_median_radius:
return "geo_too_narrow"
if len(merchant.audience_targets) < 2:
return "audience_too_narrow"
if merchant.creative_ctr < platform_median_ctr * 0.6:
return "weak_creative"
if comparable_merchants_also_underdelivering(merchant):
return "market_density"
if merchant.venue_views < expected_for_category(merchant):
return "low_inventory"
return "platform_side" # genuine pacing/system issueThis is just analytics โ we already need this data for billing accountability. Building it costs effectively nothing extra.
Layer 2 โ Coach the merchant (high leverage) โ
Surface the diagnosis on the merchant dashboard with specific recommendations:
| Detected cause | Auto-surfaced recommendation |
|---|---|
geo_too_narrow | "Widen geofence to {market_median} miles to reach more eligible users" |
audience_too_narrow | "Add Nearby Users to expand your audience pool" |
weak_creative | "Your headline is being skipped at {N}ร the platform rate โ try a sharper value prop" |
market_density | "This neighborhood currently has limited Lantern user volume โ broaden geofence or wait for the area to mature" |
low_inventory | "Your venue page got {N} views this month โ consider promoting your venue listing externally" |
platform_side | "We're investigating โ your campaign is correctly configured but under-delivering. You'll see a credit at month-end if delivery stays below floor." |
Most chronic under-delivery is merchant-fixable once they see why it's happening. This is high-leverage: cheap to build, directly improves merchant outcomes, no algorithmic distortion.
Layer 3 โ Refund or credit (the contractual remedy) โ
If the merchant is correctly configured and the platform still can't deliver, the contractual answer is to credit or refund โ not to boost forever. They paid $150 for impressions we didn't deliver; we owe them the value, not a permanent priority boost that distorts pacing for everyone else.
This is the trust mechanism. Boost is just trying to avoid the refund.
Layer 4 โ Bounded next-month boost (optional, only with safeguards) โ
For merchants who are correctly configured and the under-delivery is platform-side (not market reality), a small bounded priority adjustment can apply to the following month:
def next_month_boost(merchant):
if classify_cause(merchant) != "platform_side":
return 0 # boost only platform-caused shortfalls
shortfall_ratio = max(0, 1 - last_month.delivered / last_month.fair_share)
return min(0.20, shortfall_ratio) # capped at +20%The cap (+20%) prevents whack-a-mole. The expiration (one month, no rollover into month+2) prevents permanent distortion. The diagnostic gate prevents boosting merchants whose root cause they can fix themselves.
This layer is optional. If the diagnosis + coaching + refund layers do their job well, Layer 4 may not be necessary. Worth piloting before committing.
Layer 5 โ Honest conversation (the off-ramp) โ
After 2 consecutive months of under-delivery despite diagnosis + coaching + refund, the right step is a conversation, not another month of pacing tweaks:
"This neighborhood doesn't have the user density to deliver your fair share. Here are options:
- Broaden your geofence to
- Pause the campaign until Lantern's user base in your area grows
- End the campaign with a final pro-rated refund"
Hiding a market-fit problem behind algorithmic boosts is worse for both the merchant (they keep paying for under-delivery) and the platform (we generate refund liability that compounds month over month).
The principle โ
Pacing's job is fairness within available inventory. When the inventory itself is insufficient for a merchant's fair share, no pacing rule fixes it โ only diagnosis, coaching, refund, or honest conversation does. Treating chronic under-delivery as a pacing problem ("just boost them") is a category error.
The algorithm we do build is the detection + classification algorithm. The algorithm we don't build is a permanent priority-boost coefficient.
What we do NOT build โ
| Tempting | Why we skip it |
|---|---|
| Per-merchant priority knobs | Breaks $150-flat positioning; turns into "pay more for higher placement" |
| Per-user behavioral profiles for ad targeting | Violates Immutable Right #6 (privacy) and the ยง3.2 k-anonymity threshold |
| Real-time auctions / RTB-style bidding | Dramatically more infra cost; merchants can't predict the bill (the ยง5 anti-pattern) |
| Cross-device tracking for frequency capping | Same privacy violation as per-user profiles |
| Standalone ad server (vs Workers + DO) | Adds ops burden and a new vendor; not justified pre-Phase-3 |
| Permanent priority boost for chronically under-delivering merchants | Compounds into whack-a-mole; hides root causes; masks market-fit problems behind algorithmic distortion. Use diagnosis + refund instead. |
| Sending raw user preferences (dismissals, likes, blocks) to the server | If we'd need to decrypt it to use it, encryption is theatrical. Architectural protection > cryptographic protection. |
| Cross-device personalization in plaintext | Would require server-side per-user preference profiles. Conflicts with the anonymity story; not worth the UX gain. |
| Bloom-filter exclusion lists on selection requests | Bloom filter parameters can themselves act as fingerprints. Top-N + local filter is cleaner. |
| Cookies / device IDs / fingerprinting for tracking | Same reason โ adds linkable identity to a system designed not to have it. |
| Per-user impression history server-side | Even encrypted, the access pattern alone (request times, frequencies) is a behavioral profile. Not built. |
Part 6 โ Open questions to resolve โ
DAU ramp assumption. I'm assuming 500 โ 15,000 per city over 24 months. The business plan ยง10 doesn't model user volume directly. Need real Phase 1 data to confirm.
Inline impressions per DAU. I previously assumed 0.55 for inline and chat combined (vs 3 for hero); with the chat pill removed, this needs re-baselining for inline alone rather than reusing the combined figure. If real users browse venues 3-5x/day, inline becomes a much larger share of merchant value. Worth instrumenting in pilot before committing to architecture.
Sampling rate for impressions. 1:10 vs 1:100 changes BigQuery cost by ~10ร. Decide based on actual Phase 1 volume.
(Resolved.) Per-redemption SKU pricing. Retired, 2026-08-30: pricing is the flat per-merchant monthly rate, period; there is no per-redemption SKU.
Business plan ยง3.2 placement count. Resolved (2026-06-15): the chat-assist pill was removed, so there are now 3 placements (hero, inline, feed). This matches Business Plan ยง3.2's existing list of 3, so the docs are consistent and no ยง3.2 edit is needed.
Frequency-cap window. Business plan ยง5 mentions 1-impression-per-user-per-day default. Confirm this is right per placement (likely varies, e.g. higher for inline).
Live-event boost multiplier. I used 0.5 in the algorithm. Real number wants empirical data once live-event offers are live.
Monthly fair-share floor policy. Daily pacing resets at midnight; the $150 promise is monthly. What threshold triggers credit/refund? Working candidates:
- Flat per placement (e.g., "guaranteed 100 hero / 100 inline / 100 feed impressions per month") โ easy to communicate, harder to defend in dense markets where the floor times merchant count exceeds actual market volume.
- Pro-rated fair share (e.g., 80% of
expected_market_volume ร merchant_share) โ more rigorous, harder to communicate without a chart. - Hybrid โ flat floor as a guarantee, plus pro-rated true-up if market volume came in higher than expected.
Decision needs to be made before merchant contract terms are finalized. Probably worth piloting flat-per-placement first โ it's the simplest pitch ("$150 buys you at least N impressions per surface, every month, or you get a credit") and the merchant doesn't need to understand fair-share math to evaluate it.
Chronic under-delivery thresholds and Layer 4 boost decision. The diagnose-don't-compensate framework (Part 5) defines the layers but leaves several numbers TBD:
- How many consecutive months of under-delivery before triggering Layer 5 ("honest conversation / off-ramp")? Working number: 2 months.
- Boost cap in Layer 4 โ is +20% the right ceiling? Could be lower (more conservative) or higher (more aggressive remedy). Decide after first cohort data.
- Should Layer 4 boost ship at all in Phase 2, or only after we have data showing diagnosis + refund is insufficient? Bias toward shipping without it and adding only if observed gap warrants.
- How do we communicate refunds vs credits to merchants? Auto-credit by default with refund on request is probably the right UX, but contracts need to confirm.
Sealed identity migration timing. The Phase 3+ option to make phoneโuser_id unrecoverable without the user's passphrase is documented in Part 5. Before committing:
- When (if ever) do we move from server-resolvable phoneโuser_id to passphrase-sealed link? Likely Phase 3 boundary, but trigger criteria need to be made concrete.
- Counsel review needed first: is "we cannot decrypt without user cooperation" a defensible response under ECPA/SCA, GDPR, CCPA? Could a court compel us to modify the architecture itself (i.e., re-link)?
- Operational: do we need to redesign customer support and anti-fraud workflows before the migration, or in parallel?
- User notification: does the policy still apply if we technically can't identify the affected user from the subpoena's identifier?
- Retention: does keeping the encrypted_user_id_blob alongside phone_hash create any obligation we wouldn't have under a hashed-only model?
Personalization & preference signals. Framework documented in Part 5 but several decisions still open:
- Interest-multiplier bounds. Working default: 0.5โ2.0. More conservative (0.7โ1.5) keeps pacing dominant; more aggressive (0.3โ3.0) lets relevance dominate. Decide after first-cohort data on dismissal rates.
- Phase 2 ship decision. Do we ship interest weighting in Phase 2, or wait until Phase 3 once we have actual dismissal/click data to tune the multiplier? Recommendation: ship dismissal/block (the negative signals) in Phase 2 because they're high-signal and low-risk; defer positive interest weighting to Phase 3.
- Encrypted blob sync (Option B). Stay phone-local-only for v1 or ship cross-device sync? Recommendation: phone-local for v1; revisit if user demand for cross-device persistence becomes meaningful.
- S2 cell granularity for geo privacy. Working default: level 13 (~1km). Tighter (level 14, ~500m) gives better targeting precision but worse k-anonymity. Audit at first cohort.
- Aggregate dismissal-rate surfacing threshold. Don't surface dismissal rate to a merchant until N โฅ 3 distinct sessions to satisfy k-anonymity. Confirm 3 is the right floor or if 5+ is more conservative.
- Audience-tag granularity. Currently 4 boolean buckets. If we ever expand to more granular targeting (e.g., "frequents jazz venues"), how do we preserve privacy? Probably: any new tag has to satisfy a minimum population threshold before it can be targeted.
Part 7 โ What to instrument first (pilot) โ
Order matters. None of this is the rotation engine โ it's the data we need to decide the rotation engine.
offer_viewedevent with placement, geo cell, audience target, offer ID, session ID. Already partially wired (apps/web/src/lib/offerService.js:90-96).offer_claimedandoffer_redeemedwith the offer-impression chain (which view led to which claim).venue_clickedwith the offer that was visible in the hero rail at click time. The user already has this event in their working list; the missing piece is making sure the rail-visible-offer ID is on the payload.- Daily aggregate dashboard for: impressions per placement, per merchant, per audience target. Shape determines whether pacing is even necessary.
- Cost line item per request: Cloudflare Worker invocations, KV reads, Firestore reads. Tag by route. We can't optimize what we don't measure.
The rotation engine is easy to build once the data above is honest. The data above is hard to retrofit if we ship rotation first.
Revision log โ
- 2026-05-10 โ Initial draft. Anchored projections to cofounder agreement ยง6.1 (70/30 of profit, not revenue). 6 cofounders default. Mechelle's 50% pre-incorporation redirect (Exhibit D) reflected in Phase 1 distribution table.
- 2026-05-10 โ Corrected placement count from 3 to 4. Feed Insertion is a real placement (merchant offer-form UI confirms). Added selection rule: pacing-first within a 1-ad-per-N-organic-cards spacing cap (N=5 working default). Flagged business plan ยง3.2 update as needed.
- 2026-05-10 โ Added ELI5 section (playground metaphor) between Part 1 and Part 2 as a bridge from "what merchants get" to "how rotation works."
- 2026-05-10 โ Added "Distributed pacing under load" subsection in Part 5 covering race conditions, Durable Object atomicity, the state-table (where each piece of state lives + why), pacing-layer cost (~$14/mo at month 24), and graceful failure modes. Extended ELI5 with a "100 kids at once" analog (single clipboard / single teacher = atomic Durable Object) and a "your pocket vs. the teacher's clipboard" callout for client-side frequency capping vs. server-side pacing state.
- 2026-05-10 โ Added across-day carryover policy. ELI5 gained a "what if some kids didn't get a turn today?" section explaining daily reset + separate monthly notebook (no catch-up days, but the $150 promise is monthly and accounted at month-end). Part 5 gained a "Counter reset and monthly accountability" subsection with the two-tier accounting model (DO = today, BigQuery = month) and reasoning for daily-reset over rolling-window pacing. Part 6 gained question #8: monthly fair-share floor policy (flat-per-placement vs. pro-rated vs. hybrid).
- 2026-05-10 โ Added chronic-under-delivery handling. ELI5 gained "What if Tommy is always at the back of the line?" โ diagnosis-first, refund as contractual remedy, off-ramp conversation if structural. Part 5 gained "Chronic under-delivery โ diagnose, don't compensate" with the 5-layer response (detect/classify, coach, refund, optional bounded boost, off-ramp), pseudocode for detection + classification, classification โ recommendation table, and explicit anti-pattern reasoning. "What we do NOT build" gained a row for permanent priority-boost. Part 6 gained question #9: chronic under-delivery thresholds and Layer 4 boost decision.
- 2026-05-10 โ Added privacy architecture for personalization. ELI5 gained two sections: "Is the kid's pocket notebook actually safe?" (three-lock model โ encryption at rest, on-device-only, request payload minimization) and "What if the kid loses their notebook?" (the tethered-vs-readable distinction โ opaque user_id + encrypted blob = persistence without readability). Part 5 gained a substantial "Privacy architecture for personalization" section covering the tethered-vs-readable distinction, the three-lock model with implementation details, request payload audit (what crosses the wire), persistence options A/B (recommendation: phone-local for v1), threat model table, and the explicit honest list of what's plaintext server-side per Business Plan ยง12. "What we do NOT build" gained 5 new rows covering raw preference transmission, cross-device plaintext personalization, bloom-filter exclusions, cookies/fingerprinting, and per-user impression history. Part 6 gained question #10: personalization & preference signals (multiplier bounds, Phase 2 ship decision, blob-sync decision, S2 granularity, dismissal-rate threshold, audience-tag granularity).
- 2026-05-10 โ Added phone number handling specifics. ELI5 gained "How does the badge get tied to the kid without the playground knowing the kid's name?" (scrambler-at-the-gate metaphor for hashing, including the counterintuitive point that hashing > encryption for phone numbers). Part 5 gained a "Phone numbers โ hashing, not encrypting" subsection: why hashing wins (no need to recover plaintext), why encryption-with-server-key would be weaker, the HMAC-SHA-256 implementation, account-creation and login flows, pepper management (KMS, rotation, audit), and the three-pattern decision rule (client-encrypted / hashed / plaintext-or-server-encrypted). Updated the ELI5 data-handling table and the Part 5 plaintext-list to point at the new subsection.
- 2026-05-10 โ Added "Merchant data sharing โ what flows where" subsection in Part 5 with a Mermaid flowchart visualizing the full path from user action โ phone-side encrypted state vs server-side event โ BigQuery aggregation โ k-anonymity gate โ merchant dashboard. Included a "three checkpoints, three leak vectors" reading guide, the honest disclosure of what user_id is used for internally (de-duplication, fraud), retention caps, and a questionโsourceโk-checkโview table mapping common merchant questions to the data surface (with the explicit rejection of "who claimed my offer?" as a question the API cannot answer by design).
- 2026-05-10 โ Made user_id collection explicit. Part 5 gained "But wait โ are we collecting user_ids?" subsection: yes (opaque UUID, not PII alone, but indexes plaintext metadata per ยง12), what it's used for internally (account state, de-dup, frequency-cap fallback, fraud, pacing), what mitigations are in place, and the principle that opaque IDs are not PII but ARE a vector for inference if the metadata they index is rich. Also expanded the "Merchant SEES" / "Merchant NEVER sees" boxes in the flowchart to be explicit about user_id never being surfaced and the 3-unique-user k-anonymity threshold.
- 2026-05-10 โ Made aggregate tags & vibes explicit as a merchant deliverable. Part 5 gained "Aggregate tags & vibes โ what the merchant legitimately sees" subsection with: side-by-side allowed (aggregate distribution) vs forbidden (per-user attribute) examples covering moods, audience targets, redemption rates, time patterns, bonfire formation, CTR, and genres; SQL-style pseudocode showing how user_id is used for COUNT(DISTINCT) but never surfaced; and the targeting-layer story (four broad audience buckets, geo radius, optional venue-affinity bucket โ but NO per-user targeting, no uploaded user lists, no custom segments outside the four buckets). The line: "aggregate is fine, per-user is not."
- 2026-05-10 โ Added user-id transparency, banning flow, and subpoena response. Part 5 gained "But wait โ are we collecting user_ids?" โ explicit yes-and-here's-what-it-means treatment with internal-use table, what's reachable vs. sealed via subpoena, seven mitigations, and the principle that opaque IDs aren't PII alone but ARE a vector for inference if the metadata they index is rich. Part 5 gained "Banning users โ without storing plaintext phone numbers" โ yes we can ban via phone hash equality, ban-record schema, can/cannot table (including the structural acceptance that determined bad actors with new phone numbers can re-register), appeals workflow, and layered enforcement notes. Part 5 gained "What a subpoena can actually compel us to disclose" โ three-step inventory (resolving an identifier โ reachable data โ genuinely not reachable), complete subpoena response packet shape, user notification policy, and the principle that the architecture (not policy) is what bounds the universe of disclosable data.
- 2026-05-10 โ Added Mermaid flowcharts for three privacy-architecture sections: the phone-number flow (signup/login through hashing โ SMS verification โ passphrase decryption), the banning flow (ban check at login โ reject vs. proceed โ appeals workflow), and the subpoena response flow (identifier resolution โ response packet construction โ reachable vs. cannot-produce branches โ user notification). All three use consistent green/red/yellow semantic styling matching the existing merchant-data-sharing flowchart so the visual story reads coherently across the doc.
- 2026-05-10 โ Added "Could we sever the phone โ user_id link entirely?" as a Phase 3+ architectural option. Sealed-identity model: server stores
(phone_hash, encrypted_user_id_blob)instead of(phone_hash, user_id), with the blob encrypted by the user's passphrase-derived key. Subpoena providing a phone number returns ciphertext, not a resolvable user_id. Includes side-by-side Mermaid comparison chart of v1 vs. sealed architecture, data model diff, login flow, what-it-buys-us subpoena-response table, what-it-costs-us operational-impact table, three reasons not in v1 (engineering scope, operational maturity, marginal benefit at current scale), trigger criteria for migration, and counsel-review questions. Part 6 gained question #10: sealed identity migration timing (existing personalization question renumbered to #11). - 2026-06-05 โ Rewrote the "First-paint flicker fix (client)" subsection to document the shipped follow-up to PR #553. The gate-only fix was insufficient on the returning-user cached path (
userLocationisnullat mount, so the cached list painted heroless, geolocation landing re-closed the gate as a loader flash, and the late hero inserted above โ plus the ad only showed when the geofenced offer beat the 250ms window, i.e. intermittently). Documented the three cooperating mechanisms now inHomeView: gate hardening (offerWaitArmedโ the gate disarms once open, so only a real venue reload re-gates), the hero-offer cache (offerCache, stale-while-revalidate seed at mount with "no ad" as a first-class cached value, viafetchActiveOffersResultso a failed call can't evict a good cache), and the in-place content swap for moved-area correction (no insert/remove โ no shift). Reaffirmed rotation stays server-authoritative (cache only replays the last answer; advances once per app-open/area-change). Updated the Mermaid flow to the seedโgateโrevalidateโswap shape and added a Dev tooling note forwindow.lantern.ads.{status,reinit,forceShow}.