# 🤖 Public status dashboards — fleet, books, economics, investing, security, carbon, contributors, prompts, SSH access & music

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
public *ledger* repo is still roadmap — see `docs/planet-scale.md`).

- **Fleet status** (`index.html` + `fleet.json`) — how many instances exist,
  how many are always-on vs hibernating, how many of the hibernating ones are
  awake right now, the serving hosts and allocation capacity (as abstract
  band words — see below), the version distribution, whether the shared
  services (home / push / cloud / connect) are up, and fleet-backup
  replication health.
- **The books** (`books.html` + `books.json`) — two clearly-headed halves.
  **Income**, in aggregate: active paid subscriptions (total and by tier —
  tier names are the public price book), MRR in EUR, and gross collected this
  month. **Expenditure**, itemized per provider (AWS, Hetzner, Anthropic API,
  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.
- **Investing** (`funding.html` + `funding.json` + `waterfall.js`) — the
  funding model (`docs/funding.md` — the repo is private; this page is what
  makes the model public) and its open ledger. The JSON is **committed data**
  like economics — empty (`adopted: false`) until the model passes legal +
  tax review and a series deliberately opens; thereafter every series, note
  (pseudonymous ids only), and quarterly waterfall computation publishes
  here. `waterfall.js` is the algorithm's **normative reference
  implementation**, served from the same directory so the page's interactive
  simulator imports and runs the very file it publishes — an audit is a
  re-execution (Node-side consumers use the `tools/waterfall.mjs` re-export;
  vectors `test/waterfall-vectors.json`).
- **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
  JSON is **committed data** (`web/status/carbon.json`, accrued by
  `tools/carbon-tally.mjs` — see *Keeping the carbon tally accruing* below);
  the box timer just republishes it with the pages. Methodology + error bars:
  `docs/comparison-carbon.md`.
- **Contributors** (`contributors.html` + `contributors.json`) — who builds
  Eleven, humans and AI models on one metric: humans as GitHub commit authors,
  models as `Co-Authored-By` trailers in git history. Committed data like the
  carbon tally (`web/status/contributors.json`, refreshed by
  `tools/contributors.mjs`); republished as-is. **Contributors own their row**:
  bios seed from the GitHub profile, then edits to the committed JSON win —
  customise your `bio` (or set `"hidden": true` to leave the index) by PR, and
  the tool preserves those fields on every refresh.
- **The security ledger** (`security.html` + `security.json`) — posture, never
  a live exploit map. Four sections: the E2EE commitment (the #550 Part 12
  invariants in member language), dependency hygiene (govulncheck against the
  exact shipped binary — **status + count only**, see the disclosure rule
  below), box/service hardening as a checklist of facts, and the deliberate
  architectural edges (link previews, GIF search, plaintext branding,
  bots-as-members, the content-blind-store trade), honestly named. The JSON is
  **committed data** written at *build* time by `deploy/security-scan.sh`
  (govulncheck needs the Go toolchain, which the box deliberately lacks); the
  box republishes it as-is, and an absent `security.json` renders as "scan
  pending" — never a fake clean. Vulnerability reports:
  team@elevenmessenger.com (swap to a dedicated security@ mailbox when one exists).
- **The prompt registry** (`prompts.html` + the `prompts/` corpus in the
  bucket) — the provenance record of AI-assisted work: whole Claude Code
  session transcripts, credentials redacted and every redaction annotated in
  place. Accrued data like the carbon tally, but far too large to commit: a
  contributor runs `tools/prompt-registry.mjs` and uploads the corpus straight
  to the bucket (see *The prompt registry* below). The page is a committed
  viewer, republished with the others.
- **The SSH access ledger** (`ssh.html` + `ssh-sessions.json` +
  `ssh-entries.json`) — every operator SSH session to the router box, on the
  public record with a plain-language "why". Two halves: a standalone box-side
  collector (`deploy/ssh-ledger-collect.sh`, its own 5-minute timer) publishes
  session *metadata* from sshd's journal, and small PR-reviewed entry files
  (`web/status/ssh/*.json`) carry the human reason. See *The SSH access
  ledger* below.
- **The music ledger** (`music.html` + `music.json` + `music-YYYY-MM.json`) —
  what was playing on contributor machines while Eleven was built: the
  soundtrack companion to the prompt ledger. Committed data accrued per
  session by `tools/music-ledger.mjs`, sharded by month (the page loads one
  month at a time) with album art deduped per album and inlined as data URIs.
  See *The music ledger* below.

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 the
pages share one stylesheet, `web/status/status.css` (the design tokens and
common components — tiles, bar lists, notes, footer), and one masthead+nav
builder, `web/status/status-nav.js`: each page ships two empty mounts the
builder fills, grouping the dashboards (Operations / Money / Ledgers / Plan —
collapsed behind a Menu disclosure on narrow screens) and marking the current
page from the URL, so the nav can't drift between pages. (`milestones.html` —
the curated timeline of key moments, each entry deep-linking its ledger
session via `prompts.html#s=<id>` — joined the nav under *Plan* when the nav
was grouped; before that it was a nav-orphaned sub-page of the prompt ledger.
Still hand-appended as moments land, never generated.) Each page keeps only a
small inline `<style>` for its own bits. Both shared files are same-origin in
the status bucket (no third-party request — the privacy line holds), and the
publish script uploads them *before* the pages so a page is never served
ahead of its styles or its nav.

## The privacy rules (non-negotiable)

Everything published is public, forever (the bucket is versioned). The
aggregation boundary lives in the generator and holds by construction:

- **Fleet: aggregates only.** Counts, version strings (already public via the
  signed release feed), booleans. **Never a hostname** — member spaces
  obviously, but also `test-*` canaries; the simple rule is *no hostnames at
  all*. Never per-space member/message counts, never anything keyed by space.
