eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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:

{
  "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):

{
  "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-reloads, 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:

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-reloads, 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.