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
@@ -0,0 +1,168 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-166 §10 seam discipline — the recovery and the backfill are seams, and a seam that is never
// called is the defect class this project has shipped four times: a correct component, green unit
// tests that inject it directly, and no production caller.
//
// These walk main.go's AST. NOT strings.Contains — the sibling bootrecon test records the reason at
// first hand: a commented-out call still satisfies a substring match, so the text version passed the
// very red-proof it existed to fail. Comments are not code.
// mainBody returns func main()'s body from main.go, parsed.
func mainBody(t *testing.T) *ast.BlockStmt {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
for _, decl := range f.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "main" && fn.Body != nil {
return fn.Body
}
}
t.Fatal("func main() not found in main.go")
return nil
}
// callsInMain returns, in source order, the names of every call in func main() whose function
// expression is `x.Sel(...)` or `Sel(...)` — enough to identify the wiring calls by name.
func callsInMain(t *testing.T, body *ast.BlockStmt) []string {
t.Helper()
var names []string
ast.Inspect(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
switch fun := call.Fun.(type) {
case *ast.SelectorExpr:
names = append(names, fun.Sel.Name)
case *ast.Ident:
names = append(names, fun.Name)
}
return true
})
return names
}
func indexOfCall(names []string, want string) int {
for i, n := range names {
if n == want {
return i
}
}
return -1
}
// TestMainWiresAppStopRecovery is the Group-I seam test. Comment out the `appStopGuard.Recover()`
// line in main.go and this fails, where every behavioural test in internal/backup still passes.
func TestMainWiresAppStopRecovery(t *testing.T) {
names := callsInMain(t, mainBody(t))
if indexOfCall(names, "NewAppStopGuard") < 0 {
t.Fatal("func main() no longer builds the R-166 app-stop guard — nothing writes or reads the marker")
}
if indexOfCall(names, "SetStarter") < 0 {
t.Fatal("func main() no longer calls SetStarter on the app-stop guard — Recover would find the " +
"marker and be unable to start anything, leaving every interrupted app down")
}
if indexOfCall(names, "Recover") < 0 {
t.Fatal("func main() no longer calls Recover() on the app-stop guard — apps left stopped by an " +
"interrupted backup stay down forever (the R-166 defect, un-fixed)")
}
if indexOfCall(names, "SetAppStopGuard") < 0 {
t.Fatal("func main() no longer hands the recovered guard to the backup manager — the manager " +
"would build a SECOND guard over the same file, i.e. one file with two owners")
}
if indexOfCall(names, "SetStopGuard") < 0 {
t.Fatal("func main() no longer wires the exporter's stop guard — the .fab export path would be " +
"the one uncovered stop-and-restart site, which is how a reader concludes the class is handled")
}
}
// TestMainWiresDesiredStateBackfill pins the Part-1.5 call.
func TestMainWiresDesiredStateBackfill(t *testing.T) {
if indexOfCall(callsInMain(t, mainBody(t)), "BackfillDesiredState") < 0 {
t.Fatal("func main() no longer calls BackfillDesiredState — every existing app would stay on " +
"legacy inference until someone pressed a button on it")
}
}
// TestAppStopRecoveryPrecedesTheBootReconciler is §8.4's ORDERING requirement, and it is the reason
// the recovery returns its result instead of pushing it through a notifier seam.
//
// The recovery must COMPLETE — not merely be reached — before `go runBootReconcile(...)` is
// launched. If the boot reconciler ran first it would see an app the marker already explains, list
// it as an unexplained boot orphan, and one fault would be reported as two.
func TestAppStopRecoveryPrecedesTheBootReconciler(t *testing.T) {
names := callsInMain(t, mainBody(t))
recover := indexOfCall(names, "Recover")
bootrecon := indexOfCall(names, "runBootReconcile")
backfill := indexOfCall(names, "BackfillDesiredState")
if recover < 0 || bootrecon < 0 || backfill < 0 {
t.Fatalf("missing a call: Recover=%d runBootReconcile=%d BackfillDesiredState=%d", recover, bootrecon, backfill)
}
if recover >= bootrecon {
t.Fatal("the app-stop Recover no longer runs BEFORE the boot reconciler is launched — an app " +
"the marker explains would also be reported as an unexplained boot orphan (§8.4)")
}
if backfill >= bootrecon {
t.Fatal("the desired-state backfill no longer runs BEFORE the boot reconciler — the reconciler " +
"would decide from intent the backfill had not yet written")
}
if recover >= backfill {
t.Fatal("the backfill no longer runs AFTER the app-stop recovery — an app the recovery just " +
"restarted would still read as down and be left unrecorded")
}
}
// TestMainReportsTheInterruptedOperation pins §2.4: the recovery's outcome reaches the operator.
//
// The reporting call is deliberately far from the recovery (the notifier does not exist yet at
// recovery time), which is exactly the distance across which a wiring gets dropped.
func TestMainReportsTheInterruptedOperation(t *testing.T) {
body := mainBody(t)
names := callsInMain(t, body)
if indexOfCall(names, "NotifyBackupFailed") < 0 {
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
"controller died mid-backup and nobody is told (§2.4)")
}
// It must be guarded, not unconditional: a box with nothing to recover must not email an operator
// on every single boot.
guarded := false
ast.Inspect(body, func(n ast.Node) bool {
ifst, ok := n.(*ast.IfStmt)
if !ok || ifst.Cond == nil {
return true
}
bin, ok := ifst.Cond.(*ast.BinaryExpr)
if !ok {
return true
}
x, ok := bin.X.(*ast.Ident)
if !ok || x.Name != "appStopRecovery" {
return true
}
for _, name := range callsInMain(t, ifst.Body) {
if name == "NotifyBackupFailed" {
guarded = true
}
}
return true
})
if !guarded {
t.Fatal("the interrupted-operation alert is not guarded by `if appStopRecovery != nil` — every " +
"healthy boot would page the operator about a backup that was never interrupted")
}
}