feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)

GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub
completes it the instant any operator intent bumps that customer's in-memory
generation, then the box fires its ordinary report and the ACK delivers
everything through the unchanged machinery. 240s hold with a 25s heartbeat
newline defeats the nginx 60s proxy_read_timeout with no ingress annotation;
WriteTimeout lifted per-connection via ResponseController.

- internal/intent: per-customer generation counter + waiter registry
  (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design.
  Red-proofs: counter-vs-queue + race-closer (run-fail-reverted).
- api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B).
- web bumps after every intent write (fire-after-commit): config CRUD, claim
  resend, offsite re-issue/freeze, password regen, block/unblock, floors
  (global bumps all config-managed), controller log-tail + log-bundle.
- main.go: one intent hub shared by web+api; Close() before server.Shutdown.

Pairs with controller v0.140.0 (the long-poll client). Grounding:
documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
This commit is contained in:
2026-07-16 20:44:22 +02:00
parent 10e07f5747
commit 60244727ad
11 changed files with 790 additions and 0 deletions
+40
View File
@@ -1,5 +1,45 @@
# Felhom Hub — Changelog
## v0.58.0 — Direction-2 immediate-sync: the hub→box "sync now" wait channel (2026-07-16)
Implements option (b) of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md`: an
operator action on the hub now reaches the box in **seconds** instead of on the next ~15-min report
cycle. The box holds a hanging authenticated `GET /api/v1/wait` over the existing outbound ingress;
the hub completes it the instant any operator intent lands for that customer. The box then fires its
ordinary out-of-cycle report — the ACK delivers config/escrow/claim/floor through the UNCHANGED
machinery. The 15-min cycle stays the reconciliation backbone; every wait failure degrades to it.
Pairs with controller v0.140.0 (the long-poll client). Ground truth #1 holds: the box pulls even the
wake-up; the hub never connects inbound and no state ever rides the wait response.
- **`internal/intent` — the in-memory operator-intent notifier.** A per-customer generation counter
with a waiter registry: `Bump(customerID)` advances the generation and wakes every registered
waiter (a burst coalesces into ONE completion carrying the LATEST generation — a counter, not a
per-bump queue); `Wait(ctx, customerID, lastSeen, maxHold)` returns the instant the generation
differs from `lastSeen`, on ctx-cancel, on `maxHold`, or on `Close`. A pre-register gen-check
closes the bump-before-connect race (a bump is never lost). In-memory BY DESIGN — a hub restart
resets generations; the box compares with `!=`, so a restart costs exactly one harmless full-state
report, never a storm. No persistence, no store schema. Red-proofs: counter-vs-queue (return the
as-of-register snapshot → `TestWait_CoalescesBurstToLatestGen` fails) and the race-closer (drop the
pre-register check → `TestWait_RaceCloser_BumpBeforeWaitNotLost` fails); both run-fail-reverted.
- **`GET /api/v1/wait` (api/wait.go).** Authed via `checkAuthCustomer`; per-customer only (a global
operator key → 400; the customer is resolved from the key, no `customer_id` parameter is accepted,
so A can never observe B). Holds up to **240 s**, writing a **25 s heartbeat newline** while it
waits. nginx's `proxy_read_timeout` is measured BETWEEN upstream reads, so the heartbeat keeps the
default 60 s from ever firing — **no ingress annotation / manifest timeout change is needed** (the
transport spike measured that ceiling; §13 proves the heartbeat defeats it live). The response is
contentless — a single `{"gen":N}` line. The connection's write deadline is lifted per-request via
`http.NewResponseController().SetWriteDeadline` (the global `http.Server.WriteTimeout` of 60 s is
deliberately untouched).
- **Intent bumps (web).** Every operator-intent handler bumps the customer's generation AFTER its
successful store write (fire-after-commit, never on an error path), via nil-safe `s.bumpIntent`:
config create/update/delete, claim resend, offsite re-issue (UI + the re-enroll seam), offsite
freeze/unfreeze, retrieval-password regen, block/unblock, per-customer floor, global floor (bumps
every config-managed customer), controller log-tail request, and the CONTROLLER log-bundle request
(the AGENT ring rides the heartbeat envelope — a separate plane, deliberately not bumped).
- **Wiring + shutdown.** One `intent.New()` in `main.go`, injected into both the web server and the
API handler; `intentHub.Close()` runs before `server.Shutdown` so held waits complete instantly
instead of eating the 15 s grace window. nil-safe throughout (an unset hub → wait 503, bumps no-op).
## v0.57.0 — reinstall-of-existing-customer arc: claim continuity, offsite re-issue, escrow honesty (2026-07-16)
Closes the N100 physical-run findings F2/F3 and the correctness edge behind F4→2.3