eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
134net = G − (0.25 × n) − (0.015 × G) # Stripe fees out
135cost = 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)
138profit = net − cost
139break_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 &amp; 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 &amp; 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).
217const 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
230const $ = (id) => document.getElementById(id);
231const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
232const n1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
233// Storage renders in the unit it reads best in — GB below a TB, TB above.
234const 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.
238const 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
246function 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
311async 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}
322refresh();
323setInterval(refresh, 60 * 1000);
324void FIELDS;
325</script>
326</body>
327</html>
328