v0.189.0 — desired state + the app-stop crash marker (R-166 / D-b)
gates / gates (push) Successful in 8s

The box stops inferring the customer's intent from a container count and reads
what they actually asked for.

Part 1 — desired state. AppConfig gains a tri-state `desired_state`
(""/running/stopped), written ONLY by the customer's own action: the API action
switch, DeployStack, UpdateOptionalConfig's redeploy branch, and the .fab
import. Intent is written BEFORE the act and a failed write REFUSES the act.
StartStack/StopStack are deliberately not writers — 14 callers, only 2 are the
customer. bootrecon.isBootOrphan now reads intent instead of len(Containers)>0,
which closes R-157 mechanism B (a power cut or interrupted deploy left an app
with zero containers, read as a deliberate stop, and stranded silently).

ABSENT MEANS UNKNOWN, NEVER "running": every pre-v0.189.0 app.yaml reads absent,
so the legacy fallback is byte-identical to the old rule. A running-only startup
backfill converges the unambiguous cases; `stopped` is never inferred.

Part 2 — backup.AppStopGuard, a persisted marker over every stop→work→start
window (volume dump, offbox reconstitute, .fab export). Its own file, never
quiesce's. Written before the stop, cleared only after a restart that succeeded,
kept when one fails. Recover() completes before the boot reconciler is launched
and returns its outcome, which main.go reports on the existing backup_failed
event once the notifier exists. A defer is not the mechanism — a SIGKILL runs
none (Campaign 8 fault 10).

Also: SaveAppConfig rebuilt AppConfig field-by-field (the R-100 shape) and would
have dropped desired_state on every save across nine call sites. Replaced with
copy-and-overlay. Measured: app.yaml does not round-trip unknown YAML keys.

No hub change, no agent coupling, no user-visible string. 27/27 packages green;
7 red-proofs observed FAIL then restored.
This commit is contained in:
2026-08-02 18:40:17 +02:00
parent e7c44c0e0f
commit dbcb306fcf
17 changed files with 2211 additions and 33 deletions
+61 -8
View File
@@ -104,6 +104,23 @@ type AppConfig struct {
// EmailEnabled is the per-app app-email toggle (default off). When on AND the global toggle is
// on AND the app has an smtp_mapping, the controller injects the relay SMTP env at compose time.
EmailEnabled bool `yaml:"email_enabled,omitempty" json:"email_enabled,omitempty"`
// DesiredState (R-166 / decision D-b) is what the CUSTOMER asked for: DesiredStateRunning or
// DesiredStateStopped. It is TRI-state, and the third value is the entire safety property:
//
// ABSENT ("") MEANS UNKNOWN — IT NEVER MEANS "running".
//
// Every app.yaml on every existing box was written before this field existed, so absent is the
// overwhelmingly common value on upgrade. Reading it as "running" would start, on the next boot
// after the upgrade, every app its owner deliberately stopped — fleet-wide, silently. Where the
// state is unknown the boot reconciler falls back to its pre-R-166 behaviour instead of inventing
// an answer (see internal/bootrecon.isBootOrphan and the §8.1 table it implements).
//
// ONE OWNER: the customer's own action writes this and nothing else does. StartStack/StopStack
// are NOT writers — twelve of their fourteen callers are machines (quiesce, the backup volume
// dump, app export, the storage gate, migration, the boot reconciler), and recording intent in
// the primitive would make a nightly backup indistinguishable from the customer pressing Stop,
// which is the exact confusion this field exists to end. Writers: SetDesiredState's callers.
DesiredState string `yaml:"desired_state,omitempty" json:"desired_state,omitempty"`
}
// DeployRequest contains the user-provided values from the deploy form.
@@ -330,6 +347,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
LockedFields: lockedFields,
// R-166: deploying an app IS the customer asking for it to run, and this is the
// intent-before-the-act write (§8.2). Recorded on the transitional Deployed:false write too,
// which is harmless and correct: nothing reads desired state on a stack that is not deployed
// (isBootOrphan gates on Deployed first), and if the compose-up then fails, runComposeDeploy
// reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
DesiredState: DesiredStateRunning,
}
diskCfg := *appCfg
@@ -670,6 +693,26 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
// If deployed, recreate containers to pick up new env vars
// (docker compose restart does NOT pick up new env vars — must use up -d)
if stack.Deployed {
// R-166 — the THIRD customer-intent point, alongside the API action switch and deploy/import.
// This branch runs `up -d`, so the customer editing an app's settings ends with the app
// RUNNING; recording that keeps intent and reality in step. Written before the act (§8.2).
//
// Deliberately inside the `stack.Deployed` branch only: the other branch starts nothing, so
// it expresses no opinion about whether the app should run. Set on the already-loaded appCfg
// rather than through SetDesiredState so it rides the save just above instead of rewriting
// app.yaml twice — the load-then-save is what makes that safe (SaveAppConfig copies-and-
// overlays, so no other field is disturbed).
if appCfg.DesiredState != DesiredStateRunning {
appCfg.DesiredState = DesiredStateRunning
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("recording desired state before applying the new config: %w", err)
}
m.mu.Lock()
if s, ok := m.stacks[stackName]; ok && s.AppConfig != nil {
s.AppConfig.DesiredState = DesiredStateRunning
}
m.mu.Unlock()
}
m.logger.Printf("[INFO] [stacks] Restarting %s to apply new optional config", stackName)
env := m.stackEnv(stackDir)
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
@@ -741,14 +784,24 @@ func LoadAppConfig(stackDir string) *AppConfig {
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
encryptedCount := 0
// Clone env and encrypt sensitive values
saveCfg := &AppConfig{
Deployed: cfg.Deployed,
DeployedAt: cfg.DeployedAt,
Env: make(map[string]string, len(cfg.Env)),
LockedFields: cfg.LockedFields,
EmailEnabled: cfg.EmailEnabled,
}
// COPY-AND-OVERLAY, never a field-by-field rebuild (the R-100 lesson, v0.181.0).
//
// This used to be a struct literal naming five fields. That shape is safe exactly until someone
// adds a sixth: the new field is silently dropped on every save, and because the save path is
// shared by nine call sites the loss shows up far from the code that caused it. R-100 shipped
// with two live instances of precisely this bug (offboxConfigHandler and ApplyOffsiteTarget both
// rebuilt a target field-by-field and erased LastSuccess).
//
// A value copy carries EVERY field the struct has, including ones added after this line was
// written, so it is safe by construction. Only Env is rebuilt below — it is the one field that
// needs transforming (encryption), and it must not alias the caller's map.
//
// LIMITATION, measured not assumed (TestSaveAppConfig_UnknownYAMLKeysAreDropped): keys present in
// the on-disk YAML that this struct does not model are NOT preserved — the round-trip goes
// through the struct, so yaml.Unmarshal discards them before this function ever sees them. That
// is unchanged by R-166 and is why every writer must load-then-save rather than construct.
saveCfg := *cfg
saveCfg.Env = make(map[string]string, len(cfg.Env))
sensitiveSet := make(map[string]bool, len(sensitiveVars))
for _, v := range sensitiveVars {
sensitiveSet[v] = true