eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env bash
2# πŸ€– Public status publish (issue #550 Parts 11/12): every 5 minutes, build two
3# small world-readable JSONs β€” fleet.json (fleet state) and books.json (the
4# books: Stripe income aggregates + itemized infrastructure expenses) β€” and
5# upload them plus the static viewer pages to the public status bucket
6# (infra/prod/status.tf). docs/status.md is the operator guide.
7#
8# ============================ PRIVACY β€” NON-NEGOTIABLE ========================
9# Everything this script uploads is PUBLIC. The rules (decision #19 in #550:
10# income publishes AGGREGATED, never per-payer; invariant 12: no PII in our
11# stores, so none can leak here β€” but we hold the line at the aggregation
12# boundary anyway):
13#
14# fleet.json β€” AGGREGATES ONLY. Totals, counts, version distribution,
15# service up/down booleans, replication health. NEVER a hostname (member
16# spaces obviously, but also test-* canaries β€” the simple rule is NO
17# hostnames at all), never per-space member/message counts, never anything
18# keyed by space. Hostnames are read internally (to match sockets) and
19# must never reach the JSON.
20#
21# Capacity + hosts (Paul's call: bucketed words, not needles): the box's
22# room-for-new-spaces and per-box load publish as ABSTRACT BAND WORDS only
23# (plenty / getting-full / at-capacity; comfortable / busy / critical).
24# The caps, ratios, free GB, and percentages are read internally to pick
25# the word and MUST NEVER reach the JSON β€” no remaining-slot counts, no GB,
26# no percentages, no cap values. Host labels are whimsical name-action
27# pairs hashed from the machine id ("ada-stargazing") β€” stable per box,
28# never peer URLs, IPs, hostnames, or the machine id itself.
29#
30# A box's `region` is COARSE BY DESIGN: a continent + country pair
31# ("EU Β· Finland") β€” never a city, datacenter, IP, or hostname. Resolved
32# from the box's own cloud metadata (see box_region below); the
33# STATUS_BOX_REGION env overrides; unknown β†’ the field is omitted
34# entirely. Detected, mapped, or absent β€” never guessed.
35#
36# books.json β€” AGGREGATED INCOME ONLY. Active-subscription count (by tier β€”
37# tier names are the public price book), MRR total in EUR, gross collected
38# this month, and the Connect aggregates (spaces' gross sales volume this
39# month + our commission on it β€” Paul's call: if we know the GMV, it's
40# public; totals only, like everything else here). NEVER customer ids,
41# emails, subscription ids, connected-account ids, per-payer lines, or
42# timestamps of individual payments. The Stripe key is read for the API
43# calls and must never be echoed, logged, or uploaded.
44#
45# books.json expenses β€” the deliberate mirror image (decision #19's other
46# half: income aggregates, EXPENSES PUBLISH ITEMIZED β€” the amounts are
47# public on purpose). One line per provider: provider word + amount + its
48# own billing currency (never FX-converted β€” no rate, no misstatement).
49# NEVER account ids, invoice ids, workspace ids, resource-level lines, or
50# usage breakdowns. The AWS creds and the Anthropic Admin key are read for
51# the API calls and must never be echoed, logged, or uploaded.
52#
53# carbon.json β€” two halves. `built` is COMMITTED data (the AI-build carbon
54# tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs), staged
55# with the pages and republished as-is β€” aggregate token counts + kg only,
56# never per-session data. `run` is a LIVE operational estimate this script
57# INJECTS at upload (build_carbon): AGGREGATES ONLY β€” total vCPUs, watts,
58# grid intensity, banded kg, and the public instance count as the per-space
59# denominator (NEVER per-member, never a hostname/IP). Absent inputs β†’ run
60# stays absent and the page shows "pending", never a fake zero.
61#
62# contributors.json β€” also COMMITTED data (tools/contributors.mjs),
63# republished as-is. Public GitHub identities + commit counts only;
64# contributors own their row (bio, hidden) in the committed JSON.
65#
66# security.json β€” also COMMITTED data (deploy/security-scan.sh, run at
67# BUILD time where the Go toolchain lives β€” this box never computes a
68# scan), republished as-is. It is REDACTED BY CONSTRUCTION: a status word,
69# an advisory COUNT, a module count, the build sha, a timestamp β€” never a
70# CVE/GHSA/OSV id, package, or code location of anything unpatched (that
71# would be an attacker roadmap; see the scan script's disclosure header).
72# Absent file β†’ the page shows "scan pending", never a fake clean.
73#
74# Adding a field? It must be an aggregate over the whole fleet / all payers,
75# or it doesn't ship.
76# ==============================================================================
77#
78# Privilege: runs as the lchat user, not root β€” least privilege that works:
79# - /data/registry.db and /data/cloud.env are lchat-owned on the hardened
80# box (all units run as lchat since PR #186; /data is owned by it).
81# - `systemctl is-active` and `ss -xlH` are unprivileged read-only queries.
82# - the journal read (replication lag) needs the systemd-journal group,
83# granted via SupplementaryGroups in the unit below.
84# Only `install` (writing units, enabling the timer) needs root.
85#
86# Usage:
87# status-publish.sh # build + upload (what the timer runs)
88# status-publish.sh install # root: install unit + timer, enable
89# status-publish.sh --dry-run DIR # build both JSONs into DIR from
90# # self-constructed fake registry/Stripe
91# # fixtures; no box state touched, no
92# # upload β€” the committed contract test
93#
94# Prerequisites on the box:
95# /data/status.env β€” AWS creds for the status-publisher IAM user
96# (infra/prod/status.tf; Put/Get/List on the status
97# bucket, no Delete, plus ce:GetCostAndUsage for the
98# expenses section). chown lchat, chmod 0600. Optional
99# expense vars ride here too: STATUS_HETZNER_TOKEN (a
100# read-only Hetzner Cloud API token β€” computes the
101# monthly server cost; never echoed) and/or
102# STATUS_HETZNER_MONTHLY_CENTS (operator constant β€”
103# overrides the token when both are set),
104# STATUS_CLAUDE_TO_DATE_CENTS, STATUS_APPLE_YEARLY_CENTS,
105# STATUS_ANTHROPIC_ADMIN_KEY (an sk-ant-admin… key with
106# read-only usage scope β€” NOT the sidecars' completion
107# key), and STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT
108# (an account API token β€” read-only use here, never
109# echoed β€” plus the numeric account id).
110# /opt/lchat/status-pages/ β€” the viewer pages, copied from web/status/
111# (status.css, index.html, books.html, economics.html,
112# economics.json, carbon.html, carbon.json,
113# contributors.html, contributors.json, security.html,
114# security.json, prompts.html, icon-192.png). status.css
115# is the shared stylesheet every page links; it uploads
116# before the pages (see publish()).
117# aws CLI + sqlite3 (apt install awscli sqlite3).
118set -euo pipefail
119
120BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
121REGION=${STATUS_REGION:-eu-west-1}
122REGISTRY_DB=${STATUS_REGISTRY_DB:-/data/registry.db}
123CLOUD_ENV=${STATUS_CLOUD_ENV:-/data/cloud.env}
124# The control daemon's env (capacity caps: LCHAT_MAX_INSTANCES etc.). May be
125# root-owned/unreadable to the lchat user β€” then the code defaults below apply,
126# which are exactly what controld itself defaults to when the vars are unset.
127CONTROL_ENV=${STATUS_CONTROL_ENV:-/data/control.env}
128# The router's own env β€” read (same lchat user, same file the router loads)
129# only for the mesh's bucket/prefix, so this box can find its live peers'
130# self-published stats (mesh_peer_boxes below). Absent LCHAT_HIBERNATE_BUCKET/
131# _PREFIX here just means the mesh isn't configured on this box β€” hosts stays
132# a single-box array, byte-identical to before multi-box existed.
133ROUTER_ENV=${STATUS_ROUTER_ENV:-/data/router.env}
134# Staleness cutoff for a peer's self-published stats object β€” three missed
135# beats, mirroring mesh.go's own meshStaleAfter convention, at the STATS
136# cadence (meshstats.go's meshStatsInterval, 60s) rather than the membership
137# heartbeat's faster one.
138MESH_STATS_STALE_SECONDS=${STATUS_MESH_STATS_STALE_SECONDS:-180}
139# Box-load + disk inputs (STATUS_ overrides exist for testability; the dry-run
140# uses fixture twins instead and never reads live box state).
141MEMINFO=${STATUS_MEMINFO:-/proc/meminfo}
142DATA_DIR=${STATUS_DATA_DIR:-/data}
143# Metered-expense cache (see cached_cost_cents): AWS Cost Explorer and the
144# Anthropic cost report both bill/limit per request while this script runs
145# every 5 minutes, so their results persist here between runs (one JSON object
146# per provider: {fetched_at, month, month_to_date_cents}). Lives in /data so
147# it survives reboots; the dry-run repoints it into its fixture dir.
148COSTS_CACHE=${STATUS_COSTS_CACHE:-$DATA_DIR/status-costs-cache.json}
149# Coarse public region of this box β€” continent + country ONLY ("EU Β· Germany");
150# see PRIVACY above. Optional: unset omits the field from fleet.json. Per-box
151# regions will come from mesh heartbeats when multi-box lands. Resolution:
152# STATUS_BOX_REGION env (explicit override wins) β†’ the box's own cloud
153# metadata service (Hetzner, then AWS IMDSv2), mapped zone→coarse region →
154# omitted entirely when nothing answers. Hand-set values drift (the box was
155# labelled Germany for a day; it lives in Helsinki) β€” asking the machine
156# where it is can't. The map stays coarse by design: continent + country.
157box_region() {
158 if [ -n "${STATUS_BOX_REGION:-}" ]; then printf '%s' "$STATUS_BOX_REGION"; return; fi
159 [ -n "$DRY_RUN" ] && return # dry-run never probes live box state
160 local zone
161 zone=$(curl -sm2 http://169.254.169.254/hetzner/v1/metadata/availability-zone 2>/dev/null || true)
162 case "$zone" in
163 hel*) printf 'EU Β· Finland'; return ;;
164 fsn*|nbg*) printf 'EU Β· Germany'; return ;;
165 ash*|hil*) printf 'NA Β· USA'; return ;;
166 sin*) printf 'AS Β· Singapore'; return ;;
167 esac
168 # AWS: IMDSv2 (token required; both calls fail closed in ~2s off-cloud)
169 local tok az
170 tok=$(curl -sm2 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 60" 2>/dev/null || true)
171 [ -n "$tok" ] && az=$(curl -sm2 -H "X-aws-ec2-metadata-token: $tok" http://169.254.169.254/latest/meta-data/placement/region 2>/dev/null || true)
172 case "$az" in
173 eu-west-1) printf 'EU Β· Ireland' ;;
174 eu-west-2) printf 'EU Β· UK' ;;
175 eu-central-1) printf 'EU Β· Germany' ;;
176 eu-north-1) printf 'EU Β· Sweden' ;;
177 us-east-*|us-west-*) printf 'NA Β· USA' ;;
178 *) : ;; # unknown zone: omit, never guess
179 esac
180}
181STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
182PAGES_DIR=${STATUS_PAGES_DIR:-/opt/lchat/status-pages}
183STRIPE_API=${STATUS_STRIPE_API:-https://api.stripe.com}
184
185# Carbon "run" block coefficients (see build_carbon + docs/comparison-carbon.md
186# Β§ The run methodology; snapshot July 2026, all BANDED low/central/high). The
187# fleet hibernates and runs at a fraction of the box, so the attributable draw
188# is a low-single-digit-to-~15W slice of a lightly loaded server β€” modelled here
189# as per-vCPU average watts (marginal β†’ whole-box-idle-inclusive):
190CARBON_W_LO=${STATUS_CARBON_W_LO:-1.5} # W/vCPU, marginal (load-proportional)
191CARBON_W_CE=${STATUS_CARBON_W_CE:-3.5} # W/vCPU, central
192CARBON_W_HI=${STATUS_CARBON_W_HI:-7.5} # W/vCPU, whole-box idle-inclusive share
193CARBON_PUE=${STATUS_CARBON_PUE:-1.15} # AWS-published fleet-PUE ballpark
194# AWS storage+requests: near-zero but NOT zero β€” a small explicit banded term
195# (~a gram a day: ~0.1 TB of our ciphertext Γ— ~1 Wh/TB-h Γ— 24h Γ— a grid).
196CARBON_AWS_LO=${STATUS_CARBON_AWS_LO:-0.0002}
197CARBON_AWS_CE=${STATUS_CARBON_AWS_CE:-0.001}
198CARBON_AWS_HI=${STATUS_CARBON_AWS_HI:-0.003}
199# Bot inference (the sidecars' ongoing Anthropic API use): small today, bounded
200# wide β€” the high end allows for busy days. Precise token counts exist in the
201# provider console; we publish a bounded estimate rather than couple to it.
202CARBON_INF_LO=${STATUS_CARBON_INF_LO:-0.0005}
203CARBON_INF_CE=${STATUS_CARBON_INF_CE:-0.005}
204CARBON_INF_HI=${STATUS_CARBON_INF_HI:-0.05}
205# The fleet's start-of-life (drives the cumulative-to-date figure). The dry-run
206# pins STATUS_CARBON_DAYS instead so its cumulative is deterministic.
207CARBON_SINCE=${STATUS_CARBON_SINCE:-2026-07-05}
208SELF=/opt/lchat/status-publish.sh
209UNIT=/etc/systemd/system/lchat-status.service
210TIMER=/etc/systemd/system/lchat-status.timer
211
212DRY_RUN=""
213FIXDIR=""
214
215TMP=$(mktemp -d)
216trap 'rm -rf "$TMP"' EXIT
217
218# sq runs a query against an on-disk DB with a busy timeout (the router writes
219# the registry concurrently); sqj evaluates JSON expressions in :memory:. All
220# JSON this script emits is BUILT BY SQLITE's json functions, never by string
221# interpolation in bash β€” DB/Stripe-sourced strings (versions, tier keys) get
222# escaped correctly and can't break or inject into the output.
223sq() { sqlite3 -cmd '.timeout 3000' "$REGISTRY_DB" "$1"; }
224sqj() { sqlite3 :memory: "$1"; }
225
226now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
227
228# ---- box probes (each has a dry-run twin so --dry-run exercises the same
229# ---- aggregation code with no live systemctl/ss/journalctl) ------------------
230
231# svc_active <unit> β€” unprivileged read-only state query.
232svc_active() {
233 if [ -n "$DRY_RUN" ]; then grep -qxF "$1" "$FIXDIR/active-units.txt"; return; fi
234 systemctl is-active --quiet "$1"
235}
236
237# music_sockets β€” the kernel's list of LISTENING unix sockets. This is the
238# awake test for hibernating hosts, and the choice matters:
239# - Socket-file EXISTENCE is NOT sound: an instance unlinks a stale socket
240# file on start (main.go listen()) but never unlinks on exit, so a slept
241# instance leaves the file behind. Existence would count every host that
242# has ever woken as awake forever.
243# - Dialing the socket (curl --unix-socket) IS sound but touches the
244# instance β€” a probe every 5 minutes must not risk interacting with the
245# activator's idle accounting or show up in instance logs.
246# - `ss -xlH` reads the socket table: an entry exists iff a process has the
247# path BOUND AND LISTENING right now (the activator's children bind the
248# same /run/lchat/<host>.sock path systemd units use). Zero side effects.
249music_sockets() {
250 if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/music.txt"; return; fi
251 if command -v ss >/dev/null 2>&1; then ss -xlH 2>/dev/null || true; fi
252}
253
254# replication_lag_json β€” seconds since the fleet replicator (lchat-litestream,
255# deploy/fleet-backup.sh) last logged anything: an approximation of replication
256# lag (litestream logs each sync; a healthy replicator is chatty, a wedged one
257# goes silent). "null" when the journal is unreadable or empty β€” published as
258# null, never faked.
259replication_lag_json() {
260 if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/replication-lag.txt"; return; fi
261 local line ts now
262 line=$(journalctl -u lchat-litestream -n1 -o json 2>/dev/null || true)
263 if [ -z "$line" ]; then echo "null"; return; fi
264 printf '%s' "$line" > "$TMP/journal.json"
265 ts=$(sqj "SELECT CAST(json_extract(readfile('$TMP/journal.json'), '\$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000" || echo "")
266 if [ -z "$ts" ] || [ "$ts" = "0" ]; then echo "null"; return; fi
267 now=$(date -u +%s)
268 echo $((now - ts))
269}
270
271# ---- capacity + hosts probes (ABSTRACT BANDS ONLY β€” see PRIVACY above) -------
272
273# env_int <file> <var> <default> β€” one integer from an env file (same grep/cut
274# shape as the Stripe key read). Missing file/var or a non-numeric value falls
275# back to the default β€” for the capacity caps the defaults mirror controld.go's
276# code defaults (#663), so an absent/unreadable control.env yields the same
277# numbers controld itself enforces.
278env_int() {
279 local v
280 v=$(grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true)
281 case "$v" in ''|*[!0-9]*) echo "$3" ;; *) echo "$v" ;; esac
282}
283
284# env_str <file> <VAR> β€” like env_int but for string values (bucket names,
285# prefixes): no numeric coercion, empty when the file or var is absent.
286env_str() {
287 grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true
288}
289
290# b64url_decode reverses encodeHeartbeatKey (mesh.go): url-safe alphabet,
291# padding stripped β€” re-add it (len mod 4 tells you how much) before handing
292# off to the standard decoder. Used by mesh_peer_boxes to recognize (and
293# skip) THIS box's own entry in the stats prefix.
294b64url_decode() {
295 local s
296 s=$(printf '%s' "$1" | tr -- '-_' '+/')
297 case $(( ${#s} % 4 )) in
298 2) s="${s}==" ;;
299 3) s="${s}=" ;;
300 esac
301 printf '%s' "$s" | base64 -d 2>/dev/null
302}
303
304# disk_free_gb β€” whole GB free on the data disk (`df -Pk`: POSIX, and avail
305# matches controld's Statfs Bavail). Empty on failure β€” an unknown contributes
306# nothing to the band, never a guess.
307disk_free_gb() {
308 if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/disk-free-gb.txt"; return; fi
309 df -Pk "$DATA_DIR" 2>/dev/null | awk 'NR==2 && $4 ~ /^[0-9]+$/ { printf "%d", $4 / 1048576 }' || true
310}
311
312# meminfo_kb <Field> β€” one kB figure from /proc/meminfo (MemTotal /
313# MemAvailable drive the box-load band). Empty when unreadable (e.g. a
314# non-Linux dev machine) β€” box load then publishes null, never a guess.
315meminfo_kb() {
316 local src="$MEMINFO"
317 if [ -n "$DRY_RUN" ]; then src="$FIXDIR/meminfo.txt"; fi
318 awk -v f="$1:" '$1 == f { print $2; exit }' "$src" 2>/dev/null || true
319}
320
321# pct_band <pct> β€” the three public bands as an index: 0 below 70%, 1 at
322# 70–90%, 2 above 90%. These thresholds ARE the abstraction: only the band
323# word ships, the percentage dies here.
324pct_band() { if [ "$1" -lt 70 ]; then echo 0; elif [ "$1" -le 90 ]; then echo 1; else echo 2; fi; }
325
326# The two public vocabularies, one per surface: capacity (room for new spaces)
327# and per-box load. Same bands, different words.
328band_word() { case "$1" in 0) echo plenty ;; 1) echo getting-full ;; *) echo at-capacity ;; esac; }
329load_word() { case "$1" in 0) echo comfortable ;; 1) echo busy ;; *) echo critical ;; esac; }
330
331# ---- fleet.json --------------------------------------------------------------
332
333# box_label derives a stable, whimsical, anonymous name for this box β€”
334# "ada-stargazing" β€” from a hash of the machine id. Notable names, real and
335# fictional (history, literature, myth, science, pop culture), paired with an
336# action. The hash direction only (id -> label) is published, so the label
337# leaks nothing; the action list deliberately avoids the dashboard's own
338# status vocabulary (no sleeping/waking/resting β€” a box is not an instance).
339box_label() {
340 local names=(ada alan grace marie nikola leonardo cleopatra homer sappho
341 darwin einstein curie turing hopper tesla gandalf frodo sherlock watson
342 ahab ishmael gatsby scout atticus hermione matilda mowgli merlin arthur
343 morgana beowulf odysseus penelope achilles athena apollo freya thor loki
344 amelia boudicca cyrano quixote sancho ophelia viola rosalind puck oberon
345 titania darcy pip scrooge nemo phileas zorro heidi galileo kepler newton
346 archimedes hypatia confucius rumi frida vincent wolfgang ludwig clara)
347 local actions=(stargazing juggling whistling painting sailing gardening
348 baking skating humming doodling fishing knitting strolling yodeling
349 beekeeping fencing waltzing sketching brewing climbing cartwheeling
350 birdwatching pondering inventing exploring composing conducting
351 spelunking kayaking orienteering puppeteering tinkering)
352 local idfile=${STATUS_MACHINE_ID_FILE:-/etc/machine-id}
353 local seed
354 seed=$( { cat "$idfile" 2>/dev/null || hostname; } | sha256sum | cut -c1-16)
355 # two independent hex slices -> indices; bash arithmetic handles hex via 16#
356 local n=$(( 16#${seed:0:8} % ${#names[@]} ))
357 local a=$(( 16#${seed:8:8} % ${#actions[@]} ))
358 printf '%s-%s' "${names[$n]}" "${actions[$a]}"
359}
360
361# mesh_peer_boxes prints a JSON array of every OTHER live mesh box's
362# self-published stats object β€” {label, awake, asleep, load} β€” read from the
363# hibernation bucket's stats side-channel (internal/activator/heartbeat.go's
364# PutStats, a sibling object next to the membership heartbeat every mesh
365# member already writes; meshstats.go computes each box's own snapshot on its
366# own slower cadence). Deliberately NOT a new box-to-box port: this rides the
367# same S3 substrate the mesh already uses for membership, exactly the seam
368# mesh.go's own doc comment anticipates ("a future AWS pool" that "may not
369# even share a network"). Empty array β€” not an error β€” whenever this box
370# isn't mesh-configured, has no live peers, or any AWS call fails; a missing
371# peer here just means the Fleet page undercounts by one box, never a
372# publish failure.
373#
374# As a side effect (one listing, not two), also writes THIS box's own
375# self-published stats body to $TMP/mesh-self-stats-result.json (removed if
376# unpublished/stale/mesh off) β€” computeBoxStats (meshstats.go) already
377# ownership-partitions this box's own hib count the same way it does for a
378# peer, so build_fleet can prefer this over its own local `ss` sweep instead
379# of re-deriving an unfiltered view that double-counts against a peer's row.
380# A file, not a global var: the caller captures this function's stdout with
381# `$(...)`, which runs it in a subshell β€” a global assignment made in there
382# never survives back to the parent shell, but a file write does.
383mesh_peer_boxes() {
384 local self_out="$TMP/mesh-self-stats-result.json"
385 rm -f "$self_out"
386 if [ -n "$DRY_RUN" ]; then
387 if [ -f "$FIXDIR/mesh-self-stats.json" ]; then cp "$FIXDIR/mesh-self-stats.json" "$self_out"; fi
388 if [ -f "$FIXDIR/mesh-peers.json" ]; then cat "$FIXDIR/mesh-peers.json"; else echo '[]'; fi
389 return
390 fi
391
392 local bucket region prefix mesh_prefix self
393 bucket=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_BUCKET)
394 prefix=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_PREFIX)
395 if [ -z "$bucket" ] || [ -z "$prefix" ]; then echo '[]'; return; fi
396 region=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_REGION)
397 region=${region:-eu-west-1}
398 # This box's own mesh identity, if its OWN mesh is on (LCHAT_MESH_SELF set)
399 # β€” skipped below. Without this, a box whose mesh is on double-counts
400 # itself the moment it starts self-publishing to this same stats channel:
401 # once as "the local box" (this script's own OS reads, elsewhere in
402 # build_fleet) and again as a "peer" read back from S3. Found live
403 # 2026-07-13, box A's flip: the Fleet page briefly showed hosts.count 3
404 # (one real duplicate) instead of 2.
405 self=$(env_str "$ROUTER_ENV" LCHAT_MESH_SELF)
406 # Must match hc.meshPrefix() (activator.go) exactly β€” the membership
407 # heartbeat lives at "<prefix>/mesh/members/<peer>", and statsKey
408 # (internal/activator/heartbeat.go) appends "-stats" to that WHOLE path,
409 # not to the bare hibernation prefix: "<prefix>/mesh/members-stats/<peer>".
410 # (Caught 2026-07-13 before the flip: an earlier version of this function
411 # listed "<prefix>-stats/" instead β€” empty either way today since nothing
412 # has ever published there, so it silently proved nothing.)
413 mesh_prefix="${prefix}/mesh/members"
414
415 # `aws s3 ls` (the high-level command), never `aws s3api list-objects*` β€”
416 # the latter crashes on this fleet's aws-cli/Python combination (a Python
417 # 3.13+ argparse regression in botocore's generated help text: ValueError
418 # "badly formed help string", thrown while building the argument parser,
419 # before any request is even sent β€” found live on box A, 2026-07-13,
420 # confirmed against both list-objects-v2 and the older list-objects; `aws
421 # s3 ls`/`aws s3 cp` are unaffected, and are what the rest of this script
422 # already relies on). `s3 ls` hits the same ListObjectsV2 API underneath;
423 # its plain-text output already has everything needed (date, time, key).
424 local listing
425 listing=$(aws s3 ls "s3://$bucket/${mesh_prefix}-stats/" --region "$region" 2>/dev/null) || true
426 [ -n "$listing" ] || { echo '[]'; return; }
427
428 # A plain string compare against the cutoff is exact as long as both sides
429 # use the same "YYYY-MM-DD HH:MM:SS" shape (space-, not T-separated β€”
430 # `aws s3 ls`'s own format) β€” no date-library parsing needed, and a box
431 # whose clock is off only ever misjudges its OWN staleness, same tradeoff
432 # mesh.go's own reader-vs-S3-clock note accepts for membership.
433 local cutoff peers="[]" line date_part time_part key tmp body
434 cutoff=$(date -u -d "-${MESH_STATS_STALE_SECONDS} seconds" '+%Y-%m-%d %H:%M:%S')
435
436 while IFS= read -r line; do
437 [ -n "$line" ] || continue
438 date_part=$(printf '%s' "$line" | awk '{print $1}')
439 time_part=$(printf '%s' "$line" | awk '{print $2}')
440 key=$(printf '%s' "$line" | awk '{print $4}')
441 [ -n "$key" ] || continue
442 [ "$date_part $time_part" '>' "$cutoff" ] || continue # stale β€” treat like a dead mesh member
443 tmp="$TMP/mesh-stat-$(printf '%s' "$key" | tr '/' '_').json"
444 aws s3 cp "s3://$bucket/${mesh_prefix}-stats/$key" "$tmp" --region "$region" >/dev/null 2>&1 || continue
445 body=$(cat "$tmp" 2>/dev/null) || continue
446 [ -n "$body" ] || continue
447 if [ -n "$self" ] && [ "$(b64url_decode "$key")" = "$self" ]; then
448 printf '%s' "$body" > "$self_out"
449 continue
450 fi
451 peers=$(sqj "SELECT json_insert(
452 json('$(printf '%s' "$peers" | sed "s/'/''/g")'), '\$[#]',
453 json('$(printf '%s' "$body" | sed "s/'/''/g")'))")
454 done <<< "$listing"
455
456 printf '%s' "$peers"
457}
458
459build_fleet() {
460 local out=$1 now total systemd_n hib awake_json asleep_json versions
461 now=$(now_iso)
462
463 total=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'")
464 hib=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance' AND hibernate=1")
465 systemd_n=$((total - hib))
466
467 # Awake = hibernating hosts whose socket is currently bound+music (see
468 # music_sockets above). Hostnames stay inside this loop β€” only the
469 # counts leave it. If `ss` is unavailable the split is published as null
470 # (unknown), never guessed.
471 if [ -n "$DRY_RUN" ] || command -v ss >/dev/null 2>&1; then
472 local music awake=0 sock
473 music=$(music_sockets)
474 while IFS= read -r sock; do
475 [ -z "$sock" ] && continue
476 # -F fixed-string match: every socket path starts /run/lchat/, so one
477 # path can never be a substring of another host's line.
478 if printf '%s\n' "$music" | grep -qF "$sock"; then awake=$((awake+1)); fi
479 done < <(sq "SELECT socket FROM routes WHERE kind='instance' AND hibernate=1")
480 awake_json=$awake
481 asleep_json=$((hib - awake))
482 else
483 awake_json=null
484 asleep_json=null
485 fi
486
487 # Version distribution: version strings and counts only (git SHAs /
488 # release versions β€” already public via the signed release feed). No
489 # hostnames, ever.
490 versions=$(sq "SELECT COALESCE(json_group_array(json_object('version', v, 'count', c)), json('[]'))
491 FROM (SELECT CASE WHEN version='' THEN 'unknown' ELSE version END AS v, COUNT(*) AS c
492 FROM routes WHERE kind='instance' GROUP BY v ORDER BY c DESC, v)")
493
494 local home_up=false push_up=false cloud_up=false connect_up=false rep_ok=false
495 svc_active lchat-home && home_up=true
496 svc_active lchat-push && push_up=true
497 svc_active lchat-cloud && cloud_up=true
498 svc_active lchat-connect && connect_up=true
499 svc_active lchat-litestream && rep_ok=true
500 local lag
501 lag=$(replication_lag_json)
502
503 # ---- capacity: the abstract room-for-new-spaces band ------------------------
504 # Worst-of over every input controld's atCapacity() enforces (controld.go
505 # #663) plus memory pressure, each folded through pct_band's 70/90 lines.
506 # Only the resulting WORD is published; every number below dies in this
507 # block. Caps come from the control daemon's env, defaulting to controld's
508 # own code defaults: 40 instances, 5 GB disk floor, registered ceiling
509 # 0 β†’ 10x instances.
510 local max_inst min_free max_reg hib_creates band=0 b
511 max_inst=$(env_int "$CONTROL_ENV" LCHAT_MAX_INSTANCES 40)
512 min_free=$(env_int "$CONTROL_ENV" LCHAT_MIN_FREE_GB 5)
513 max_reg=$(env_int "$CONTROL_ENV" LCHAT_MAX_REGISTERED 0)
514 hib_creates=$(env_int "$CONTROL_ENV" LCHAT_CONTROL_HIBERNATE_CREATES 0)
515 # A zero cap would be a config typo, and we must not divide by it.
516 [ "$max_inst" -gt 0 ] || max_inst=40
517
518 if [ "$hib_creates" = 1 ]; then
519 # hibernate-creates on: the instance cap guards what's MATERIALIZED
520 # (systemd-managed rows β€” hibernating rows hold no process); total
521 # registrations get the higher sanity ceiling. Mirrors atCapacity().
522 b=$(pct_band $((systemd_n * 100 / max_inst))); if [ "$b" -gt "$band" ]; then band=$b; fi
523 [ "$max_reg" -gt 0 ] || max_reg=$((10 * max_inst))
524 b=$(pct_band $((total * 100 / max_reg))); if [ "$b" -gt "$band" ]; then band=$b; fi
525 else
526 b=$(pct_band $((total * 100 / max_inst))); if [ "$b" -gt "$band" ]; then band=$b; fi
527 fi
528
529 # Disk: the floor is controld's hard refusal line, so at/below it is
530 # at-capacity outright. Above it, band the PROXIMITY to the floor
531 # (floor/free): free at 10x the floor reads 10% (plenty), at 1.25x it reads
532 # 80% (getting-full).
533 local free_gb
534 free_gb=$(disk_free_gb)
535 if [ -n "$free_gb" ]; then
536 if [ "$free_gb" -le "$min_free" ]; then
537 band=2
538 else
539 b=$(pct_band $((min_free * 100 / free_gb))); if [ "$b" -gt "$band" ]; then band=$b; fi
540 fi
541 fi
542
543 # Memory: utilization (MemTotal - MemAvailable)/MemTotal feeds both the
544 # capacity band and this box's load word. Unreadable meminfo β†’ load is
545 # null and memory contributes nothing (unknowns never guess).
546 local mem_total mem_avail mem_pct load_json=null
547 mem_total=$(meminfo_kb MemTotal)
548 mem_avail=$(meminfo_kb MemAvailable)
549 if [ -n "$mem_total" ] && [ -n "$mem_avail" ] && [ "$mem_total" -gt 0 ]; then
550 mem_pct=$(((mem_total - mem_avail) * 100 / mem_total))
551 b=$(pct_band "$mem_pct"); if [ "$b" -gt "$band" ]; then band=$b; fi
552 load_json="\"$(load_word "$b")\""
553 fi
554 local capacity_level
555 capacity_level=$(band_word "$band")
556
557 # ---- hosts: this box, plus every other live mesh member --------------------
558 # Labels are whimsical name-action pairs derived from a HASH of the machine
559 # id ("ada-stargazing") β€” stable per box, and never peer URLs, IPs,
560 # hostnames, or the machine id itself. Each box's awake/asleep entry here is
561 # its OWN locally-verified split. mesh_peer_boxes must run FIRST: besides
562 # every peer's own snapshot, it also writes THIS box's own self-published
563 # one to $TMP/mesh-self-stats-result.json β€” computeBoxStats (meshstats.go)
564 # already filters that to routes this box currently HRW-owns, so preferring
565 # it here
566 # (over the raw local `ss` sweep above) keeps this box's row from
567 # double-reporting a mesh-replicated route a peer now owns. Falls back to
568 # the local sweep when mesh is off, unpublished yet, or stale β€” same
569 # "unknowns degrade gracefully" rule as the rest of this file. (Below, the
570 # top-level `instances.awake`/`asleep` fields are summed across every box in
571 # this array β€” a per-box figure here is this box's own ownership-scoped
572 # view, not the fleet total.) The optional region is the operator-set coarse
573 # continent + country word pair (see PRIVACY) β€” absent env, absent key,
574 # never guessed; escaped like the version strings so a quote can't break out
575 # of the SQL literal. A peer's region (if its own LCHAT_BOX_REGION is set β€”
576 # meshstats.go's boxRegion) rides through automatically: mesh_peer_boxes
577 # inserts each peer's whole self-published object verbatim, so an extra key
578 # needs no bash change here. `always_on` stays a genuinely local-box-only
579 # concept, though β€” no peer publishes one.
580 local boxes peer_boxes region_kv=""
581 peer_boxes=$(mesh_peer_boxes)
582 local self_stats_file="$TMP/mesh-self-stats-result.json"
583 if [ -s "$self_stats_file" ]; then
584 local self_esc self_awake
585 self_esc=$(cat "$self_stats_file" | sed "s/'/''/g")
586 self_awake=$(sqj "SELECT json_extract('$self_esc', '\$.awake')")
587 if [ "$self_awake" != null ] && [ -n "$self_awake" ]; then
588 awake_json=$self_awake
589 asleep_json=$(sqj "SELECT json_extract('$self_esc', '\$.asleep')")
590 fi
591 fi
592 # Resolved here, not at file top: DRY_RUN/argv must be parsed first so the
593 # dry-run's no-live-probes guard actually guards (and the dry-run fixture's
594 # direct BOX_REGION= assignment still wins over a re-resolve).
595 [ -z "${BOX_REGION:-}" ] && BOX_REGION=$(box_region)
596 if [ -n "$BOX_REGION" ]; then
597 region_kv=", 'region', '$(printf '%s' "$BOX_REGION" | sed "s/'/''/g")'"
598 fi
599 boxes=$(sqj "SELECT json_array(json_object(
600 'label', '$(box_label)',
601 'always_on', $systemd_n,
602 'awake', json('$awake_json'),
603 'asleep', json('$asleep_json'),
604 'load', json('$load_json')$region_kv))")
605
606 boxes=$(sqj "SELECT json_group_array(json(value)) FROM (
607 SELECT value FROM json_each('$(printf '%s' "$boxes" | sed "s/'/''/g")')
608 UNION ALL
609 SELECT value FROM json_each('$(printf '%s' "$peer_boxes" | sed "s/'/''/g")'))")
610 local host_count
611 host_count=$(sqj "SELECT json_array_length('$(printf '%s' "$boxes" | sed "s/'/''/g")')")
612
613 # ---- fleet-wide awake/asleep: sum every box's own verified count ----------
614 # `hib` above is already fleet-wide (this box's registry is the replicated
615 # superset β€” mesh-seed/fan-out only ever ADD rows to a peer, never remove
616 # them here), but `awake_json`/`asleep_json` up to this point are still only
617 # this box's own `ss` check β€” a host actually running on a peer has no local
618 # socket, so it silently read as "asleep" here even while genuinely awake.
619 # `boxes` now holds every live box's own verified split, so summing across
620 # it turns the local view into the real fleet-wide one. If any box's awake
621 # is unknown (null β€” e.g. `ss` missing there), the fleet split stays null
622 # too, rather than guessing: same "unknowns never guess" rule as the rest of
623 # this file.
624 local boxes_esc unknown_awake
625 boxes_esc=$(printf '%s' "$boxes" | sed "s/'/''/g")
626 unknown_awake=$(sqj "SELECT COUNT(*) FROM json_each('$boxes_esc') WHERE json_extract(value, '\$.awake') IS NULL")
627 if [ "$unknown_awake" = 0 ]; then
628 awake_json=$(sqj "SELECT SUM(json_extract(value, '\$.awake')) FROM json_each('$boxes_esc')")
629 asleep_json=$((hib - awake_json))
630 else
631 awake_json=null
632 asleep_json=null
633 fi
634
635 # Assembled by sqlite json_object: the only interpolated values are
636 # script-controlled numerals/booleans/band-words and the sqlite-built arrays.
637 sqj "SELECT json_object(
638 'generated_at', '$now',
639 'instances', json_object(
640 'total', $total,
641 'systemd', $systemd_n,
642 'hibernating', $hib,
643 'awake', json('$awake_json'),
644 'asleep', json('$asleep_json')),
645 'hosts', json_object(
646 'count', $host_count,
647 'boxes', json('$boxes')),
648 'capacity', json_object(
649 'level', '$capacity_level'),
650 'versions', json('$(printf '%s' "$versions" | sed "s/'/''/g")'),
651 'services', json_object(
652 'home', json('$home_up'),
653 'push', json('$push_up'),
654 'cloud', json('$cloud_up'),
655 'connect', json('$connect_up')),
656 'replication', json_object(
657 'healthy', json('$rep_ok'),
658 'lag_seconds', json('$lag')))" > "$out"
659}
660
661# ---- books.json --------------------------------------------------------------
662
663# stripe_get <path-and-query> <outfile> β€” one authenticated GET. The key rides
664# only in the header; -sS keeps curl from ever printing it. Dry-run reads
665# numbered fixture files instead (same downstream aggregation code).
666STRIPE_FIXTURE_N=0
667stripe_get() {
668 if [ -n "$DRY_RUN" ]; then
669 STRIPE_FIXTURE_N=$((STRIPE_FIXTURE_N + 1))
670 local kind=subs
671 case "$1" in
672 /v1/balance_transactions*) kind=bal ;;
673 /v1/application_fees*) kind=fees ;;
674 esac
675 cp "$FIXDIR/stripe-$kind-$STRIPE_FIXTURE_N.json" "$2"
676 return
677 fi
678 curl -sSg --fail --max-time 30 -H "Authorization: Bearer $STRIPE_KEY" \
679 "$STRIPE_API$1" > "$2"
680}
681
682# fetch_pages <path> <query> <prefix> β€” follow Stripe's cursor pagination
683# (has_more + starting_after) into $TMP/<prefix>-N.json. Hard cap of 50 pages
684# (5000 objects) keeps a pathological account from wedging the run. Every
685# failure is an explicit `return 1` β€” callers run this inside an `if !`, where
686# set -e is suspended, and a half-fetched month must degrade to "unavailable",
687# never aggregate to zeros that read as "no income".
688fetch_pages() {
689 local path=$1 query=$2 prefix=$3 after="" n=0 f has_more
690 while :; do
691 n=$((n + 1))
692 f="$TMP/$prefix-$n.json"
693 stripe_get "$path?limit=100${query:+&$query}${after:+&starting_after=$after}" "$f" || return 1
694 # A page that isn't a valid list envelope is a failed/garbled fetch.
695 [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1
696 has_more=$(sqj "SELECT json_extract(readfile('$f'), '\$.has_more')")
697 [ "$has_more" = "1" ] && [ "$n" -lt 50 ] || break
698 after=$(sqj "SELECT json_extract(readfile('$f'), '\$.data[#-1].id')")
699 [ -n "$after" ] || return 1
700 done
701 # Reset the fixture counter so the next endpoint's dry-run pages start at 1.
702 STRIPE_FIXTURE_N=0
703}
704
705books_unavailable() { # $1=out $2=reason (fixed, script-controlled) $3=expenses json ("" = omit)
706 local exp_kv=""
707 [ -n "${3:-}" ] && exp_kv=", 'expenses', json('$3')"
708 sqj "SELECT json_object('generated_at', '$(now_iso)', 'available', json('false'), 'reason', '$2'$exp_kv)" > "$1"
709}
710
711# ---- expenses (infrastructure spend) -----------------------------------------
712# Decision #19's other half: income aggregates, EXPENSES PUBLISH ITEMIZED β€”
713# one line per provider, real amount, that provider's own billing currency
714# (never FX-converted: publishing USD as USD and EUR as EUR means there is no
715# exchange rate to misstate). A provider that can't be honestly resolved is
716# OMITTED β€” never zero, never a guess β€” and with no providers at all the whole
717# expenses object is omitted, matching books_unavailable's degrade discipline.
718
719# env_cents <value> β€” validate an operator-set integer-cents constant; echoes
720# nothing for anything that isn't a plain non-negative integer.
721env_cents() { case "${1:-}" in ''|*[!0-9]*) ;; *) printf '%s' "$1" ;; esac; }
722
723# fetch_aws_cents <month> β€” one live Cost Explorer query: month start β†’
724# tomorrow (so today's partial day is included), MONTHLY granularity,
725# UnblendedCost. Cost Explorer is a GLOBAL endpoint homed in us-east-1 β€” the
726# --region below is the CE API's home, unrelated to STATUS_REGION (the status
727# bucket's region). Integrity before publishing: the Amount must be a plain
728# decimal and the Unit USD, or the fetch counts as failed β€” a garbled or
729# negative figure (credits) is omitted, never misstated. Dry-run twin reads a
730# fixture instead of calling AWS.
731fetch_aws_cents() {
732 local month=$1 f="$TMP/aws-ce.json" end amount unit
733 if [ -n "$DRY_RUN" ]; then
734 [ -f "$FIXDIR/aws-ce.json" ] || return 1
735 cp "$FIXDIR/aws-ce.json" "$f"
736 else
737 command -v aws >/dev/null 2>&1 || return 1
738 end=$(sqj "SELECT date('now','+1 day')")
739 aws ce get-cost-and-usage --region us-east-1 \
740 --time-period "Start=$month-01,End=$end" \
741 --granularity MONTHLY --metrics UnblendedCost \
742 --cli-connect-timeout 10 --cli-read-timeout 30 \
743 --output json > "$f" 2>/dev/null || return 1
744 fi
745 [ "$(sqj "SELECT json_valid(readfile('$f'))")" = "1" ] || return 1
746 amount=$(sqj "SELECT json_extract(readfile('$f'), '\$.ResultsByTime[0].Total.UnblendedCost.Amount')")
747 unit=$(sqj "SELECT json_extract(readfile('$f'), '\$.ResultsByTime[0].Total.UnblendedCost.Unit')")
748 [ "$unit" = "USD" ] || return 1
749 case "$amount" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac
750 sqj "SELECT CAST(ROUND($amount * 100) AS INTEGER)"
751}
752
753# hetzner_get_page <page> <outfile> β€” one authenticated GET of the Hetzner
754# Cloud servers list (Bearer auth; the token rides only in the header β€” -sS
755# keeps curl from ever printing it). Dry-run reads numbered fixture pages
756# instead, so the caller's pagination loop is exercised by the twin.
757hetzner_get_page() {
758 if [ -n "$DRY_RUN" ]; then
759 [ -f "$FIXDIR/hetzner-servers-$1.json" ] || return 1
760 cp "$FIXDIR/hetzner-servers-$1.json" "$2"
761 return
762 fi
763 [ -n "${STATUS_HETZNER_TOKEN:-}" ] || return 1
764 curl -sS --fail --max-time 30 \
765 -H "Authorization: Bearer $STATUS_HETZNER_TOKEN" \
766 "https://api.hetzner.cloud/v1/servers?page=$1&per_page=50" > "$2"
767}
768
769# fetch_hetzner_cents <month> β€” the account's monthly server cost, computed
770# from the Cloud API: GET /v1/servers, pages followed via
771# meta.pagination.next_page (hard cap 10 pages / 500 servers β€” a partial sum
772# must never publish). Each server embeds its type's per-location price list
773# (server_type.prices[]); the entry whose location matches the server's own
774# location.name prices it β€” price_monthly.gross, a decimal string, VAT
775# included. EXACTLY one entry may match per server or the fetch counts as
776# failed: no match means the server can't be honestly priced, and a duplicate
777# would double-count. The prices carry no currency field, so the published
778# `eur` is the documented constant (Hetzner bills in EUR), not parsed. The
779# figure is SERVER COMPUTE ONLY, deliberately: primary IPs, backups, volumes,
780# and traffic overages price via the separate /pricing endpoint, not
781# per-server β€” revisit if that delta ever matters. The <month> arg is unused
782# (a flat price list, not month-to-date); it exists for cached_cost_cents'
783# shared interface. STATUS_HETZNER_TOKEN is a READ-ONLY Cloud API token and
784# must never be echoed or logged; the operator constant
785# STATUS_HETZNER_MONTHLY_CENTS overrides this fetch entirely (expenses_object).
786fetch_hetzner_cents() {
787 local page=1 n=0 f next amt load="CREATE TABLE srv(j TEXT);"
788 local match="json_extract(p.value,'\$.location') = json_extract(s.j,'\$.location.name')"
789 while :; do
790 n=$((n + 1))
791 f="$TMP/hetzner-servers-$n.json"
792 hetzner_get_page "$page" "$f" || return 1
793 [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array'
794 AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1
795 load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');"
796 next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')")
797 [ -n "$next" ] || break
798 case "$next" in *[!0-9]*) return 1 ;; esac
799 [ "$n" -lt 10 ] || return 1
800 page=$next
801 done
802 # Exactly one matching price row per server (see above), and every matched
803 # amount a plain decimal β€” or the figure is omitted, never misstated.
804 [ "$(sqj "$load SELECT COUNT(*) FROM srv s
805 WHERE (SELECT COUNT(*) FROM json_each(s.j, '\$.server_type.prices') p WHERE $match) <> 1")" = "0" ] || return 1
806 while IFS= read -r amt; do
807 case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac
808 done < <(sqj "$load SELECT json_extract(p.value, '\$.price_monthly.gross')
809 FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match")
810 sqj "$load SELECT CAST(ROUND(COALESCE(SUM(json_extract(p.value, '\$.price_monthly.gross')), 0) * 100) AS INTEGER)
811 FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match"
812}
813
814# fetch_anthropic_cents <month> β€” one live Admin API cost-report call:
815# GET /v1/organizations/cost_report, daily buckets from the month start
816# (limit=31 covers a full month in one page; no ending_at β†’ runs to now, so
817# today's partial day is included). Amounts come back as decimal strings of
818# CENTS ("123.45" = $1.23), currency documented as always USD β€” a non-USD row
819# or a has_more page we didn't fetch counts as failure (never publish a
820# partial or unverified figure). STATUS_ANTHROPIC_ADMIN_KEY is an ADMIN key
821# (sk-ant-admin…) with read-only usage scope β€” NOT the sidecars' completion
822# key: it can read org cost/usage metadata and nothing else. It rides only in
823# the x-api-key header (-sS keeps curl from printing it) and must never be
824# echoed or logged. Dry-run twin reads a fixture instead.
825fetch_anthropic_cents() {
826 local month=$1 f="$TMP/anthropic-cost.json"
827 if [ -n "$DRY_RUN" ]; then
828 [ -f "$FIXDIR/anthropic-cost.json" ] || return 1
829 cp "$FIXDIR/anthropic-cost.json" "$f"
830 else
831 [ -n "${STATUS_ANTHROPIC_ADMIN_KEY:-}" ] || return 1
832 curl -sS --fail --max-time 30 \
833 -H "x-api-key: $STATUS_ANTHROPIC_ADMIN_KEY" \
834 -H "anthropic-version: 2023-06-01" \
835 "https://api.anthropic.com/v1/organizations/cost_report?starting_at=${month}-01T00:00:00Z&bucket_width=1d&limit=31" \
836 > "$f" || return 1
837 fi
838 [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1
839 [ "$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.has_more'), 0)")" != "1" ] || return 1
840 [ "$(sqj "SELECT COUNT(*) FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r
841 WHERE json_extract(r.value, '\$.currency') <> 'USD'")" = "0" ] || return 1
842 sqj "SELECT CAST(ROUND(COALESCE(SUM(json_extract(r.value, '\$.amount')), 0)) AS INTEGER)
843 FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r"
844}
845
846# fetch_dnsimple_cents <month> β€” one billing-charges page from DNSimple's API
847# (the product domains live there): GET /v2/{account}/billing/charges with
848# start_date=<month>-01 / end_date=<tomorrow> (ISO dates; tomorrow so today's
849# charges are included), per_page=100. A month of domain charges is a handful β€”
850# pagination.total_pages > 1 counts as failure (a partial sum must never
851# publish). Amounts are decimal strings of DOLLARS ("14.50"); the response
852# carries NO currency field, so the published currency is the documented
853# constant (DNSimple's price book is USD), not parsed. Only `collected`
854# charges sum β€” a refunded charge is not spend β€” and every summed amount must
855# be a plain decimal or the fetch counts as failed. STATUS_DNSIMPLE_TOKEN is
856# an account API token, read-only use here: it rides only in the
857# Authorization header (-sS keeps curl from printing it) and must never be
858# echoed or logged. Dry-run twin reads a fixture instead.
859fetch_dnsimple_cents() {
860 local month=$1 f="$TMP/dnsimple-charges.json" end amt
861 if [ -n "$DRY_RUN" ]; then
862 [ -f "$FIXDIR/dnsimple-charges.json" ] || return 1
863 cp "$FIXDIR/dnsimple-charges.json" "$f"
864 else
865 [ -n "${STATUS_DNSIMPLE_TOKEN:-}" ] && [ -n "${STATUS_DNSIMPLE_ACCOUNT:-}" ] || return 1
866 end=$(sqj "SELECT date('now','+1 day')")
867 curl -sS --fail --max-time 30 \
868 -H "Authorization: Bearer $STATUS_DNSIMPLE_TOKEN" \
869 "https://api.dnsimple.com/v2/$STATUS_DNSIMPLE_ACCOUNT/billing/charges?start_date=${month}-01&end_date=$end&per_page=100" \
870 > "$f" || return 1
871 fi
872 [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1
873 [ "$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.pagination.total_pages'), 1) <= 1")" = "1" ] || return 1
874 while IFS= read -r amt; do
875 case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac
876 done < <(sqj "SELECT json_extract(c.value, '\$.total_amount')
877 FROM json_each(readfile('$f'), '\$.data') c
878 WHERE json_extract(c.value, '\$.state')='collected'")
879 sqj "SELECT CAST(ROUND(COALESCE(SUM(json_extract(c.value, '\$.total_amount')), 0) * 100) AS INTEGER)
880 FROM json_each(readfile('$f'), '\$.data') c
881 WHERE json_extract(c.value, '\$.state')='collected'"
882}
883
884# cached_cost_cents <provider> <month> [default-age-hours] β€” the shared
885# fetch-through-cache for the API-computed providers (aws, hetzner, anthropic,
886# dnsimple β€” fetch_<provider>_cents above). These APIs charge or rate-limit
887# per request (Cost Explorer bills ~$0.01 per query) and this script runs
888# every 5 minutes β€” an uncached publisher would spend real money asking how
889# much it spends. So each provider's result is cached in $COSTS_CACHE and
890# refetched only when its entry is older than STATUS_COSTS_MAX_AGE_HOURS
891# (default: the caller's default-age-hours arg, else 6 β€” hetzner passes 24,
892# its flat price list changes ~never) or from a previous month (last month's
893# total is not this month's spend β€” a cross-month entry never publishes; for
894# hetzner that just means one refetch at the month turn). A failed refetch
895# falls back to the valid-but-stale same-month entry (stale beats absent);
896# no usable entry + failure β†’ echoes nothing and the provider is omitted.
897# The cache field name month_to_date_cents is generic storage β€” for hetzner
898# it holds the flat monthly figure.
899cached_cost_cents() {
900 local provider=$1 month=$2 now have_cache="" cache_ts=0 cache_month="" cache_cents="" max_age_h cents
901 now=$(date -u +%s)
902 max_age_h=${STATUS_COSTS_MAX_AGE_HOURS:-${3:-6}}
903 case "$max_age_h" in ''|*[!0-9]*) max_age_h=6 ;; esac
904
905 if [ -f "$COSTS_CACHE" ] && [ "$(sqj "SELECT json_valid(readfile('$COSTS_CACHE'))" 2>/dev/null)" = "1" ]; then
906 have_cache=1
907 cache_ts=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.fetched_at'), 0)")
908 cache_month=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.month'), '')")
909 cache_cents=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.month_to_date_cents'), '')")
910 fi
911 case "$cache_ts" in ''|*[!0-9]*) cache_ts=0 ;; esac
912 case "$cache_cents" in ''|*[!0-9]*) cache_cents="" ;; esac
913 [ "$cache_month" = "$month" ] || cache_cents="" # cross-month cache never publishes
914
915 if [ -n "$cache_cents" ] && [ $((now - cache_ts)) -lt $((max_age_h * 3600)) ]; then
916 echo "$cache_cents"
917 return 0
918 fi
919
920 cents=$("fetch_${provider}_cents" "$month") || cents=""
921 case "$cents" in ''|*[!0-9]*) cents="" ;; esac
922 if [ -n "$cents" ]; then
923 # Merge this provider's entry into the cache (other providers' entries survive).
924 local base="'{}'"
925 [ -n "$have_cache" ] && base="readfile('$COSTS_CACHE')"
926 sqj "SELECT json_set($base, '\$.$provider',
927 json_object('fetched_at', $now, 'month', '$month', 'month_to_date_cents', $cents))" \
928 > "$COSTS_CACHE.tmp" && mv "$COSTS_CACHE.tmp" "$COSTS_CACHE"
929 echo "$cents"
930 return 0
931 fi
932 [ -n "$cache_cents" ] && echo "$cache_cents"
933 return 0
934}
935
936# expenses_object β€” the expenses JSON object, or nothing when no provider
937# resolves. Providers, in publish order:
938# aws β€” Cost Explorer month-to-date (metered; cached above), USD.
939# hetzner β€” the flat-rate Hetzner Cloud servers. No billing API exists,
940# but the servers API prices each server (its type's embedded
941# per-location price list), so STATUS_HETZNER_TOKEN computes
942# the account's monthly server cost (fetch_hetzner_cents β€”
943# server compute only; cached, 24h default freshness). The
944# operator-set constant STATUS_HETZNER_MONTHLY_CENTS overrides
945# the API when set β€” operator truth wins. EUR either way
946# (Hetzner bills in EUR). Neither set β†’ omitted.
947# anthropic β€” Admin API cost report month-to-date (metered; cached), USD β€”
948# the bot sidecars' API spend.
949# claude.ai β€” the claude.ai subscriptions: no API for them, and the honest
950# figure Paul can attest is LIFETIME spend, not a monthly β€” so
951# the operator-set constant STATUS_CLAUDE_TO_DATE_CENTS (EUR)
952# publishes as to_date_cents, the cumulative shape. It's an
953# operator-attested estimate by nature, which is why the page
954# labels it "(approx.)".
955# dnsimple β€” the product domains (elevenmessenger.com, 11ch.at,
956# 11factor.org): billing-charges month-to-date (metered;
957# cached), USD.
958# apple β€” the Apple Developer Program membership: fixed YEARLY, no
959# billing API, so the operator-set constant
960# STATUS_APPLE_YEARLY_CENTS (EUR). Published as billed β€”
961# yearly_cents, never divided into a fake monthly.
962expenses_object() {
963 local month providers="" cents
964 month=$(sqj "SELECT strftime('%Y-%m','now')")
965
966 cents=$(cached_cost_cents aws "$month")
967 [ -n "$cents" ] && providers=$(sqj "SELECT json_object('provider','aws','month_to_date_cents',$cents,'currency','usd')")
968
969 # Operator constant first β€” when set, the API is never called (or cached).
970 cents=$(env_cents "${STATUS_HETZNER_MONTHLY_CENTS:-}")
971 [ -n "$cents" ] || cents=$(cached_cost_cents hetzner "$month" 24)
972 [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','hetzner','monthly_cents',$cents,'currency','eur')")"
973
974 cents=$(cached_cost_cents anthropic "$month")
975 [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','anthropic','month_to_date_cents',$cents,'currency','usd')")"
976
977 # Cumulative, not monthly: the operator attests lifetime spend to date (an
978 # approximation by nature β€” the page carries the "(approx.)" marker).
979 cents=$(env_cents "${STATUS_CLAUDE_TO_DATE_CENTS:-}")
980 [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','to_date_cents',$cents,'currency','eur')")"
981
982 cents=$(cached_cost_cents dnsimple "$month")
983 [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','dnsimple','month_to_date_cents',$cents,'currency','usd')")"
984
985 cents=$(env_cents "${STATUS_APPLE_YEARLY_CENTS:-}")
986 [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','apple','yearly_cents',$cents,'currency','eur')")"
987
988 [ -n "$providers" ] || return 0
989 sqj "SELECT json_object('month', '$month', 'providers', json('[$providers]'))"
990}
991
992build_books() {
993 local out=$1 now month month_start expenses_json expenses_kv=""
994 now=$(now_iso)
995
996 # Expenses resolve independently of Stripe β€” income being unavailable must
997 # not hide infrastructure spend we can honestly state, so the expenses
998 # object rides the degraded shape too (and is simply absent when no
999 # provider resolves).
1000 expenses_json=$(expenses_object)
1001 [ -n "$expenses_json" ] && expenses_kv=",
1002 'expenses', json('$expenses_json')"
1003
1004 # The Stripe key comes from the cloud provisioner's env (billing.go reads the
1005 # same var). Absent β†’ billing isn't configured here; publish an honest
1006 # "unavailable" rather than zeros that would read as "no income".
1007 STRIPE_KEY=$(grep -E '^LCHAT_STRIPE_SECRET_KEY=' "$CLOUD_ENV" 2>/dev/null | head -n1 | cut -d= -f2- || true)
1008 if [ -z "${STRIPE_KEY:-}" ]; then
1009 books_unavailable "$out" "billing not configured" "$expenses_json"
1010 return
1011 fi
1012
1013 # Month boundary via sqlite (portable β€” GNU/BSD date flags differ).
1014 month=$(sqj "SELECT strftime('%Y-%m','now')")
1015 month_start=$(sqj "SELECT strftime('%s', datetime('now','start of month'))")
1016
1017 # The three aggregation sources (decision #19 β€” these are the ONLY Stripe
1018 # reads, all fleet-wide):
1019 # GET /v1/subscriptions?status=active&limit=100 (+ cursor pages)
1020 # β†’ active count, per-tier counts (metadata.tier = public price-book
1021 # key), MRR from each item's price amount normalized to monthly.
1022 # GET /v1/balance_transactions?created[gte]=<month start>&limit=100
1023 # β†’ gross collected this month (positive charge/payment amounts).
1024 # GET /v1/application_fees?created[gte]=<month start>&expand[]=data.charge
1025 # β†’ spaces selling to their own members via Stripe Connect
1026 # (subscriptions.go's stripeConnect: direct charges on the space
1027 # admin's connected account, an application fee to the platform).
1028 # Those charges live on the CONNECTED accounts, so the two reads
1029 # above never see them; the fee list is the platform's one honest
1030 # window: each fee's amount is our commission, and its expanded
1031 # charge carries the gross sale amount β†’ GMV.
1032 # Degrade to "unavailable" on any fetch failure β€” a Stripe outage must not
1033 # publish half-numbers.
1034 if ! fetch_pages /v1/subscriptions "status=active" subs ||
1035 ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal ||
1036 ! fetch_pages /v1/application_fees "created[gte]=$month_start&expand[]=data.charge" fees; then
1037 books_unavailable "$out" "stripe unreachable" "$expenses_json"
1038 return
1039 fi
1040
1041 # Aggregate with sqlite json_each β€” never per-object output, only SUM/COUNT/
1042 # GROUP BY. Per-payer fields (ids, customer, created) die inside this query.
1043 {
1044 echo "CREATE TABLE subs(j TEXT);"
1045 echo "CREATE TABLE bal(j TEXT);"
1046 echo "CREATE TABLE fees(j TEXT);"
1047 local f
1048 for f in "$TMP"/subs-*.json; do
1049 echo "INSERT INTO subs SELECT value FROM json_each(readfile('$f'), '\$.data');"
1050 done
1051 for f in "$TMP"/bal-*.json; do
1052 echo "INSERT INTO bal SELECT value FROM json_each(readfile('$f'), '\$.data');"
1053 done
1054 for f in "$TMP"/fees-*.json; do
1055 echo "INSERT INTO fees SELECT value FROM json_each(readfile('$f'), '\$.data');"
1056 done
1057 # MRR: unit_amount Γ— quantity per item, normalized to a month. EUR items
1058 # only (the price book is EUR; mixing currencies without FX would lie).
1059 # Unknown intervals contribute 0 to MRR but still count as subscribers.
1060 # Connect: commission = the fee amounts, GMV = each fee's expanded charge
1061 # amount β€” same gross semantics as collected_month_cents (positive EUR
1062 # amounts; refunds don't net). GMV is the spaces' money, not ours: our
1063 # income from these sales is exactly the commission line, which is why it
1064 # ships as its own field and never folds into collected_month_cents.
1065 cat <<SQL
1066SELECT json_object(
1067 'generated_at', '$now',
1068 'available', json('true'),
1069 'currency', 'eur',
1070 'month', '$month',
1071 'subscriptions', json_object(
1072 'active', (SELECT COUNT(*) FROM subs),
1073 'by_tier', (SELECT COALESCE(json_group_array(json_object('tier', tier, 'count', c)), json('[]'))
1074 FROM (SELECT COALESCE(NULLIF(json_extract(j,'\$.metadata.tier'),''),'other') AS tier,
1075 COUNT(*) AS c
1076 FROM subs GROUP BY tier ORDER BY c DESC, tier))),
1077 'mrr_cents', CAST(ROUND(COALESCE((
1078 SELECT SUM(
1079 json_extract(i.value,'\$.price.unit_amount')
1080 * COALESCE(json_extract(i.value,'\$.quantity'),1)
1081 * (CASE json_extract(i.value,'\$.price.recurring.interval')
1082 WHEN 'month' THEN 1.0
1083 WHEN 'year' THEN 1.0/12.0
1084 WHEN 'week' THEN 52.0/12.0
1085 WHEN 'day' THEN 365.0/12.0
1086 ELSE 0 END)
1087 / COALESCE(json_extract(i.value,'\$.price.recurring.interval_count'),1))
1088 FROM subs s, json_each(s.j, '\$.items.data') i
1089 WHERE json_extract(i.value,'\$.price.currency')='eur'), 0)) AS INTEGER),
1090 'collected_month_cents', CAST(COALESCE((
1091 SELECT SUM(json_extract(b.j,'\$.amount')) FROM bal b
1092 WHERE json_extract(b.j,'\$.type') IN ('charge','payment')
1093 AND json_extract(b.j,'\$.currency')='eur'
1094 AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER),
1095 'connect', json_object(
1096 'gmv_month_cents', CAST(COALESCE((
1097 SELECT SUM(json_extract(f.j,'\$.charge.amount')) FROM fees f
1098 WHERE json_extract(f.j,'\$.charge.currency')='eur'
1099 AND json_extract(f.j,'\$.charge.amount') > 0), 0) AS INTEGER),
1100 'commission_month_cents', CAST(COALESCE((
1101 SELECT SUM(json_extract(f.j,'\$.amount')) FROM fees f
1102 WHERE json_extract(f.j,'\$.currency')='eur'
1103 AND json_extract(f.j,'\$.amount') > 0), 0) AS INTEGER))$expenses_kv);
1104SQL
1105 } | sqlite3 :memory: > "$out"
1106}
1107
1108# ---- carbon.json: republish the committed built ledger + inject a live run block
1109
1110# grid_band β€” the box region's grid carbon intensity as "<low> <central> <high>"
1111# g COβ‚‚e/kWh. low is always ~0 (a market-based renewable contract β€” Hetzner's
1112# EU sites run on renewable electricity); central+high are the LOCATION grid,
1113# banded. Region resolves from box_region() (coarse continent+country; the prod
1114# box is Helsinki β†’ Finland's low-carbon grid). Operator overrides:
1115# STATUS_CARBON_GRID_CENTRAL / _HIGH. Cited in docs/comparison-carbon.md.
1116grid_band() {
1117 local gce ghi region
1118 region=${BOX_REGION:-$(box_region)}
1119 case "$region" in
1120 "EU Β· Finland") gce=100; ghi=130 ;; # Finnish grid ~80–130 (Fingrid/ElectricityMaps 2026)
1121 "EU Β· Sweden") gce=40; ghi=60 ;;
1122 "EU Β· Germany") gce=350; ghi=400 ;;
1123 "EU Β· Ireland") gce=300; ghi=380 ;;
1124 "EU Β· UK") gce=210; ghi=280 ;;
1125 "NA Β· USA") gce=370; ghi=480 ;;
1126 *) gce=350; ghi=480 ;; # unknown: IEA-ish global band
1127 esac
1128 [ -n "${STATUS_CARBON_GRID_CENTRAL:-}" ] && gce=$STATUS_CARBON_GRID_CENTRAL
1129 [ -n "${STATUS_CARBON_GRID_HIGH:-}" ] && ghi=$STATUS_CARBON_GRID_HIGH
1130 printf '0 %s %s' "$gce" "$ghi"
1131}
1132
1133# hetzner_vcpus β€” total vCPUs across every server in the Hetzner account (the
1134# hardware the whole fleet runs on; sums so it scales as boxes are added). Same
1135# paginated servers-API read as fetch_hetzner_cents (dry-run twin included);
1136# each server_type.cores must be an integer or we can't honestly total. Prints
1137# the sum, or returns 1 (no token / bad page) so the run block stays absent.
1138hetzner_vcpus() {
1139 local page=1 n=0 f next load="CREATE TABLE srv(j TEXT);"
1140 while :; do
1141 n=$((n + 1))
1142 f="$TMP/hetzner-vcpus-$n.json"
1143 hetzner_get_page "$page" "$f" || return 1
1144 [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array'
1145 AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1
1146 load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');"
1147 next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')")
1148 [ -n "$next" ] || break
1149 case "$next" in *[!0-9]*) return 1 ;; esac
1150 [ "$n" -lt 10 ] || return 1
1151 page=$next
1152 done
1153 [ "$(sqj "$load SELECT COUNT(*) FROM srv WHERE json_type(j, '\$.server_type.cores') <> 'integer'")" = "0" ] || return 1
1154 sqj "$load SELECT COALESCE(SUM(json_extract(j, '\$.server_type.cores')), 0) FROM srv"
1155}
1156
1157# carbon_run_object β€” the live operational estimate as a JSON object, or nothing
1158# printed when the router term can't be resolved (no Hetzner token / fetch fail)
1159# so the published `run` stays absent and the page shows "pending", never a fake
1160# zero. Math (all BANDED): daily kg = vCPUs Γ— W/vCPU Γ— 24h Γ— grid(g/kWh) Γ— PUE
1161# / 1e6, plus small explicit AWS-storage + bot-inference terms. Reported as a
1162# daily rate, a cumulative-to-date (Γ— days since CARBON_SINCE, or a pinned
1163# STATUS_CARBON_DAYS), and normalised per active space (denominator = the public
1164# instance count from the registry β€” NEVER per-member). Aggregates only: vCPU
1165# total, watts, grid, kg, space count β€” no hostnames/IPs/per-space needles.
1166carbon_run_object() {
1167 local vcpus glo gce ghi spaces days since now
1168 vcpus=$(hetzner_vcpus) || vcpus=""
1169 case "$vcpus" in ''|*[!0-9]*|0) return 0 ;; esac # no router term β†’ run absent
1170 set -- $(grid_band); glo=$1 gce=$2 ghi=$3
1171 spaces=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'" 2>/dev/null || echo "")
1172 case "$spaces" in ''|*[!0-9]*|0) spaces=1 ;; esac
1173 since=$CARBON_SINCE
1174 days=${STATUS_CARBON_DAYS:-}
1175 [ -n "$days" ] || days=$(sqj "SELECT MAX(1, CAST(julianday('now') - julianday('$since') AS INTEGER))")
1176 case "$days" in ''|*[!0-9]*) days=1 ;; esac
1177 now=$(now_iso)
1178 # Daily bands live in the CTE d(lo,ce,hi); cumulative/per-space derive from the
1179 # UNROUNDED daily so rounding never compounds. Watts round to whole; kg to 6dp.
1180 sqj "WITH d(lo, ce, hi) AS (VALUES(
1181 $vcpus*$CARBON_W_LO*24*$glo*$CARBON_PUE/1e6 + $CARBON_AWS_LO + $CARBON_INF_LO,
1182 $vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6 + $CARBON_AWS_CE + $CARBON_INF_CE,
1183 $vcpus*$CARBON_W_HI*24*$ghi*$CARBON_PUE/1e6 + $CARBON_AWS_HI + $CARBON_INF_HI))
1184 SELECT json_object(
1185 'generated_at', '$now',
1186 'since', '$since',
1187 'spaces', $spaces,
1188 'pue', $CARBON_PUE,
1189 'vcpus', $vcpus,
1190 'watts', json_object('low', CAST(ROUND($vcpus*$CARBON_W_LO) AS INTEGER),
1191 'central', CAST(ROUND($vcpus*$CARBON_W_CE) AS INTEGER),
1192 'high', CAST(ROUND($vcpus*$CARBON_W_HI) AS INTEGER)),
1193 'grid_g_per_kwh', json_object('low', $glo, 'central', $gce, 'high', $ghi),
1194 'daily_kg', json_object('low', ROUND(lo,6), 'central', ROUND(ce,6), 'high', ROUND(hi,6)),
1195 'cumulative_kg', json_object('low', ROUND(lo*$days,6), 'central', ROUND(ce*$days,6), 'high', ROUND(hi*$days,6)),
1196 'per_space_daily_kg', json_object('low', ROUND(lo/$spaces,6), 'central', ROUND(ce/$spaces,6), 'high', ROUND(hi/$spaces,6)),
1197 'services', json_array(
1198 json_object('name', 'router + instances', 'daily_kg_central', ROUND($vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6, 6)),
1199 json_object('name', 'object storage (AWS)', 'daily_kg_central', $CARBON_AWS_CE),
1200 json_object('name', 'bot inference', 'daily_kg_central', $CARBON_INF_CE)))
1201 FROM d"
1202}
1203
1204# build_carbon <out> <src> β€” republish the committed carbon.json (the built
1205# ledger, tools/carbon-tally.mjs) and inject a live `run` block. We nest HERE
1206# rather than in the tool: the committed file stays exactly the built ledger
1207# ({ built:{…} }), and the only transform is this one merge β€” so "carbon is
1208# committed data" holds and the run block never touches git. A malformed source
1209# or no live inputs β†’ republished verbatim (run stays absent β†’ page = pending).
1210build_carbon() {
1211 local out=$1 src=$2
1212 if [ "$(sqj "SELECT json_valid(readfile('$src')) AND json_type(readfile('$src'), '\$.built')='object'")" != "1" ]; then
1213 cp "$src" "$out"; return
1214 fi
1215 carbon_run_object > "$TMP/carbon-run.json" || true
1216 if [ -s "$TMP/carbon-run.json" ]; then
1217 sqj "SELECT json_set(readfile('$src'), '\$.run', json(readfile('$TMP/carbon-run.json')))" > "$out"
1218 else
1219 cp "$src" "$out"
1220 fi
1221}
1222
1223# ---- upload ------------------------------------------------------------------
1224
1225upload() { # file key content-type cache-control
1226 aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \
1227 --content-type "$3" --cache-control "$4" --no-progress >/dev/null
1228}
1229
1230publish() {
1231 command -v aws >/dev/null 2>&1 || { echo "aws CLI missing (apt install awscli)" >&2; exit 1; }
1232 # Creds for the scoped status-publisher IAM user (never in tofu state; mint
1233 # by hand per infra/prod/status.tf). The unit injects them via
1234 # EnvironmentFile; a manual run sources the same file.
1235 if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then
1236 set -a; . "$STATUS_ENV"; set +a
1237 fi
1238
1239 build_fleet "$TMP/fleet.json"
1240 build_books "$TMP/books.json"
1241
1242 # JSONs: short cache so "as of" stays honest at the 5-minute cadence.
1243 upload "$TMP/fleet.json" fleet.json "application/json" "max-age=60"
1244 upload "$TMP/books.json" books.json "application/json" "max-age=60"
1245
1246 # The viewer pages + brand mark (from web/status/, staged on the box).
1247 # Re-uploading tiny idempotent files every run self-heals a missed copy.
1248 if [ -d "$PAGES_DIR" ]; then
1249 # The shared stylesheet goes up FIRST, before any page that <link>s it, so a
1250 # freshly-uploaded page is never served ahead of its styles (a page fetched
1251 # in the gap would render unstyled until it revalidates). Same-origin in the
1252 # status bucket β€” no third-party request, so the privacy line holds.
1253 [ -f "$PAGES_DIR/status.css" ] && upload "$PAGES_DIR/status.css" status.css "text/css; charset=utf-8" "max-age=300"
1254 # The shared masthead+nav builder rides up with the stylesheet, before any
1255 # page that mounts it, for the same never-served-ahead reason.
1256 [ -f "$PAGES_DIR/status-nav.js" ] && upload "$PAGES_DIR/status-nav.js" status-nav.js "text/javascript; charset=utf-8" "max-age=300"
1257 [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300"
1258 [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300"
1259 # Tier economics: page + committed snapshot JSON (web/status/economics.json,
1260 # re-derived by hand when prices or input rates change β€” docs/status.md
1261 # Β§ Keeping the tier-economics page current). Republished as-is.
1262 [ -f "$PAGES_DIR/economics.html" ] && upload "$PAGES_DIR/economics.html" economics.html "text/html; charset=utf-8" "max-age=300"
1263 [ -f "$PAGES_DIR/economics.json" ] && upload "$PAGES_DIR/economics.json" economics.json "application/json" "max-age=300"
1264 # Investing: page + committed funding ledger + the waterfall reference
1265 # implementation (docs/funding.md). waterfall.js is the NORMATIVE
1266 # algorithm module β€” the page imports and runs the same file it serves,
1267 # so it must publish with an explicit JS content-type (module imports
1268 # fail on a guessed one). Republished as-is, like economics.
1269 [ -f "$PAGES_DIR/funding.html" ] && upload "$PAGES_DIR/funding.html" funding.html "text/html; charset=utf-8" "max-age=300"
1270 [ -f "$PAGES_DIR/funding.json" ] && upload "$PAGES_DIR/funding.json" funding.json "application/json" "max-age=300"
1271 [ -f "$PAGES_DIR/waterfall.js" ] && upload "$PAGES_DIR/waterfall.js" waterfall.js "text/javascript; charset=utf-8" "max-age=300"
1272 [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300"
1273 # The carbon tally is committed data (the built ledger) β€” republished as-is
1274 # EXCEPT for a live `run` block injected here (build_carbon). Built untouched,
1275 # run recomputed each publish; absent inputs β†’ run stays absent (page pending).
1276 if [ -f "$PAGES_DIR/carbon.json" ]; then
1277 build_carbon "$TMP/carbon.json" "$PAGES_DIR/carbon.json"
1278 upload "$TMP/carbon.json" carbon.json "application/json" "max-age=300"
1279 fi
1280 [ -f "$PAGES_DIR/contributors.html" ] && upload "$PAGES_DIR/contributors.html" contributors.html "text/html; charset=utf-8" "max-age=300"
1281 [ -f "$PAGES_DIR/contributors.json" ] && upload "$PAGES_DIR/contributors.json" contributors.json "application/json" "max-age=300"
1282 # The security ledger: page + committed, build-time-scanned, REDACTED JSON
1283 # (deploy/security-scan.sh β€” see the header). A missing security.json is
1284 # honest: the page renders "scan pending" rather than a fake clean, so the
1285 # page uploads even when the JSON hasn't been staged.
1286 [ -f "$PAGES_DIR/security.html" ] && upload "$PAGES_DIR/security.html" security.html "text/html; charset=utf-8" "max-age=300"
1287 [ -f "$PAGES_DIR/security.json" ] && upload "$PAGES_DIR/security.json" security.json "application/json" "max-age=300"
1288 # The prompt registry viewer. Its corpus (prompts/…) is NOT uploaded here:
1289 # a contributor accrues + syncs it straight to the bucket β€” far too large
1290 # for a 5-minute republish (docs/status.md Β§ The prompt registry).
1291 [ -f "$PAGES_DIR/prompts.html" ] && upload "$PAGES_DIR/prompts.html" prompts.html "text/html; charset=utf-8" "max-age=300"
1292 # The music ledger: page + committed index + month shards (accrued per
1293 # session by tools/music-ledger.mjs β€” art rides inside the shards as
1294 # data URIs, so the page stays external-request-free). Republished as-is,
1295 # like economics; the glob picks up new months automatically.
1296 [ -f "$PAGES_DIR/music.html" ] && upload "$PAGES_DIR/music.html" music.html "text/html; charset=utf-8" "max-age=300"
1297 for f in "$PAGES_DIR"/music*.json; do
1298 [ -f "$f" ] && upload "$f" "$(basename "$f")" "application/json" "max-age=300"
1299 done
1300 # Milestones: reconciled from the box's running copy 2026-07-18 β€” the line
1301 # was hand-added there (pre-dating this file's funding additions) and the
1302 # repo owed it back.
1303 [ -f "$PAGES_DIR/milestones.html" ] && upload "$PAGES_DIR/milestones.html" milestones.html "text/html; charset=utf-8" "max-age=300"
1304 # Roadmap: the forward companion to milestones β€” the plan for the road to V1.
1305 # Static like milestones (no JSON feed, no contract-test coverage).
1306 [ -f "$PAGES_DIR/roadmap.html" ] && upload "$PAGES_DIR/roadmap.html" roadmap.html "text/html; charset=utf-8" "max-age=300"
1307 [ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400"
1308 else
1309 echo "warning: $PAGES_DIR missing β€” JSONs published, pages skipped (copy web/status/* there)" >&2
1310 fi
1311 echo "published to s3://$BUCKET (fleet + books + pages, $(now_iso))"
1312}
1313
1314# ---- install (root) ----------------------------------------------------------
1315
1316install_units() {
1317 [ "$(id -u)" = 0 ] || { echo "install needs root (writes systemd units)" >&2; exit 1; }
1318 [ -f "$STATUS_ENV" ] || { echo "missing $STATUS_ENV (AWS creds for status-publisher) β€” mint per infra/prod/status.tf" >&2; exit 1; }
1319
1320 if [ "$(readlink -f "$0" 2>/dev/null || echo "$0")" != "$SELF" ]; then
1321 cp "$0" "$SELF" && chmod 0755 "$SELF"
1322 fi
1323 mkdir -p "$PAGES_DIR"
1324
1325 # Same hardening posture as the litestream unit. ReadWritePaths=/data is
1326 # required even though we only SELECT: WAL readers take shared-memory locks
1327 # (writes to the -shm file), so a read-only /data breaks registry reads.
1328 cat > "$UNIT" <<'EOF'
1329[Unit]
1330Description=Eleven public status publish (fleet + books aggregates)
1331Wants=network-online.target
1332After=network-online.target
1333
1334[Service]
1335Type=oneshot
1336User=lchat
1337Group=lchat
1338SupplementaryGroups=systemd-journal
1339EnvironmentFile=/data/status.env
1340ExecStart=/opt/lchat/status-publish.sh
1341NoNewPrivileges=true
1342ProtectSystem=strict
1343ReadWritePaths=/data
1344ProtectHome=true
1345PrivateTmp=true
1346MemoryMax=256M
1347EOF
1348
1349 # Persistent=false on purpose: a missed run's output is superseded by the
1350 # next one five minutes later β€” catch-up publishes are pure noise.
1351 cat > "$TIMER" <<'EOF'
1352[Unit]
1353Description=Eleven public status publish β€” every 5 minutes
1354
1355[Timer]
1356OnBootSec=2min
1357OnUnitActiveSec=5min
1358RandomizedDelaySec=20
1359Persistent=false
1360
1361[Install]
1362WantedBy=timers.target
1363EOF
1364
1365 systemctl daemon-reload
1366 systemctl enable --now lchat-status.timer
1367 echo "installed β€” verify with: systemctl list-timers lchat-status.timer; journalctl -u lchat-status -n 20"
1368 echo "remember: copy web/status/* (pages, committed JSONs, icon-192.png) to $PAGES_DIR"
1369}
1370
1371# ---- dry-run: self-constructed fixtures, real aggregation code ----------------
1372
1373dry_run() {
1374 local outdir=$1
1375 mkdir -p "$outdir"
1376 DRY_RUN=1
1377 FIXDIR="$TMP/fixtures"
1378 mkdir -p "$FIXDIR"
1379 REGISTRY_DB="$FIXDIR/registry.db"
1380 CLOUD_ENV="$FIXDIR/cloud.env"
1381 CONTROL_ENV="$FIXDIR/control.env"
1382
1383 # A fake registry: 6 instances (2 systemd-managed, 4 hibernating of which 2
1384 # are awake), 2 service rows (must not count as instances), 3 versions.
1385 sqlite3 "$REGISTRY_DB" <<'SQL'
1386CREATE TABLE routes (
1387 host TEXT PRIMARY KEY, kind TEXT NOT NULL, socket TEXT NOT NULL,
1388 version TEXT NOT NULL DEFAULT '', hibernate INTEGER NOT NULL DEFAULT 0
1389);
1390INSERT INTO routes VALUES
1391 ('alpha.example', 'instance', '/run/lchat/alpha.example.sock', 'v100', 0),
1392 ('bravo.example', 'instance', '/run/lchat/bravo.example.sock', 'v100', 0),
1393 ('charlie.example', 'instance', '/run/lchat/charlie.example.sock', 'v100', 1),
1394 ('delta.example', 'instance', '/run/lchat/delta.example.sock', 'v101', 1),
1395 ('echo.example', 'instance', '/run/lchat/echo.example.sock', 'v101', 1),
1396 ('foxtrot.example', 'instance', '/run/lchat/foxtrot.example.sock', '', 1),
1397 ('home.example', 'service', '/run/lchat/home.sock', '', 0),
1398 ('push.example', 'service', '/run/lchat/push.sock', '', 0);
1399SQL
1400
1401 # ss -xlH shape: charlie + delta music (awake), echo + foxtrot not.
1402 cat > "$FIXDIR/music.txt" <<'EOF'
1403u_str LISTEN 0 4096 /run/lchat/alpha.example.sock 12001 * 0
1404u_str LISTEN 0 4096 /run/lchat/bravo.example.sock 12002 * 0
1405u_str LISTEN 0 4096 /run/lchat/charlie.example.sock 12003 * 0
1406u_str LISTEN 0 4096 /run/lchat/delta.example.sock 12004 * 0
1407EOF
1408 printf '%s\n' lchat-home lchat-push lchat-cloud lchat-litestream > "$FIXDIR/active-units.txt" # connect down on purpose
1409 # Fixed machine-id fixture so the dry-run label is deterministic everywhere
1410 # (a dev Mac has no /etc/machine-id; hostname fallback would vary per machine).
1411 echo dry-run-fixture-machine-id > "$FIXDIR/machine-id"
1412 export STATUS_MACHINE_ID_FILE="$FIXDIR/machine-id"
1413 # Fixed region fixture: exercises the STATUS_BOX_REGION seam end-to-end (the
1414 # contract test pins both the value and its coarse continent+country shape).
1415 BOX_REGION="EU Β· Germany"
1416 echo 42 > "$FIXDIR/replication-lag.txt"
1417
1418 # Multi-host fixture (STATUS_DRY_MESH_PEER=1): one other live mesh box's
1419 # self-published stats snapshot, exactly the shape internal/activator/
1420 # heartbeat.go's PutStats writes and mesh_peer_boxes reads back. Default
1421 # (unset) keeps hosts.count at 1, byte-identical to the single-box shape β€”
1422 # the mesh's own "absence changes nothing" convention, mirrored here. This
1423 # also seeds THIS box's own self-published stats fixture
1424 # (mesh-self-stats.json), gated the same way: when mesh is off, this box
1425 # never self-publishes either, so the single-box shape must stay untouched.
1426 if [ "${STATUS_DRY_MESH_PEER:-}" = 1 ]; then
1427 # awake=1 here, not some arbitrary number: this peer only ever runs
1428 # instances mesh-replicated FROM this box's own registry, so its awake
1429 # count must fit within this box's own currently-"asleep" locally instances
1430 # (echo + foxtrot, 2 of them) β€” otherwise the fleet-wide awake/asleep sum
1431 # below goes negative, which would mean the fixture models an impossible
1432 # state, not a real one.
1433 # region present here (unlike an earlier version of this fixture) β€”
1434 # exercises a peer that HAS set its own LCHAT_BOX_REGION, proving the
1435 # merge's raw passthrough carries an extra key with no bash-side change.
1436 cat > "$FIXDIR/mesh-peers.json" <<'EOF'
1437[{"label":"turing-orienteering","awake":1,"asleep":1,"load":"busy","region":"UK Β· London"}]
1438EOF
1439 # awake=1/asleep=2 (3 total) here, one fewer than the local `ss` sweep's
1440 # own awake=2/asleep=2 (4 total) above β€” proves build_fleet actually
1441 # prefers this ownership-filtered snapshot over its own unfiltered local
1442 # sweep, not just reads it and ignores it.
1443 cat > "$FIXDIR/mesh-self-stats.json" <<'EOF'
1444{"label":"hypatia-sketching","awake":1,"asleep":2,"load":"comfortable","region":"EU Β· Germany"}
1445EOF
1446 fi
1447
1448 # Capacity fixtures (control caps + disk + meminfo): one set per public band
1449 # (default plenty; STATUS_DRY_CAPACITY=getting-full|at-capacity), so the
1450 # abstract derivation is testable end-to-end at every level without touching
1451 # box state. hibernate-creates is on in all sets (the live posture the bands
1452 # mirror); the caps are otherwise left unset so the default set exercises
1453 # controld.go's code defaults (40 / 5 GB floor / 10x registered).
1454 echo "LCHAT_CONTROL_HIBERNATE_CREATES=1" > "$FIXDIR/control.env"
1455 case "${STATUS_DRY_CAPACITY:-}" in
1456 getting-full)
1457 # Registered ratio 6/8 = 75% β†’ getting-full, while disk (100 GB) and
1458 # memory (25% used) stay roomy β€” capacity and box load are independent
1459 # signals, so load reads comfortable here.
1460 echo "LCHAT_MAX_REGISTERED=8" >> "$FIXDIR/control.env"
1461 echo 100 > "$FIXDIR/disk-free-gb.txt"
1462 printf 'MemTotal: 4014980 kB\nMemAvailable: 3011235 kB\n' > "$FIXDIR/meminfo.txt"
1463 ;;
1464 at-capacity)
1465 # Disk below the 5 GB floor β†’ at-capacity outright; memory at 95% used
1466 # β†’ load critical.
1467 echo 3 > "$FIXDIR/disk-free-gb.txt"
1468 printf 'MemTotal: 4014980 kB\nMemAvailable: 200749 kB\n' > "$FIXDIR/meminfo.txt"
1469 ;;
1470 *)
1471 # Everything roomy: ratios ~5%/1.5%, floor proximity 5%, memory 25%
1472 # used β†’ plenty / comfortable.
1473 echo 100 > "$FIXDIR/disk-free-gb.txt"
1474 printf 'MemTotal: 4014980 kB\nMemAvailable: 3011235 kB\n' > "$FIXDIR/meminfo.txt"
1475 ;;
1476 esac
1477
1478 # STATUS_DRY_BOOKS=unavailable exercises the degraded books path (no key).
1479 if [ "${STATUS_DRY_BOOKS:-}" = unavailable ]; then
1480 : > "$FIXDIR/cloud.env"
1481 else
1482 echo "LCHAT_STRIPE_SECRET_KEY=sk_test_dryrun_not_a_real_key" > "$FIXDIR/cloud.env"
1483 fi
1484
1485 # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache|hetzner-constant;
1486 # default = all six providers resolve, hetzner from its API fixture). The
1487 # cost cache is repointed into FIXDIR β€” the dry run never touches $DATA_DIR.
1488 # The API fixtures carry fixture-only ids (request id, workspace id, charge
1489 # references/domains, server names): only the parsed integers may survive
1490 # into books.json β€” the leak grep below is the proof.
1491 COSTS_CACHE="$FIXDIR/costs-cache.json"
1492 case "${STATUS_DRY_EXPENSES:-}" in
1493 omit)
1494 # No API fixtures, no cache, no operator constants β†’ every provider,
1495 # and then the whole expenses object, is omitted.
1496 STATUS_HETZNER_MONTHLY_CENTS=""
1497 STATUS_CLAUDE_TO_DATE_CENTS=""
1498 STATUS_APPLE_YEARLY_CENTS=""
1499 ;;
1500 stale-cache)
1501 # No API fixtures (all four "fetches" fail) but valid same-month cache
1502 # entries well past the max age β†’ the stale figures serve rather than
1503 # vanishing (stale beats absent for month-to-date).
1504 STATUS_HETZNER_MONTHLY_CENTS=""
1505 STATUS_CLAUDE_TO_DATE_CENTS=""
1506 STATUS_APPLE_YEARLY_CENTS=""
1507 sqj "SELECT json_object(
1508 'aws', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 999),
1509 'hetzner', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 1399),
1510 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888),
1511 'dnsimple', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 777))" \
1512 > "$COSTS_CACHE"
1513 ;;
1514 *)
1515 # Default: hetzner's constant is cleared so its API fixture drives the
1516 # figure (and an ambient operator env can't leak in);
1517 # STATUS_DRY_EXPENSES=hetzner-constant sets it WITH the fixture still
1518 # present, asserting the constant wins over a resolvable API path.
1519 STATUS_HETZNER_MONTHLY_CENTS=""
1520 [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ] && STATUS_HETZNER_MONTHLY_CENTS=1400
1521 STATUS_CLAUDE_TO_DATE_CENTS=229000
1522 STATUS_APPLE_YEARLY_CENTS=9900
1523 # Cost Explorer shape; Amount 12.3399999 USD exercises the cents rounding (β†’ 1234).
1524 cat > "$FIXDIR/aws-ce.json" <<'EOF'
1525{"ce_fixture_request_id":"req_fixture_1","ResultsByTime":[{"TimePeriod":{"Start":"2026-07-01","End":"2026-07-12"},"Total":{"UnblendedCost":{"Amount":"12.3399999","Unit":"USD"}},"Groups":[],"Estimated":true}]}
1526EOF
1527 # Hetzner servers shape, TWO pages (exercises meta.pagination.next_page):
1528 # each server carries its type's per-location price list, and only the
1529 # entry matching the server's own location.name may price it β€” the
1530 # decoy entries for the other location differ on purpose. Gross monthly
1531 # EUR strings: 16.4288 (cpx31 @ hel1; NOT the fsn1 16.1840) + 4.5100
1532 # (cx22 @ fsn1; NOT the hel1 4.6900) = 20.9388 β†’ rounds to 2094 cents.
1533 # Server names are fixture markers that must not survive aggregation.
1534 cat > "$FIXDIR/hetzner-servers-1.json" <<'EOF'
1535{"servers":[
1536 {"id":101,"name":"hzfix-alpha","status":"running",
1537 "location":{"id":3,"name":"hel1","description":"Helsinki DC Park 1","country":"FI","city":"Helsinki","network_zone":"eu-central"},
1538 "server_type":{"id":104,"name":"cpx31","cores":4,"memory":8,"disk":160,"deprecated":false,
1539 "prices":[
1540 {"location":"fsn1","price_hourly":{"net":"0.0225","gross":"0.0268"},"price_monthly":{"net":"13.6000","gross":"16.1840"}},
1541 {"location":"hel1","price_hourly":{"net":"0.0229","gross":"0.0273"},"price_monthly":{"net":"13.8057","gross":"16.4288"}}
1542 ]}}
1543],"meta":{"pagination":{"page":1,"per_page":1,"previous_page":null,"next_page":2,"last_page":2,"total_entries":2}}}
1544EOF
1545 cat > "$FIXDIR/hetzner-servers-2.json" <<'EOF'
1546{"servers":[
1547 {"id":102,"name":"hzfix-bravo","status":"running",
1548 "location":{"id":1,"name":"fsn1","description":"Falkenstein DC Park 1","country":"DE","city":"Falkenstein","network_zone":"eu-central"},
1549 "server_type":{"id":22,"name":"cx22","cores":2,"memory":4,"disk":40,"deprecated":false,
1550 "prices":[
1551 {"location":"fsn1","price_hourly":{"net":"0.0063","gross":"0.0075"},"price_monthly":{"net":"3.7900","gross":"4.5100"}},
1552 {"location":"hel1","price_hourly":{"net":"0.0066","gross":"0.0079"},"price_monthly":{"net":"3.9400","gross":"4.6900"}}
1553 ]}}
1554],"meta":{"pagination":{"page":2,"per_page":1,"previous_page":1,"next_page":null,"last_page":2,"total_entries":2}}}
1555EOF
1556 # Anthropic cost-report shape: two daily buckets, amounts are decimal
1557 # strings of CENTS (150.25 + 99.5 β†’ rounds to 250). The workspace id is
1558 # a fixture marker that must not survive aggregation.
1559 cat > "$FIXDIR/anthropic-cost.json" <<'EOF'
1560{"data":[
1561 {"starting_at":"2026-07-01T00:00:00Z","ending_at":"2026-07-02T00:00:00Z",
1562 "results":[{"amount":"150.25","currency":"USD","workspace_id":"wrkspc_fixture_1"}]},
1563 {"starting_at":"2026-07-02T00:00:00Z","ending_at":"2026-07-03T00:00:00Z",
1564 "results":[{"amount":"99.5","currency":"USD","workspace_id":"wrkspc_fixture_1"}]}
1565],"has_more":false,"next_page":null}
1566EOF
1567 # DNSimple billing-charges shape (amounts = decimal DOLLAR strings, no
1568 # currency field, pagination envelope): two collected charges
1569 # (14.50 + 10.00 β†’ 2450 cents) plus a refunded one that must NOT count β€”
1570 # the state filter is part of the contract. References and domains are
1571 # fixture markers that must not survive aggregation.
1572 cat > "$FIXDIR/dnsimple-charges.json" <<'EOF'
1573{"data":[
1574 {"invoiced_at":"2026-07-02T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1001","state":"collected",
1575 "items":[{"description":"Renewal dnsfix-one.example","amount":"14.50","product_id":11,"product_type":"domain-renewal","product_reference":"dnsfix-one.example"}]},
1576 {"invoiced_at":"2026-07-03T09:00:00Z","total_amount":"10.00","balance_amount":"0.00","reference":"dnsfix-1002","state":"collected",
1577 "items":[{"description":"Renewal dnsfix-two.example","amount":"10.00","product_id":12,"product_type":"domain-renewal","product_reference":"dnsfix-two.example"}]},
1578 {"invoiced_at":"2026-07-04T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1003","state":"refunded",
1579 "items":[{"description":"Register dnsfix-three.example","amount":"14.50","product_id":13,"product_type":"domain-registration","product_reference":"dnsfix-three.example"}]}
1580],"pagination":{"current_page":1,"per_page":100,"total_entries":3,"total_pages":1}}
1581EOF
1582 ;;
1583 esac
1584
1585 # Two subscription pages (exercises pagination) + one balance page. Shapes
1586 # follow Stripe's list envelope; per-payer fields present in the fixture
1587 # (ids, customer) must NOT survive into books.json β€” that's the test.
1588 cat > "$FIXDIR/stripe-subs-1.json" <<'EOF'
1589{"object":"list","has_more":true,"data":[
1590 {"id":"sub_fixture_a","customer":"cus_fixture_a","status":"active","metadata":{"tier":"friends","space_id":"7"},
1591 "items":{"data":[{"quantity":1,"price":{"unit_amount":400,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}},
1592 {"id":"sub_fixture_b","customer":"cus_fixture_b","status":"active","metadata":{"tier":"family","space_id":"9"},
1593 "items":{"data":[{"quantity":1,"price":{"unit_amount":800,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}}
1594]}
1595EOF
1596 cat > "$FIXDIR/stripe-subs-2.json" <<'EOF'
1597{"object":"list","has_more":false,"data":[
1598 {"id":"sub_fixture_c","customer":"cus_fixture_c","status":"active","metadata":{"tier":"friends","space_id":"12"},
1599 "items":{"data":[{"quantity":1,"price":{"unit_amount":4800,"currency":"eur","recurring":{"interval":"year","interval_count":1}}}]}}
1600]}
1601EOF
1602 cat > "$FIXDIR/stripe-bal-1.json" <<'EOF'
1603{"object":"list","has_more":false,"data":[
1604 {"id":"txn_fixture_1","type":"charge","amount":400,"currency":"eur","created":1751328000},
1605 {"id":"txn_fixture_2","type":"charge","amount":800,"currency":"eur","created":1751414400},
1606 {"id":"txn_fixture_3","type":"refund","amount":-400,"currency":"eur","created":1751500800},
1607 {"id":"txn_fixture_4","type":"payout","amount":-1200,"currency":"eur","created":1751587200}
1608]}
1609EOF
1610 # Application fees with the charge expanded (Connect: spaces selling to
1611 # their members). Two EUR sales β€” a €9.00 PWYW sale with its 90c commission
1612 # (the real first-sale story) plus a €5.50 one β€” and a USD decoy that must
1613 # not count on either side of the currency filter. GMV 900+550=1450,
1614 # commission 90+55=145. The fee/charge/account ids are fixture markers that
1615 # must not survive aggregation.
1616 cat > "$FIXDIR/stripe-fees-1.json" <<'EOF'
1617{"object":"list","has_more":false,"data":[
1618 {"id":"fee_fixture_1","account":"acct_fixture_a","amount":90,"currency":"eur","refunded":false,
1619 "charge":{"id":"ch_fixture_1","object":"charge","amount":900,"currency":"eur","paid":true}},
1620 {"id":"fee_fixture_2","account":"acct_fixture_a","amount":55,"currency":"eur","refunded":false,
1621 "charge":{"id":"ch_fixture_2","object":"charge","amount":550,"currency":"eur","paid":true}},
1622 {"id":"fee_fixture_3","account":"acct_fixture_b","amount":120,"currency":"usd","refunded":false,
1623 "charge":{"id":"ch_fixture_3","object":"charge","amount":1200,"currency":"usd","paid":true}}
1624]}
1625EOF
1626
1627 build_fleet "$outdir/fleet.json"
1628 build_books "$outdir/books.json"
1629
1630 # Self-checks: valid JSON, and no per-payer fixture value leaked through.
1631 sqj "SELECT CASE WHEN json_valid(readfile('$outdir/fleet.json')) AND json_valid(readfile('$outdir/books.json')) THEN 'ok' ELSE 'INVALID' END" \
1632 | grep -qx ok || { echo "dry-run: output is not valid JSON" >&2; exit 1; }
1633 if grep -qE 'sub_fixture|cus_fixture|txn_fixture|fee_fixture|ch_fixture|acct_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|hzfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then
1634 echo "dry-run: PRIVACY LEAK β€” a per-payer/provider-account id or hostname reached the output" >&2
1635 exit 1
1636 fi
1637 # Connect aggregates: GMV from the expanded charges, commission from the
1638 # fee amounts, both EUR-only (the USD decoy proves the filter). Skipped in
1639 # the degraded-books mode, where no income fields exist at all.
1640 if [ "${STATUS_DRY_BOOKS:-}" != unavailable ]; then
1641 [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.connect.gmv_month_cents')")" = "1450" ] ||
1642 { echo "dry-run: connect GMV must sum the expanded charges, EUR only (want 1450)" >&2; exit 1; }
1643 [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.connect.commission_month_cents')")" = "145" ] ||
1644 { echo "dry-run: connect commission must sum the fee amounts, EUR only (want 145)" >&2; exit 1; }
1645 fi
1646 # Capacity must publish as an abstract band word β€” a known word, and not a
1647 # single digit anywhere in the capacity subtree (no slots, GB, percentages).
1648 local lvl cap
1649 lvl=$(sqj "SELECT json_extract(readfile('$outdir/fleet.json'), '\$.capacity.level')")
1650 case "$lvl" in plenty|getting-full|at-capacity) ;; *)
1651 echo "dry-run: capacity.level '$lvl' is not one of the three band words" >&2
1652 exit 1 ;;
1653 esac
1654 cap=$(sqj "SELECT json_extract(readfile('$outdir/fleet.json'), '\$.capacity')")
1655 if printf '%s' "$cap" | grep -q '[0-9]'; then
1656 echo "dry-run: PRIVACY β€” capacity subtree carries a number: $cap" >&2
1657 exit 1
1658 fi
1659 # Expenses: itemized by design, but the shape is still pinned β€” and the
1660 # cache seams must behave (cached_cost_cents). Default: all six providers
1661 # from fixtures/env (hetzner computed from its servers-API fixture), with
1662 # the API results freshly cached; hetzner-constant: same fixtures but the
1663 # operator constant wins; omit: no expenses key at all; stale-cache: all
1664 # four API fetches failed and the seeded same-month cache served
1665 # (aws 999 / hetzner 1399 / anthropic 888 / dnsimple 777).
1666 local aws_c hz_c an_c dn_c
1667 aws_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[0].month_to_date_cents')")
1668 case "${STATUS_DRY_EXPENSES:-}" in
1669 omit)
1670 [ "$(sqj "SELECT json_type(readfile('$outdir/books.json'), '\$.expenses') IS NULL")" = "1" ] ||
1671 { echo "dry-run: expenses must be omitted entirely when no provider resolves" >&2; exit 1; }
1672 ;;
1673 stale-cache)
1674 hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")
1675 an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')")
1676 dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].month_to_date_cents')")
1677 [ "$aws_c" = "999" ] && [ "$hz_c" = "1399" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] ||
1678 { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' hetzner='$hz_c' anthropic='$an_c' dnsimple='$dn_c')" >&2; exit 1; }
1679 ;;
1680 *)
1681 an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')")
1682 dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[4].month_to_date_cents')")
1683 [ "$aws_c" = "1234" ] || { echo "dry-run: CE fixture must yield 1234 cents (got '$aws_c')" >&2; exit 1; }
1684 [ "$an_c" = "250" ] || { echo "dry-run: anthropic fixture must yield 250 cents (got '$an_c')" >&2; exit 1; }
1685 [ "$dn_c" = "2450" ] || { echo "dry-run: dnsimple fixture must yield 2450 cents β€” collected only (got '$dn_c')" >&2; exit 1; }
1686 hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")
1687 if [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ]; then
1688 [ "$hz_c" = "1400" ] ||
1689 { echo "dry-run: hetzner constant must override the API fixture β€” 1400 cents (got '$hz_c')" >&2; exit 1; }
1690 # The constant must short-circuit the API entirely: nothing cached.
1691 [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner') IS NULL")" = "1" ] ||
1692 { echo "dry-run: hetzner constant must skip the API (a hetzner cache entry exists)" >&2; exit 1; }
1693 else
1694 [ "$hz_c" = "2094" ] ||
1695 { echo "dry-run: hetzner servers fixture must yield 2094 cents β€” location-matched gross, rounded (got '$hz_c')" >&2; exit 1; }
1696 [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner.month_to_date_cents')")" = "2094" ] ||
1697 { echo "dry-run: hetzner API result must be cached" >&2; exit 1; }
1698 fi
1699 [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].to_date_cents')")" = "229000" ] ||
1700 { echo "dry-run: claude.ai constant must yield 229000 cents, cumulative (to_date_cents)" >&2; exit 1; }
1701 [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[5].yearly_cents')")" = "9900" ] ||
1702 { echo "dry-run: apple constant must yield 9900 cents, as billed (yearly)" >&2; exit 1; }
1703 [ -f "$COSTS_CACHE" ] &&
1704 [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.aws.month_to_date_cents')")" = "1234" ] &&
1705 [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.anthropic.month_to_date_cents')")" = "250" ] &&
1706 [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.dnsimple.month_to_date_cents')")" = "2450" ] ||
1707 { echo "dry-run: metered results must be cached per provider" >&2; exit 1; }
1708 ;;
1709 esac
1710
1711 # ---- carbon.json: the committed BUILT ledger (a fixed fixture here, so the
1712 # run fixture stays stable across real accruals) + a live-computed RUN block.
1713 # The run block folds from the Hetzner servers fixture (cpx31=4 + cx22=2 β†’ 6
1714 # vCPUs), the box region grid (Germany β†’ 0/350/400 g/kWh), PUE 1.15, a pinned
1715 # 10-day span and the fixture registry's 6 instances. Only the expense modes
1716 # that stage the Hetzner servers fixture (default + hetzner-constant) give the
1717 # run its vCPU input; omit/stale-cache legitimately leave run absent (pending).
1718 cat > "$FIXDIR/carbon-built.json" <<'EOF'
1719{
1720 "updated_at": "2026-07-11T00:00:00.000Z",
1721 "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md",
1722 "built": {
1723 "unit": "kg CO2e",
1724 "factors_mg_co2e_per_output_token": { "low": 0.1, "central": 0.55, "high": 3 },
1725 "not_counted": "cache reads, cloud-side agent runs, and contributor machines not yet accrued",
1726 "entries": [
1727 { "period_start": "2026-07-01T00:00:00.000Z", "period_end": "2026-07-11T00:00:00.000Z",
1728 "source": "ada-sketching", "basis": "measured", "sessions": 10, "output_tokens": 1000000,
1729 "fresh_input_tokens": 5000000, "cache_read_tokens": 90000000,
1730 "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3, "note": "dry-run fixture" }
1731 ],
1732 "totals": { "output_tokens": 1000000, "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3 }
1733 }
1734}
1735EOF
1736 local cvc cwt cgr cda ccu cps
1737 CARBON_SINCE=2026-07-02
1738 STATUS_CARBON_DAYS=10
1739 build_carbon "$outdir/carbon.json" "$FIXDIR/carbon-built.json"
1740 [ "$(sqj "SELECT json_valid(readfile('$outdir/carbon.json'))")" = "1" ] ||
1741 { echo "dry-run: carbon.json is not valid JSON" >&2; exit 1; }
1742 [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.built')='object'")" = "1" ] ||
1743 { echo "dry-run: carbon built ledger missing (built must be republished verbatim)" >&2; exit 1; }
1744 case "${STATUS_DRY_EXPENSES:-}" in
1745 omit|stale-cache)
1746 [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.run') IS NULL")" = "1" ] ||
1747 { echo "dry-run: carbon run must be absent when no server inputs resolve (pending, never a fake zero)" >&2; exit 1; }
1748 ;;
1749 *)
1750 cvc=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.vcpus')")
1751 cwt=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.watts.central')")
1752 cgr=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.grid_g_per_kwh.central')")
1753 cda=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.daily_kg.central')")
1754 ccu=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.cumulative_kg.central')")
1755 cps=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.per_space_daily_kg.central')")
1756 # daily central = 6 Γ— 3.5 W Γ— 24h Γ— 350 g/kWh Γ— 1.15 / 1e6 + 0.001 + 0.005
1757 # = 0.20886 kg; cumulative = Γ—10 days = 2.0886; per space = Γ·6.
1758 [ "$cvc" = "6" ] && [ "$cwt" = "21" ] && [ "$cgr" = "350" ] &&
1759 [ "$cda" = "0.20886" ] && [ "$ccu" = "2.0886" ] && [ "$cps" = "0.03481" ] ||
1760 { echo "dry-run: carbon run math (got vcpus=$cvc watts=$cwt grid=$cgr daily=$cda cumulative=$ccu per_space=$cps)" >&2; exit 1; }
1761 ;;
1762 esac
1763 # Privacy: no fixture marker, hostname, socket path, or IP may reach carbon.json.
1764 if grep -qE 'hzfix|ada-sketching@|\.example|\.sock|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$outdir/carbon.json"; then
1765 echo "dry-run: PRIVACY LEAK β€” a hostname/IP reached carbon.json" >&2
1766 exit 1
1767 fi
1768
1769 echo "--- fleet.json ---"; cat "$outdir/fleet.json"
1770 echo "--- books.json ---"; cat "$outdir/books.json"
1771 echo "--- carbon.json ---"; cat "$outdir/carbon.json"
1772 echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}, carbon run ${cda:-absent}"
1773}
1774
1775case "${1:-}" in
1776 install) install_units ;;
1777 --dry-run) [ -n "${2:-}" ] || { echo "usage: $0 --dry-run <outdir>" >&2; exit 1; }
1778 dry_run "$2" ;;
1779 "") publish ;;
1780 *) echo "usage: $0 [install | --dry-run <outdir>]" >&2; exit 1 ;;
1781esac
1782