kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s

This commit is contained in:
2026-10-21 15:46:10 +02:00
parent 8f5bea0148
commit 5929b8e345
34 changed files with 2152 additions and 44 deletions
+28 -16
View File
@@ -1167,6 +1167,11 @@ func main() {
// Hand it to the updater and reconcile — if the box is below the floor it auto-updates to
// the floor (reusing the Phase 1 swap). This rides the existing report cycle; no new timer.
// latest_version stays informational (the customer's opt-in button), NOT the auto-target.
// SLICE 3: run the escrow auto-confirm on the same ACK. R-1176 (2026-10-21): it runs BEFORE SetFloor, because
// the off-site apply-bridge's settle gate GOes the moment the floor is known — and a rebuilt box must already
// have recorded „the hub holds the household's package" by then, or its mint guard (R-241) cannot stop a
// fresh repository password. Pinned by TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown.
escrowConfirmer.Reconcile(resp.Escrow)
if updater != nil {
updater.SetFloor(resp.MinControllerVersion)
updater.MaybeAutoUpdate()
@@ -1196,9 +1201,6 @@ func main() {
Logger: logger,
}
cr.Reconcile(resp.ConfigVersion)
// SLICE 3: run the escrow auto-confirm on the same ACK (after the config refresh decision —
// a refresh-restart re-enters here anyway on the next cycle).
escrowConfirmer.Reconcile(resp.Escrow)
// v0.111.0: operator log-tail requests ride the same ACK (pull pattern — the hub
// never reaches in). The NEXT report cycle collects + ships the tails; the hub
// clears its pending request on receipt. An empty list clears any stale local set.
@@ -3380,6 +3382,9 @@ func (a *geoStackAdapter) GetDeployedHostnames() map[string]string {
type exportAdapter struct {
mgr *stacks.Manager
encKey []byte
// R-1191: whether the app had no record before the import (SaveEncryptedAppConfig → PrepareImportedApp).
importFreshMu sync.Mutex
importFresh map[string]bool
}
func (a *exportAdapter) GetStackDir(name string) (string, bool) {
@@ -3522,20 +3527,27 @@ func (a exportStopGuard) End() { a.g.End() }
func (a exportStopGuard) ReleaseFailed(stacks ...string) { a.g.ReleaseFailed(stacks...) }
func (a *exportAdapter) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
meta := stacks.LoadMetadata(stackDir)
sensitiveVars := stacks.SensitiveEnvVars(&meta)
cfg := &stacks.AppConfig{
Deployed: true,
DeployedAt: time.Now().Format(time.RFC3339),
Env: env,
// R-166 — a CUSTOMER-INTENT POINT, and the one that is not the API action switch. Importing
// a `.fab` bundle is the customer installing that app on this box, and the import path starts
// it (appexport/restore.go). Without this the app would come back from a restore with NO
// recorded intent and fall to legacy boot behaviour — meaning a power cut days later would
// strand it, which is exactly the failure this release exists to remove.
DesiredState: stacks.DesiredStateRunning,
// R-1191: the record the unit restore would build (locked fields, the prior record's life records, desired state
// running — R-166), not a bare one; the gates follow in PrepareImportedApp, once the compose file is in place.
fresh, err := a.mgr.SaveImportedAppConfig(stackDir, env)
if err != nil {
return err
}
return stacks.SaveAppConfig(stackDir, cfg, a.encKey, sensitiveVars)
a.importFreshMu.Lock()
if a.importFresh == nil {
a.importFresh = map[string]bool{}
}
a.importFresh[stackDir] = fresh
a.importFreshMu.Unlock()
return nil
}
func (a *exportAdapter) PrepareImportedApp(stackDir string, env map[string]string) error {
a.importFreshMu.Lock()
fresh := a.importFresh[stackDir]
delete(a.importFresh, stackDir)
a.importFreshMu.Unlock()
return a.mgr.PrepareImportedApp(stackDir, env, fresh)
}
func (a *exportAdapter) RefreshStacks() error {
@@ -0,0 +1,59 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and
// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package
// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the
// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the
// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night.
// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it.
// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails.
func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var escrowPos, floorPos token.Pos
ast.Inspect(f, func(n ast.Node) bool {
as, ok := n.(*ast.AssignStmt)
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
return true
}
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "OnPushResponse" {
return true
}
ast.Inspect(as.Rhs[0], func(m ast.Node) bool {
call, ok := m.(*ast.CallExpr)
if !ok {
return true
}
if s, ok := call.Fun.(*ast.SelectorExpr); ok {
if x, ok := s.X.(*ast.Ident); ok {
switch {
case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0:
escrowPos = call.Pos()
case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0:
floorPos = call.Pos()
}
}
}
return true
})
return true
})
if escrowPos == 0 || floorPos == 0 {
t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos)
}
if escrowPos > floorPos {
t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package",
fset.Position(escrowPos), fset.Position(floorPos))
}
}