- **Capacity + hosts: abstract band words only** (Paul's call: bucketed words,
  not needles). `capacity.level` is `plenty` / `getting-full` / `at-capacity`;
  a box's `load` is `comfortable` / `busy` / `critical`. **Never** remaining
  slot counts, free GB, percentages, or cap values — the caps and ratios are
  read internally to pick the word and die in the generator. Host labels are
  whimsical name-action pairs hashed from the machine id (`ada-stargazing`) — stable per box; never peer URLs, IPs, hostnames, or the machine id itself.
  A box's optional `region` is coarse by design: an operator-set continent +
  country pair (e.g. `EU · Germany`) — never a city, datacenter, IP, or
  hostname; unset, the field is omitted (never guessed). The local box (the
  one running the publisher) reads `STATUS_BOX_REGION`; a mesh peer publishes
  its own via `LCHAT_BOX_REGION` (set in its own `/data/router.env`, read by
  the router binary itself) — two different env vars on two different code
  paths, since a peer's region rides the mesh stats side-channel, never the
  publisher script.
- **Books: aggregated income only** (decision #19 in #550: income publishes
  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
  exchange rate to misstate). Still never account ids, invoice ids, workspace
  ids, or resource-level lines — provider word + amount + currency is the
  entire schema, and the contract test allowlists it. A provider that can't
  be honestly resolved is omitted, never zeroed or guessed. Disbursements
  belong to the future ledger repo, not these dashboards.
- **Contributors: public GitHub identity only, owned by its subject.** Login,
  display name, avatar, profile link, commit count — all already public on
  GitHub — plus a bio the contributor writes. Never emails (the contract test
  greps), never private activity. Anyone listed can hide themselves with
  `"hidden": true` in the committed JSON.
- **Carbon: aggregate token counts and kg estimates only.** Never per-session
  data, never prompt or transcript content in *this feed* — the tally reads
  token *usage numbers* from local transcripts and publishes only their sums.
  (Transcript *content* publishes only through the prompt registry, below,
  under its own explicitly different bar.)
- **The prompt registry publishes transcripts on purpose — its bar is
  credentials-only** (Paul's decision, 2026-07-12; it supersedes the
  no-hostnames rule for this feed alone). Transcripts are a contributor's own
  working record, published *whole* as provenance: hostnames and ops narrative
  stay (instance hostnames are already public record via Certificate
  Transparency logs), while every secret, token, key, and the gitignored
  CLAUDE.local.md runbook are redacted with an in-place annotation saying why.
  The build is fail-closed: `tools/prompt-registry.mjs` re-scans its own
  output with the same detectors and refuses to emit the corpus on any hit.
  Publishing a machine's registry is **that contributor's choice** — the tool
  only reads the machine it runs on, and nothing here auto-collects anyone's
  transcripts. Each contributor also keeps a **small personal redaction
  budget** (`--personal <file>`: literal strings or `/regex/`, one per line —
  a `/regex/` line also matches itself literally, so a path-shaped literal is
  never silently reinterpreted as a pattern):
  things removed for personal reasons, no justification owed. They publish as
  `⟦redacted: personal⟧` and are **counted per session like every other
  category** — the public count is the budget's honesty mechanism; a row with
  three personal redactions reads very differently from one with three
  hundred.
