eleven / ocho Public

status: publish the tier economics as a seventh status page (#862)

* status: publish the tier economics as a seventh status page

The public snapshot of docs/tier-economics.md (the repo is private, so the
status site is what makes #562's 'price = cost + resilience' commitment
actually checkable): the price book, storage allowances, input rates, the
cost model, and per-tier revenue/cost/break-even estimates, beside the books.

- web/status/economics.json — committed snapshot data (carbon/security
  pattern), re-derived by hand when prices or input rates change
- web/status/economics.html — viewer page, FIELDS manifest, tables
- nav gains Economics on all seven pages; status.css comments updated
- deploy/status-publish.sh uploads the two new files
- test/status-contract.test.mjs pins the arithmetic: net follows the Stripe
  fee line, profit ≈ net − cost, margin = profit/gross, and every tier's
  allowance sits below its break-even (structurally-profitable claim)
- docs/status.md: page description, privacy rule (never the per-box instance
  cap or box disk size — they'd reconstruct the remaining-slot needle), and
  a 'Keeping the tier-economics page current' section; docs/tier-economics.md
  cross-references it so a re-derivation updates both in the same change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status+docs: honest copy — paying is not anonymous, self-hosting is no-obligation, AI is opt-in

Paul's corrections to the tier-economics principles, applied at the source
(docs/tier-economics.md), on the new Economics page, and everywhere the same
claims echo (billing.go invariant comment, docs/cloud-provisioner.md,
docs/planet-scale.md, the planet-scale guide's spaces page, books.html's
income explainer):

- 'Paying deanonymizes you to Stripe, not to us' was untrue — as the
  merchant we see names, emails, and card details in Stripe's dashboard.
  The honest invariant: OUR stores carry no billing identity.
- 'Self-hosting is free forever' overclaims — the truth is no-obligation,
  and a self-hosted space leaning on shared glue (push relay, release feed)
  is warmly invited, never required, to contribute.
- AI: never bundled AND off by default — always opt-in, metered when opted
  into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status+docs: storage allowances become a function of margin (80/20); C&F 10→20 GB

Paul's rule: an allowance is the storage a space could fill forever with the
tier still keeping ≥80% of gross — 20% of the price buys storage, 80% stays
for resilience. allowance = (net − 0.80×gross − compute) ÷ (storage_rate×12),
at the tighter monthly billing case.

- C&F: 10 → 20 GB (enforced now — comparable box exposure to the
  long-standing Business 25 GB cap); estimates re-derived (profile 20 GB,
  cost ~€5.50, margins 84%/81%)
- Business: enforced 25 GB holds (box-bound), published off-box target ~1 TB
- Family: held at 5 GB as the documented price-bound exception (€5/y buys
  ~3 GB at strict 80/20; floor ~74%) — held, not shrunk
- Free: 1 GB, cost containment (no revenue to derive from)
- page + economics.json gain the target column/field; contract test pins
  target ≥ enforced and target < break-even
- box env change (LCHAT_CLOUD_STORAGE_CAPS friends 10→20) ships separately

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status: economics target column shows every tier's value, not dashes

A column of em dashes read as missing data; a repeated value reads as
'already at its 80/20 target', which is the actual message. The ~ stays on
Business alone — the one projection not yet enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status: storage allowances render as a plain table again

'Enforced today' / '80/20 target (off-box)' were operator words on a member
page — one allowance column, and Business's ~1 TB off-box rise moves into
the note as a sentence. The schedule stays machine-readable (target_gb in
economics.json, still pinned by the contract test); the page just stops
making members parse it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status+docs: the allowances ARE the targets — Business 1 TB, schedule retired

One number per tier (1 GB / 5 GB / 20 GB / 1 TB); target_gb leaves the JSON
and the contract test. The published table and LCHAT_CLOUD_STORAGE_CAPS must
always match; Business's 1 TB exceeds the box's disk and is honored on
demand (attach a volume, minutes) until attachments move off-box to R2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7b0fc4c19da18ca0cbfd36e437c2ce4918fbdd5a parent 13a6b9e
17 files changed, +537 −49
  • billing.go +3 −2
  • deploy/status-publish.sh +11 −5
  • docs/cloud-provisioner.md +8 −4
  • docs/planet-scale-guide/spaces.html +1 −1
  • docs/planet-scale.md +2 −1
  • docs/status.md +35 −4
  • docs/tier-economics.md +49 −23
  • test/status-contract.test.mjs +59 −2
  • web/status/books.html +5 −3
  • web/status/carbon.html +1 −0
  • web/status/contributors.html +1 −0
  • web/status/economics.html +324 −0
  • web/status/economics.json +30 −0
  • web/status/index.html +1 −0
  • web/status/prompts.html +1 −0
  • web/status/security.html +1 −0
  • web/status/status.css +5 −4
diff --git a/billing.go b/billing.go
index d54ac64..a5239f0 100644
--- a/billing.go
+++ b/billing.go
@@ -10,8 +10,9 @@ package main
//
// Privacy invariants (see docs/cloud-provisioner.md § Billing):
// - We store two opaque Stripe ids, a flag, a tier key, and a period end.
-// Never an email, name, address, or card detail — Stripe holds those;
-// payment deanonymizes only on Stripe's side (#562's honest residual).
+// Never an email, name, address, or card detail — Stripe holds those,
+// and the operator can see them in Stripe's dashboard. Paying is not
+// anonymous; the invariant is that OUR stores carry no billing identity.
// - Checkout Sessions are created WITHOUT customer details; the only thing we
// tell Stripe about a space is its numeric ledger id (client_reference_id +
// subscription metadata), which is what routes webhook events back to it.
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index 5ff9f39..da44bf9 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -105,11 +105,12 @@
# (an account API token — read-only use here, never
# echoed — plus the numeric account id).
# /opt/lchat/status-pages/ — the viewer pages, copied from web/status/
-# (status.css, index.html, books.html, carbon.html,
-# carbon.json, contributors.html, contributors.json,
-# security.html, security.json, prompts.html,
-# icon-192.png). status.css is the shared stylesheet every
-# page links; it uploads before the pages (see publish()).
+# (status.css, index.html, books.html, economics.html,
+# economics.json, carbon.html, carbon.json,
+# contributors.html, contributors.json, security.html,
+# security.json, prompts.html, icon-192.png). status.css
+# is the shared stylesheet every page links; it uploads
+# before the pages (see publish()).
# aws CLI + sqlite3 (apt install awscli sqlite3).
set -euo pipefail
@@ -1219,6 +1220,11 @@ publish() {
[ -f "$PAGES_DIR/status.css" ] && upload "$PAGES_DIR/status.css" status.css "text/css; charset=utf-8" "max-age=300"
[ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300"
[ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300"
+ # Tier economics: page + committed snapshot JSON (web/status/economics.json,
+ # re-derived by hand when prices or input rates change — docs/status.md
+ # § Keeping the tier-economics page current). Republished as-is.
+ [ -f "$PAGES_DIR/economics.html" ] && upload "$PAGES_DIR/economics.html" economics.html "text/html; charset=utf-8" "max-age=300"
+ [ -f "$PAGES_DIR/economics.json" ] && upload "$PAGES_DIR/economics.json" economics.json "application/json" "max-age=300"
[ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300"
# The carbon tally is committed data (the built ledger) — republished as-is
# EXCEPT for a live `run` block injected here (build_carbon). Built untouched,
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index c4905e2..a8590ed 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -356,8 +356,11 @@ sweep reconciles drift — self-healing, best-effort at every step. A
unit-managed instance is bounced (`try-restart`, seconds) so the cap applies
now; a hibernating one picks it up at next wake — never wake a space to limit
it. Unset = no enforcement, and operator-run instances (not in this ledger)
-are never touched. Caps are sized to the box today (see
-`tier-economics.md`); they rise when attachments move off-box (#550).
+are never touched. Caps follow the 80/20 margin
+rule in `tier-economics.md` (an allowance is what 20% of a tier's net buys
+in storage, forever) and always match the published allowance table.
+Business's 1 TB exceeds the box's disk — honored on demand (attach a Hetzner
+volume) until attachments move off-box (#550).
## The usage pipe (#857, #668 slices 2–3) — AI usage into the ledger
@@ -436,8 +439,9 @@ key in both metadata blobs — that's how events route back to a space.
**Privacy (the point):** the ledger stores two opaque Stripe ids
(`stripe_customer_id`, `stripe_subscription_id`), the `entitlement` flag, and
`paid_until`. **Never an email, name, address, or card detail** — Stripe holds
-those on its side; paying deanonymizes you *to Stripe*, not to the ledger
-(#562's documented residual). Checkout Sessions are created with no customer
+those on its side, and the operator can see them in Stripe's dashboard.
+Paying is not anonymous; the invariant (#562's documented residual) is that
+the *ledger* carries no billing identity. Checkout Sessions are created with no customer
fields at all. The webhook is the one public unauthenticated-by-token endpoint;
its gate is Stripe's HMAC signature over the raw payload (±5 min replay window).
diff --git a/docs/planet-scale-guide/spaces.html b/docs/planet-scale-guide/spaces.html
index ce7b48c..5437ee3 100644
--- a/docs/planet-scale-guide/spaces.html
+++ b/docs/planet-scale-guide/spaces.html
@@ -102,7 +102,7 @@ admin link: https://chat-c.example.com/admin#token=9f2&hellip;</span></code></p
<li><strong>Caller tokens.</strong> Services that may request creations (in practice: the home server) authenticate with tokens the operator mints at runtime — <code>POST /api/admin/tokens</code> with the provisioner&rsquo;s admin token; tokens are stored hashed and are revocable individually.</li>
<li><strong>Quotas.</strong> Each requesting user may hold at most <code>LCHAT_CLOUD_QUOTA</code> (default 3) <em>free</em> spaces at a time; a deleted space frees its slot, and a paid space does not count. There is also an optional fleet-wide circuit breaker, <code>LCHAT_CLOUD_MAX_CREATES_PER_DAY</code>, whose count deliberately survives create-and-delete games.</li>
<li><strong>Names.</strong> New spaces get short subdomains like <code>k3f.example.com</code> — an encoding (sqids) of the space&rsquo;s ledger row number, so names are unique by construction, never reused, and carry no information. The base domain comes from <code>LCHAT_CLOUD_BASE_DOMAIN</code> and needs the wildcard DNS record.</li>
- <li><strong>The ledger.</strong> Its own small database (<code>/data/cloud.db</code>) records who created what, plus the dormant billing rails: an opaque Stripe customer pointer and an entitlement flag per space — deliberately no email, name, or card data anywhere. <span class="status partial">partially built</span> (dormant until the Stripe environment variables are set).</li>
+ <li><strong>The ledger.</strong> Its own small database (<code>/data/cloud.db</code>) records who created what, plus the dormant billing rails: an opaque Stripe customer pointer and an entitlement flag per space — deliberately no email, name, or card data in the ledger itself (identity lives in Stripe, where the operator can see it). <span class="status partial">partially built</span> (dormant until the Stripe environment variables are set).</li>
</ul>
<p>Upstream of both sits the <strong>home server&rsquo;s eligibility gate</strong>: it forwards a creation request only for a signed-in account that already holds at least one space backup, which keeps space creation invitation-shaped without the server reading anything encrypted. The member&rsquo;s identity crosses to the provisioner only as an opaque random identifier — the quota is per that identifier, and no personal information flows down the chain.</p>
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
index 432827c..a5f0c10 100644
--- a/docs/planet-scale.md
+++ b/docs/planet-scale.md
@@ -302,7 +302,8 @@ Small PRs that exist so later phases are config, not surgery:
- **Billing rails** (PR #628): the cloud ledger's spaces table holds exactly
Part 11's shape — `stripe_customer_id` (an opaque pointer into Stripe) and
an `entitlement` flag — no email, name, or card detail anywhere in our
- stores. Dormant until the Stripe env is set.
+ stores (identity lives in Stripe, where the operator can see it). Dormant
+ until the Stripe env is set.
- **The release-feed mirror** (PR #662, `deploy/release-server.sh` +
`updater.go`): the fleet self-updates from GitHub, whose CDN has served
stale manifests — the Part 9 SPOF. Publishing now mirrors the signed feed
diff --git a/docs/status.md b/docs/status.md
index 29b90d6..bc5b350 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -1,4 +1,4 @@
-# 🤖 Public status dashboards — fleet, books, security, carbon, contributors & prompts
+# 🤖 Public status dashboards — fleet, books, economics, security, carbon, contributors & prompts
World-readable pages, published every 5 minutes from the router box: the
running half of #550 Part 11/12's transparency direction (the append-only
@@ -17,6 +17,14 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
claude.ai, DNSimple, Apple Developer). Each section renders independently:
a degraded income feed shows its notice inside the Income section and never
hides Expenditure.
+- **Tier economics** (`economics.html` + `economics.json`) — the cost-model
+ half of the books: the public price book, storage allowances, input rates,
+ and per-tier revenue/cost/break-even estimates, published so #562's
+ commitment — *the price is the cost plus resilience* — is a checkable
+ claim. The JSON is **committed data** (`web/status/economics.json`), a
+ hand-derived snapshot of `docs/tier-economics.md` (the repo is private;
+ this page is what makes the doc actually public), republished as-is. See
+ *Keeping the tier-economics page current* below.
- **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate
of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative
chart + ledger with everyday equivalents. Unlike the other two feeds, the
@@ -54,9 +62,9 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
Pieces: generator `deploy/status-publish.sh` (box timer, mirrors
`deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM
-`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All six
+`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All seven
pages share one stylesheet, `web/status/status.css` — the design tokens, the
-brand masthead, the six-item nav that steps through every dashboard, and the
+brand masthead, the seven-item nav that steps through every dashboard, and the
common components (tiles, bar lists, notes, footer). Each page keeps only a
small inline `<style>` for its own bits. It's same-origin in the status bucket
(no third-party request — the privacy line holds), and the publish script
@@ -84,6 +92,13 @@ aggregation boundary lives in the generator and holds by construction:
aggregated, never per-payer; invariant 12: no PII in our stores). Never
customer ids, emails, subscription ids, per-payer lines, or timestamps of
individual payments.
+- **Economics: the cost model, never live utilization.** Prices, storage
+ allowances, unit rates, and per-tier estimates are fleet-level constants,
+ published on purpose. Two source-doc numbers deliberately do NOT publish:
+ the per-box instance cap and the box's disk size — either one, combined
+ with the fleet feed's public instance count, would reconstruct the
+ remaining-slot needle the capacity band words exist to hide. Keep the slot
+ rate's denominator qualitative ("full instance capacity"), never the cap.
- **Expenses: itemized by design** — decision #19's other half: our spend is
ours to disclose, so it publishes one line per provider with the real
amount, in that provider's *own billing currency* (never FX-converted — no
@@ -318,6 +333,22 @@ status-pages dir. No hook nudges for this one: unlike the carbon tally there
is no pruning window, so a stale count costs nothing and catches up on the
next refresh.
+## Keeping the tier-economics page current
+
+`web/status/economics.json` is a hand-derived snapshot of
+`docs/tier-economics.md` — the formulas are the contract, the numbers a
+snapshot. Whenever prices, storage allowances, the free-space quota, or the
+input rates change (a Stripe price-book edit, a provider rate move, a quota
+default actually shipping), update BOTH in the same change: re-derive the
+doc's tables, mirror them into the JSON (and the page's static prose if a
+reading shifted), and run `test/status-contract.test.mjs` — it pins net
+against the Stripe fee line, profit/margin consistency, and that every
+tier's allowance stays below its break-even. Then copy
+`web/status/economics.{html,json}` to the box's `/opt/lchat/status-pages/`;
+the next 5-minute publish uploads them. No timer regenerates this feed — a
+stale snapshot is the failure mode, so treat a pricing change as incomplete
+until the snapshot moves with it.
+
## Keeping the carbon tally accruing
The tally's raw material is the local Claude Code transcripts on **each
@@ -443,7 +474,7 @@ Accruing a machine's sessions:
completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…`
(a DNSimple account API token — read-only use here, never echoed — plus
the numeric account id). `chown lchat:lchat`, `chmod 0600`.
-3. **Pages on the box**: `scp web/status/{status.css,index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,prompts.html,icon-192.png}
+3. **Pages on the box**: `scp web/status/{status.css,index.html,books.html,economics.html,economics.json,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,prompts.html,icon-192.png}
root@<box>:/opt/lchat/status-pages/`. `status.css` is the shared stylesheet
every page links (uploaded before the pages by the publish script).
4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/`
diff --git a/docs/tier-economics.md b/docs/tier-economics.md
index a798b9d..959ea72 100644
--- a/docs/tier-economics.md
+++ b/docs/tier-economics.md
@@ -12,17 +12,22 @@ Living doc: the *formulas* are the contract; the *numbers* are a snapshot
## Principles (decided in #562, held here)
-- **Self-hosting is free forever.** The paid thing is our hosting convenience,
- never the software.
+- **Self-hosting is no-obligation.** The paid thing is our hosting
+ convenience, never the software. A self-hosted space owes nothing — and one
+ that leans on shared glue we run (the push relay, the signed release feed)
+ is warmly invited, never required, to contribute.
- **We can only price what ciphertext exposes**: storage bytes, member counts,
space counts. Never content, never analytics.
-- **Paying deanonymizes you to Stripe, not to us.** The ledger holds two opaque
- Stripe ids, a tier key, and a period end per paid space — no email, name, or
- card detail, ever.
+- **Paying is not anonymous.** Checkout runs on Stripe, which collects a
+ name, email, and card — and as the merchant we can see those in Stripe's
+ dashboard. Our own stores still hold only two opaque Stripe ids, a tier
+ key, and a period end per paid space, so a compromise of our systems
+ exposes no billing identity.
- **The free tier is first-class.** Constraints (storage quotas) ratchet later
and only for *new* spaces; existing spaces keep what they have.
-- **AI is never bundled.** Model tokens are the one marginal cost that can
- dwarf a subscription; bot/AI usage is a metered add-on (design in #562).
+- **AI is never bundled — and off by default.** Model tokens are the one
+ marginal cost that can dwarf a subscription; bot/AI usage is always opt-in,
+ and when opted into it's a metered add-on (design in #562).
## The price book (as of 2026-07-11)
@@ -36,26 +41,41 @@ What paying buys today: the space stops counting toward the account's
three-space creation limit, it is never reclaimed as unused, and it carries a
larger storage allowance (below).
-## Storage allowances (as of 2026-07-11 — insurance-grade)
+## Storage allowances (as of 2026-07-14 — margin-derived)
Space creation is also gated by an account quota (3 free spaces; paid spaces
don't count) and, when the home server enables it, a hashcash proof-of-work
per creation — the anti-Sybil cost that runs inside the create animation.
-| Tier | Included storage |
+Allowances are **a function of margin** (Paul's 80/20 rule, 2026-07-14): an
+allowance is the storage a space could fill *forever* with the tier still
+keeping ≥80% of gross as profit —
+
+```
+allowance_GB = (net − 0.80 × gross − compute_cost) ÷ (storage_rate × 12)
+```
+
+— computed at the tighter (monthly) billing case (Business: 1,068 GB annual
+/ 1,259 GB monthly, published as 1 TB). Two honest exceptions: the Free tier
+has no revenue (its 1 GB is pure cost containment), and Family at €5/year
+buys only ~3 GB at strict 80/20 — we hold it at 5 GB and accept a ~74%
+worst-case floor rather than shrink the family tier.
+
+| Tier | Allowance |
|---|---|
| Free | 1 GB |
| Family | 5 GB |
-| Community & Friends | 10 GB |
-| Business | 25 GB |
-
-These are deliberately **conservative, box-sized caps** — the shared machine
-currently holds ~38 GB of disk, so today's allowances are insurance against
-any one space exhausting it, not the product's ambition. The steady-state
-allowances this document's break-evens support (~20 GB Family, ~100–200 GB
-C&F, ~1–2 TB Business) arrive when attachment storage moves off-box to R2
-(#550); raising a cap is one config change. Uploads past an allowance are
-refused (nothing is ever deleted, and messages always keep working).
+| Community & Friends | 20 GB |
+| Business | 1 TB |
+
+The allowances ARE the enforced caps (`LCHAT_CLOUD_STORAGE_CAPS`) — the
+published table and the config must always match. Business's 1 TB
+deliberately exceeds the shared machine's ~38 GB disk: the promise is
+honored **on demand** — a Business space genuinely growing toward its
+allowance gets a Hetzner volume attached (minutes, up to 10 TB per volume)
+until attachment storage moves off-box to R2 (#550), which retires that
+ceremony entirely. Uploads past an allowance are refused (nothing is ever
+deleted, and messages always keep working).
## Input rates (snapshot, 2026-07-11)
@@ -142,15 +162,15 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri
Message *text* is never material: a decade of busy family chat is ~50 MB
(~€0.01/year). The only way a space outspends its price is stored media.
-## Estimates per tier (snapshot, 2026-07-11)
+## Estimates per tier (snapshot, 2026-07-14)
Usage profiles are typical-case estimates, not caps:
| Tier | Gross/yr | Net/yr | Profile (est.) | Cost/yr (est.) | Profit/yr (est.) | Margin | Break-even storage |
|---|---|---|---|---|---|---|---|
| Family €5/y | €5.00 | €4.68 | 10 members, 5 GB, mostly asleep | ~€1.00 | ~€3.70 | ~74% | ~28 GB |
-| C&F €40/y | €40.00 | €39.15 | 40 members, 25 GB, awake ~20% | ~€6.30 | ~€32.90 | ~82% | ~230 GB |
-| C&F €4/m | €48.00 | €44.28 | same | ~€6.30 | ~€38.00 | ~79% | ~265 GB |
+| C&F €40/y | €40.00 | €39.15 | 40 members, 20 GB, awake ~20% | ~€5.50 | ~€33.70 | ~84% | ~230 GB |
+| C&F €4/m | €48.00 | €44.28 | same | ~€5.50 | ~€38.80 | ~81% | ~265 GB |
| Business €999/y | €999.00 | €983.77 | 150 members, 100 GB, awake ~50% | ~€22.00 | ~€962 | ~96% | ~5.9 TB |
| Business €99/m | €1,188.00 | €1,167.18 | same | ~€22.00 | ~€1,145 | ~96% | ~7 TB |
@@ -186,7 +206,13 @@ Readings:
## Re-deriving
Update the two snapshot tables (input rates + estimates) and the date stamps;
-the formulas don't change. Current prices live in the provisioner's price book
+the formulas don't change. **This document publishes:** the status site's
+Economics page (`web/status/economics.{html,json}`) is the public snapshot of
+these tables — update it in the same change and copy it to the box
+(`docs/status.md § Keeping the tier-economics page current`; the contract
+test pins the arithmetic). Two numbers stay out of the public copy by
+design: the per-box instance cap and the box disk size (the status site's
+capacity privacy rule — band words, never cap values). Current prices live in the provisioner's price book
(`LCHAT_STRIPE_PRICES` on the cloud service; see
`cloud-provisioner.md § Billing`); money truth lives in the Stripe dashboard;
entitlement truth in the provisioner ledger. Related: #550 (architecture +
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index dd44511..2d3f926 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -94,6 +94,11 @@ const carbonHtml = read("web/status/carbon.html");
// The contributors index is COMMITTED data too (tools/contributors.mjs).
const contributors = JSON.parse(read("web/status/contributors.json"));
const contributorsHtml = read("web/status/contributors.html");
+// The tier-economics snapshot is COMMITTED data (re-derived by hand from
+// docs/tier-economics.md when prices or input rates change) — the real
+// web/status/economics.json is its own fixture, like the carbon ledger.
+const economics = JSON.parse(read("web/status/economics.json"));
+const economicsHtml = read("web/status/economics.html");
// The security ledger: the dry-run fixture (attention shape — proves the
// dirty path redacts) plus, when present, the committed real scan. The real
// file may legitimately be absent (the page then shows "scan pending", never
@@ -135,6 +140,12 @@ await test("contributors page fields all exist in the committed contributors.jso
}
});
+await test("economics page fields all exist in the committed economics.json", () => {
+ for (const f of fields(economicsHtml)) {
+ assert.notEqual(resolve(economics, f), undefined, "economics.json is missing " + f);
+ }
+});
+
await test("security page fields all exist in the scanner's security.json", () => {
for (const [name, s] of securityShapes) {
for (const f of fields(securityHtml)) {
@@ -164,10 +175,56 @@ await test("contributors entries are well-formed, sorted, and email-free", () =>
"an email address leaked into contributors.json");
});
+// The tier-economics snapshot must stay internally consistent as it's
+// re-derived (docs/tier-economics.md: the formulas are the contract, the
+// numbers a snapshot): net follows the Stripe fee line exactly (the two fee
+// constants below mirror the input-rates table — update both together),
+// profit ≈ net − cost within the doc's ~rounding, margin is profit over
+// GROSS, and every tier's included allowance sits below its break-even —
+// the "no tier can structurally lose money" claim the page makes in prose.
+await test("economics snapshot arithmetic + structurally-profitable claim hold", () => {
+ assert.match(economics.as_of, /^\d{4}-\d{2}-\d{2}$/, "as_of is a date");
+ assert.equal(economics.currency, "eur");
+ const q = economics.free_spaces_per_account;
+ assert.ok(Number.isInteger(q) && q >= 1, "free-space quota is a positive integer");
+ assert.ok(economics.price_book.length > 0 && economics.storage_allowances.length > 0
+ && economics.input_rates.length > 0 && economics.estimates.length > 0, "all four tables non-empty");
+ for (const p of economics.price_book) {
+ assert.ok(Number.isInteger(p.yearly_cents) && p.yearly_cents > 0, p.tier + ": yearly_cents is integer cents");
+ if ("monthly_cents" in p) {
+ assert.ok(Number.isInteger(p.monthly_cents) && p.monthly_cents > 0, p.tier + ": monthly_cents is integer cents");
+ // A monthly toggle must cost more per year than the annual price, or
+ // the annual default the page defends would be the worse deal.
+ assert.ok(12 * p.monthly_cents >= p.yearly_cents, p.tier + ": 12×monthly ≥ yearly");
+ }
+ }
+ const allowance = new Map(economics.storage_allowances.map((s) => [s.tier, s.gb]));
+ for (const s of economics.storage_allowances) {
+ assert.ok(Number.isInteger(s.gb) && s.gb > 0, s.tier + ": allowance is a positive GB integer");
+ }
+ for (const e of economics.estimates) {
+ const label = e.plan + " · " + e.billing;
+ const charges = e.billing === "monthly" ? 12 : 1;
+ // Stripe fee constants (1.5% + €0.25 per charge) from the input-rates table.
+ const net = e.gross_year_cents - 25 * charges - 0.015 * e.gross_year_cents;
+ assert.ok(Math.abs(e.net_year_cents - net) <= 1,
+ label + ": net must follow the fee model (" + e.net_year_cents + " vs " + net.toFixed(1) + ")");
+ assert.ok(e.cost_year_cents > 0 && e.cost_year_cents < e.net_year_cents, label + ": cost sits inside net");
+ const tol = Math.max(5, Math.round(e.gross_year_cents * 0.005)); // costs are ~ estimates
+ assert.ok(Math.abs(e.profit_year_cents - (e.net_year_cents - e.cost_year_cents)) <= tol,
+ label + ": profit ≈ net − cost (" + e.profit_year_cents + ")");
+ assert.ok(Math.abs(e.margin_pct - (100 * e.profit_year_cents) / e.gross_year_cents) <= 1,
+ label + ": margin is profit over gross");
+ const gb = allowance.get(e.plan);
+ assert.ok(Number.isInteger(gb), label + ": plan has a storage-allowance row");
+ assert.ok(e.break_even_gb > gb, label + ": allowance must sit below break-even (structurally profitable)");
+ }
+});
+
// A manifest entry nobody reads is drift too: each leaf property must appear
// in the page's render code, not just the manifest.
await test("every declared field is actually read by its page", () => {
- for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)], [securityHtml, fields(securityHtml)]]) {
+ for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)], [securityHtml, fields(securityHtml)], [economicsHtml, fields(economicsHtml)]]) {
const body = html.slice(html.indexOf("];") + 2);
for (const f of fs) {
const leaf = f.split(".").pop().replace("[]", "");
@@ -188,7 +245,7 @@ await test("degraded books shape carries available:false + reason", () => {
// customer, transaction, or provider-account/workspace id may appear in
// published output.
await test("fixtures contain no per-payer ids or hostnames", () => {
- for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, ...securityShapes.map(([, s]) => s)]) {
+ for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, economics, ...securityShapes.map(([, s]) => s)]) {
const s = JSON.stringify(j);
assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
diff --git a/web/status/books.html b/web/status/books.html
index b1c808b..45e0593 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -45,6 +45,7 @@
<nav class="status-nav" aria-label="Status">
<a href="./index.html">Fleet</a>
<span aria-current="page">Income &amp; Expenditure</span>
+ <a href="./economics.html">Economics</a>
<a href="./security.html">Security</a>
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
@@ -63,9 +64,10 @@
inside Income, and never hides Expenditure (or vice versa). -->
<section aria-labelledby="h-income" id="s-income">
<h2 id="h-income">Income</h2>
- <p class="note explain"><strong>Aggregated by design:</strong> our billing
- records hold no customer identities (Stripe does), and what appears here
- is totals only — never a per-payer line, never an individual payment.</p>
+ <p class="note explain"><strong>Aggregated by design:</strong> our own
+ records hold no customer identities (they live in Stripe, where we can
+ see them), and what appears here is totals only — never a per-payer
+ line, never an individual payment.</p>
<p id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p>
<div id="income-body">
<div class="tiles">
diff --git a/web/status/carbon.html b/web/status/carbon.html
index 5968612..3aba61a 100644
--- a/web/status/carbon.html
+++ b/web/status/carbon.html
@@ -104,6 +104,7 @@
<nav class="status-nav" aria-label="Status">
<a href="./index.html">Fleet</a>
<a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
<a href="./security.html">Security</a>
<span aria-current="page">Carbon use</span>
<a href="./contributors.html">Contributors</a>
diff --git a/web/status/contributors.html b/web/status/contributors.html
index 065e859..46d0993 100644
--- a/web/status/contributors.html
+++ b/web/status/contributors.html
@@ -46,6 +46,7 @@
<nav class="status-nav" aria-label="Status">
<a href="./index.html">Fleet</a>
<a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
<a href="./security.html">Security</a>
<a href="./carbon.html">Carbon use</a>
<span aria-current="page">Contributors</span>
diff --git a/web/status/economics.html b/web/status/economics.html
new file mode 100644
index 0000000..c395584
--- /dev/null
+++ b/web/status/economics.html
@@ -0,0 +1,324 @@
+<!-- 🤖 Public tier-economics page: what each tier pays us against what a space
+ costs us to run — the public snapshot of docs/tier-economics.md (the repo
+ is private; this page is what makes the #562 commitment checkable). The
+ JSON is COMMITTED data (web/status/economics.json), re-derived by hand
+ whenever prices or input rates change, and republished as-is by
+ deploy/status-publish.sh. Fully self-contained: inline CSS/JS, no external
+ requests except ./economics.json (relative). Two numbers from the source
+ doc deliberately do NOT publish: the per-box instance cap and the box's
+ disk size (the fleet feed's capacity rule — band words, never cap values). -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven — tier economics</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<link rel="stylesheet" href="./status.css" />
+<style>
+ /* Economics-only: the data tables and the cost-model block. Everything
+ shared lives in status.css. Wide tables scroll inside their own wrapper —
+ the page body never scrolls horizontally. */
+ .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
+ table { border-collapse: collapse; width: 100%; font-size: 14px; }
+ caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
+ th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); }
+ thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; }
+ tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
+ tbody th { font-weight: 600; white-space: nowrap; }
+ .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+ .profile { min-width: 170px; }
+
+ .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; }
+
+ /* Prose bullets (principles, readings) — body ink, comfortable rhythm. */
+ ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; }
+ ul.prose .lead-in { font-weight: 650; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>Tier economics</h1>
+ </div>
+ </header>
+ <nav class="status-nav" aria-label="Status">
+ <a href="./index.html">Fleet</a>
+ <a href="./books.html">Income &amp; Expenditure</a>
+ <span aria-current="page">Economics</span>
+ <a href="./security.html">Security</a>
+ <a href="./carbon.html">Carbon use</a>
+ <a href="./contributors.html">Contributors</a>
+ <a href="./prompts.html">Prompt Ledger</a>
+ </nav>
+ <p class="lede">How the space-upgrade prices relate to what spaces actually
+ cost to run. The price is the cost plus resilience — and that should be a
+ checkable claim, so here is the cost model, in the open beside the books.</p>
+ <p class="asof">Snapshot dated <time id="asof" datetime="">—</time> — the
+ formulas are the contract; the numbers are re-derived whenever prices or
+ input rates change.</p>
+
+ <p id="error" role="alert">Couldn’t load the economics snapshot. It may be mid-publish — this page retries every minute.</p>
+
+ <section aria-labelledby="h-principles">
+ <h2 id="h-principles">Principles</h2>
+ <ul class="prose">
+ <li><span class="lead-in">Self-hosting is no-obligation.</span> The paid
+ thing is our hosting convenience, never the software. A self-hosted space
+ owes us nothing — and one that leans on shared glue we run (the push
+ relay, the signed release feed) is warmly invited, never required, to
+ contribute.</li>
+ <li><span class="lead-in">We can only price what ciphertext exposes:</span>
+ storage bytes, member counts, space counts. Never content, never
+ analytics.</li>
+ <li><span class="lead-in">Paying is not anonymous.</span> Checkout runs
+ on Stripe, which collects your name, email, and card — and as the
+ merchant we can see those in Stripe’s dashboard. Our own stores still
+ hold only two opaque Stripe ids, a tier key, and a period end per paid
+ space, so a compromise of our systems exposes no billing identity.</li>
+ <li><span class="lead-in">The free tier is first-class.</span> Constraints
+ ratchet only for <em>new</em> spaces; existing spaces keep what they
+ have.</li>
+ <li><span class="lead-in">AI is never bundled — and off by default.</span>
+ Model tokens are the one marginal cost that can dwarf a subscription, so
+ bot/AI usage is always opt-in, and when opted into it’s a metered add-on —
+ never hidden in a flat price.</li>
+ </ul>
+ </section>
+
+ <section aria-labelledby="h-prices">
+ <h2 id="h-prices">The price book</h2>
+ <div class="tablewrap">
+ <table>
+ <caption>Tiers and prices</caption>
+ <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Price</th><th scope="col">Billing</th></tr></thead>
+ <tbody id="prices"></tbody>
+ </table>
+ </div>
+ <p class="note" style="margin-top:10px">What paying buys today: the space
+ stops counting toward the account’s free-space limit, it is never reclaimed
+ as unused, and it carries a larger storage allowance (below).</p>
+ </section>
+
+ <section aria-labelledby="h-storage">
+ <h2 id="h-storage">Storage allowances</h2>
+ <div class="tablewrap">
+ <table>
+ <caption>Included storage per tier</caption>
+ <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Included storage</th></tr></thead>
+ <tbody id="storage"></tbody>
+ </table>
+ </div>
+ <p class="note" style="margin-top:10px" id="quota-note"></p>
+ <p class="note">Allowances are a function of margin: an allowance is the
+ storage a space could fill <em>forever</em> with the tier still keeping at
+ least 80% of gross — 20% of the price buys your storage, 80% stays for
+ resilience. Two honest exceptions: Free has no revenue (its 1 GB is cost
+ containment), and Family’s €5/year buys only ~3 GB at strict 80/20 — we
+ hold it at 5 GB rather than shrink the family tier. Uploads past an
+ allowance are refused; nothing is ever deleted, and messages always keep
+ working.</p>
+ </section>
+
+ <section aria-labelledby="h-rates">
+ <h2 id="h-rates">Input rates</h2>
+ <div class="tablewrap">
+ <table>
+ <caption>Cost inputs to the model</caption>
+ <thead><tr><th scope="col">Input</th><th scope="col" class="num">Rate</th><th scope="col">Source</th></tr></thead>
+ <tbody id="rates"></tbody>
+ </table>
+ </div>
+ <p class="note" style="margin-top:10px">Hibernation is live in production:
+ an idle space parks as stored bytes, not a running process, so its marginal
+ cost while asleep is storage alone — the compute rate applies only to the
+ fraction of time it’s awake. One deliberate exception: a space with a bot
+ sidecar never sleeps, one more reason AI usage carries its own meter.</p>
+ </section>
+
+ <section aria-labelledby="h-model">
+ <h2 id="h-model">The model</h2>
+ <pre class="model" tabindex="0">For a tier with gross annual revenue G across n charges/year:
+
+net = G − (0.25 × n) − (0.015 × G) # Stripe fees out
+cost = GB_stored × storage_rate × 12 # media, the only real dial
+ + slot_rate × 12 × awake_fraction # compute while awake
+ + ~€0.10 # wakes, requests, push (noise)
+profit = net − cost
+break_even_GB = net ÷ (storage_rate × 12) # where a space stops covering itself</pre>
+ <p class="note" style="margin-top:10px">Message <em>text</em> is never
+ material — a decade of busy family chat is a few cents a year of storage.
+ The only way a space outspends its price is stored media.</p>
+ </section>
+
+ <section aria-labelledby="h-estimates">
+ <h2 id="h-estimates">Estimates per tier</h2>
+ <p class="note explain">Usage profiles are typical-case estimates, not
+ caps. Gross and net are exact; costs and margins are estimates.</p>
+ <div class="tablewrap">
+ <table>
+ <caption>Per-tier revenue, cost, and break-even estimates</caption>
+ <thead><tr>
+ <th scope="col">Plan</th>
+ <th scope="col" class="num">Gross/yr</th>
+ <th scope="col" class="num">Net/yr</th>
+ <th scope="col" class="profile">Profile (est.)</th>
+ <th scope="col" class="num">Cost/yr</th>
+ <th scope="col" class="num">Profit/yr</th>
+ <th scope="col" class="num">Margin</th>
+ <th scope="col" class="num">Break-even storage</th>
+ </tr></thead>
+ <tbody id="estimates"></tbody>
+ </table>
+ </div>
+ <ul class="prose" style="margin-top:14px">
+ <li><span class="lead-in">Break-even storage is the number that
+ matters.</span> Every tier’s included allowance sits far beneath its
+ break-even, so no tier can structurally lose money — while still feeling
+ unlimited in practice.</li>
+ <li><span class="lead-in">Stripe’s fixed fee punishes small monthly
+ charges,</span> which is why the picker defaults to annual (and why a
+ space is priced as a years-scale commitment).</li>
+ <li><span class="lead-in">The fixed floor is small.</span> A modest number
+ of Family spaces, a dozen Community &amp; Friends annuals, or a single
+ Business space covers the whole serving plane; beyond that, margin is
+ contribution toward resilience.</li>
+ <li><span class="lead-in">Business margin is deliberately not an infra
+ story.</span> At that margin the price is buying priority support and
+ features to come, not bytes. If that feels wrong when read beside the
+ books, this page exists to force that conversation.</li>
+ </ul>
+ </section>
+
+ <section aria-labelledby="h-invert">
+ <h2 id="h-invert">What could invert these numbers</h2>
+ <ul class="prose">
+ <li><span class="lead-in">AI/bot usage</span> — excluded above by design;
+ metered add-on or bring-your-own key.</li>
+ <li><span class="lead-in">Media hoarding past break-even</span> — bounded
+ by the storage allowances.</li>
+ <li><span class="lead-in">A space that never sleeps</span> — bot-sidecar
+ spaces; today that’s a known subsidy, priced into the margins above.</li>
+ <li><span class="lead-in">Rate changes</span> — Stripe, storage, or
+ machine pricing move; the snapshot is re-derived.</li>
+ </ul>
+ </section>
+
+ <footer>
+ <p>A committed snapshot, republished with the status pages — the cost-model
+ half of the books (income and spending as they stand:
+ <a href="./books.html">Income &amp; Expenditure</a>). Prices are EUR; money
+ truth lives in the Stripe dashboard. Source: the repo’s
+ docs/tier-economics.md, re-derived whenever prices or input rates change.</p>
+ </footer>
+</div>
+
+<script>
+// contract: the fields this page reads from ./economics.json — committed data,
+// checked by test/status-contract.test.mjs (which also pins the arithmetic:
+// net = gross − Stripe fees, profit ≈ net − cost, margin = profit ÷ gross,
+// and every allowance < its tier's break-even).
+const FIELDS = [
+ "as_of", "currency", "free_spaces_per_account",
+ "price_book[].tier", "price_book[].monthly_cents", "price_book[].yearly_cents",
+ "storage_allowances[].tier", "storage_allowances[].gb",
+ "input_rates[].input", "input_rates[].rate", "input_rates[].source",
+ "estimates[].plan", "estimates[].billing", "estimates[].profile",
+ "estimates[].gross_year_cents", "estimates[].net_year_cents",
+ "estimates[].cost_year_cents", "estimates[].profit_year_cents",
+ "estimates[].margin_pct", "estimates[].break_even_gb",
+];
+
+const $ = (id) => document.getElementById(id);
+const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
+const n1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
+// Storage renders in the unit it reads best in — GB below a TB, TB above.
+const gbFmt = (gb) => gb >= 1000 ? n1.format(gb / 1000) + " TB" : n1.format(gb) + " GB";
+
+// All rendering is textContent / createElement — never innerHTML of fetched
+// data, even though the feed is our own.
+const cell = (tag, cls, text) => {
+ const el = document.createElement(tag);
+ if (cls) el.className = cls;
+ el.textContent = text;
+ if (tag === "th") el.scope = "row";
+ return el;
+};
+
+function render(d) {
+ $("asof").dateTime = d.as_of;
+ $("asof").textContent = new Date(d.as_of + "T00:00:00Z")
+ .toLocaleDateString(undefined, { dateStyle: "long", timeZone: "UTC" });
+ void d.currency; // fixed "eur" today; the formatter would key on it if that changes
+
+ const prices = $("prices");
+ prices.replaceChildren();
+ for (const p of d.price_book || []) {
+ const tr = document.createElement("tr");
+ const hasMonthly = typeof p.monthly_cents === "number";
+ const price = (hasMonthly ? eur.format(p.monthly_cents / 100) + "/month or " : "")
+ + eur.format(p.yearly_cents / 100) + "/year";
+ tr.append(cell("th", null, p.tier),
+ cell("td", "num", price),
+ cell("td", null, hasMonthly ? "monthly or annual" : "annual only"));
+ prices.append(tr);
+ }
+
+ const storage = $("storage");
+ storage.replaceChildren();
+ for (const s of d.storage_allowances || []) {
+ const tr = document.createElement("tr");
+ tr.append(cell("th", null, s.tier), cell("td", "num", gbFmt(s.gb)));
+ storage.append(tr);
+ }
+ const q = d.free_spaces_per_account;
+ $("quota-note").textContent = "Space creation is also gated by an account quota ("
+ + n1.format(q) + (q === 1 ? " free space" : " free spaces")
+ + "; paid spaces don’t count) and, when enabled, a proof-of-work per creation.";
+
+ const rates = $("rates");
+ rates.replaceChildren();
+ for (const r of d.input_rates || []) {
+ const tr = document.createElement("tr");
+ tr.append(cell("th", null, r.input), cell("td", "num", r.rate), cell("td", null, r.source));
+ rates.append(tr);
+ }
+
+ const est = $("estimates");
+ est.replaceChildren();
+ for (const e of d.estimates || []) {
+ const tr = document.createElement("tr");
+ tr.append(
+ cell("th", null, e.plan + " · " + (e.billing === "monthly" ? "monthly" : "annual")),
+ cell("td", "num", eur.format(e.gross_year_cents / 100)),
+ cell("td", "num", eur.format(e.net_year_cents / 100)),
+ cell("td", "profile", e.profile),
+ cell("td", "num", "~" + eur.format(e.cost_year_cents / 100)),
+ cell("td", "num", "~" + eur.format(e.profit_year_cents / 100)),
+ cell("td", "num", "~" + n1.format(e.margin_pct) + "%"),
+ cell("td", "num", "~" + gbFmt(e.break_even_gb)));
+ est.append(tr);
+ }
+}
+
+async function refresh() {
+ try {
+ const res = await fetch("./economics.json", { cache: "no-cache" });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ render(await res.json());
+ $("error").classList.remove("show");
+ } catch (e) {
+ // Keep the previous render (no skeleton flash); surface the failure inline.
+ $("error").classList.add("show");
+ }
+}
+refresh();
+setInterval(refresh, 60 * 1000);
+void FIELDS;
+</script>
+</body>
+</html>
diff --git a/web/status/economics.json b/web/status/economics.json
new file mode 100644
index 0000000..32ff53a
--- /dev/null
+++ b/web/status/economics.json
@@ -0,0 +1,30 @@
+{
+ "as_of": "2026-07-14",
+ "currency": "eur",
+ "free_spaces_per_account": 3,
+ "price_book": [
+ { "tier": "Family", "yearly_cents": 500 },
+ { "tier": "Community & Friends", "monthly_cents": 400, "yearly_cents": 4000 },
+ { "tier": "Business", "monthly_cents": 9900, "yearly_cents": 99900 }
+ ],
+ "storage_allowances": [
+ { "tier": "Free", "gb": 1 },
+ { "tier": "Family", "gb": 5 },
+ { "tier": "Community & Friends", "gb": 20 },
+ { "tier": "Business", "gb": 1000 }
+ ],
+ "input_rates": [
+ { "input": "Stripe fee (EU consumer card)", "rate": "1.5% + €0.25 per charge", "source": "Stripe pricing; EUR settlement, no FX" },
+ { "input": "Attachment storage (R2)", "rate": "~€0.014 per GB-month", "source": "$0.015, zero egress" },
+ { "input": "DB snapshots (S3)", "rate": "~€0.021 per GB-month", "source": "$0.023" },
+ { "input": "Box (whole cloud plane)", "rate": "~€35 per month", "source": "Hetzner + fixed services" },
+ { "input": "Resident-process slot", "rate": "~€0.81 per month", "source": "the box rate divided across its full instance capacity" }
+ ],
+ "estimates": [
+ { "plan": "Family", "billing": "yearly", "gross_year_cents": 500, "net_year_cents": 468, "profile": "10 members, 5 GB, mostly asleep", "cost_year_cents": 100, "profit_year_cents": 370, "margin_pct": 74, "break_even_gb": 28 },
+ { "plan": "Community & Friends", "billing": "yearly", "gross_year_cents": 4000, "net_year_cents": 3915, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3370, "margin_pct": 84, "break_even_gb": 230 },
+ { "plan": "Community & Friends", "billing": "monthly", "gross_year_cents": 4800, "net_year_cents": 4428, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3880, "margin_pct": 81, "break_even_gb": 265 },
+ { "plan": "Business", "billing": "yearly", "gross_year_cents": 99900, "net_year_cents": 98377, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 96200, "margin_pct": 96, "break_even_gb": 5900 },
+ { "plan": "Business", "billing": "monthly", "gross_year_cents": 118800, "net_year_cents": 116718, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 114500, "margin_pct": 96, "break_even_gb": 7000 }
+ ]
+}
diff --git a/web/status/index.html b/web/status/index.html
index 66cbd55..e9e97e2 100644
--- a/web/status/index.html
+++ b/web/status/index.html
@@ -63,6 +63,7 @@
<nav class="status-nav" aria-label="Status">
<span aria-current="page">Fleet</span>
<a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
<a href="./security.html">Security</a>
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
diff --git a/web/status/prompts.html b/web/status/prompts.html
index 1934505..a8f2c1c 100644
--- a/web/status/prompts.html
+++ b/web/status/prompts.html
@@ -111,6 +111,7 @@
<nav class="status-nav" aria-label="Status">
<a href="./index.html">Fleet</a>
<a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
<a href="./security.html">Security</a>
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
diff --git a/web/status/security.html b/web/status/security.html
index 28611e8..51042d0 100644
--- a/web/status/security.html
+++ b/web/status/security.html
@@ -65,6 +65,7 @@
<nav class="status-nav" aria-label="Status">
<a href="./index.html">Fleet</a>
<a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
<span aria-current="page">Security</span>
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
diff --git a/web/status/status.css b/web/status/status.css
index 81b48fb..04f1abf 100644
--- a/web/status/status.css
+++ b/web/status/status.css
@@ -1,7 +1,8 @@
/* 🤖 Shared stylesheet for the public status pages (web/status/*.html): fleet,
- income & expenditure, security, carbon, contributors, and the prompt ledger.
+ income & expenditure, tier economics, security, carbon, contributors, and
+ the prompt ledger.
ONE design system — the design tokens, the reset, and every component the
- pages share (the brand masthead, the six-item nav, tiles, the single-series
+ pages share (the brand masthead, the seven-item nav, tiles, the single-series
bar list, notes, the "couldn't load" alert, footer). Each page <link>s this
and keeps only a SMALL inline <style> for its own unique bits.
@@ -11,7 +12,7 @@
this file BEFORE the html so a page is never served ahead of its stylesheet.
Palette: the dataviz-validated role set (six-checks, light + dark) already in
- use across all six pages — the violet slot (--series / --link) is the Eleven
+ use across all seven pages — the violet slot (--series / --link) is the Eleven
brand family; --ink-2 carries ALL small text (>=4.5:1 in both modes). The
names are the ones every page already used; this file is simply their single
home (the union of every role any page referenced). */
@@ -56,7 +57,7 @@ header img { width: 44px; height: 44px; border-radius: 10px; }
header h1 { font-size: 22px; font-weight: 650; }
header .brand { color: var(--ink-2); font-size: 14px; }
-/* The status nav — a horizontal stepper through the six public dashboards,
+/* The status nav — a horizontal stepper through the seven public dashboards,
wrapping onto more rows on narrow widths. The current page is a non-link,
marked aria-current and bolded, so weight (not color alone) carries it. */
nav.status-nav { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; margin: 8px 0 14px; }