docs: R-166 SHIPPED — the desired/in-flight/observed split (D-b)
gates / gates (push) Successful in 8s

Controller v0.189.0 implements operator decision D-b. Docs only here; no hub
change and no hub version bump.

- architecture/02-controller-module-map.md §0a — NEW, and it is the S-1 contract:
  desired (app.yaml) / in-flight (own marker file) / observed (not persisted),
  with the rule that ties them — never derive one from another. Absent desired
  state means UNKNOWN, never "running". One file, one writer. D-b's binding
  safety rule quoted verbatim.
- 00-capability-map.md — the boot-recovery row now rests on a recorded signal,
  with the three live flows from 9201. The interrupted-operation half is marked
  IMPLEMENTED, not PROVEN-LIVE: nobody killed the controller mid-backup on metal.
- OPEN-ITEMS/ROADMAP — R-166 SHIPPED with both blocking facts and their answers;
  R-157 mechanism B CLOSED and A restated as the whole item; R-170 NEW (the
  drive-backed boot gate still infers a Stop from a container count).
- STATUS.md — the "an app can stay switched off and nothing says so" line
  rewritten to what is actually left: timing.
- CLAUDE.md — end-of-session checklist gains: confirm your own last push's CI run
  went green, BY RUN ID. The failure email is a push signal; this is the pull check.
- CONTEXT.md S-12 — the rulings, and the two lessons worth carrying: a test that
  constructs the thing it should prove the caller constructs is hollow (its
  red-proof will say so), and a field-by-field struct rebuild in a save path is a
  defect on sight.
This commit is contained in:
2026-08-02 18:58:27 +02:00
parent c04ea4f6c2
commit 5c97fbc397
7 changed files with 136 additions and 7 deletions
+43
View File
@@ -164,6 +164,49 @@ Five decisions were deliberately **left open for the operator** and are recorded
stated**) · Hetzner as a single failure domain · and `local` vzdump sharing a physical device with
the guest it backs up. Gaps minted the same session: **R-102 … R-108**.
**S-12 — D-b is BUILT (controller v0.189.0, 2026-08-02, R-166).** The desired/in-flight/observed
split now exists; the S-1 contract lives in `architecture/02-controller-module-map.md` §0a.
**Both facts D-b said to establish first were established at source, and both changed the shape.**
(a) *Does the crash-safe journal in the backup code already cover the in-flight case?* The pattern
DID already exist — twice (`quiesce` marker+`Recover`, `migrate` journal+`RecoverMigration`) — and
covered **none** of the app-data path: `DumpAppVolumesSafe` stopped and restarted an app with no
marker, no journal and **not even a `defer`**. So the answer was neither "it exists, wire it" nor
"build it": copy the proven shape into its own file. It was the fifth time the question was worth
asking and the first time the answer was "the pattern, not the coverage". (b) *Is the SQLite store
reachable?* Reachable and **deliberately not used**`metrics.db` is optional by design (the
controller runs with it absent), and operational state must not live in a store built to be dropped.
**The ruling that carried the design: absent means UNKNOWN, never "running".** Every `app.yaml` on
every box predates the field, so absent is what the whole fleet reads on upgrade; reading it as
running would have started every deliberately-stopped app on the first boot after the upgrade —
fleet-wide, silently. Where intent is unknown the box keeps the OLD inference rather than inventing
an answer. That is also why the backfill is **running-only**: "zero containers ⇒ stopped" is the
defect itself, so an ambiguous app stays ambiguous until a customer presses a button.
**The other load-bearing ruling: `StartStack`/`StopStack` are NOT writers of intent.** A census found
14 callers, of which exactly 2 are the customer. Recording intent in the primitive would make a
nightly backup indistinguishable from the customer pressing Stop — the confusion being removed.
**Found on the way, and it would have silently eaten the feature: `SaveAppConfig` rebuilt `AppConfig`
field-by-field.** That is the R-100 shape, which v0.181.0 shipped two live instances of. The literal
named five fields, so the sixth would have been dropped on every save across nine call sites — a
customer's Stop erased by the next unrelated `app.yaml` write. Copy-and-overlay is safe by
construction; the failure mode is generic, so **treat any field-by-field struct rebuild in a save
path as a defect on sight.** Measured and documented rather than assumed: `app.yaml` does NOT
round-trip YAML keys the struct does not model.
**Closes R-157 mechanism B; mechanism A (the sweep observes ~5 s after start and never re-checks) is
untouched and is now the whole of R-157** — and B's fix makes A cost more, since the sweep now has
more it could legitimately recover. **New: R-170**`shouldRecreateOnBoot`
(`internal/web/intermediary.go:131`) still infers a Stop from `hasContainers`, i.e. the same defect
one gate over for drive-backed apps. Left deliberately: the task scoped `bootrecon`, and two boot
behaviour changes under one live validation is one too many.
**Live on 9201, three flows, each with a positive observable.** The interrupted-operation half is
**IMPLEMENTED, not PROVEN-LIVE** — unit-proven and red-proofed, but nobody killed the controller
mid-backup on real hardware; the capability map says so rather than rounding it up.
**S-5 — four operator decisions taken in discussion on 2026-08-02, recorded before anything is
built.** They existed only in conversation, which is the condition the standing rules were written
against. Labels are the ones used in the discussion (**D-a … D-d**) and are deliberately kept