The hub has written every customer e-mail in Hungarian whatever the box was set to. The box has published its language since controller v0.247.0; nothing read it. Now it does. Nothing an operator reads changes. The Hungarian mails are byte-identical, and that is a diff rather than a reading: 56 goldens per language captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after every sentence was routed through the new bundle. - internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0. - customerMessages/severityLabels are DERIVED from the bundle, so a sentence is written in one place and all 40+ tests that read those maps still work. - Language order: last reported -> created-with -> hu. reports.language defaults to EMPTY, never hu: "never told us" is not "chose Hungarian". - message_customer on POST /api/v1/event, additive and optional forever, for the sentences the box composes and the hub cannot translate. - The bind page is per-language, and its `expired` state stays Hungarian: it is the state an unknown token lands in, so rendering a real English customer's token in English would make the LANGUAGE answer what the TEXT refuses to. Two defects found inside the release: - R-581: the newest report was picked by received_at, which has SECOND granularity, so same-second reports tied and the winner was arbitrary. Ordered by the autoincrement id now. GetCustomers() still has the shape - row open. - R-582: the English copy-guard stems, ported word for word from Hungarian, convicted 141 honest sentences. The English claim is a phrase with a modal. R-555 closed: the language allowlist entry is out of wire_contract_gate.py. hub_copy_gate.py follows the sentences into the bundle - without that it would have scanned four files that no longer hold any customer text and reported success. Three new decoys incl. an innocent control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
4.5 KiB
paths
| paths | |
|---|---|
|
Hub — architecture, constraints, deploy
What the hub is
Operator backend. Authors operator intent, mirrors box reality, holds no data-plane role, and never connects inbound to a box. If it dies, apps keep serving; only management degrades.
Three inbound contracts, and one is frozen: the agent's host-domain report
(POST /api/v1/host-report, the heartbeat/dead-man's-switch), the legacy controller report
(POST /api/v1/report, frozen until the slice-10 cutover — do not modify), and structured
controller events (POST /api/v1/event, gated by allowedEventTypes).
Everything else — checkers, the two-tier notification dispatcher, the app-mail relay, customer-config
and Day-0 artifact-manifest management, the operator UI — is mapped in REUSE.md. Full design:
documentation/architecture/05-hub-architecture.md.
The two stack constraints
The dependency list is hub/go.mod's business and the deploy shape is manifests/. Only these two
cannot be read off the code:
- No web frameworks. Go stdlib
net/http+html/template, and it stays that way. - Secrets via out-of-band
secretKeyRef— never inlinestringData(REUSE.md§3).
Deploy — GitOps, and the manifest is the truth
Full runbook: the felhom-build-deploy skill. The load-bearing rules:
- A code change + CHANGELOG bump deploys NOTHING. The running image changes only when
manifests/hub.yaml'simage:tag changes in git and the app is synced. - Pin explicit versions, never
:latest. Never barekubectl set image/kubectl apply— reverted on the next sync. - The live image can lag the CHANGELOG when a bump was committed but the manifest/sync step never happened — reconcile via the manifest, not the changelog.
- Green gate before any hub commit:
go build ./... && go vet ./... && go test ./...inhub/.
Rules that bite here
- Seam-wiring — it covers TEMPLATE GATES: a feature is not shipped until its entry point is
reachable. Any conditional affordance (
{{if .Flag}}around a button, form or script) ships with a render test per branch of the gate. Handler tests that POST directly prove nothing about reachability. - A health check issues no block I/O. A probe that touches a wedged device enters uninterruptible
sleep, survives
SIGKILL, and cannot be recovered until the device returns or the host reboots — sosystemctl restarthangs too. A timeout protects the caller's control flow and nothing else: the blocked thread remains. Liveness is decided from/procand kernel state, never by reading or writing the filesystem. - New event types must enter
allowedEventTypesandcustomerMessagestogether, orPOST /event400s. From v0.118.0 thecustomerMessageshalf is a line ininternal/i18n/locales/hu.json(mail.event.<type>) AND its English twin — the map is derived from the bundle, and the missing-key gate is held at zero. Customer copy lives in the bundle; the operator's mails do not. - Status logic: OK (report younger than
alerting.stale_threshold), WARN (past the threshold orhealth=warn), DOWN (past 2× the threshold orhealth=fail). The threshold is configuration (manifests/hub.yaml; 45 m by operator ruling 2026-09-17, R-549), and the display (controllerStatus,hostStatus) and both checkers read the same value — never hardcode it. Host-liveness thresholds are shared between UI and checker — never invent a second definition. - SQLite timestamps vary in format — always
parseSQLiteTime(). - Logging: DEBUG = flow detail, INFO = state change + duration; operator English; keys never
values (
documentation/runbooks/logging-conventions.md). The bundle secret-gate fails closed.