| 1 | <!-- 🤖 Public tier-economics page: what each tier pays us against what a space |
| 2 | costs us to run — the public snapshot of docs/tier-economics.md (the repo |
| 3 | is private; this page is what makes the #562 commitment checkable). The |
| 4 | JSON is COMMITTED data (web/status/economics.json), re-derived by hand |
| 5 | whenever prices or input rates change, and republished as-is by |
| 6 | deploy/status-publish.sh. Fully self-contained: inline CSS/JS, no external |
| 7 | requests except ./economics.json (relative). Two numbers from the source |
| 8 | doc deliberately do NOT publish: the per-box instance cap and the box's |
| 9 | disk size (the fleet feed's capacity rule — band words, never cap values). --> |
| 10 | <!doctype html> |
| 11 | <html lang="en"> |
| 12 | <head> |
| 13 | <meta charset="utf-8" /> |
| 14 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 15 | <meta name="color-scheme" content="light dark" /> |
| 16 | <title>Eleven — tier economics</title> |
| 17 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 18 | <link rel="stylesheet" href="./status.css" /> |
| 19 | <style> |
| 20 | /* Economics-only: the data tables and the cost-model block. Everything |
| 21 | shared lives in status.css. Wide tables scroll inside their own wrapper — |
| 22 | the page body never scrolls horizontally. */ |
| 23 | .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| 24 | table { border-collapse: collapse; width: 100%; font-size: 14px; } |
| 25 | caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } |
| 26 | th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); } |
| 27 | thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; } |
| 28 | tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; } |
| 29 | tbody th { font-weight: 600; white-space: nowrap; } |
| 30 | .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| 31 | .profile { min-width: 170px; } |
| 32 | |
| 33 | .model { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; overflow-x: auto; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; } |
| 34 | |
| 35 | /* Prose bullets (principles, readings) — body ink, comfortable rhythm. */ |
| 36 | ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; } |
| 37 | ul.prose .lead-in { font-weight: 650; } |
| 38 | </style> |
| 39 | </head> |
| 40 | <body> |
| 41 | <div class="wrap"> |
| 42 | <header data-title="Tier economics"></header> |
| 43 | <nav class="status-nav" aria-label="Status"></nav> |
| 44 | <script src="./status-nav.js"></script> |
| 45 | <p class="lede">How the space-upgrade prices relate to what spaces actually |
| 46 | cost to run. The price is the cost plus resilience — and that should be a |
| 47 | checkable claim, so here is the cost model, in the open beside the books.</p> |
| 48 | <p class="asof">Snapshot dated <time id="asof" datetime="">—</time> — the |
| 49 | formulas are the contract; the numbers are re-derived whenever prices or |
| 50 | input rates change.</p> |
| 51 | |
| 52 | <p id="error" role="alert">Couldn’t load the economics snapshot. It may be mid-publish — this page retries every minute.</p> |
| 53 | |
| 54 | <section aria-labelledby="h-principles"> |
| 55 | <h2 id="h-principles">Principles</h2> |
| 56 | <ul class="prose"> |
| 57 | <li><span class="lead-in">Self-hosting is no-obligation.</span> The paid |
| 58 | thing is our hosting convenience, never the software. A self-hosted space |
| 59 | owes us nothing — and one that leans on shared glue we run (the push |
| 60 | relay, the signed release feed) is warmly invited, never required, to |
| 61 | contribute.</li> |
| 62 | <li><span class="lead-in">We can only price what ciphertext exposes:</span> |
| 63 | storage bytes, member counts, space counts. Never content, never |
| 64 | analytics.</li> |
| 65 | <li><span class="lead-in">Paying is not anonymous.</span> Checkout runs |
| 66 | on Stripe, which collects your name, email, and card — and as the |
| 67 | merchant we can see those in Stripe’s dashboard. Our own stores still |
| 68 | hold only two opaque Stripe ids, a tier key, and a period end per paid |
| 69 | space, so a compromise of our systems exposes no billing identity.</li> |
| 70 | <li><span class="lead-in">The free tier is first-class.</span> Constraints |
| 71 | ratchet only for <em>new</em> spaces; existing spaces keep what they |
| 72 | have.</li> |
| 73 | <li><span class="lead-in">AI is never bundled — and off by default.</span> |
| 74 | Model tokens are the one marginal cost that can dwarf a subscription, so |
| 75 | bot/AI usage is always opt-in, and when opted into it’s a metered add-on — |
| 76 | never hidden in a flat price.</li> |
| 77 | </ul> |
| 78 | </section> |
| 79 | |
| 80 | <section aria-labelledby="h-prices"> |
| 81 | <h2 id="h-prices">The price book</h2> |
| 82 | <div class="tablewrap"> |
| 83 | <table> |
| 84 | <caption>Tiers and prices</caption> |
| 85 | <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Price</th><th scope="col">Billing</th></tr></thead> |
| 86 | <tbody id="prices"></tbody> |
| 87 | </table> |
| 88 | </div> |
| 89 | <p class="note" style="margin-top:10px">What paying buys today: the space |
| 90 | stops counting toward the account’s free-space limit, it is never reclaimed |
| 91 | as unused, and it carries a larger storage allowance (below).</p> |
| 92 | </section> |
| 93 | |
| 94 | <section aria-labelledby="h-storage"> |
| 95 | <h2 id="h-storage">Storage allowances</h2> |
| 96 | <div class="tablewrap"> |
| 97 | <table> |
| 98 | <caption>Included storage per tier</caption> |
| 99 | <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Included storage</th></tr></thead> |
| 100 | <tbody id="storage"></tbody> |
| 101 | </table> |
| 102 | </div> |
| 103 | <p class="note" style="margin-top:10px" id="quota-note"></p> |
| 104 | <p class="note">Allowances are a function of margin: an allowance is the |
| 105 | storage a space could fill <em>forever</em> with the tier still keeping at |
| 106 | least 80% of gross — 20% of the price buys your storage, 80% stays for |
| 107 | resilience. Two honest exceptions: Free has no revenue (its 1 GB is cost |
| 108 | containment), and Family’s €5.50/year buys only ~3 GB at strict 80/20 — we |
| 109 | hold it at 5 GB rather than shrink the family tier. Uploads past an |
| 110 | allowance are refused; nothing is ever deleted, and messages always keep |
| 111 | working.</p> |
| 112 | </section> |
| 113 | |
| 114 | <section aria-labelledby="h-rates"> |
| 115 | <h2 id="h-rates">Input rates</h2> |
| 116 | <div class="tablewrap"> |
| 117 | <table> |
| 118 | <caption>Cost inputs to the model</caption> |
| 119 | <thead><tr><th scope="col">Input</th><th scope="col" class="num">Rate</th><th scope="col">Source</th></tr></thead> |
| 120 | <tbody id="rates"></tbody> |
| 121 | </table> |
| 122 | </div> |
| 123 | <p class="note" style="margin-top:10px">Hibernation is live in production: |
| 124 | an idle space parks as stored bytes, not a running process, so its marginal |
| 125 | cost while asleep is storage alone — the compute rate applies only to the |
| 126 | fraction of time it’s awake. One deliberate exception: a space with a bot |
| 127 | sidecar never sleeps, one more reason AI usage carries its own meter.</p> |
| 128 | </section> |
| 129 | |
| 130 | <section aria-labelledby="h-model"> |
| 131 | <h2 id="h-model">The model</h2> |
| 132 | <pre class="model" tabindex="0">For a tier with gross annual revenue G across n charges/year: |
| 133 | |
| 134 | net = G − (0.25 × n) − (0.015 × G) # Stripe fees out |
| 135 | cost = GB_stored × storage_rate × 12 # media, the only real dial |
| 136 | + slot_rate × 12 × awake_fraction # compute while awake |
| 137 | + ~€0.10 # wakes, requests, push (noise) |
| 138 | profit = net − cost |
| 139 | break_even_GB = net ÷ (storage_rate × 12) # where a space stops covering itself</pre> |
| 140 | <p class="note" style="margin-top:10px">Message <em>text</em> is never |
| 141 | material — a decade of busy family chat is a few cents a year of storage. |
| 142 | The only way a space outspends its price is stored media.</p> |
| 143 | </section> |
| 144 | |
| 145 | <section aria-labelledby="h-estimates"> |
| 146 | <h2 id="h-estimates">Estimates per tier</h2> |
| 147 | <p class="note explain">Usage profiles are typical-case estimates, not |
| 148 | caps. Gross and net are exact; costs and margins are estimates.</p> |
| 149 | <div class="tablewrap"> |
| 150 | <table> |
| 151 | <caption>Per-tier revenue, cost, and break-even estimates</caption> |
| 152 | <thead><tr> |
| 153 | <th scope="col">Plan</th> |
| 154 | <th scope="col" class="num">Gross/yr</th> |
| 155 | <th scope="col" class="num">Net/yr</th> |
| 156 | <th scope="col" class="profile">Profile (est.)</th> |
| 157 | <th scope="col" class="num">Cost/yr</th> |
| 158 | <th scope="col" class="num">Profit/yr</th> |
| 159 | <th scope="col" class="num">Margin</th> |
| 160 | <th scope="col" class="num">Break-even storage</th> |
| 161 | </tr></thead> |
| 162 | <tbody id="estimates"></tbody> |
| 163 | </table> |
| 164 | </div> |
| 165 | <ul class="prose" style="margin-top:14px"> |
| 166 | <li><span class="lead-in">Break-even storage is the number that |
| 167 | matters.</span> Every tier’s included allowance sits far beneath its |
| 168 | break-even, so no tier can structurally lose money — while still feeling |
| 169 | unlimited in practice.</li> |
| 170 | <li><span class="lead-in">Stripe’s fixed fee punishes small monthly |
| 171 | charges,</span> which is why the picker defaults to annual (and why a |
| 172 | space is priced as a years-scale commitment).</li> |
| 173 | <li><span class="lead-in">The fixed floor is small.</span> A modest number |
| 174 | of Family spaces, a dozen Community & Friends annuals, or one |
| 175 | mid-sized Business team covers the whole serving plane; beyond that, |
| 176 | margin is contribution toward resilience.</li> |
| 177 | <li><span class="lead-in">Business is per-seat, and seats settle at |
| 178 | renewal.</span> A seat is a person (helpers and bots are free), counted |
| 179 | server-side. The price adjusts to the team’s size before each annual |
| 180 | renewal — shrinkage renews cheaper, growth renews dearer, and nothing is |
| 181 | charged or credited mid-year.</li> |
| 182 | <li><span class="lead-in">Business margin is deliberately not an infra |
| 183 | story.</span> At that margin the price is buying priority support and |
| 184 | features to come, not bytes. If that feels wrong when read beside the |
| 185 | books, this page exists to force that conversation.</li> |
| 186 | </ul> |
| 187 | </section> |
| 188 | |
| 189 | <section aria-labelledby="h-invert"> |
| 190 | <h2 id="h-invert">What could invert these numbers</h2> |
| 191 | <ul class="prose"> |
| 192 | <li><span class="lead-in">AI/bot usage</span> — excluded above by design; |
| 193 | metered add-on or bring-your-own key.</li> |
| 194 | <li><span class="lead-in">Media hoarding past break-even</span> — bounded |
| 195 | by the storage allowances.</li> |
| 196 | <li><span class="lead-in">A space that never sleeps</span> — bot-sidecar |
| 197 | spaces; today that’s a known subsidy, priced into the margins above.</li> |
| 198 | <li><span class="lead-in">Rate changes</span> — Stripe, storage, or |
| 199 | machine pricing move; the snapshot is re-derived.</li> |
| 200 | </ul> |
| 201 | </section> |
| 202 | |
| 203 | <footer> |
| 204 | <p>A committed snapshot, republished with the status pages — the cost-model |
| 205 | half of the books (income and spending as they stand: |
| 206 | <a href="./books.html">Income & Expenditure</a>). Prices are EUR; money |
| 207 | truth lives in the Stripe dashboard. Source: the repo’s |
| 208 | docs/tier-economics.md, re-derived whenever prices or input rates change.</p> |
| 209 | </footer> |
| 210 | </div> |
| 211 | |
| 212 | <script> |
| 213 | // contract: the fields this page reads from ./economics.json — committed data, |
| 214 | // checked by test/status-contract.test.mjs (which also pins the arithmetic: |
| 215 | // net = gross − Stripe fees, profit ≈ net − cost, margin = profit ÷ gross, |
| 216 | // and every allowance < its tier's break-even). |
| 217 | const FIELDS = [ |
| 218 | "as_of", "currency", "free_spaces_per_account", |
| 219 | "price_book[].tier", "price_book[].yearly_cents", |
| 220 | "price_book[].per_seat", "price_book[].per_member", "price_book[].min_members", |
| 221 | "storage_allowances[].tier", "storage_allowances[].gb", |
| 222 | "storage_allowances[].per_seat", |
| 223 | "input_rates[].input", "input_rates[].rate", "input_rates[].source", |
| 224 | "estimates[].plan", "estimates[].billing", "estimates[].profile", |
| 225 | "estimates[].gross_year_cents", "estimates[].net_year_cents", |
| 226 | "estimates[].cost_year_cents", "estimates[].profit_year_cents", |
| 227 | "estimates[].margin_pct", "estimates[].break_even_gb", |
| 228 | ]; |
| 229 | |
| 230 | const $ = (id) => document.getElementById(id); |
| 231 | const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }); |
| 232 | const n1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }); |
| 233 | // Storage renders in the unit it reads best in — GB below a TB, TB above. |
| 234 | const gbFmt = (gb) => gb >= 1000 ? n1.format(gb / 1000) + " TB" : n1.format(gb) + " GB"; |
| 235 | |
| 236 | // All rendering is textContent / createElement — never innerHTML of fetched |
| 237 | // data, even though the feed is our own. |
| 238 | const cell = (tag, cls, text) => { |
| 239 | const el = document.createElement(tag); |
| 240 | if (cls) el.className = cls; |
| 241 | el.textContent = text; |
| 242 | if (tag === "th") el.scope = "row"; |
| 243 | return el; |
| 244 | }; |
| 245 | |
| 246 | function render(d) { |
| 247 | $("asof").dateTime = d.as_of; |
| 248 | $("asof").textContent = new Date(d.as_of + "T00:00:00Z") |
| 249 | .toLocaleDateString(undefined, { dateStyle: "long", timeZone: "UTC" }); |
| 250 | void d.currency; // fixed "eur" today; the formatter would key on it if that changes |
| 251 | |
| 252 | const prices = $("prices"); |
| 253 | prices.replaceChildren(); |
| 254 | for (const p of d.price_book || []) { |
| 255 | const tr = document.createElement("tr"); |
| 256 | const hasMonthly = typeof p.monthly_cents === "number"; |
| 257 | const price = p.per_member |
| 258 | ? eur.format(p.yearly_cents / 100) + " per member per year (" |
| 259 | + eur.format((p.min_members * p.yearly_cents) / 100) + " minimum — first " |
| 260 | + n1.format(p.min_members) + " members included)" |
| 261 | : p.per_seat |
| 262 | ? eur.format(p.yearly_cents / 100) + " per person per year" |
| 263 | : (hasMonthly ? eur.format(p.monthly_cents / 100) + "/month or " : "") |
| 264 | + eur.format(p.yearly_cents / 100) + "/year"; |
| 265 | tr.append(cell("th", null, p.tier), |
| 266 | cell("td", "num", price), |
| 267 | cell("td", null, p.per_member ? "annual only, per member" |
| 268 | : p.per_seat ? "annual only, per seat" |
| 269 | : hasMonthly ? "monthly or annual" : "annual only")); |
| 270 | prices.append(tr); |
| 271 | } |
| 272 | |
| 273 | const storage = $("storage"); |
| 274 | storage.replaceChildren(); |
| 275 | for (const s of d.storage_allowances || []) { |
| 276 | const tr = document.createElement("tr"); |
| 277 | tr.append(cell("th", null, s.tier), |
| 278 | cell("td", "num", gbFmt(s.gb) + (s.per_seat ? " per person (pooled)" : ""))); |
| 279 | storage.append(tr); |
| 280 | } |
| 281 | const q = d.free_spaces_per_account; |
| 282 | $("quota-note").textContent = "Space creation is also gated by an account quota (" |
| 283 | + n1.format(q) + (q === 1 ? " free space" : " free spaces") |
| 284 | + "; paid spaces don’t count) and, when enabled, a proof-of-work per creation."; |
| 285 | |
| 286 | const rates = $("rates"); |
| 287 | rates.replaceChildren(); |
| 288 | for (const r of d.input_rates || []) { |
| 289 | const tr = document.createElement("tr"); |
| 290 | tr.append(cell("th", null, r.input), cell("td", "num", r.rate), cell("td", null, r.source)); |
| 291 | rates.append(tr); |
| 292 | } |
| 293 | |
| 294 | const est = $("estimates"); |
| 295 | est.replaceChildren(); |
| 296 | for (const e of d.estimates || []) { |
| 297 | const tr = document.createElement("tr"); |
| 298 | tr.append( |
| 299 | cell("th", null, e.plan + " · " + (e.billing === "monthly" ? "monthly" : "annual")), |
| 300 | cell("td", "num", eur.format(e.gross_year_cents / 100)), |
| 301 | cell("td", "num", eur.format(e.net_year_cents / 100)), |
| 302 | cell("td", "profile", e.profile), |
| 303 | cell("td", "num", "~" + eur.format(e.cost_year_cents / 100)), |
| 304 | cell("td", "num", "~" + eur.format(e.profit_year_cents / 100)), |
| 305 | cell("td", "num", "~" + n1.format(e.margin_pct) + "%"), |
| 306 | cell("td", "num", "~" + gbFmt(e.break_even_gb))); |
| 307 | est.append(tr); |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | async function refresh() { |
| 312 | try { |
| 313 | const res = await fetch("./economics.json", { cache: "no-cache" }); |
| 314 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 315 | render(await res.json()); |
| 316 | $("error").classList.remove("show"); |
| 317 | } catch (e) { |
| 318 | // Keep the previous render (no skeleton flash); surface the failure inline. |
| 319 | $("error").classList.add("show"); |
| 320 | } |
| 321 | } |
| 322 | refresh(); |
| 323 | setInterval(refresh, 60 * 1000); |
| 324 | void FIELDS; |
| 325 | </script> |
| 326 | </body> |
| 327 | </html> |
| 328 | |