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
@@ -53,6 +53,80 @@ Risk tags: **clean** · **needs-rework** · **hazard** (entangles a delete-targe
---
## 0a. App state: desired / in-flight / observed — S-1 CONTRACT (2026-08-02, decision D-b, R-166)
> **This section is a live contract, not migration history** — the rest of this document is the
> v0.33 keep/port/delete inventory. Read this before touching `stacks/`, `backup/` or `bootrecon/`.
> Shipped in controller **v0.189.0**.
An app's state is **three different kinds of information**, and conflating them is what produced
R-157 mechanism B and F-CRIT-1. They are stored differently on purpose.
| Kind | Question it answers | Where it lives | Persisted? |
|---|---|---|---|
| **Desired** | *What did the customer ask for?* | `app.yaml``desired_state` | Yes, beside the app's other settings |
| **In-flight** | *Is an operation part-way through, and did it finish?* | its **own** marker file under `<data_dir>` | Yes, written before the operation and cleared after |
| **Observed** | *Is it running, unhealthy, restarting, is its drive gone?* | nowhere | **No — rebuilt by looking** |
**The rule that ties them together: never derive one from another.** The defect this replaced did
exactly that — it derived *desired* from *observed* (zero containers ⇒ "the customer stopped it"),
and zero containers is equally what a power cut mid-compose, an interrupted deploy and an interrupted
backup leave behind. Two real faults were therefore read as deliberate stops and stranded silently.
### Desired — `app.yaml`, `desired_state`
Tri-state: `""` (unknown) · `"running"` · `"stopped"`.
- **ONE OWNER: the customer's own action.** Writers are the `/api/stacks/{name}/{action}` switch,
`DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` import. **`StartStack` and
`StopStack` are NOT writers** — a census found 14 callers of which only 2 are the customer; the
rest are quiesce, the backup volume dump, offbox reconstitution, app export/restore, the storage
gate, migration and the boot reconciler. Intent recorded in the primitive would make a nightly
backup indistinguishable from the customer pressing Stop.
- **Written BEFORE the act; a failed write REFUSES the act.**
- **Absent means UNKNOWN — never "running".** Every `app.yaml` predating v0.189.0 lacks the field,
so consumers must fall back to the pre-v0.189.0 behaviour rather than assume. A running-only
backfill converges the unambiguous cases; **`stopped` is never inferred, from any signal.**
### In-flight — a marker file, one per owner
Two exist and they are deliberately **separate files**: `quiesce-state.json` (the whole-guest backup
window, `internal/quiesce`) and `appstop-state.json` (app-data operations that stop an app —
`backup.AppStopGuard`, covering the volume dump, offbox reconstitution and `.fab` export). **One
file, one writer**: sharing would give one record two lifetimes, and one owner clearing the other's
note is a stranded app by a different route.
- Written **before** the stop; cleared **only** after a restart that succeeded; a **failed** restart
keeps the marker so the next startup retries.
- **A `defer` is not the mechanism.** A SIGKILL runs no deferred function — established on live
hardware by Campaign 8 fault 10, where what brought the stacks back was the marker read at startup.
- Recovery runs at startup and **completes before** the boot reconciler is launched, so an app the
marker explains is not also reported as an unexplained boot orphan.
### Observed — not persisted, by design
`aggregateState` walks **every container** of a stack and any unhealthy or mixed result wins, so a
partly-dead app cannot read as healthy (F-CRIT-1's shape). This requirement is met here and must not
be re-implemented downstream. Nothing about observed state is written to disk: a controller restart
re-observes it within one refresh, whereas persisting it risks carrying a stale verdict across the
very restart that fixed it (the same argument as `RestartingSince`).
### The binding safety rule (verbatim, from decision D-b)
> *Losing the state store must never cause an app to be deleted, restarted wrongly, or reported
> healthy when it is not — the worst acceptable outcome is re-running a backup that already ran.*
Applied: a lost or corrupt marker means the app is not auto-restarted **by that mechanism**, which is
the pre-v0.189.0 position, not a new hazard. A lost `app.yaml` already means the app is not deployed.
**Nothing here may make an absent file more dangerous than a present one.**
### Not migrated
`shouldRecreateOnBoot` (`internal/web/intermediary.go`) — the drive-backed boot gate — still derives
desired from observed via `hasContainers`. Tracked as **R-170**.
---
## 1. v0.33 module inventory (package → purpose, key deps)
| Package | Purpose | Key internal deps |