docs: design suite — demo spaces, the free pool, pricing bands (#854)
* docs: design suite — demo spaces, the free pool, pricing bands Three interlocking designs (status: designed, not built): - docs/demo-spaces.md — instant no-account 24h trial instances: unconditional reap (no trash archive, no S3 replication ever), tiny limits, the graduated-friction anti-burner ladder (adaptive hashcash, per-IP-bucket semaphore, cooldown >= TTL, separate fleet caps), and the claim path that converts a demo into the account's free space. - docs/free-pool.md — the free tier as an error budget for money: free+demo spend never above X% of total cost (X=25), revenue-indexed (Y=30%) with an operator floor, one pressure scalar driving a pre-agreed green/amber/red ladder that only ever gates admissions, never existing spaces. Demo carve-out keeps the try-it path alive at red. - docs/pricing-bands.md — four open bands (Supporter x1.4 / Base / Reduced x0.5 / Solidarity x0.25) on the EUR base prices, driven by the World Bank price level ratio into a committed public country->band table. Location suggests, never decides: every band one tap away for everyone (the honor mechanism, the VPN answer, and the EU/Swiss legal posture in one). US defaults to Supporter. Includes the +15% annual review cap (hold+invite past the cap), Stripe Tax enablement, and pre-checkout local-currency display from ECB rates by browser locale. Decisions recorded per Paul, 2026-07-14. Research reports with full source citations live outside the repo (session artifacts); the docs distill them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: VAT split — Family/C&F prices tax-inclusive, Business tax-exclusive Consumer tiers keep the sticker as the total (tax_behavior=inclusive); Business is quoted ex-VAT (tax_behavior=exclusive) with tax_id_collection at checkout so EU businesses with a VAT ID get the reverse charge. Adds the concrete dashboard runbook (Settings->Tax vs Tax->Registrations, and why the 'Include tax in prices' account default is superseded by explicit per-Price tax_behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: defer Stripe Tax registrations — Phase 0 ships the plumbing, registrations activate it later Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: demo no-backups is structural (never-hibernate) — match slice-1 implementation (PR #874) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+716
−0
docs/demo-spaces.md+141 −0docs/free-pool.md+221 −0docs/pricing-bands.md+354 −0
diff --git a/docs/demo-spaces.md b/docs/demo-spaces.md| new file mode 100644 |
| index 0000000..fa5394d |
| --- /dev/null |
| +++ b/docs/demo-spaces.md |
| @@ -0,0 +1,141 @@ |
| +# 🤖 Demo spaces — instant, no-account, 24-hour trial instances |
| + |
| +Design doc, 2026-07-14. Status: **designed, not built.** Companion docs: |
| +`docs/free-pool.md` (the budget that subsidises demos and free spaces) and |
| +`docs/pricing-bands.md` (the pricing that funds the pool). Grounding: |
| +`cloud.go`, `controld.go`, `reaper.go`, `home/pow.go`, |
| +`docs/cloud-provisioner.md`. |
| + |
| +## What a demo space is |
| + |
| +A real, fully-isolated instance — same binary, same E2EE, same subdomain |
| +scheme (`<sqid>.11ch.at`, riding the wildcard DNS + cert, so creation burns |
| +nothing) — that anyone can create **without an account**, and that the system |
| +**reaps unconditionally 24 hours after creation**. It is the hosted analog of |
| +`lchat try` trials (which mint `try-*.11relay.com` and die with the gateway): |
| +zero-commitment, self-destructing, honest about both. |
| + |
| +Why it exists when every account already gets one persistent free space |
| +(`LCHAT_CLOUD_QUOTA=1`): the demo is *try before passkey*. It removes the last |
| +step between "curious" and "typing in a real space" — and its claim path is |
| +the conversion funnel. |
| + |
| +A demo is deliberately **strictly worse than a free space**: shorter-lived, |
| +smaller, no backups. That ordering is load-bearing for abuse resistance |
| +(§ Anti-burner): nobody rational farms the worse thing when the better thing |
| +is one passkey away. |
| + |
| +## Lifecycle |
| + |
| +1. **Create.** The home page's "Try it now" calls the provisioner with |
| + `kind=demo`. No account, no email, no name. The cloud allocates an |
| + `entitlement='demo'` row in the existing `spaces` ledger (same table as |
| + free/paid — one column distinguishes them, mirroring how `'paid'` works |
| + today) and controld materialises the instance. The creator lands in the |
| + space with an admin session, exactly like a claimed setup link. |
| +2. **Live.** A persistent in-app banner shows the countdown ("This demo space |
| + disappears in 19 hours") with a one-tap **Claim** button. The banner is |
| + inline (never a floating toast) and survives every view. |
| +3. **Claim (the happy exit).** Claim = sign in / create a passkey account on |
| + home, which converts the row `demo → free` (or straight to paid): the TTL |
| + is cleared, S3 replication turns on, the space starts counting against the |
| + account's free quota. If the account's free slot is already taken, the |
| + claim surface offers the same choices as a quota-blocked create today: |
| + reclaim your own dead weight, or upgrade. Data is kept intact either way. |
| +4. **Reap (the default exit).** At `created_at + 24h` the reaper deletes the |
| + instance. Unconditionally: content does not protect a demo (unlike the |
| + never-set-up rule for real spaces), because the 24h death was disclosed at |
| + birth and re-disclosed on a banner the whole time. |
| + |
| +## Limits (the capability-starvation layer) |
| + |
| +Demos are sized so their value to an abuser is lower than the cost of minting |
| +one. Suggested defaults, all env-tunable on the cloud service: |
| + |
| +| Limit | Default | Knob | |
| +|---|---|---| |
| +| Lifetime | 24h | `LCHAT_DEMO_TTL_HOURS` (the free pool may shorten it under pressure — see `docs/free-pool.md § amber`) | |
| +| Members | 5 | `LCHAT_DEMO_MAX_MEMBERS` | |
| +| Storage | 25 MB | demo entry in `LCHAT_CLOUD_STORAGE_CAPS` | |
| +| Bots / AI | none | no sidecar is ever provisioned for a demo | |
| +| Backups | none | see below | |
| +| Relay hostname, custom anything | none | demos are `<sqid>.11ch.at` only | |
| + |
| +**No backups is a hard property, not a missing feature** (decided 2026-07-14): |
| + |
| +- Reaping **skips the trash archive** — a deliberate, documented exception to |
| + the "deleteInstance never destroys" rule, safe because the death was the |
| + contract from the first second. |
| +- **No S3 replication, ever.** Implemented structurally (slice 1, PR #874): |
| + a demo is **never activator-managed at all** — controld forces |
| + `Hibernate=false` for demo creates even on hibernate-by-default boxes, so |
| + there is no lease, no litestream replica, and no parked prefix by |
| + construction. Without this, thousands of 24h demos would pay S3 PUT tails |
| + and strand orphan prefixes — the exact request-cost class the #836 cadence |
| + work eliminated. The price is that a demo holds a materialized slot for |
| + its 24h life, which the live-demo cap bounds; a softer |
| + "sleep locally without replicating" registry flag remains optional future |
| + work if demo slot pressure ever warrants it. Claiming a demo converts it |
| + to an ordinary free space, which hibernates (and replicates) normally. |
| + |
| +## Anti-burner gates (the graduated-friction ladder) |
| + |
| +The demo path must stay essentially always available to humans while being |
| +uneconomical to farm. Layered, in order of cheapness to evade: |
| + |
| +1. **Proof-of-work.** The existing hashcash gate (`home/pow.go`, |
| + `LCHAT_POW_BITS`) fronts demo creation at a higher difficulty than |
| + free-space creation, and the difficulty **adapts**: bits scale up with |
| + trailing-hour demo-creation velocity, so an attack makes minting expensive |
| + without punishing a quiet Tuesday. |
| +2. **One live demo per IP bucket** — a semaphore, not a counter. Buckets are |
| + `sha256(ip, rotating_salt)` with short retention (~48h), IPv6 bucketed at |
| + /56 so single-address rotation buys nothing. Only **home** ever sees the |
| + IP (it already does, as the TLS terminus for signup); cloud and controld |
| + see only the opaque bucket hash. |
| +3. **Cooldown ≥ TTL.** After a demo dies (reaped or abandoned), its bucket |
| + cannot mint another for 24h. This is the single most load-bearing rule: |
| + it makes serial recreation converge to *one rolling slot* — no better |
| + than the free tier — so the burner incentive is structurally gone rather |
| + than merely rate-limited. |
| +4. **Fleet caps, separate from real capacity.** A live-demo cap |
| + (`LCHAT_DEMO_MAX_LIVE`, suggested 10) and a demo creations/day budget |
| + (`LCHAT_DEMO_MAX_CREATES_PER_DAY`, suggested 50), both distinct from |
| + `LCHAT_CLOUD_MAX_CREATES_PER_DAY` and both tunable by the free pool's |
| + pressure signal — so demo farming can never crowd out real signups, and |
| + real-signup pressure never kills the try-it path. |
| +5. **Physical backstops** (controld `at_capacity`) remain the only thing that |
| + can ever hard-refuse a demo; the UI answers "give us a minute" and |
| + retries, which the 24h churn makes genuinely true. |
| + |
| +What we deliberately do **not** do: credit-card gates, phone/email |
| +verification, disposable-domain blocklists, IP reputation services, |
| +fingerprinting. Accounts here are anonymous passkeys by design; the ladder |
| +above (cost imposition + velocity limits + starved capability + separate |
| +pool) carries the weight that identity checks carry elsewhere. |
| + |
| +## Where each check lives |
| + |
| +| Layer | Sees | Enforces | |
| +|---|---|---| |
| +| home | IP, PoW solution | hashcash difficulty; IP-bucket semaphore + cooldown; relays `kind=demo` | |
| +| cloud | ledger (`entitlement='demo'`), budgets | live cap, daily budget, TTL bookkeeping, claim conversion, storage cap push | |
| +| controld | processes, disk | physical capacity; the no-replication registry flag | |
| +| reaper (cloud) | ledger + controld stats | the unconditional 24h reap, no archive | |
| + |
| +This preserves the standing privilege architecture: policy in the sandboxed |
| +cloud service, physics in root controld, IP-adjacent checks in home. |
| + |
| +## Phasing |
| + |
| +1. **PR 1 — ledger + reap.** `entitlement='demo'` rows, TTL reap rule |
| + (unconditional, no archive), the no-replication registry flag. Ships dark |
| + behind `LCHAT_DEMO_TTL_HOURS` unset. |
| +2. **PR 2 — the gates.** IP-bucket semaphore + cooldown in home, adaptive PoW |
| + difficulty, demo caps in cloud. |
| +3. **PR 3 — the surface.** Home "Try it now", the countdown banner, the claim |
| + flow (including the quota-blocked variants). |
| +4. **PR 4 — pool coupling.** TTL and live-cap become pressure-tunable once |
| + `docs/free-pool.md` PR 3 lands. |
| + |
| +Each PR is independently revertible; nothing before PR 3 is user-visible. |
diff --git a/docs/free-pool.md b/docs/free-pool.md| new file mode 100644 |
| index 0000000..c0b8ac3 |
| --- /dev/null |
| +++ b/docs/free-pool.md |
| @@ -0,0 +1,221 @@ |
| +# 🤖 The free pool — a cost-governed budget for free & demo spaces |
| + |
| +Design doc, 2026-07-14. Status: **designed, not built.** Companion docs: |
| +`docs/demo-spaces.md` (the demo tier the pool contains) and |
| +`docs/pricing-bands.md` (the pricing that funds it). This doc extends the |
| +`docs/tier-economics.md` discipline: **formulas are the contract, numbers are |
| +a dated snapshot.** |
| + |
| +## The idea in one paragraph |
| + |
| +Run the free tier as an **error budget for money**. A small accounting loop |
| +inside the cloud provisioner's existing 6-hour meter sweep computes (a) the |
| +estimated daily cost of every free space and demo from a tiny published cost |
| +model, (b) a daily free-pool *budget* derived from a percent-of-total-cost |
| +ceiling, net revenue, and an operator floor, and (c) one scalar, |
| +`pressure = spend / budget`. Pre-agreed thresholds on that scalar drive |
| +graceful degradation — shorter demo TTLs, then a pause on **new** free |
| +creates — and **never touch an existing space**. Demos get a carve-out funded |
| +first from the floor, so the try-it path survives even a red pool; only |
| +physical box limits can ever actually block a demo. The pool is |
| +revenue-indexed — paid spaces fund it — and the gauge is publishable on the |
| +status page next to the books, making the subsidy a checkable claim. |
| + |
| +Survey honesty: no SaaS publicly runs a free tier as an explicit |
| +%-of-cost invariant. Every *part* is established — SRE error budgets with |
| +pre-committed exhaustion policies, cloud budget actions that actuate, |
| +Borg/spot-style surplus tiers, the Neon/Render/Heroku-Eco idle-suspend + |
| +suspend-don't-bill pattern, and the FCC Universal Service Fund as a real |
| +revenue-indexed pool (whose uncapped ratio ratcheting to ~38% is precisely |
| +the failure the X-ceiling here prevents). The composition is novel; every |
| +part is boring. |
| + |
| +## The budget formula |
| + |
| +``` |
| +net_revenue_day = Σ over paid ledger spaces of net_eur_year / 365 (smoothed ~90d) |
| + |
| +invariant_cap = (X/100) × total_cost_day # free never exceeds X% of total cost |
| +revenue_pool = (Y/100) × net_revenue_day # the growth engine |
| +base_floor = LCHAT_POOL_BASE_EUR_DAY # a pool exists at zero revenue |
| + |
| +budget_day = min( invariant_cap , max(base_floor, revenue_pool) ) |
| +demo_budget = (D/100) × budget_day # reserved for demos, funded first |
| +pressure = free_cost_day / budget_day |
| +``` |
| + |
| +**Chosen parameters (Paul, 2026-07-14):** `X = 25`, `Y = 30`, |
| +`floor = €0.20/day`, `D = 20`. All config; changing them is philosophy, not |
| +engineering. |
| + |
| +Shape notes: |
| + |
| +- **X is a ceiling, revenue is the engine, the floor is the bootstrap.** At |
| + zero revenue the sunk box subsidises the free tier (the spot-capacity |
| + logic: the capacity is already paid for). As revenue grows, the Y-term |
| + takes over. X clamps everything — the anti-ratchet guard: the ratio can |
| + never float upward with demand; admission control absorbs excess demand |
| + instead. |
| +- **Smoothing:** revenue is computed over trailing ~90 days of entitlement |
| + state, so one big customer churning shrinks the pool gradually — and a |
| + shrink only ever gates *admissions*; existing spaces are grandfathered by |
| + construction, because no actuator touches them. |
| +- Demos are the cheapest thing offered (~€0.01 lifetime each), so the demo |
| + carve-out is small in euros and huge in units. |
| + |
| +## The cost model |
| + |
| +Per instance and day — awake time, storage, noise: |
| + |
| +``` |
| +cost_day(i) = awake_frac(i) × (C_slot + C_req) # RAM/CPU share + S3 request tail while awake |
| + + stored_gb(i) × C_store # S3 replica (× retention) or disk share |
| + + C_noise # registry row, cert, wakes (≈ 0) |
| + |
| +free_cost_day = Σ cost_day(i) over spaces with entitlement in ('free','demo') |
| +total_cost_day = C_fixed + Σ cost_day(i) over ALL ledger spaces |
| +``` |
| + |
| +Awake-hours (wall-clock) is deliberately the metered thing, not "activity": |
| +utilization thresholds spawn keep-alive arms races (Oracle's free-tier idle |
| +detector did); wall-clock awake time is what the activator already measures, |
| +and the one gaming vector — holding a WebSocket open — makes that space |
| +expensive *on paper*, which is exactly what a later per-space awake-hours cap |
| +would key on. |
| + |
| +Snapshot constants (2026-07, re-measure at tier-economics re-derivations): |
| + |
| +| Constant | Placeholder | Source | |
| +|---|---|---| |
| +| `C_fixed` | ~€1.17/day | whole cloud plane: box + router/home/push/cloud | |
| +| `C_slot` | ~€0.027/day | box ÷ 40-instance materialized capacity | |
| +| `C_req` | ~€0.002/day | post-#836 always-awake S3 request tail | |
| +| `C_store` | ~€0.0014/GB-day | S3 × ~2 Litestream retention | |
| +| `awake_frac` free | 0.05 (assumed) | hibernate-by-default | |
| +| `awake_frac` demo | 0.30 (assumed) | 24h life, actively poked | |
| + |
| +Worked typicals: a hibernation-dominant free space costs **~€0.6–0.9/year**; |
| +a 24h demo **~€0.01 total**; a Community & Friends subscription nets |
| +~€39/year. The arithmetic is comfortable — even a 30% revenue share funds |
| +tens of free spaces per C&F subscription. At today's one-box scale the euro |
| +budget is *not* the binding constraint (RAM slots and disk are); the pool |
| +exists to be legible now and binding at scale. |
| + |
| +Deriving cost from a config-declared model instead of metering is a |
| +deliberate trade: real per-instance cost attribution is heavy machinery for |
| +cents. It means the gauge is an **estimate and must say so wherever |
| +published.** |
| + |
| +## The control loop |
| + |
| +Measured every existing meter sweep (6h) from the ledger + cost model; one |
| +row per sweep in a new additive `pool_meter` table |
| +(`ts, free_count, demo_count, paid_count, free_cost_day, total_cost_day, |
| +budget_day, pressure`, pruned to ~a year). Creates are gated against the |
| +latest reading; spikes are caught by the existing create breaker and |
| +controld's physical caps, and a correlated wake-storm shows up next sweep as |
| +a fast burn. |
| + |
| +**The pre-agreed policy** (written before the budget is ever spent — |
| +SRE-style): |
| + |
| +| Pressure | State | Behavior | |
| +|---|---|---| |
| +| < 0.8 | green | nothing. Surplus exists — this is the "free capacity available to anyone" signal `docs/pricing-bands.md` consumes | |
| +| 0.8 – 1.0 | amber | new demos get shorter TTL (24h → ~8h); status gauge amber; loud log line per sweep | |
| +| ≥ 1.0 | red | **pause new free creates** — stable error `free_pool_full`, honest copy below. Demos continue at amber settings | |
| +| ≥ 1.5 | deep red | demos shorten further / small retry-wait. Existing spaces: still untouched | |
| + |
| +**Never actuated, ever:** suspending or deleting an existing space because of |
| +pool pressure. The only lifecycle ends remain the reaper's never-set-up rule |
| +and the demo TTL — pre-declared lifecycle, not budget enforcement. |
| + |
| +At red, a free create answers `503 free_pool_full`: |
| + |
| +> "The free pool is full right now. Paying supports free spaces for those |
| +> who can't — as more people upgrade, the pool grows. Check back soon, or |
| +> upgrade to create a space now." |
| + |
| +- Paid creates are never pool-gated (they *fund* the pool); the upgrade path |
| + stays open at red. |
| +- **No waitlist** (decided): accounts are anonymous passkeys with no contact |
| + channel, and the survey found no product running a free-tier waitlist. |
| + Honest-no + the live public gauge instead — a refusal that's a checkable |
| + claim, not a shrug. |
| +- Self-reclaim of the account's own dead slot still works at red (it creates |
| + no net-new spend). |
| + |
| +## Messaging (decided 2026-07-14) |
| + |
| +**Aggregate form only.** The canonical line is: |
| + |
| +> **"Paying supports free spaces for those who can't."** |
| + |
| +The gauge publishes the aggregate ("paid spaces currently fund the free pool |
| +M× over"), never a per-space "your €5 funds these two spaces" — the personal |
| +link isn't mechanically true and invites weird expectations. M is re-derived |
| +with the tier-economics numbers and is verifiable against the public books. |
| + |
| +## Knobs and placement |
| + |
| +``` |
| +LCHAT_POOL_MAX_SHARE=25 # X (0/unset = pool off) |
| +LCHAT_POOL_REVENUE_SHARE=30 # Y |
| +LCHAT_POOL_BASE_EUR_DAY=0.20 # floor |
| +LCHAT_POOL_DEMO_SHARE=20 # D |
| +LCHAT_POOL_COSTS="fixed_day=1.17,slot_day=0.027,req_day=0.002,storage_gb_day=0.0014,awake_free=0.05,awake_demo=0.30" |
| +LCHAT_POOL_DRY_RUN=1 # rollout: compute, log, publish — gate nothing |
| +``` |
| + |
| +Revenue input: an additive `net_eur_year` field per entry in the existing |
| +`LCHAT_STRIPE_PRICES` JSON (unknown fields are ignored by today's decode; |
| +entries without it contribute 0 and log a warning). With pricing bands |
| +(`docs/pricing-bands.md`), each band×tier entry carries its own |
| +`net_eur_year` — discounted spaces grow the pool exactly in proportion to |
| +what they actually pay, which is also the honest version of the marketing |
| +line. Once Stripe Tax ships (tax-inclusive prices), `net_eur_year` figures |
| +are net-of-VAT approximations; the pool tolerates that error comfortably. |
| + |
| +| Layer | Enforces | |
| +|---|---| |
| +| home | nothing pool-related (IP/PoW gates are the demo doc's) | |
| +| cloud | the pool: budget math, pressure, `free_pool_full` admission, demo carve-out + dynamic TTL, `pool_meter`, the reap | |
| +| controld | physical capacity only; the `awake` stat (phase 4). Never learns about money | |
| +| router/activator | hibernation; (phase 4) per-entitlement idle-window override | |
| + |
| +Existing knobs keep their jobs: `LCHAT_CLOUD_MAX_CREATES_PER_DAY` (blunt |
| +spike breaker), `LCHAT_CLOUD_QUOTA`, the physical caps, `LCHAT_POW_BITS`, |
| +the reaper family, storage caps. |
| + |
| +## Interfaces |
| + |
| +- **Demos** (`docs/demo-spaces.md`): live in the same `spaces` ledger as |
| + `entitlement='demo'`; draw from the carve-out; TTL and live-cap are |
| + pressure-tunable; **never S3-replicate** (decided — the flag is the demo |
| + doc's PR 1). |
| +- **Pricing** (`docs/pricing-bands.md`): the pool consumes one number per |
| + paid space (`net_eur_year`). "Free capacity exists" is defined as |
| + `pressure < 0.8`, exposed read-only (`GET /api/pool`) so the pricing |
| + surfaces and the status page share one source of truth. |
| + |
| +## Phasing |
| + |
| +1. **PR 1 — the gauge, no behavior change.** Parse `LCHAT_POOL_*` + |
| + `net_eur_year`; compute spend/budget/pressure in the meter sweep; |
| + `pool_meter` table; log line; admin-token `GET /api/pool`. Ships dark |
| + (pool off unless `LCHAT_POOL_MAX_SHARE` is set). Pure budget-math unit |
| + tests + a seeded meter-sweep test. |
| +2. **PR 2 — admission control.** `free_pool_full` at `pressure ≥ 1` in |
| + `createSpaceHandler` (free creates only); home relays the message; |
| + `LCHAT_POOL_DRY_RUN` gates. Tests mirror the createBudget ones. |
| +3. **PR 3 — demo integration.** Carve-out budget, pressure-tuned TTL. |
| + (Sequenced with the demo doc's PRs.) |
| +4. **PR 4 — measurement.** controld stats gain `awake`; the meter samples a |
| + trailing per-space `awake_frac`; measured beats assumed. Optional |
| + idle-window tightening at amber. |
| +5. **PR 5 — publish.** The gauge on the status page next to the books |
| + ("the free pool: N spaces, funded M× over, state: green"), estimate |
| + caveat included. |
| + |
| +Each PR is independently revertible; nothing before PR 2 can refuse anyone. |
| +The rollout follows the reaper's proven dry-run-then-arm pattern. |
diff --git a/docs/pricing-bands.md b/docs/pricing-bands.md| new file mode 100644 |
| index 0000000..9554c07 |
| --- /dev/null |
| +++ b/docs/pricing-bands.md |
| @@ -0,0 +1,354 @@ |
| +# 🤖 Pricing bands — global pricing that subsidises, never interrogates |
| + |
| +Design doc, 2026-07-14. Status: **designed, not built.** Companion docs: |
| +`docs/free-pool.md` (what the Supporter band funds), `docs/demo-spaces.md`, |
| +`docs/tier-economics.md` (the cost-anchored base prices this doc multiplies). |
| +Decisions in this doc were made by Paul 2026-07-14; legal findings are |
| +research summaries, not legal advice — a human legal review gates Phase 1. |
| + |
| +## The design in one sentence |
| + |
| +> **Location suggests, never decides.** Every buyer sees the same four |
| +> prices; the only thing geography ever does is choose which one is |
| +> *pre-selected* — and stepping to any other band is one tap, no questions |
| +> asked, in both directions, with a sentence explaining what the step means. |
| + |
| +This single choice is simultaneously the honor mechanism ("click a link, get |
| +the lower price, instantly"), what makes VPN games pointless without any |
| +policing, and the legal defence (§ Law). It is what separates this from |
| +personalised/surveillance pricing: the country→band table is public, the |
| +suggestion can be computed client-side, and no IP is ever stored. |
| + |
| +## The bands |
| + |
| +Multipliers on the existing EUR base prices, charm-rounded: |
| + |
| +| Tier | Supporter ×1.4 | Base ×1.0 | Reduced ×0.5 | Solidarity ×0.25 | |
| +|---|---|---|---|---| |
| +| Family (annual) | €7/y | €5/y | €2.50/y | €1.25/y | |
| +| C&F monthly | €5.50/m | €4/m | €2/m | — | |
| +| C&F annual | €56/y | €40/y | €20/y | €10/y | |
| +| Business monthly | €139/m | €99/m | €49/m | €25/m | |
| +| Business annual | €1,399/y | €999/y | €499/y | €249/y | |
| + |
| +- **Family and C&F prices are VAT-inclusive; Business prices are |
| + VAT-exclusive** (decided 2026-07-14) — the standard B2C/B2B split: a |
| + consumer's €5 means €5 with VAT inside; Business is quoted ex-VAT and tax |
| + is added at checkout (or reverse-charged) — § VAT below. |
| +- **Solidarity is annual-only**: Stripe's fixed ~€0.25/charge makes €1/m |
| + fee-hostile (~26% of gross), and annual matches how those buyers think |
| + about €10. |
| +- **All bands are always visible and any is selectable** — adjacent-only |
| + stepping was considered and rejected; friction is the enemy of the story. |
| +- No band is structurally loss-making while hibernation holds (even |
| + Solidarity Family nets ~€1 against a mostly-asleep space's ~€0.2/y |
| + storage+noise); the real exposure remains media hoarding, already bounded |
| + by tier storage caps. |
| +- Validation against revealed practice: Switzerland at 1.4× the euro base is |
| + *conservative* (Netflix/Spotify charge CH ≈1.5–1.65× their German price); |
| + Solidarity ×0.25 matches Steam's post-2022 India/CIS recommendations and |
| + goes a step past the parity movement's 50–60%-off norm — because the free |
| + pool, not the price, is the real floor here. |
| + |
| +## The index and the country→band table |
| + |
| +**Primary index: the World Bank price level ratio** (`PA.NUS.PPPC.RF` — PPP |
| +conversion factor ÷ market exchange rate; ~200 economies, annual, free bulk |
| +data). Not an income index: the number needed is *"what multiplier makes €5 |
| +feel like €5 there"*, which is literally what a price index measures. The two |
| +diverge where it matters — the Gulf states and Singapore are rich by income |
| +but mid-priced by price level; a price index honestly puts them at Base. |
| + |
| +- Published vs US = 1.00; **rebased to a GDP-weighted euro-area anchor |
| + (~0.73)** so everything reads as a multiplier on the EUR base. |
| +- Band edges: ≥1.15 → Supporter · 0.75–1.15 → Base · 0.45–0.75 → Reduced · |
| + <0.45 → Solidarity. |
| +- **Fallback** for countries with no price-level datum (Taiwan, Cuba; stale |
| + series): World Bank income classification — high → Base, upper-middle → |
| + Reduced, low/lower-middle → Solidarity; neither datum → Base. The honor |
| + picker bounds the harm of any wrong default. |
| +- **Decided placements (2026-07-14):** **United States → Supporter ×1.4** |
| + (the index says 1.37; the one-tap step down is the escape hatch — this is |
| + the boldest call in the scheme and the band-mix telemetry below is how we |
| + check it). Band-edge UK (~1.16) and Canada (~1.15) → **Base**; revisit at |
| + the annual table regeneration. EU/EEA → Base *by law*, Switzerland → |
| + suggestion-only Supporter (§ Law). |
| + |
| +**The index never touches runtime code.** A small committed tool |
| +(`tools/pricing-bands.mjs`, human-legible) reads a vendored CSV of the index |
| +and emits a committed, reviewable country→band JSON table with the index name |
| +and vintage in a header (`"index": "wb-pli-2024"`). Swapping the index = |
| +regenerate from a different CSV, read the diff, commit. Regenerate annually; |
| +band *reassignments* reach existing subscribers only through the annual |
| +review (§ The 15% cap), which >15% jumps turn into hold+invite by |
| +construction. The table is public information — serve it on the pricing page; |
| +publishing it *is* the transparency story. |
| + |
| +## Law (the part that shaped the design) |
| + |
| +- **EU Geo-blocking Regulation 2018/302** (electronically supplied services — |
| + hosting is squarely in scope): a trader may not *apply* different prices by |
| + reason of the customer's residence, but is free to *offer* different |
| + country price lists that anyone can access. Therefore: **one uniform |
| + EU/EEA default (Base)** — Denmark/Luxembourg/Norway/Iceland forgo their |
| + index-justified premium; the voluntary step-up remains available to them. |
| +- **Switzerland** has its own geo-price-discrimination ban (UCA Art. 3a, in |
| + force 2022, from the Fair Price Initiative; civil enforcement only). A |
| + *hard* Swiss surcharge would sit inside its text; a pre-selected suggestion |
| + any Swiss customer can decline in one tap for the same product arguably |
| + discriminates against no one. The Swiss premium ships **suggestion-only** |
| + (as everywhere). |
| +- The open-band design is the belt *and* braces: inside the EU/EEA the |
| + question never arises (uniform default); everywhere else, every price being |
| + openly offered to every customer with no eligibility test is the defence. |
| +- Consumer transparency: EU rules require the total, VAT-inclusive price |
| + before purchase — § VAT below — and the one-line band explanations double |
| + as the required transparency. |
| + |
| +**Sub-national pricing ("wealthier part of a country") — never as |
| +detection** (decided): no precedent exists for residence-based sub-national |
| +digital pricing; region-level IP geolocation is 55–80% accurate and exactly |
| +the interrogation this product refuses. The honor picker already solves it |
| +per-person, more accurately than any geo-database could — say so on the |
| +pricing page. |
| + |
| +## The picker (the honor mechanism, concretely) |
| + |
| +1. The plan picker shows the tier as today, with a **band row** beneath: four |
| + prices, one pre-selected. Pre-selection: geo-suggestion (Phase 1, CDN |
| + country header, evaluated in the page, never stored) for non-EU/EEA |
| + visitors; otherwise Base. |
| +2. One quiet line under the selection: *"Prices are suggested by country. |
| + Pick the one that's right for you — no questions asked."* |
| +3. **Deep links** (`…/upgrade?tier=family&band=reduced`) pre-select a band — |
| + Paul's "click a link, get the lower price, instantly": newsletters, NGO |
| + wikis, a friend's message. The server honours whatever band arrives. No |
| + check, ever. |
| +4. **Not Stripe promotion codes** (considered, rejected): percent-off coupons |
| + persist across Billing Portal plan switches, hide the band from |
| + `tierByPrice()` and the ledger, and frame Reduced as a deal off *your* |
| + price when it is simply *the* price for that band. **Separate Price IDs |
| + per band** are architecturally native — the price book already maps |
| + price→tier; it grows one dimension. |
| + |
| +### Local-currency display before checkout (added 2026-07-14) |
| + |
| +Show every picker price with an approximate local figure — *"€7/year |
| +(≈ $8.20)"* — **before** the buyer reaches Stripe: |
| + |
| +- **Display-only conversion.** Billing stays EUR (the tier-economics |
| + EUR-settlement stance). The local figure is an estimate and is labelled: |
| + *"billed in euros — ≈ shown in your currency at today's rate."* |
| +- **Rates:** ECB daily reference rates (~30 currencies, free, no key). The |
| + home service fetches once daily and serves a tiny cached JSON alongside the |
| + price book, with the last-good snapshot as fallback — the client never |
| + talks to a third party (CSP stays pinned). |
| +- **Currency choice comes from the browser locale** (`Intl.NumberFormat` / |
| + `navigator.language`), not IP — privacy-consistent, works offline-ish, and |
| + a small currency override sits next to the figure. Nothing is stored. |
| +- **Later, optional (Phase 3):** true multi-currency *charging* via Stripe |
| + `currency_options` (one amount per currency, charm-rounded, curated |
| + annually) — only if EUR-billing measurably hurts non-EU conversion. Note |
| + `currency_options` cannot express bands *inside* a currency — one amount |
| + per currency per Price — which is exactly why bands are separate Prices |
| + and currency presentment is an independent layer on top. Stripe's |
| + Adaptive Pricing was evaluated and rejected: it is FX localisation with a |
| + 2–4% customer-paid conversion fee, not PPP adjustment, and the hidden fee |
| + is off-brand. |
| + |
| +## The +15% annual review cap (Paul's pricing promise) |
| + |
| +**The rule:** every live price — monthly or annual billing alike — is |
| +reviewed once a year (anchored to the subscription anniversary, so reviews |
| +stagger naturally); silent renewal can carry at most **+15%**; anything |
| +larger requires an explicit opt-in to a new subscription. |
| + |
| +The cap is **the sole propagation channel**: index updates, band |
| +reassignments, FX drift, and base-price changes reach existing subscribers |
| +only through reviews. New customers always pay the current table. Stripe |
| +grandfathers by default (a subscription renews on *its* Price until updated), |
| +so "current table" (the env price book) and "your price" (the subscription's |
| +Price id) separate cleanly; the ledger gains a pending-review notice |
| +(space id, from-price, to-price, effective date, consent-required flag). |
| + |
| +The review sweep, per subscription: |
| + |
| +- **Current ≤ subscribed** → schedule the decrease at next renewal. |
| + Unconditional, no ceremony, message it proudly. (The asymmetry — decreases |
| + automatic, increases capped/consented — is deliberately public: German and |
| + Italian courts voided Netflix's and Spotify's adjustment clauses partly for |
| + being increase-only. Symmetry is the cure.) |
| +- **Gap ≤ 15%** → schedule the increase at next renewal with in-product |
| + notice and an easy cancel/step-down path. |
| +- **Gap > 15%** → **hold + invite** (decided): no silent change, no |
| + compounding 15-15-15 ladder; the subscriber stays on the old price until |
| + they affirmatively opt in to the current table (or a lower band). |
| +- Discipline: the silent path only ever moves a subscription onto an |
| + *existing catalog Price* (current or `"retired": true` vintages kept in the |
| + price book) — never bespoke per-cohort amounts chasing "exactly +15%", |
| + which would explode the book and break `tierByPrice()` legibility. |
| + |
| +**Honor-band subscribers track their own band's price** — Reduced is band C's |
| +real price, not a discount off band A — so there is no mechanism by which an |
| +honor price silently walks back up: a band reassignment is >15% by |
| +construction, hence always hold+invite. The invite surface may gently show |
| +the suggested band ("You're welcome on the reduced price as long as you need |
| +it — the suggested price for your country is €5"), keeping the social |
| +contract visible without enforcing it. |
| + |
| +**Notice without email** (accounts are anonymous passkeys; Checkout collects |
| +no email — verify nothing downstream assumes one): notices land in-product — |
| +(1) an inline row on the home page where Upgrade/Manage live ("Renews 12 Aug |
| +at €5.75 — review / change / cancel"), (2) the space's Admin-app update |
| +stream + admin push (the announce.go channel), (3) Eleven can explain any |
| +price from the public band table. "Explicit opt-in" = an authenticated |
| +affirmative act on the home page (same ownership gate as checkout), recorded |
| +in the ledger, then executed as a Stripe subscription update (never |
| +cancel+recreate — preserves the customer object, anniversary, and ledger |
| +row). Mechanics: plain subscription update with `proration_behavior=none` |
| +near renewal; `invoice.upcoming` webhook as the notice trigger. |
| + |
| +Regulatory shape-check: Ofcom's 2025 rule (in-contract rises must be |
| +disclosed in pounds-and-pence at point of sale) says state the cap **in |
| +euros on the picker** — "Family: never more than €0.75/yr more at renewal |
| +without your say-so" — not as a bare percentage. The terms need a |
| +transparent variation clause encoding cap + cadence + index reference + |
| +symmetric downside + cancel-anytime, drafted by a human lawyer before |
| +Phase 2 ships. |
| + |
| +## Stripe implementation |
| + |
| +**20 Prices** (5 today × 4 bands), created once in the dashboard, ids pasted |
| +into `/data/cloud.env` via the vault flow. The price book grows one |
| +backward-compatible dimension (a tier with no `bands` behaves exactly as |
| +today): |
| + |
| +```json |
| +[{"key":"family","name":"Family", |
| + "label":"€5/year","price":"price_baseFam", |
| + "net_eur_year":4.68, |
| + "bands":{ |
| + "supporter":{"label":"€7/year","price":"price_supFam","net_eur_year":6.55}, |
| + "reduced":{"label":"€2.50/year","price":"price_redFam","net_eur_year":2.31}, |
| + "solidarity":{"label":"€1.25/year","price":"price_solFam","net_eur_year":0.98}}}] |
| +``` |
| + |
| +(`net_eur_year` is the free pool's revenue input — see `docs/free-pool.md`.) |
| + |
| +Touch points: |
| + |
| +- `checkoutHandler`: optional `band` param (default `base`), resolve |
| + `tier.bands[band].price`, stamp `metadata[band]`. Ownership gates, |
| + no-customer-identity, webhook routing: unchanged. |
| +- Webhook: `tierByPrice` → `tierBandByPrice` (still a flat scan over a few |
| + dozen rows); the ledger records the band. |
| +- Billing Portal: four per-band portal configurations (portals are minted |
| + per-session, so the cloud picks the customer's band's config); band |
| + *changes* go through the home surface, which can message them — never the |
| + portal. |
| +- Telemetry that respects the ethos: band mix by month from the ledger — no |
| + per-user tracking — is how the US-at-×1.4 call gets checked. |
| + |
| +### VAT — Stripe Tax (decided: enable; consumer-inclusive, Business-exclusive) |
| + |
| +Today nothing sets `automatic_tax`; the EUR figure is charged as-is and VAT |
| +is implicit. With bands multiplying the price book, this gets fixed properly. |
| +**The split (decided 2026-07-14): Family and C&F prices are VAT-inclusive; |
| +Business prices are VAT-exclusive** — the standard B2C/B2B convention. |
| + |
| +- **Code (this repo):** set `automatic_tax[enabled]=true` on Checkout |
| + Sessions. **Family/C&F Prices** are created with `tax_behavior=inclusive` |
| + — labels stay the total price (€5 means €5, VAT inside, satisfying EU |
| + price-indication rules) and net revenue varies by buyer country (VAT |
| + 17–27% comes out of the sticker). **Business Prices** are created with |
| + `tax_behavior=exclusive` — the sticker is ex-VAT, tax is added at |
| + checkout, and Business checkout enables `tax_id_collection` so an EU |
| + business with a valid VAT ID gets the **reverse charge** (no VAT |
| + collected, they self-account). Picker labels for Business carry |
| + "excl. VAT" wherever the price appears. Portal configs follow the same |
| + behaviors. |
| +- **Bookkeeping:** consumer-tier `net_eur_year` figures become net-of-VAT |
| + approximations across buyer countries (the free pool tolerates that |
| + comfortably); Business `net_eur_year` is *more* stable than today — |
| + €999 is €999 regardless of buyer country. |
| +- **Caveat:** ex-VAT display is standard for business-directed offers |
| + (Slack/Notion-style), but EU price-indication rules technically demand |
| + consumer-facing totals be inclusive — so the Business tier should present |
| + as business-directed (it already does), show "excl. VAT" adjacent to |
| + every price, and Checkout shows the final total before payment. Include |
| + in the Phase 1 legal review. |
| +- **Dashboard (operator — Paul):** two places. |
| + *Settings → Tax* (dashboard.stripe.com/settings/tax): origin address + |
| + defaults — note the "Include tax in prices" default (Automatic/Yes/No) |
| + is **superseded by our explicit per-Price `tax_behavior`**, so its value |
| + doesn't matter once the 20 Prices exist; its Automatic mode |
| + (currency-based: USD/CAD exclusive, EUR inclusive) is *not* our |
| + inclusive/exclusive split, which is per-tier, not per-currency. |
| + *Tax → Registrations* (dashboard.stripe.com/tax/registrations): **the |
| + load-bearing step** — Stripe Tax only calculates where a registration is |
| + added and merely monitors thresholds elsewhere. Needed eventually: the |
| + home-country VAT registration + the **EU OSS (Union scheme)**, plus |
| + others as the monitoring tab flags obligations. These are account-level |
| + actions the code cannot and should not do — and until the code opts in |
| + (`automatic_tax[enabled]=true` per session), a configured dashboard |
| + still changes nothing at checkout. |
| + **Registrations are deferred (Paul, 2026-07-14).** Phase 0 ships without |
| + them and behaves gracefully: with zero registrations Stripe Tax |
| + calculates €0 everywhere, so inclusive prices are unchanged for the |
| + buyer and Business checkouts add nothing yet — the plumbing |
| + (per-Price `tax_behavior`, `automatic_tax`, `tax_id_collection`) is all |
| + in place, and adding a registration later activates it with no code |
| + change. Stripe Tax's threshold-monitoring tab is the revisit trigger |
| + (note: monitoring tracks configuration, not obligation — whether an |
| + obligation already exists today is an accountant question, not a |
| + dashboard one). |
| +- Existing subscriptions keep their current Prices; the new tax behaviors |
| + apply as new Prices roll through checkout and reviews. |
| + |
| +## Messaging (drafts, not final copy — lock-and-key voice) |
| + |
| +The canonical cross-subsidy line (decided, aggregate form only): |
| + |
| +> **"Paying supports free spaces for those who can't."** |
| + |
| +- **Supporter:** "This is the supporter price for Switzerland. Paying |
| + supports free spaces for those who can't — the difference doesn't go to |
| + us, it goes to the free pool. If it's not right for you, pick the standard |
| + price — no questions asked." |
| +- **Base:** "The standard price. It covers your space's costs with margin |
| + for resilience — the books are public." |
| +- **Reduced / Solidarity:** "You're on the reduced price, carried by |
| + supporters. It's yours as long as you need it." Never "discount", never |
| + "eligible", never a country name on this side. |
| +- **The cap, everywhere prices appear:** "Reviewed once a year. Never up |
| + more than 15% — for Family, at most €0.75 — never onto a new plan without |
| + your explicit yes. Down happens by itself." |
| +- Never a per-space "your €7 funds N spaces" claim (decided): the aggregate |
| + pool gauge ("paid spaces fund the free pool M× over") is the verifiable |
| + form. Precedent says transparent cross-subsidy works with an anchor, a |
| + chosen default, and visible reciprocity (Guardian, solidarity tickets) and |
| + backfires when hidden or anchorless (Amazon 2000, Panera) — this design |
| + keeps the standard-price tap in the same glance as the supporter ask. |
| + |
| +## Phasing |
| + |
| +- **Phase 0 — bands, pure honor, no geolocation.** The 20 Prices; price-book |
| + `bands` + `net_eur_year`; `tierBandByPrice`; checkout `band` param; picker |
| + with all four bands, Base pre-selected for everyone; deep-link band param; |
| + local-currency ≈ display (ECB rates via home); Stripe Tax |
| + (code + dashboard runbook above). Legally inert everywhere — it's four |
| + prices — and already delivers "anyone, anywhere, one click to the lower |
| + price" plus the supporter story. Measure band mix. |
| +- **Phase 1 — geo-suggested defaults.** Commit `tools/pricing-bands.mjs` + |
| + the country→band table; pre-select by CDN country header (client-side, |
| + never stored); EU/EEA pinned to Base; publish the table on the pricing |
| + page. **Human legal review gates this phase.** |
| +- **Phase 2 — the annual review machinery.** Ledger review/consent fields; |
| + the sweep; home inline notices + admin announce; decreases automatic, |
| + ≤15% with notice, >15% hold+invite; terms variation clause (lawyer). |
| +- **Phase 3 — optional refinements:** true multi-currency charging via |
| + `currency_options` (only if EUR-billing measurably hurts conversion); |
| + annual index refresh cadence (Phase 2 already absorbs band moves); |
| + free-pool gauge coupling on the pricing page. |
| +- **Never:** sub-national pricing as detection; promotion-code discounts; |
| + Adaptive Pricing's customer-paid FX fee. |