---
paths: ["hub/**"]
---

# 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 inline `stringData`** (`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`'s `image:` tag changes in git and the app is synced.
- **Pin explicit versions, never `:latest`. Never bare `kubectl 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 ./...` in `hub/`.

## 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 —
  so `systemctl restart` hangs too. A timeout protects the caller's control flow and nothing else:
  the blocked thread remains. Liveness is decided from `/proc` and kernel state, never by reading or
  writing the filesystem.
- New event types must enter `allowedEventTypes` **and** `customerMessages` together, or `POST
  /event` 400s.
- Status logic: OK (report < 30m), WARN (30m–1h or `health=warn`), DOWN (> 1h or `health=fail`).
  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.

<!--
Seam-wiring / template gates: the fourth inert seam, hub v0.70.1. The v0.70.0 ghost-delete was fully
implemented server-side and fully dead UI because the button sat inside the wrong gate.

Health check / block I/O: measured, R-117 spike §6.3
(documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md): a probe stayed in D state 3m50s after
kill -9; a buffered write with no fsync blocked too (O_CREAT needs journal access); and
statfs/getdents returned HEALTHY on a namespace that EIOs every byte — fast, and wrong.

This rule is duplicated in felhom-agent/.claude/rules/health-checks.md and
felhom-controller/.claude/rules/gates.md. That is deliberate: all three write health checks, and a
pointer to a sibling repo's rule file does not load.
-->
