eleven / ocho Public

status: public SSH access ledger β€” ssh.html + box collector (#884)

* deploy: box-side SSH ledger collector + 5-minute systemd timer

Reads new sshd journal events via a persisted cursor, groups accepted
publickey connections into per-(user, fingerprint) sessions (15-min join
window), and publishes only sessions quiet >= 20 min β€” metadata only, never
commands or content. Deliberately standalone from status-publish.sh (the
box's copy has diverged); uploads its own outputs with the same
status-publisher creds. Documented test mode exercised against a fixture
journal (grouping, join edge, emission, dedupe-on-replay, corrupt-ledger and
malformed-entry guards).

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

* status: public SSH access ledger page, operator map, why-entries + docs

ssh.html joins the collector's ssh-sessions.json with the PR-reviewed
why-entries (web/status/ssh/*.json, merged to ssh-entries.json on the box);
a session with no entry renders loudly as "no summary recorded".
Operator handles come from the committed fingerprint map
web/status/ssh-operators.json. Adds the Access Ledger nav item to all
status pages, the docs/status.md section (convention + redaction bar +
deploy steps), the seed wireguard-rollout entry, and the changelog note.

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

* status: point the seed why-entry at the real PR number (#884)

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6a1a60b71047f29b936201f69262ada01b642d2a parent 736e5c2
17 files changed, +721 −8
  • changelog/2026-07-14-ssh-access-ledger.md +8 −0
  • deploy/ssh-ledger-collect.sh +205 −0
  • deploy/systemd/lchat-ssh-ledger.service +38 −0
  • deploy/systemd/lchat-ssh-ledger.timer +12 −0
  • docs/status.md +87 −3
  • web/status/books.html +1 −0
  • web/status/carbon.html +1 −0
  • web/status/contributors.html +1 −0
  • web/status/economics.html +1 −0
  • web/status/index.html +1 −0
  • web/status/prompts.html +2 −1
  • web/status/security.html +1 −0
  • web/status/ssh-operators.json +5 −0
  • web/status/ssh.html +294 −0
  • web/status/ssh/2026-07-14-wireguard-rollout.json +7 −0
  • web/status/ssh/README.md +53 −0
  • web/status/status.css +4 −4
diff --git a/changelog/2026-07-14-ssh-access-ledger.md b/changelog/2026-07-14-ssh-access-ledger.md
new file mode 100644
index 0000000..3b1a60f
--- /dev/null
+++ b/changelog/2026-07-14-ssh-access-ledger.md
@@ -0,0 +1,8 @@
+πŸ€– Operator access to the server is now on the public record. Whenever one of
+us signs in to the machine that runs Eleven, that session appears on the
+public status site β€” who it was, when, and a plain-language reason why β€”
+at status.elevenmessenger.com/ssh.html. Only the fact of the access is
+published, never what was typed or seen, and a session with no reason given
+is flagged loudly rather than hidden. It's the same idea as the prompt
+ledger: if someone's hands were on the infrastructure your messages pass
+through, you should be able to see that they were, and why.
diff --git a/deploy/ssh-ledger-collect.sh b/deploy/ssh-ledger-collect.sh
new file mode 100755
index 0000000..f9d1631
--- /dev/null
+++ b/deploy/ssh-ledger-collect.sh
@@ -0,0 +1,205 @@
+#!/usr/bin/env bash
+# πŸ€– Public SSH access ledger β€” the box-side collector (docs/status.md Β§ The
+# SSH access ledger). Every 5 minutes (lchat-ssh-ledger.timer) this reads NEW
+# sshd journal events, groups accepted connections into operator sessions, and
+# appends closed sessions to the public ledger the status site serves at
+# ssh.html. Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh
+# (the unit's ExecStart points there).
+#
+# ============================ PRIVACY β€” NON-NEGOTIABLE ========================
+# Everything this script publishes is PUBLIC, forever (the bucket is
+# versioned). The rules:
+# - METADATA ONLY, NEVER CONTENT. Who (operator handle via public-key
+# fingerprint), when (start/end of the accepted-connection window), how
+# many connections, and wireguard-or-public source. No commands, no
+# command output, no session content β€” nothing here even reads it.
+# - PUBLISH ON CLOSE ONLY. A session row is emitted only after the group has
+# been quiet for >= QUIET_S β€” the public page must never be a live
+# "an operator is connected right now" beacon for an attacker to watch.
+# - Key FINGERPRINTS are public-key material (safe to publish); client IPs
+# are read only to pick the source word and never reach the JSON.
+# ==============================================================================
+#
+# This script is DELIBERATELY STANDALONE from deploy/status-publish.sh (the
+# box's copy of that script has diverged from main and must not be forced to
+# reconcile for this feature): it keeps its own state, writes its own outputs,
+# and uploads them itself with the same status-publisher creds.
+#
+# The human "why" half lives in the repo: web/status/ssh/*.json entry files,
+# PR-reviewed (review is the redaction gate), land in $PAGES_DIR/ssh/ via the
+# normal viewer-page scp step. This script merges them into one
+# ssh-entries.json so the page fetches exactly two JSON files.
+#
+# Usage:
+# ssh-ledger-collect.sh # what the timer runs
+#
+# Test mode (no journalctl, no upload, no box state): point every input at a
+# fixture β€” a file of journalctl -o json lines β€” and pin "now":
+# SSH_LEDGER_JOURNAL_FILE=events.jsonl SSH_LEDGER_STATE_DIR=/tmp/state \
+# SSH_LEDGER_PAGES_DIR=/tmp/pages SSH_LEDGER_NOW=<epoch> ./ssh-ledger-collect.sh
+#
+# Prerequisites on the box: jq (apt install jq) + the aws CLI and
+# /data/status.env creds already there for the status publisher.
+set -euo pipefail
+
+STATE_DIR=${SSH_LEDGER_STATE_DIR:-/data/ssh-ledger}
+PAGES_DIR=${SSH_LEDGER_PAGES_DIR:-/opt/lchat/status-pages}
+SSH_UNIT=${SSH_LEDGER_UNIT:-ssh} # Ubuntu's sshd unit is ssh.service
+BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
+REGION=${STATUS_REGION:-eu-west-1}
+STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
+WG_PREFIX="172.22.22." # the wg0 admin subnet (/24)
+
+# Grouping windows. JOIN_S: reconnects are routine while working (a dropped
+# terminal, a second window, scp beside a shell), so accepts within 15 minutes
+# of the previous one are one session, not many rows. QUIET_S: a group only
+# publishes after 20 quiet minutes β€” longer than the join window on purpose,
+# so a row can never appear while its session could still legally grow, and
+# (the publish-on-close rule) the page never advertises a live connection.
+JOIN_S=900
+QUIET_S=1200
+
+NOW=${SSH_LEDGER_NOW:-$(date -u +%s)}
+CURSOR="$STATE_DIR/cursor"
+PENDING="$STATE_DIR/pending.tsv" # open groups: user fp start last connections source
+SESSIONS="$PAGES_DIR/ssh-sessions.json"
+ENTRIES="$PAGES_DIR/ssh-entries.json"
+OPERATORS="$PAGES_DIR/ssh-operators.json"
+
+command -v jq >/dev/null 2>&1 || { echo "jq missing (apt install jq)" >&2; exit 1; }
+mkdir -p "$STATE_DIR" "$PAGES_DIR"
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+# iso <epoch> β€” GNU date on the box; the -r fallback keeps the test mode
+# runnable on a BSD/macOS dev machine.
+iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ; }
+
+# atomic_write <src> <dest> β€” temp beside the target + mv, so a reader (or a
+# mid-write upload) never sees a half-written JSON. Same-directory temp keeps
+# the rename atomic (never across filesystems).
+atomic_write() { cp "$1" "$2.tmp.$$" && mv "$2.tmp.$$" "$2"; }
+
+# ---- 1. read new sshd events -------------------------------------------------
+# journalctl's --cursor-file persists our read position in $STATE_DIR (not the
+# journal itself), so it survives journald rotation: rotation drops old
+# entries, never the cursor, and the next run resumes exactly where this one
+# stopped. First run (no cursor yet) backfills from the start of the journal.
+if [ -n "${SSH_LEDGER_JOURNAL_FILE:-}" ]; then
+ cat "$SSH_LEDGER_JOURNAL_FILE" > "$TMP/journal.jsonl"
+else
+ journalctl -u "$SSH_UNIT" -o json --cursor-file "$CURSOR" --no-pager > "$TMP/journal.jsonl" || true
+fi
+
+# One TSV line per accepted-publickey login: epoch-seconds, user, client IP,
+# key fingerprint. Password/other auth is off on this box, so "Accepted
+# publickey" is the complete accept vocabulary; anything else in the sshd
+# journal (disconnects, probes, scanner noise) is deliberately ignored.
+jq -r 'select((.MESSAGE // "") | startswith("Accepted publickey for "))
+ | [(.__REALTIME_TIMESTAMP | tonumber / 1000000 | floor), .MESSAGE] | @tsv' \
+ "$TMP/journal.jsonl" > "$TMP/accepts.tsv"
+
+# ---- 2. fold accepts into pending groups -------------------------------------
+# pending.tsv holds every not-yet-published group. More than one group per
+# (user, fingerprint) can coexist briefly: a reconnect 16 minutes after the
+# last (past JOIN_S) starts a new group while the old one is still inside its
+# QUIET_S wait β€” the old row publishes a few minutes later on its own clock.
+[ -f "$PENDING" ] || : > "$PENDING"
+cp "$PENDING" "$TMP/pending.tsv"
+
+msg_re='^Accepted publickey for ([^ ]+) from ([0-9a-fA-F:.]+) port [0-9]+ ssh2: [^ ]+ (SHA256:[A-Za-z0-9+/=]+)'
+while IFS=$'\t' read -r ts msg; do
+ [[ "$msg" =~ $msg_re ]] || continue
+ user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" fp="${BASH_REMATCH[3]}"
+ # Source word only β€” the IP itself dies here (see PRIVACY above). A public
+ # accept should be rare now port 22 is wireguard-only, which is exactly why
+ # it must publish loudly when it happens.
+ case "$ip" in "$WG_PREFIX"*) src=wireguard ;; *) src=public ;; esac
+ # Join the newest open group for this (user, fp) if this accept is within
+ # JOIN_S of its last connection; otherwise open a new group. awk rewrites
+ # the whole (tiny) pending file per event β€” clarity over cleverness.
+ awk -v u="$user" -v f="$fp" -v ts="$ts" -v src="$src" -v join="$JOIN_S" '
+ BEGIN { OFS="\t"; joined=0 }
+ $1==u && $2==f && !joined && ts-$4 <= join && ts >= $4 {
+ $4=ts; $5=$5+1
+ if (src=="public") $6="public" # one public hop marks the whole group
+ joined=1
+ }
+ { print }
+ END { if (!joined) print u, f, ts, ts, 1, src }
+ ' "$TMP/pending.tsv" > "$TMP/pending.next" && mv "$TMP/pending.next" "$TMP/pending.tsv"
+done < "$TMP/accepts.tsv"
+
+# ---- 3. publish groups that have gone quiet ----------------------------------
+# Split pending into "still open" (kept for next run) and "quiet >= QUIET_S"
+# (emitted below). This is the publish-on-close rule from the header.
+awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 >= quiet' "$TMP/pending.tsv" > "$TMP/closed.tsv"
+awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 < quiet' "$TMP/pending.tsv" > "$TMP/open.tsv"
+
+# Operator attribution by fingerprint β€” web/status/ssh-operators.json, staged
+# with the viewer pages. An unmapped fingerprint publishes as "unknown key":
+# render-worthy on purpose (an unexplained key on the box is itself a signal),
+# never silently dropped.
+[ -f "$OPERATORS" ] || echo '{}' > "$OPERATORS"
+
+echo '[]' > "$TMP/new.json"
+while IFS=$'\t' read -r user fp start last conns src; do
+ [ -n "$user" ] || continue
+ jq --arg id "$user+$fp+$(iso "$start")" \
+ --arg fp "$fp" --arg start "$(iso "$start")" --arg end "$(iso "$last")" \
+ --argjson conns "$conns" --arg src "$src" \
+ --slurpfile ops "$OPERATORS" \
+ '. + [{id: $id, operator: ($ops[0][$fp] // "unknown key"),
+ fingerprint: $fp, start: $start, end: $end,
+ connections: $conns, source: $src}]' \
+ "$TMP/new.json" > "$TMP/new.next" && mv "$TMP/new.next" "$TMP/new.json"
+done < "$TMP/closed.tsv"
+
+# Append to the cumulative ledger, deduped on id (user+fingerprint+start), so
+# a re-run over replayed journal lines can never double-publish a session. A
+# missing ledger starts fresh; a CORRUPT one aborts the run β€” never clobber
+# published history with an empty file.
+if [ -f "$SESSIONS" ]; then
+ jq -e . "$SESSIONS" >/dev/null || { echo "corrupt $SESSIONS β€” refusing to overwrite" >&2; exit 1; }
+else
+ echo '{"sessions": []}' > "$SESSIONS"
+fi
+jq --arg now "$(iso "$NOW")" --slurpfile add "$TMP/new.json" \
+ '{generated: $now, sessions: ((.sessions // []) + $add[0] | unique_by(.id) | sort_by(.start))}' \
+ "$SESSIONS" > "$TMP/sessions.json"
+atomic_write "$TMP/sessions.json" "$SESSIONS"
+atomic_write "$TMP/open.tsv" "$PENDING"
+
+# ---- 4. merge the human why-entries ------------------------------------------
+# web/status/ssh/*.json (one file per entry β€” parallel PRs never conflict)
+# merge into one array so the page fetches exactly two JSONs. A malformed
+# entry fails the run loudly rather than silently dropping someone's "why".
+echo '[]' > "$TMP/entries.json"
+for f in "$PAGES_DIR"/ssh/*.json; do
+ [ -e "$f" ] || continue # no entries staged yet β€” publish an empty array
+ jq -e . "$f" >/dev/null 2>&1 || { echo "malformed why-entry $f β€” fix or revert it" >&2; exit 1; }
+ jq --slurpfile e "$f" '. + $e' "$TMP/entries.json" > "$TMP/entries.next" && \
+ mv "$TMP/entries.next" "$TMP/entries.json"
+done
+jq 'sort_by(.date) | reverse' "$TMP/entries.json" > "$TMP/entries.sorted"
+atomic_write "$TMP/entries.sorted" "$ENTRIES"
+
+# ---- 5. upload ----------------------------------------------------------------
+# Standalone by design (see header): this script uploads its own outputs with
+# the same scoped status-publisher creds status-publish.sh uses. Test mode
+# (fixture journal) never uploads. Re-uploading the page + operator map every
+# run self-heals a missed copy, same as the main publisher.
+if [ -z "${SSH_LEDGER_JOURNAL_FILE:-}" ]; then
+ command -v aws >/dev/null 2>&1 || { echo "aws CLI missing β€” collected, not uploaded" >&2; exit 1; }
+ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then
+ set -a; . "$STATUS_ENV"; set +a
+ fi
+ up() { aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \
+ --content-type "$3" --cache-control "$4" --no-progress >/dev/null; }
+ up "$SESSIONS" ssh-sessions.json "application/json" "max-age=60"
+ up "$ENTRIES" ssh-entries.json "application/json" "max-age=60"
+ [ -f "$PAGES_DIR/ssh.html" ] && up "$PAGES_DIR/ssh.html" ssh.html "text/html; charset=utf-8" "max-age=300"
+ [ -f "$OPERATORS" ] && up "$OPERATORS" ssh-operators.json "application/json" "max-age=300"
+ echo "ssh ledger published to s3://$BUCKET ($(iso "$NOW"))"
+fi
diff --git a/deploy/systemd/lchat-ssh-ledger.service b/deploy/systemd/lchat-ssh-ledger.service
new file mode 100644
index 0000000..f537f57
--- /dev/null
+++ b/deploy/systemd/lchat-ssh-ledger.service
@@ -0,0 +1,38 @@
+[Unit]
+Description=lchat public SSH access ledger (one collect pass)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+# Optional overrides: STATUS_BUCKET / STATUS_REGION (mirror status-publish.sh).
+EnvironmentFile=-/data/status.env
+# "+" = outside the sandbox: both ReadWritePaths must exist before
+# ProtectSystem=strict mounts them, or the first-ever run fails to namespace.
+ExecStartPre=+/usr/bin/mkdir -p /data/ssh-ledger /opt/lchat/status-pages
+ExecStart=/opt/lchat/ssh-ledger-collect.sh
+
+# --- hardening (see docs/instance-router.md Β§ Hardening) ---
+# Runs as ROOT deliberately: the sshd journal is root/systemd-journal readable
+# and /data/ssh-ledger + /opt/lchat/status-pages are its only writes β€”
+# ProtectSystem=strict pins exactly those. It is fired by
+# lchat-ssh-ledger.timer, never routed or listening, and publishes METADATA
+# ONLY (see the script's privacy header).
+ProtectSystem=strict
+ReadWritePaths=/data/ssh-ledger /opt/lchat/status-pages
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+NoNewPrivileges=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
diff --git a/deploy/systemd/lchat-ssh-ledger.timer b/deploy/systemd/lchat-ssh-ledger.timer
new file mode 100644
index 0000000..b6527b5
--- /dev/null
+++ b/deploy/systemd/lchat-ssh-ledger.timer
@@ -0,0 +1,12 @@
+[Unit]
+Description=5-minute public SSH access ledger collect
+
+[Timer]
+# A short boot delay so journald is settled before the first pass; the random
+# spread keeps this from landing in the same second as the status publisher.
+OnBootSec=2min
+OnUnitActiveSec=5min
+RandomizedDelaySec=30s
+
+[Install]
+WantedBy=timers.target
diff --git a/docs/status.md b/docs/status.md
index f806a88..c536992 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -1,4 +1,4 @@
-# πŸ€– Public status dashboards β€” fleet, books, economics, security, carbon, contributors & prompts
+# πŸ€– Public status dashboards β€” fleet, books, economics, security, carbon, contributors, prompts & SSH access
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
@@ -59,12 +59,19 @@ public *ledger* repo is still roadmap β€” see `docs/planet-scale.md`).
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.
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 seven
+`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All eight
pages share one stylesheet, `web/status/status.css` β€” the design tokens, the
-brand masthead, the seven-item nav that steps through every dashboard, and the
+brand masthead, the eight-item nav that steps through every dashboard, and the
common components (tiles, bar lists, notes, footer). Each page keeps only a
small inline `<style>` for its own bits. It's same-origin in the status bucket
(no third-party request β€” the privacy line holds), and the publish script
@@ -179,6 +186,17 @@ aggregation boundary lives in the generator and holds by construction:
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
@@ -468,6 +486,72 @@ Accruing a machine's sessions:
`/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` (the box's copy of that script
+ has diverged from main and is not forced to reconcile for this): it uploads
+ its own outputs with the same status-publisher creds (`/data/status.env`).
+- **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. `scp deploy/ssh-ledger-collect.sh root@<box>:/opt/lchat/` (the unit's
+ `ExecStart` expects that exact path) and
+ `scp deploy/systemd/lchat-ssh-ledger.{service,timer} root@<box>:/etc/systemd/system/`.
+2. `ssh root@<box> 'systemctl daemon-reload && systemctl enable --now lchat-ssh-ledger.timer'`.
+3. Stage the viewer + entries with the other pages:
+ `scp web/status/ssh.html web/status/ssh-operators.json root@<box>:/opt/lchat/status-pages/`
+ and `scp web/status/ssh/*.json root@<box>:/opt/lchat/status-pages/ssh/`
+ (make the `ssh/` dir first; the README stays repo-only). Re-run the entries
+ scp whenever a why-entry lands β€” a session published before its entry
+ arrives shows "no summary recorded" until the next collect pass after the
+ copy.
+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.
+
## Install (router box)
1. **Apply the infra** (deliberate, named β€” not yet applied):
diff --git a/web/status/books.html b/web/status/books.html
index 45e0593..867caa8 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -50,6 +50,7 @@
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">Eleven’s income and spending, published as they stand β€” part
of running the service in the open.</p>
diff --git a/web/status/carbon.html b/web/status/carbon.html
index 3aba61a..14f800f 100644
--- a/web/status/carbon.html
+++ b/web/status/carbon.html
@@ -109,6 +109,7 @@
<span aria-current="page">Carbon use</span>
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">Eleven is AI-written, and the honest carbon ledger has two
halves: what it cost to <em>build</em> the app (the AI tokens spent writing
diff --git a/web/status/contributors.html b/web/status/contributors.html
index 46d0993..2d630b3 100644
--- a/web/status/contributors.html
+++ b/web/status/contributors.html
@@ -51,6 +51,7 @@
<a href="./carbon.html">Carbon use</a>
<span aria-current="page">Contributors</span>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">Who builds Eleven β€” humans and AI models on the same metric.
This app is AI-written, so the models appear in the index exactly as the
diff --git a/web/status/economics.html b/web/status/economics.html
index c395584..118eaaa 100644
--- a/web/status/economics.html
+++ b/web/status/economics.html
@@ -54,6 +54,7 @@
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">How the space-upgrade prices relate to what spaces actually
cost to run. The price is the cost plus resilience β€” and that should be a
diff --git a/web/status/index.html b/web/status/index.html
index e9e97e2..fbdc52d 100644
--- a/web/status/index.html
+++ b/web/status/index.html
@@ -68,6 +68,7 @@
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">Live aggregate health of the Eleven fleet β€” how many spaces are running
(always-on or on-demand, and of the on-demand ones how many are awake), what
diff --git a/web/status/prompts.html b/web/status/prompts.html
index a8f2c1c..77895ff 100644
--- a/web/status/prompts.html
+++ b/web/status/prompts.html
@@ -17,7 +17,7 @@
<link rel="icon" type="image/png" href="./icon-192.png" />
<link rel="stylesheet" href="./status.css" />
<style>
- /* Prompt-ledger-only styles; the tokens, reset, masthead, and the six-item
+ /* Prompt-ledger-only styles; the tokens, reset, masthead, and the shared
nav live in status.css. This page is wider than the others and adds the
search controls, the sessions table, and the transcript viewer. */
.wrap { max-width: 860px; margin: 0 auto; }
@@ -116,6 +116,7 @@
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
<span aria-current="page">Prompt Ledger</span>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<div id="home">
diff --git a/web/status/security.html b/web/status/security.html
index 51042d0..c89e3ec 100644
--- a/web/status/security.html
+++ b/web/status/security.html
@@ -70,6 +70,7 @@
<a href="./carbon.html">Carbon use</a>
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
</nav>
<p class="lede">Eleven’s security posture, published as it stands. This is
what is true by design and how the hygiene looks today β€” never a map of open
diff --git a/web/status/ssh-operators.json b/web/status/ssh-operators.json
new file mode 100644
index 0000000..929b5c8
--- /dev/null
+++ b/web/status/ssh-operators.json
@@ -0,0 +1,5 @@
+{
+ "SHA256:5JUe8bzNg0hBATwv4cHOJJ0ZDQzaPWUqTWAs7qDwCyI": "Paul Campbell",
+ "SHA256:U4nJjkf9E6nFIfmMnGJyWMLZKO2K/K4vtIs/0BdxTKI": "James Adam",
+ "SHA256:eVQy2zp7AHszs159VfsEAH4PiRj+Jc+BxXU0xL6tsVk": "James Adam"
+}
diff --git a/web/status/ssh.html b/web/status/ssh.html
new file mode 100644
index 0000000..a7cb49a
--- /dev/null
+++ b/web/status/ssh.html
@@ -0,0 +1,294 @@
+<!-- πŸ€– Public SSH access ledger: every operator SSH session to the router box,
+ published with a plain-language "why" (docs/status.md Β§ The SSH access
+ ledger). Two halves joined here: ./ssh-sessions.json is the automatic
+ truth (deploy/ssh-ledger-collect.sh reads sshd's journal β€” metadata only,
+ published only after a session closes) and ./ssh-entries.json is the
+ human why-entries (web/status/ssh/*.json in the repo, PR-reviewed).
+ Fully self-contained: inline CSS/JS, no external requests except those
+ two JSONs (relative). A session with no matching entry renders loudly as
+ "no summary recorded" β€” that state is the convention's enforcement. -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven β€” the SSH access ledger</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<link rel="stylesheet" href="./status.css" />
+<style>
+ /* Access-ledger-only styles β€” the session rows, source badges, and the loud
+ no-summary state. Everything shared (tokens, header, nav, tiles, #error,
+ footer) is in status.css. */
+ .sessions { list-style: none; padding: 0; }
+ .sessions li {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 12px; padding: 14px 16px; margin: 10px 0;
+ }
+ .sessions .top { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; }
+ .sessions .who { font-weight: 650; }
+ .sessions .when { color: var(--ink-2); font-size: 14px; font-variant-numeric: tabular-nums; }
+ .sessions .facts { color: var(--ink-2); font-size: 14px; margin-top: 4px; font-variant-numeric: tabular-nums; }
+ .sessions .why { margin-top: 8px; }
+ .sessions .refs { margin-top: 4px; font-size: 14px; }
+ .fp { color: var(--ink-2); font-size: 12px; font-family: ui-monospace, monospace; overflow-wrap: anywhere; margin-top: 6px; }
+ /* Source badge: the word carries the state (never color alone); public and
+ unknown-key get the warn treatment because they're the signals to see. */
+ .badge {
+ display: inline-block; font-size: 12px; font-weight: 650; border-radius: 999px;
+ padding: 1px 10px; border: 1px solid var(--border); color: var(--ink-2);
+ }
+ .badge.public, .badge.unknown { color: var(--warn); border-color: var(--warn); }
+ /* The loud no-summary state: bold text + border + background β€” readable as
+ a warning in both themes without relying on color alone. */
+ .nosummary {
+ display: inline-block; margin-top: 8px; padding: 4px 10px; font-weight: 650;
+ background: var(--redact-bg); color: var(--redact-ink);
+ border: 1px solid var(--warn); border-radius: 8px;
+ }
+ .withheld { color: var(--redact-ink); background: var(--redact-bg); border-radius: 6px; padding: 2px 8px; }
+ .policy { border-left: 4px solid var(--series); background: var(--surface); border-top: 1px solid var(--border); border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; margin: 14px 0; }
+ .policy summary { cursor: pointer; font-weight: 600; }
+ .policy summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
+ .policy p { margin-top: 8px; }
+ .empty { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>The SSH access ledger</h1>
+ </div>
+ </header>
+ <nav class="status-nav" aria-label="Status">
+ <a href="./index.html">Fleet</a>
+ <a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
+ <a href="./security.html">Security</a>
+ <a href="./carbon.html">Carbon use</a>
+ <a href="./contributors.html">Contributors</a>
+ <a href="./prompts.html">Prompt Ledger</a>
+ <span aria-current="page">Access Ledger</span>
+ </nav>
+
+ <p class="lede">Every operator SSH session to the server that runs the fleet,
+ on the public record with a plain-language reason β€” like the prompt ledger,
+ but for hands on the box. The server publishes the automatic half itself
+ (who connected, when, how many times, over the admin tunnel or from the
+ public internet β€” <em>metadata only, never commands or content</em>), and
+ each session's "why" is a small file reviewed in the open in the
+ <a href="https://github.com/elevenmessenger/messenger/tree/main/web/status/ssh">repo</a>.
+ Rows appear only after a session has closed and gone quiet β€” this page is a
+ record, never a live "someone is connected right now" beacon.</p>
+
+ <p class="asof">Last collected <time id="asof" datetime="">β€”</time></p>
+
+ <p id="error" role="alert">Couldn’t load the access ledger. It may be mid-publish β€” this page retries every minute.</p>
+
+ <details class="policy">
+ <summary>What this page can and can’t prove</summary>
+ <p>πŸ€– <strong>This is transparency, not tamper-proof forensics.</strong> The
+ box publishes its own ledger from its own logs: an attacker with root could
+ stop the collector or rewrite what it publishes, and an operator could too.
+ What it honestly gives you is a standing public record that legitimate
+ operator access must keep up with β€” a session with no reason renders loudly
+ below as <span class="nosummary">no summary recorded</span>, and a
+ security-sensitive session appears as <span class="withheld">(withheld:
+ security)</span> rather than vanishing. The bucket this publishes to is
+ versioned, so after-the-fact edits leave a history.</p>
+ <p>πŸ€– Sessions are grouped from sshd’s accepted-connection log: reconnects
+ within 15 minutes count as one session, and a session publishes only after
+ 20 quiet minutes. Operators are named by their public key fingerprint (the
+ committed <a href="https://github.com/elevenmessenger/messenger/blob/main/web/status/ssh-operators.json">operator
+ map</a>); a fingerprint the map doesn’t know renders as β€œunknown key” β€” on
+ purpose, because an unexplained key is exactly what this page exists to
+ surface.</p>
+ </details>
+
+ <div class="tiles" id="totals"></div>
+
+ <section aria-labelledby="h-sessions">
+ <h2 id="h-sessions">Sessions, newest first</h2>
+ <ol class="sessions" id="sessions" reversed></ol>
+ <p class="empty" id="empty" hidden>No closed sessions in the ledger yet β€”
+ the collector publishes each session after it ends.</p>
+ </section>
+
+ <footer>
+ <p>Collected on the box every 5 minutes by
+ <code>deploy/ssh-ledger-collect.sh</code> from sshd’s journal; the why-entries
+ are PR-reviewed files in <code>web/status/ssh/</code>. Convention and
+ redaction bar: <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/status.md">docs/status.md</a>.</p>
+ </footer>
+</div>
+
+<script>
+(async function () {
+ "use strict";
+ const $ = (id) => document.getElementById(id);
+
+ const fmtInt = (n) => Number(n).toLocaleString();
+ // "1 connection, 2 connections" β€” never a bare label beside a count.
+ const PLURAL = new Intl.PluralRules();
+ const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s");
+ const nOf = (n, one, many) => fmtInt(n) + " " + plural(n, one, many);
+ const fmtDay = (iso) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
+ const fmtTime = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ // The window is an approximation (span of accepted connections), so the
+ // duration reads as one: a single connection shows no span at all.
+ const fmtSpan = (a, b) => {
+ const mins = Math.round((new Date(b) - new Date(a)) / 60000);
+ if (mins <= 0) return "";
+ if (mins < 60) return "about " + nOf(mins, "minute");
+ const h = Math.floor(mins / 60), m = mins % 60;
+ return "about " + nOf(h, "hour") + (m ? " " + nOf(m, "minute") : "");
+ };
+
+ // Join an entry to a session: same operator + same UTC date, preferring an
+ // overlapping "HH:MM–HH:MM" window, else the nearest by midpoint β€” forgiving
+ // by design (windows are approximate, and one entry may cover several
+ // sessions, so entries are never consumed).
+ const winRe = /^(\d{2}):(\d{2})[–-](\d{2}):(\d{2})/;
+ const utcMins = (iso) => { const d = new Date(iso); return d.getUTCHours() * 60 + d.getUTCMinutes(); };
+ function entryFor(s, entries) {
+ const day = (s.start || "").slice(0, 10);
+ const c = entries.filter((e) => e.operator === s.operator && e.date === day);
+ if (!c.length) return null;
+ if (c.length === 1) return c[0];
+ const s0 = utcMins(s.start), s1 = Math.max(utcMins(s.end), s0);
+ let best = null, bestScore = Infinity;
+ for (const e of c) {
+ const m = winRe.exec(e.window || "");
+ if (!m) { if (!best) { best = e; bestScore = 720; } continue; } // window-less entry: weak match
+ const e0 = Number(m[1]) * 60 + Number(m[2]), e1 = Number(m[3]) * 60 + Number(m[4]);
+ const overlaps = s0 <= e1 && e0 <= s1;
+ const dist = overlaps ? 0 : Math.abs((s0 + s1) / 2 - (e0 + e1) / 2);
+ if (dist < bestScore) { best = e; bestScore = dist; }
+ }
+ return best;
+ }
+
+ function render(sessions, entries) {
+ const list = $("sessions");
+ list.textContent = "";
+ const newest = sessions.slice().sort((a, b) => (b.start || "").localeCompare(a.start || ""));
+ $("empty").hidden = newest.length > 0;
+
+ let unexplained = 0, connections = 0;
+ const operators = new Set();
+ for (const s of newest) {
+ connections += s.connections || 0;
+ operators.add(s.operator);
+ const li = document.createElement("li");
+
+ const top = document.createElement("div");
+ top.className = "top";
+ const who = document.createElement("span");
+ who.className = "who";
+ who.textContent = s.operator;
+ const when = document.createElement("span");
+ when.className = "when";
+ when.textContent = fmtDay(s.start) + " Β· " + fmtTime(s.start) +
+ (s.end !== s.start ? "–" + fmtTime(s.end) : "");
+ const badge = document.createElement("span");
+ const pub = s.source !== "wireguard";
+ badge.className = "badge" + (pub ? " public" : "") + (s.operator === "unknown key" ? " unknown" : "");
+ badge.textContent = pub ? "public internet" : "wireguard";
+ top.append(who, when, badge);
+ li.appendChild(top);
+
+ const facts = document.createElement("div");
+ facts.className = "facts";
+ const span = fmtSpan(s.start, s.end);
+ facts.textContent = nOf(s.connections, "connection") + (span ? " over " + span : "");
+ li.appendChild(facts);
+
+ const entry = entryFor(s, entries);
+ if (entry) {
+ const why = document.createElement("p");
+ why.className = "why";
+ if (/^\(withheld:/.test(entry.why || "")) {
+ const w = document.createElement("span");
+ w.className = "withheld";
+ w.textContent = entry.why;
+ why.appendChild(w);
+ } else {
+ why.textContent = entry.why || "";
+ }
+ li.appendChild(why);
+ const refs = (entry.refs || []).filter(Boolean);
+ if (refs.length) {
+ const p = document.createElement("p");
+ p.className = "refs";
+ p.append("refs: ");
+ refs.forEach((r, i) => {
+ if (i) p.append(", ");
+ const url = /^https?:/.test(String(r)) ? String(r)
+ : "https://github.com/elevenmessenger/messenger/pull/" + String(r).replace(/^#/, "");
+ const a = document.createElement("a");
+ a.href = url;
+ const m = /\/(?:pull|issues)\/(\d+)$/.exec(url);
+ a.textContent = m ? "#" + m[1] : url;
+ p.appendChild(a);
+ });
+ li.appendChild(p);
+ }
+ } else {
+ unexplained++;
+ const p = document.createElement("p");
+ const mark = document.createElement("strong");
+ mark.className = "nosummary";
+ mark.textContent = "⚠ no summary recorded";
+ p.appendChild(mark);
+ li.appendChild(p);
+ }
+
+ const fp = document.createElement("div");
+ fp.className = "fp";
+ fp.textContent = s.fingerprint;
+ li.appendChild(fp);
+ list.appendChild(li);
+ }
+
+ const tiles = [
+ [plural(newest.length, "session"), fmtInt(newest.length)],
+ [plural(connections, "connection"), fmtInt(connections)],
+ [plural(operators.size, "operator"), fmtInt(operators.size)],
+ ["without a summary", fmtInt(unexplained)],
+ ];
+ const totals = $("totals");
+ totals.textContent = "";
+ for (const [label, val] of tiles) {
+ const tile = document.createElement("div");
+ tile.className = "tile";
+ const l = document.createElement("div"); l.className = "label"; l.textContent = label;
+ const v = document.createElement("div"); v.className = "value"; v.textContent = val;
+ tile.append(l, v);
+ totals.appendChild(tile);
+ }
+ }
+
+ async function load() {
+ try {
+ const [sr, er] = await Promise.all([fetch("./ssh-sessions.json"), fetch("./ssh-entries.json")]);
+ if (!sr.ok || !er.ok) throw 0;
+ const sdoc = await sr.json();
+ const entries = await er.json();
+ $("error").classList.remove("show");
+ const asof = $("asof");
+ asof.dateTime = sdoc.generated || "";
+ asof.textContent = sdoc.generated ? fmtDay(sdoc.generated) + " " + fmtTime(sdoc.generated) : "β€”";
+ render(sdoc.sessions || [], Array.isArray(entries) ? entries : []);
+ } catch {
+ $("error").classList.add("show");
+ }
+ }
+ await load();
+ setInterval(load, 60000); // same retry cadence as the other dashboards
+})();
+</script>
+</body>
+</html>
diff --git a/web/status/ssh/2026-07-14-wireguard-rollout.json b/web/status/ssh/2026-07-14-wireguard-rollout.json
new file mode 100644
index 0000000..aa0b2fd
--- /dev/null
+++ b/web/status/ssh/2026-07-14-wireguard-rollout.json
@@ -0,0 +1,7 @@
+{
+ "date": "2026-07-14",
+ "operator": "Paul Campbell",
+ "window": "09:00–13:00 UTC (approx)",
+ "why": "WireGuard hardening rollout: brought up the wg admin tunnel, closed public port 22, added named operator accounts and this ledger's collector.",
+ "refs": ["https://github.com/elevenmessenger/messenger/pull/884"]
+}
diff --git a/web/status/ssh/README.md b/web/status/ssh/README.md
new file mode 100644
index 0000000..c4dc735
--- /dev/null
+++ b/web/status/ssh/README.md
@@ -0,0 +1,53 @@
+# πŸ€– SSH access ledger β€” the why-entries
+
+One JSON file per entry β€” changelog-style, so parallel PRs never conflict.
+These are the *human* half of the public SSH access ledger
+(status.elevenmessenger.com/ssh.html): the box-side collector
+(`deploy/ssh-ledger-collect.sh`) publishes the automatic truth (who connected,
+when, from where) from sshd's journal; the files here say **why**. The
+collector merges them into `ssh-entries.json` and the page joins entry to
+session by operator + date/time-window overlap. Full picture:
+`docs/status.md Β§ The SSH access ledger`.
+
+## The convention
+
+**Any session β€” Claude or human β€” that SSHes to the router box adds an entry
+file here, in the same PR as the work (or an adjacent one).** A session the
+collector can't match to an entry renders loudly on the public page as
+"no summary recorded" β€” that state is the enforcement, so don't let it be
+yours. One entry may cover several sessions in the same working window.
+
+File name: `YYYY-MM-DD-<slug>.json` (like a changelog entry's name), e.g.
+`2026-07-14-wireguard-rollout.json`.
+
+```json
+{
+ "date": "YYYY-MM-DD",
+ "operator": "Full Name (as in web/status/ssh-operators.json)",
+ "window": "HH:MM–HH:MM UTC (approx)",
+ "why": "terse what-not-how summary",
+ "refs": ["https://github.com/elevenmessenger/messenger/pull/NNN"]
+}
+```
+
+- `date` + `window` are UTC and approximate β€” the page matches forgivingly
+ (same operator, same date, nearest/overlapping window), so don't sweat
+ minutes; do keep the date right.
+- `operator` must match a handle in `web/status/ssh-operators.json`, or the
+ join will miss.
+- `refs` β€” PR/issue URLs or numbers the session's work landed in; may be empty.
+
+## The redaction bar (mirrors the prompt registry's)
+
+These entries are public forever. PR review is the redaction gate β€” reviewers
+hold this line:
+
+- **What, not how.** "Rotated the push relay's tokens" β€” never the procedure,
+ the file paths where key material lives, or anything that maps the defences.
+- **Never a credential, never an email**, never a secret's value or location.
+- **Security-sensitive sessions don't vanish β€” they tombstone.** A session
+ whose honest summary would itself weaken the box publishes as
+ `"why": "(withheld: security)"` β€” the access stays on the public record,
+ only the reason is withheld. Silence (no entry at all) is not an option:
+ that renders as "no summary recorded", which reads as *unexplained* access,
+ not *protected* access.
diff --git a/web/status/status.css b/web/status/status.css
index 04f1abf..9a17909 100644
--- a/web/status/status.css
+++ b/web/status/status.css
@@ -1,8 +1,8 @@
/* πŸ€– Shared stylesheet for the public status pages (web/status/*.html): fleet,
- income & expenditure, tier economics, security, carbon, contributors, and
- the prompt ledger.
+ income & expenditure, tier economics, security, carbon, contributors, the
+ prompt ledger, and the SSH access ledger.
ONE design system β€” the design tokens, the reset, and every component the
- pages share (the brand masthead, the seven-item nav, tiles, the single-series
+ pages share (the brand masthead, the eight-item nav, tiles, the single-series
bar list, notes, the "couldn't load" alert, footer). Each page <link>s this
and keeps only a SMALL inline <style> for its own unique bits.
@@ -57,7 +57,7 @@ header img { width: 44px; height: 44px; border-radius: 10px; }
header h1 { font-size: 22px; font-weight: 650; }
header .brand { color: var(--ink-2); font-size: 14px; }
-/* The status nav β€” a horizontal stepper through the seven public dashboards,
+/* The status nav β€” a horizontal stepper through the eight public dashboards,
wrapping onto more rows on narrow widths. The current page is a non-link,
marked aria-current and bolded, so weight (not color alone) carries it. */
nav.status-nav { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; margin: 8px 0 14px; }