- **Sessions are opt-in by default, with two session-level escape hatches**
  (`--sessions <file>`): `withhold <id> <reason>` publishes a **tombstone**
  (date, prompt count, reason — no content), `omit <id>` leaves only the
  aggregate omitted count. Reasons vocabulary: `personal-safety` (sharing
  would cause or attract harm to a person), `security` (would reveal secrets
  whose exposure could cause harm, incl. national security), `third-party`
  (the session centres on an identifiable person who didn't opt into the
  ledger — the blanket email redaction hides addresses, but a session *about*
  someone withholds whole), `beyond-intent` (consequences beyond revealing
  the human's intent — the thing transparency is for). **One case is mandatory: centralised-service security.** A session
  whose content could compromise the push relay, home, cloud/billing,
  connect, the tunnel gateway, or the router box is withheld *whole* —
  credential redaction is not enough when the narrative itself maps the
  defences (where key material lives, how backups restore, what the sandbox
  permits). Sweep before every accrual and review the hits:

  ```sh
  find ~/.claude/projects -maxdepth 2 -name '*.jsonl' -path '*<repo>*' -print0 |
    while IFS= read -r -d '' f; do
      n=$(grep -c -E 'AGE-SECRET-KEY|secrets-vault|sops -d |vault-bundle|/data/push\.env|LCHAT_APNS_KEY|control\.token|litestream\.env|sk_live_|whsec_|LCHAT_STRIPE_SECRET|/data/acme|LCHAT_DNSIMPLE_TOKEN|LoadCredential' "$f")
      [ "${n:-0}" -ge 3 ] && echo "withhold $(basename "$f" .jsonl) security"
    done >> <your --sessions file>
  ```

  The ≥3 threshold is deliberately conservative — over-withholding is
  recoverable (drop the line, rebuild), publication to a versioned public
  bucket is not.
- **Security: status + count, never a name — the disclosure line.** The
  ledger publishes posture: dependency findings ship as `clean` or "N
  advisories under remediation" — **never** the CVE/GHSA/OSV ids, affected
  packages, versions, or code locations of anything *unpatched* (naming an
  open hole is a responsible-disclosure violation and an attacker roadmap;
  once patched it's history and may be named, e.g. in a changelog). No secret
  inventory, no internal component versions beyond the build sha the signed
  release feed already publishes, and nothing that locates a box — hardening
  publishes as facts ("firewall default-deny", "SSH key-only"), never
  addresses. The full govulncheck report stays on the operator's terminal;
  the summarizer refuses to write an output containing an id-shaped string,
  and the contract test greps for every id grammar.
- **The SSH access ledger: metadata only, published on close.** Who (operator
  handle via public-key fingerprint — public key material, safe to publish),
  when, connection count, and a wireguard-or-public source word. **Never
  commands, command output, or session content** — the collector doesn't even
  read them — and client IPs are read only to pick the source word, dying in
  the collector like the capacity numbers do. Rows publish only after a
  session has been quiet ≥20 minutes, so the page is never a live
  "operator connected right now" beacon. The human why-entries hold the
  prompt registry's redaction bar: what, not how; never credentials or
  emails; security-sensitive sessions publish as `(withheld: security)`
  rather than vanishing (see *The SSH access ledger* below).
- Adding a field? It must be an aggregate over the whole fleet / all payers,
  or it doesn't ship (the prompt registry, a whole-transcript feed by design,
  is the one standing exception). The `--dry-run` self-check and the contract
  test both fail if a fixture hostname or per-payer id reaches the output.

## What's in the JSONs

`fleet.json`:

```json
{
  "generated_at": "2026-07-11T13:00:00Z",
  "instances": { "total": 6, "systemd": 2, "hibernating": 4, "awake": 2, "asleep": 2 },
  "hosts": { "count": 1, "boxes": [ { "label": "ada-stargazing", "region": "EU · Germany", "always_on": 2, "awake": 2, "asleep": 2, "load": "comfortable" } ] },
  "capacity": { "level": "plenty" },
  "versions": [ { "version": "v100", "count": 3 } ],
  "services": { "home": true, "push": true, "cloud": true, "connect": true },
  "replication": { "healthy": true, "lag_seconds": 42 }
}
```

`awake`/`asleep` split the hibernating set by whether each host's unix socket
is currently *bound and listening* (`ss -xlH`) — socket-file existence is not
sound (instances never unlink on exit, so slept hosts leave stale files) and
dialing the socket would touch the instance; the kernel socket table is
side-effect-free. `lag_seconds` approximates replication lag as seconds since
the litestream replicator last logged (null when unknown). Both `awake` and
`asleep` are null if `ss` is unavailable — unknowns publish as null, never as
guesses.

`capacity.level` is the worst of four internally-measured signals, each banded
at the same lines (**plenty** below 70%, **getting-full** 70–90%,
**at-capacity** above 90%): the materialized-instance ratio against
`LCHAT_MAX_INSTANCES`, the registered-space ratio against
`LCHAT_MAX_REGISTERED` (both mirror `controld.go`'s `atCapacity()`, including
its hibernate-creates branch and code defaults — 40 / 5 GB floor / 10×), disk
proximity to the `LCHAT_MIN_FREE_GB` floor (at or below the floor is
at-capacity outright), and memory utilization (`/proc/meminfo`). The caps are
read from `/data/control.env`. A box's `load` word bands memory utilization
the same way (`comfortable` / `busy` / `critical`, null when unknown). Only
the words publish — never the ratios, GB, or caps behind them. `hosts`
aggregates across every live mesh box, not just the one running the
publisher: each member self-publishes its own capacity snapshot as a sibling
object next to its membership heartbeat (a slower, one-minute cadence — this
is visibility, not coordination), and the publisher folds every live peer's
snapshot into the array alongside its own. Each box's own awake/asleep count
is filtered to the hosts it currently owns by the mesh's placement hash, so a
host re-homed to a peer is never reported as "asleep" by more than one box —
`instances.awake`/`asleep` at the top level sum every box's own count, which
is exactly why that filtering matters. Single-box behavior (no live mesh
peer) is unchanged: `hosts.count` stays `1`, byte-identical to before the
mesh existed. Per-box `region` rides the same self-published snapshot
(`LCHAT_BOX_REGION`, see the privacy rules above) — a peer with no region set
simply omits the field, same as the local box's own `STATUS_BOX_REGION` seam.

`books.json` (or `{"available": false, "reason": "…"}` when billing isn't
configured / Stripe is unreachable):

```json
{
  "generated_at": "2026-07-11T13:00:00Z",
  "available": true,
  "currency": "eur",
  "month": "2026-07",
  "subscriptions": { "active": 3, "by_tier": [ { "tier": "friends", "count": 2 } ] },
  "mrr_cents": 1600,
  "collected_month_cents": 1200,
  "connect": { "gmv_month_cents": 1450, "commission_month_cents": 145 },
  "expenses": { "month": "2026-07", "providers": [
    { "provider": "aws", "month_to_date_cents": 1234, "currency": "usd" },
    { "provider": "hetzner", "monthly_cents": 2094, "currency": "eur" },
    { "provider": "anthropic", "month_to_date_cents": 250, "currency": "usd" },
    { "provider": "claude.ai", "to_date_cents": 229000, "currency": "eur" },
    { "provider": "dnsimple", "month_to_date_cents": 2450, "currency": "usd" },
    { "provider": "apple", "yearly_cents": 9900, "currency": "eur" }
  ] }
}
```

MRR sums each active subscription item's `unit_amount × quantity` normalized
to a month (yearly ÷ 12, etc.), EUR items only. Collected-this-month is the
gross of positive `charge`/`payment` balance transactions since the month
started (refunds and payouts excluded — it's gross income, not net cash).

`connect` is spaces selling to their own members through Stripe Connect
(in-space subscriptions charge directly on the space admin's connected
account, with an application fee to the platform — `subscriptions.go`).
Those charges never appear in the two reads above, so the fee list is the
window: `commission_month_cents` sums this month's application-fee amounts
(the platform's actual income from these sales), and `gmv_month_cents` sums
each fee's expanded charge amount — the gross volume the spaces sold. The
GMV is published deliberately (Paul's call: if we know it, it's public) but
it is the spaces' money, not Eleven's — which is why neither figure folds
into `collected_month_cents`. Same gross semantics as the rest: positive
EUR amounts, refunds don't net.

`expenses` is infrastructure spend, itemized per provider — the deliberate
mirror image of income's aggregation (see the privacy rules above). Four
provider shapes exist, exactly one amount key each: metered providers carry
`month_to_date_cents`, flat monthly subscriptions `monthly_cents`, flat
yearly ones `yearly_cents`, and a cumulative operator-attested figure
`to_date_cents` — each as billed, in its own billing currency (a yearly fee
is published yearly, never divided into a fake monthly; a lifetime figure is
published to date, never smeared into months).

- **aws** — month-to-date UnblendedCost from Cost Explorer (a us-east-1
  global endpoint, regardless of where the bucket lives). CE bills ~$0.01
  per request and the publisher runs every 5 minutes, so the result is
  cached in `$DATA_DIR/status-costs-cache.json` (per-provider entries with
  `fetched_at`) and refetched only when older than
  `STATUS_COSTS_MAX_AGE_HOURS` (default 6) or from a previous month. A
  failed refetch serves the same-month stale cache; failure with no cache
  omits the entry.
- **hetzner** — the flat-rate Hetzner Cloud servers. Hetzner has no billing
  API, but the servers API prices each server: with `STATUS_HETZNER_TOKEN`
  (a **read-only** Cloud API token) the generator computes the account's
  monthly server cost from `GET /v1/servers` (pagination followed via
  `meta.pagination.next_page`) — each server's embedded
  `server_type.prices[]` entry for its own location, `price_monthly.gross`
  summed, rounded once at the total. The prices carry no currency field, so
  the published `eur` is the documented constant (Hetzner bills in EUR).
  The figure is **server compute only** — primary IPs, backups, volumes,
  and traffic overages price via the separate `/pricing` endpoint and are
  deliberately not included. Same cache treatment as AWS but with 24h
  default freshness (a flat price list changes ~never;
  `STATUS_COSTS_MAX_AGE_HOURS` still overrides). Exactly one price entry
  must match per server and every amount must parse cleanly, or the fetch
  fails — and a failed figure is omitted, never misstated. The operator-set
  constant `STATUS_HETZNER_MONTHLY_CENTS` (EUR) **overrides the API** when
  set — operator truth wins; neither set omits the provider.
- **anthropic** — the bot sidecars' API spend: month-to-date from the Admin
  API cost report (`GET /v1/organizations/cost_report`, daily buckets summed;
  amounts arrive as decimal cents, USD). Authenticated with
  `STATUS_ANTHROPIC_ADMIN_KEY` — an *Admin* key with read-only usage scope,
  not the sidecars' completion key. Same cache treatment as AWS.
- **claude.ai** — the claude.ai subscriptions: no API for them, and the
  honest figure the operator can attest is **lifetime spend**, not a
  monthly — so the operator-set constant `STATUS_CLAUDE_TO_DATE_CENTS`
  (EUR) publishes as `to_date_cents`, the cumulative shape. It's an
  estimate by nature, which is why the page labels it
  "claude.ai · to date (approx.)".
- **dnsimple** — the product domains (elevenmessenger.com, 11ch.at,
  11factor.org): month-to-date from the billing-charges API
  (`GET https://api.dnsimple.com/v2/{account}/billing/charges` with
  `start_date`/`end_date`; `collected` charges summed — refunds are not
  spend). Amounts arrive as decimal dollar strings with **no currency field**,
  so the published `usd` is the documented constant (DNSimple's price book is
  USD), not parsed. Authenticated with `STATUS_DNSIMPLE_TOKEN` +
  `STATUS_DNSIMPLE_ACCOUNT` — an account API token, read-only use here, never
  echoed. Same cache treatment as AWS; more than one page of charges in a
  month counts as failure (a partial sum never publishes).
- **apple** — the Apple Developer Program membership: fixed yearly with no
  billing API, so the operator-set constant `STATUS_APPLE_YEARLY_CENTS`
  (EUR), published as billed (`yearly_cents`) — never divided into a fake
  monthly.

No providers → the whole `expenses` object is omitted. Expenses ride the
degraded income-unavailable shape too — a Stripe outage doesn't hide spend
the generator can still honestly state.

`carbon.json` (committed at `web/status/carbon.json`, republished as-is): an
`entries[]` ledger of measurement periods — `period_start`/`period_end`,
`source` (which contributor machine, as an anonymous name-pair label — same
no-hostnames rule as the fleet's box labels), `basis`
(`measured` | `extrapolated`), `sessions`, `output_tokens`,
`fresh_input_tokens`, `cache_read_tokens`, `kg_low`/`kg_central`/`kg_high` —
plus the conversion `factors_mg_co2e_per_output_token`, a `totals` block, and
a `not_counted` honesty string. The contract test pins the arithmetic: totals
must equal the ledger sum, each entry's kg must derive from its tokens × the
factors, and each source's periods must be chronological and non-overlapping.

## Refreshing the contributors index

`node tools/contributors.mjs --write` (needs `gh` authenticated) refreshes
commit counts, avatars (embedded as data URIs — the pages allow no external
hosts), and profile links, preserving `bio`/`name`/`hidden`. Run it when the
index looks stale — after a burst of merges, or when a new contributor lands —
commit, and copy `web/status/contributors.{html,json}` to the box's
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
contributor's machine**, and those are **pruned after roughly a week** — a
missed week is lost to the ledger and has to be extrapolated by hand (like
the tally's first entry). Every contributor machine accrues its own entries
under a stable anonymous `source` label (hashed from user@host; the tool
prints yours; override with `LCHAT_CARBON_SOURCE`).

**The nudge is automatic.** A `SessionStart` hook in `.claude/settings.json`
runs `node tools/carbon-tally.mjs check` (fast — no transcript sweep): when
this machine's newest entry is older than 3 days (`LCHAT_CARBON_STALE_DAYS`),
the session is told to accrue. The accrual itself:

1. `node tools/carbon-tally.mjs --write` (from the repo root) — sweeps this
   machine's transcripts and appends a `measured` entry. Cutoffs are
   **per-source** and consult both the working tree and `origin/main`, so
   repeated runs and parallel machines never double-count.
2. Commit the updated `web/status/carbon.json` — prefer a tiny standalone PR,
   merged promptly: two long-lived branches accruing the same window is the
   one race the cutoffs can't see, and the contract test fails main on the
   overlap (fix: drop the younger duplicate entry, re-run `--write`).
3. `./deploy/ops pages push` — copies the committed `carbon.json` to the box's
   `/opt/lchat/status-pages/` (add `carbon.html` as an argument if the page
   changed); the next 5-minute publish uploads it.

The conversion factors live in the JSON so a recalibration (better public
data) is one committed change; if the factors change, recompute the whole
ledger's kg columns rather than mixing eras (`docs/comparison-carbon.md`
carries the sources and the "as of" date).

## The prompt registry

The provenance record of AI-assisted work: whole Claude Code session
transcripts, published at `prompts.html` with credentials redacted and every
redaction annotated in place (the redaction bar and its rationale: *The
privacy rules* above). Like the carbon tally, the raw material is the local
transcripts that **prune after roughly a week** — accrue before they age out
or the sessions are lost to the registry.

Where it stands today: **Paul's sessions, published by his own choice.** The
direction is that every contribution built with AI includes its prompt
transcript, but that becomes a *requirement* on other contributors only once
agreed with them — don't encode it as a rule anywhere until that conversation
has happened.

**Bucket layout is per-contributor** — corpora can never clobber each other:

```
prompts/contributors.json        ← the manifest: [{ slug, name }] the page merges
prompts/<slug>/index.json        ← one prefix per contributor (slug = kebab-cased
prompts/<slug>/prompts.json        --contributor name); everything the tool
prompts/<slug>/issues.json         builds lands under the contributor's own slug
prompts/<slug>/<session>.jsonl
prompts/<slug>/<session>.sum.json
```

The tool merges this machine's entry into the *live* manifest (a public read —
no creds needed) before writing contributors.json, so uploading it never drops
other contributors. If it prints the OFFLINE warning, don't upload the
manifest from that build — re-run online.

**How another contributor joins** (their choice, per the policy above): run
the tool on their machine with `--contributor "Their Name"` (plus their own
`--personal` / `--sessions` files and the security sweep — same rules for
everyone), then publish their prefix one of two ways:

- **With bucket creds** (mint a scoped IAM key allowing PutObject on
  `prompts/<their-slug>/*` + `prompts/contributors.json` only): run the two
  `aws s3` commands the tool prints — `sync` of their own prefix, `cp` of the
  manifest.
- **Without creds**: `tar czf corpus.tgz -C <out>/prompts <slug>
  contributors.json` and hand the tarball to an operator, who extracts and
  runs the same two commands from the router box (`set -a; . /data/status.env;
  set +a` for the creds). The operator never edits the corpus — the
  contributor's build is what publishes, fail-closed scan and all.

**The nudge is automatic here too.** The same `SessionStart` hook runs
`node tools/prompt-registry.mjs check` (fast — reads the last build's
`generated_at`, no sweep or redaction): when this machine's corpus is older
than 3 days (`LCHAT_REGISTRY_STALE_DAYS`) *and* transcripts have been touched
since, the session is told to accrue. A machine that has never built a corpus
at the default `--out` (`~/.cache/eleven-registry`) hears nothing — publishing
is opt-in (see above), so the nudge follows that choice, never precedes it.
The check can't run the accrual itself: the security sweep and the withhold
decisions are judgment calls, so a session acts on the nudge the way it acts
on the carbon one.

Accruing a machine's sessions:

1. `node tools/prompt-registry.mjs --out <dir>` (from the repo root) — sweeps
   this machine's transcripts for this repo (worktree sessions included),
   redacts, and writes `<dir>/prompts/` (the redacted JSONL corpus +
   `index.json`). The build is **fail-closed**: outputs are re-scanned with
   the same detectors and the corpus is not emitted on any hit. Publishing is
   whole-corpus-per-machine for now; per-session accrual can come later if
   two machines ever need to merge into one index.
2. **Summarise new sessions before publishing — dispatch haiku subagents.**
   A keyless build carries prior summaries/gists forward but leaves *new*
   sessions bare (the index falls back to first prompts), so don't upload
   straight from step 1. Fan haiku subagents out over the **redacted** build
   output only — never the raw transcripts: one-line session summaries from
   each new session's prompts (≤90 chars, ending with the session's most
   relevant GitHub URL — THE SUMMARY RULE in the tool's header), and ≤80-char
   reply gists for replies missing from `<id>.sum.json` (keys are
   `parsedLine:block`, reconstructable from the redacted JSONL: assistant text
   blocks >200 chars). Write the results into the out dir (`index.json`
   `summary` fields + merged `<id>.sum.json`), then **re-run step 1**: the
   rebuild carries them through today's redaction rules and the fail-closed
   verifier, and hashes exactly what publishes.
3. Upload the corpus straight to the status bucket (it's far too large to
   commit — a quarter GB and growing). The tool prints the exact commands:
   `aws s3 sync <dir>/prompts/<slug> s3://eleven-status-<account>/prompts/<slug>
   --delete --cache-control max-age=300`, then `aws s3 cp` the manifest (run
   on the box with the status-publisher creds — `set -a; . /data/status.env;
   set +a` — or anywhere with equivalent PutObject rights). Sync **only your
   own prefix**; the manifest upload is a `cp`, never a sync with `--delete`
   at the `prompts/` root, which would erase other contributors.
4. Commit the hash ledger entry (the tool prints the exact command): the
   corpus's `manifest.sha256.json` goes to the **public**
   `elevenmessenger/releases` repo at `prompt-registry/<slug>.json` via
   `gh api -X PUT`. Its git history is the registry's tamper-evidence — a
   few KB per accrual, and any after-the-fact change to a published
   transcript shows as a dated hash change. (The manifest also uploads with
   the corpus, so the bucket is self-describing.)
5. `prompts.html` itself is a committed viewer page: copy it to the box's
   `/opt/lchat/status-pages/` like the other pages; the 5-minute publish
   re-uploads it.

## The SSH access ledger

Every operator SSH session to the router box, on the public record with a
plain-language reason — the prompt registry's idea applied to hands-on-the-box
access, prompted by the WireGuard hardening (public port 22 closed; operators
connect over the wg admin tunnel, `172.22.22.0/24`). Two halves:

- **Automatic truth — the box-side collector.** `deploy/ssh-ledger-collect.sh`
  (installed at exactly `/opt/lchat/ssh-ledger-collect.sh`, run every 5 minutes
  by `lchat-ssh-ledger.timer`) reads *new* sshd journal events via a persisted
  cursor (`/data/ssh-ledger/` — survives journald rotation), parses accepted
  publickey logins, and groups them into sessions per (user, key fingerprint):
  a reconnect within 15 minutes joins the session, and a session publishes
  only after 20 quiet minutes — **publish-on-close**, so the page never
  advertises a live connection. Rows append to the cumulative
  `ssh-sessions.json` (`{id, operator, fingerprint, start, end, connections,
  source}`; `source` is `wireguard` or `public`), deduped on id across
  re-runs. Operator handles come from the committed fingerprint map
  `web/status/ssh-operators.json`; an unmapped fingerprint publishes as
  `unknown key` — deliberately render-worthy, an unexplained key is the
  signal the page exists to surface. The collector is **deliberately
  standalone** from `deploy/status-publish.sh` (it uploads its own outputs with
  the same status-publisher creds, `/data/status.env`, on its own timer — so a
  publish-script change never gates the ledger).
- **The human "why" — PR-reviewed entry files.** One JSON per entry in
  `web/status/ssh/` (changelog-style — parallel PRs never conflict):
  `{date, operator, window, why, refs}`. **The convention: any session,
  Claude or human, that SSHes to the box adds an entry in the same or an
  adjacent PR.** PR review is the redaction gate, holding the prompt
  registry's bar: summaries are *what, not how*; never credentials or emails;
  a security-sensitive session publishes as `"why": "(withheld: security)"`
  rather than vanishing. Format + full convention: `web/status/ssh/README.md`.
  The entries land on the box via the normal viewer-page scp step, and the
  collector merges them into one `ssh-entries.json` so the page fetches
  exactly two JSONs.

`ssh.html` joins the halves client-side — same operator + same UTC date,
preferring an overlapping (else nearest) time window; one entry may cover
several sessions. **A session with no matching entry renders loudly as "no
summary recorded"** — not an error, the enforcement: unexplained access should
look unexplained. The page also carries the honest caveat in its policy card:
the box publishes its own ledger, so this is transparency, not tamper-proof
forensics (the versioned bucket keeps history of after-the-fact edits, but a
root attacker could stop or falsify the collector).

Deploying it (the box side; needs `jq` — `apt install jq`):

1. **The collector, its units, and the viewer page ride the binary.** `lchat
   bootstrap` (fresh box) or `sudo -n lchat bootstrap --collectors-only`
   (refresh over WireGuard — no root shell) writes
   `/opt/lchat/ssh-ledger-collect.sh`, its `lchat-ssh-ledger.{service,timer}`
   units, and the viewer `ssh.html` into `/opt/lchat/status-pages/`,
   `daemon-reload`s, and enables the timer. There is no root scp — the hardened
   box has no root shell to copy files in, so the install path is the binary
   itself. (`--collectors-only` skips the router entirely, so a refresh causes
   no member-facing blip.)
2. **Creds**: the collector uploads with the status-publisher creds in
   `/data/status.env` (same key the status publisher uses) — see *Install
   (router box)*.
3. **The operator data JSON is NOT embedded** (it changes independently of the
   binary): the fingerprint map `ssh-operators.json` and the PR-reviewed
   why-entries (`web/status/ssh/*.json`) are collector/operator-produced and
   land in `/opt/lchat/status-pages/` (and `…/ssh/`) via the operator's normal
   status-page copy, not `bootstrap`. A session published before its entry
   arrives shows "no summary recorded", and an unmapped fingerprint publishes
   as `unknown key`, until the map/entry lands and the next collect pass runs.
4. **Verify**: `systemctl list-timers lchat-ssh-ledger.timer`,
   `journalctl -u lchat-ssh-ledger -n 20`, then load `ssh.html` (rows appear
   only once a session has closed and gone 20 minutes quiet).

A new operator key = add its fingerprint to `web/status/ssh-operators.json` by
PR *and* re-scp it, or their sessions publish as `unknown key`. Rotating the
box's SSH setup doesn't touch the ledger — it only reads the journal.

## The music ledger

🤖 What was playing on contributor machines while Eleven was built — the
soundtrack companion to the prompt ledger (`music.html` +
`music.json`, committed data like the carbon tally). A session logs
Apple Music locally (one TSV row per track change, sampled every 20s), and
the contributor accrues it with:

```sh
node tools/music-ledger.mjs <session.tsv> --listener "Full Name"
```

The tool filters skips (a track must hold ≥60s to publish), resolves
song.link/album.link ids once via the iTunes Search API, and inlines album
art as data URIs — the page makes **no external requests** (same rule as
contributor avatars). Rows are attributed by the `ssh-operators.json`
naming, and each carries an honest choice word: a queue or radio names
itself; a bare catalog stream carries no signal because Music's scripting
interface can't distinguish a direct pick from autoplay. Publishing is
deliberate: run the tool, review the JSON diff (it's your music
history), commit by PR.

🤖 **Streaming (issue #1224).** `--stream` skips the release train entirely:
each track appends as a hash-chained entry to the `ledgers/music/` stream in
the status bucket (`tools/ledgerchain.mjs` is the chain spec,
`tools/ledger-verify.mjs music` walks and re-hashes it — no credentials
needed, the whole chain is public-read), album art goes up once per album as
a content-addressed blob, and the derived views the page reads
(`ledgers/music/music.json` + month shards) rebuild in place — live in
seconds. The page prefers the stream and falls back to the committed files.
Writes use the scoped `status-ledger-writer` key (`infra/prod/status.tf`:
PutObject under `ledgers/` only, no deletes — tamper-EVIDENT via the chain,
matching the SSH ledger's honesty about what it can prove). The generic
appender is `tools/ledger-append.mjs`; any future ledger streams the same
way.

## The fleet hardening dashboard

🤖 Per box in the mesh, whether SSH is locked down — on the public record
(`hardening.html` + `hardening.json`), the access ledger's transparency idea
one level down at the machines themselves. Prompted by the fleet growing a
second mesh box that could still have public SSH / root login open: an
unhardened box must be *visible*, not buried in an operator's head. Two halves,
mirroring the ledger's automatic-truth split:

- **The local self-read (authoritative).** `deploy/hardening-posture.sh`
  (installed at exactly `/opt/lchat/hardening-posture.sh`, run every 5 minutes
  by `lchat-hardening.timer`, as root) reads the **local** box's own posture
  from its OS: `ufw status` → `firewall_ssh_scope` (`wireguard-only` when port
  22 is allowed only over `wg0` and there is no bare public rule /
  `internet-facing` the moment a bare `22/tcp … ALLOW … Anywhere` rule exists /
  `unknown` when ufw is inactive or unreadable), `sshd -T` → `root_login` +
  `password_auth`, `systemctl is-active unattended-upgrades` → `auto_updates`,
  `ip link show wg0` → `wireguard`, and the ufw first line → `firewall_active`.
  A box is the truth about *itself*.
- **The peer `:22` probe (a vantage-dependent hint).** The same run enumerates
  the mesh — membership objects in the hibernation bucket
  (`LCHAT_HIBERNATE_BUCKET`/`_PREFIX`/`_REGION` from `/data/router.env`, listed
  under `<prefix>/mesh/members/`, joined to `<prefix>/mesh/members-stats/` for
  each box's whimsical `label` + coarse `region`, exactly as
  `status-publish.sh`'s `mesh_peer_boxes` reads them) — and probes each **peer**
  box's port 22 from the outside: a connect-and-banner-grab, never a login.
  Because an external probe only sees the fleet from the probing box's vantage
  (a firewall may admit one box and deny another), it is read conservatively:
  only a returned `SSH-…` banner is a confident **exposed**; a clean refusal or
  a connect timeout is **not reachable**; a completed connection with no SSH
  banner — or anything that isn't a clean refusal — is **inconclusive**, a warn
  state, never a pass. The self box uses its *local* read (it never self-probes
  `:22` — that would hairpin through its own NAT).

`hardening.json` is `{generated, boxes:[…]}`, boxes **sorted problems-first**
(internet-facing, then unknown, then hardened), each `{label, region, is_self,
ssh_internet_facing, detail, …sub-states…, source}`. **No hostnames ever** — a
peer's real host is read only to point the probe and dies in the process; a box
is named by the same whimsical `label` the Fleet page shows.

**The contract (enforced page-wide): unknown is loud, never a guessed pass.** A
signal the collector can't read is `"unknown"` (or a JSON `null`
`ssh_internet_facing`) and `hardening.html` renders it as a bordered warn chip —
an unreadable firewall or an un-self-reported peer must look *unresolved*, not
green. **The honest caveat** (in the page's policy card, the same framing as
`ssh.html`): the box publishes its own report, so this is transparency, not
tamper-proof forensics; the outside `:22` probe is a best-effort hint, and the
definitive answer for any box is that box self-reading its own ufw once it runs
the collector. The bucket is versioned, so after-the-fact edits leave history.

Deploying it (the box side; needs `jq` — `apt install jq`):

1. **The collector, its units, and the viewer page ride the binary.** `lchat
   bootstrap` (fresh box) or `sudo -n lchat bootstrap --collectors-only`
   (refresh over WireGuard — no root shell) writes
   `/opt/lchat/hardening-posture.sh`, its `lchat-hardening.{service,timer}`
   units, and `hardening.html` into `/opt/lchat/status-pages/`,
   `daemon-reload`s, and enables the timer. There is no root scp — the hardened
   box has no root shell to copy files in.
2. **Creds**: the collector reads mesh membership and uploads with the
   status-publisher creds in `/data/status.env` (those need read access to the
   hibernation bucket — the same read the Fleet page relies on).
3. `hardening.json` is **generated on the box** by the collector each pass —
   never embedded; only the viewer `hardening.html` rides the binary (the
   shared `status.css` too, published alongside).
4. **Verify**: `systemctl list-timers lchat-hardening.timer`,
   `journalctl -u lchat-hardening -n 20`, then load `hardening.html`.

Test mode is documented in the script header: point every input at a fixture
(`HARDENING_UFW_CMD`, `HARDENING_SSHD_CMD`, `HARDENING_SYSTEMCTL_CMD`,
`HARDENING_IP_CMD`, `HARDENING_ROUTER_ENV`, `HARDENING_MEMBERS_FILE`), pin
`HARDENING_NOW`, set `HARDENING_NO_UPLOAD=1`, and it builds `hardening.json`
without touching the box, S3, or the network — no `:22` probe, no upload.

## Install (router box)

1. **Apply the infra** (deliberate, named — not yet applied):
   `AWS_PROFILE=… tofu -chdir=infra/prod apply`, then mint the publisher key:
   `aws iam create-access-key --user-name status-publisher` (keys never land
   in tofu state).
2. **Creds on the box**: write `/data/status.env` with
   `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…` (the status-publisher
   key also covers the Cost Explorer read), plus the optional expense vars:
   `STATUS_HETZNER_TOKEN=…` (a Hetzner Cloud API token minted **read-only**
   in the project's Security → API tokens — it only lists servers here, and
   is never echoed; store it in 1Password like the other per-service
   tokens) and/or `STATUS_HETZNER_MONTHLY_CENTS=…` (operator constant —
   overrides the token's API-computed figure when both are set),
   `STATUS_CLAUDE_TO_DATE_CENTS=…` (cumulative lifetime claude.ai spend —
   an operator-attested estimate, labelled "(approx.)" on the page),
   `STATUS_APPLE_YEARLY_CENTS=…`, `STATUS_ANTHROPIC_ADMIN_KEY=…` (an
   `sk-ant-admin…` key with read-only usage scope — never the sidecars'
   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 + the publish script on the box**: the viewer `.html` pages, the
   shared `status.css`, the static page scripts (`waterfall.js`), AND the main
   `status-publish.sh` itself all ride the binary — `lchat bootstrap` (or
   `bootstrap --collectors-only`) writes them into `/opt/lchat/status-pages/`
   (pages) and `/opt/lchat/status-publish.sh` (the script), so there's no scp
   for them, and the script's per-page upload set can't drift stale on the box
   (that drift is how funding + roadmap uploads went missing — fixed by
   embedding the script alongside the other collectors, 2026-07-20). So a new
   page = add the `.html` to `web/status/` + its `upload` line in
   `deploy/status-publish.sh`, and a release carrying both + a
   `bootstrap --collectors-only` is the whole deploy. Only the data JSON + icon
   still copy in (`economics.json`, `funding.json`, `carbon.json`,
   `contributors.json`, `security.json`, `icon-192.png`) — those are
   generated/asset files, deliberately not embedded. Post-hardening note for
   those copies: the dir is group-writable for `lchat-operators`, but the
   *files* in it aren't — replace by scp-ing to `<name>.new` and `mv`-ing over
   (directory write allows the rename). `status.css` is the shared stylesheet
   every page links (uploaded before the pages by the publish script).
4. **Install the timer** (first stand-up only — the script itself now rides the
   binary, so this is just its systemd unit): `/opt/lchat/status-publish.sh
   install` (run once via a root path at bootstrap). The unit runs as the
   `lchat` user (least privilege: registry + cloud env are lchat-owned;
   `systemctl is-active` / `ss` are unprivileged; the journal read rides
   `SupplementaryGroups=systemd-journal`).
5. **Verify**: `systemctl list-timers lchat-status.timer`,
   `journalctl -u lchat-status -n 20`, then load the page.

The dashboards live at
`https://eleven-status-<account-id>.s3.<region>.amazonaws.com/index.html`
(the `status_url` tofu output; account `427880590669`, region `eu-west-1` →
`https://eleven-status-427880590669.s3.eu-west-1.amazonaws.com/index.html`,
books at `…/books.html`). A friendlier hostname (e.g.
`status.elevenmessenger.com` via CloudFront or a CNAME-compatible front) is
deliberately later work — the pages use relative URLs so they work at any
origin unchanged.

## Testing

- `./deploy/status-publish.sh --dry-run <dir>` builds both JSONs from
  self-constructed fake registry/Stripe/provider-billing fixtures (exercising
  pagination and the awake/asleep split) and fails on invalid JSON or a
  privacy leak — including any digit inside the capacity subtree, or a
  fixture workspace/request id surviving into the expenses. No box state, no
  network, no upload. `STATUS_DRY_CAPACITY=getting-full|at-capacity` swaps in
  the capacity fixture set for that band (default: plenty).
  `STATUS_DRY_EXPENSES=omit` removes every expense source (asserts the whole
  object is omitted); `STATUS_DRY_EXPENSES=stale-cache` fails all four API
  fetches against a seeded, past-max-age same-month cache (asserts the stale
  figures serve) — so the cache seams are testable end-to-end. The default
  run computes hetzner from a two-page servers fixture (asserts pagination,
  the per-location price match, and the cents rounding);
  `STATUS_DRY_EXPENSES=hetzner-constant` sets the operator constant with the
  fixture still resolvable (asserts the constant wins and the API is never
  called).
- `./deploy/security-scan.sh` (run at build time, alongside
  `deploy/build-linux.sh`) builds and scans the shipped linux binary with
  govulncheck (`-mode=binary`; installed on the fly if absent) and writes the
  **redacted** `web/status/security.json` — commit it with the release. Full
  findings print to the terminal only. `--dry-run <dir>` folds a
  self-constructed govulncheck fixture (fake GO-/CVE-/GHSA- ids, a
  module-level informational that must not count) into `<dir>/security.json`
  and fails on any id or package path surviving — regenerate the committed
  test fixture with `./deploy/security-scan.sh --dry-run test/status-fixtures`.
- `node test/status-contract.test.mjs` (in `npm test`) pins the pages' FIELDS
  manifests against the dry-run fixtures in `test/status-fixtures/` — if the
  generator's shape and the pages drift, it fails. It also allowlists the
  hosts/capacity schema **and** the expenses schema (any extra key fails, so
  a precise number or an account id can't ship), pins one fleet fixture per
  capacity band (`fleet-getting-full.json`, `fleet-at-capacity.json`), pins
  expense omission (`books-no-expenses.json`), and pins hetzner's
  API-computed cents + constant override (`books.json` /
  `books-hetzner-constant.json`). Regenerate fixtures from a fresh dry-run
  after changing the generator (the per-band ones via `STATUS_DRY_CAPACITY`,
  the no-expenses one via `STATUS_DRY_EXPENSES=omit`, the hetzner-constant
  one via `STATUS_DRY_EXPENSES=hetzner-constant`). For the security ledger it
  allowlists the hygiene schema (status word + integer counts + ISO timestamp
  + build sha, nothing else), requires status and count to agree, and greps
  both the dry-run fixture and the committed `web/status/security.json` for
  advisory ids in every grammar (`GO-`, `CVE-`, `GHSA-`) — the redaction
  guard.
