eleven / ocho Public

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>
Paul Campbell 8a833ccd01f090110701b2180ffc66e72ce07afd parent 62e6407
3 files changed, +716 −0
  • docs/demo-spaces.md +141 −0
  • docs/free-pool.md +221 −0
  • docs/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.