carlos / platform Public

host: the agent shares one proof gate, keeps a waiting route in the fast loop, and asks root to mint

One release.ProofGate per agent process (agentProofGate, bake dir dataDir()) rides adoptLoop, fastConvergeLoop and the cold-wake hook. fastMembers sweeps a listed route whose proof wait is due; a wait the sweep left standing backs off like an unstaged route (ruling 5). The mint asker asks for a root pass every 2 minutes, through its own requester, until the live build's proof is in the bucket, and writes the pending note. A policy that does not parse asks nothing (root mints nothing until it parses).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 13400c9171492f21eaeb41bd83679e3f292ac7e9 parent d70e93c
3 files changed, +454 −13
  • internal/host/agent.go +78 −13
  • internal/host/proofask.go +139 −0
  • internal/host/proofask_test.go +237 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index d938b6289..c4719edd7 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -787,8 +787,10 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st
optDir := release.OptDir()
stageReq := stageRequester(dd)
return func(ctx context.Context, host string) error {
+ // The process's one proof gate (proofask.go), so a cold wake cannot
+ // adopt an unproven build of a listed app.
return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host,
- release.OnStageBlocked(stageReq))
+ release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate()))
}
}
@@ -869,7 +871,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps)
release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())),
release.RestartHibernating(restart),
release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)),
- release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))); err != nil {
+ release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv)),
+ release.WithProofGate(agentProofGate())); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
// The schedule converge's hourly floor (schedule spec §6.2), AFTER the
@@ -1223,14 +1226,33 @@ const stageRequestEvery = 15 * time.Second
// cannot be written costs latency, never correctness, because
// carlos-adopt.timer remains the floor underneath it.
func stageRequester(dd string) func(host, ver string, err error) {
+ return markerRequester(dd, func(host, ver string, err error) {
+ if err != nil {
+ log.Printf("agent: cannot request root stage for %s %s: %v", host, ver, err)
+ return
+ }
+ log.Printf("agent: %s needs %s staged and this process cannot write the version store — asked for a root pass", host, ver)
+ })
+}
+
+// mintRequester is the mint asker's (proofask.go) request: the same marker
+// and the same throttle shape as stageRequester's, with its own budget and
+// its own words. The asker logs why it asks; this logs only a failed write.
+func mintRequester(dd string) func(host, ver string, err error) {
+ return markerRequester(dd, func(host, ver string, err error) {
+ if err != nil {
+ log.Printf("agent: cannot request a root pass to mint a proof for %s %s: %v", host, ver, err)
+ }
+ })
+}
+
+// markerRequester touches the stage-request marker through a fresh
+// stageThrottle and hands said each write's outcome.
+func markerRequester(dd string, said func(host, ver string, err error)) func(host, ver string, err error) {
t := &stageThrottle{every: stageRequestEvery, now: time.Now, after: func(d time.Duration, f func()) { time.AfterFunc(d, f) },
write: func(host, ver string) {
- if err := os.WriteFile(filepath.Join(dd, stageRequestMarker),
- []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
- log.Printf("agent: cannot request root stage for %s %s: %v", host, ver, err)
- return
- }
- log.Printf("agent: %s needs %s staged and this process cannot write the version store — asked for a root pass", host, ver)
+ said(host, ver, os.WriteFile(filepath.Join(dd, stageRequestMarker),
+ []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644))
}}
return func(host, ver string, _ error) { t.request(host, ver) }
}
@@ -1430,6 +1452,8 @@ type fastDeps struct {
wakeLeg func(ctx context.Context) // the sidecar wake check (design: sidecar members), or a test hook
scheduleLeg func(ctx context.Context) // the schedule converge (spec §6.2), or a test hook; nil = not armed
systemLeg *systemLeg // the platform self-update leg (spec §4); nil = not armed
+ proof *release.ProofGate // D3's gate (agentProofGate); nil = no proof waits to keep
+ mintLeg func(ctx context.Context) // D3's mint asker (proofask.go); nil = not armed
lastSerial int64
seeded bool
lastFull time.Time
@@ -1515,6 +1539,9 @@ func fastPassOpts(dd string, stageReq func(host, ver string, err error), restart
// - unstaged routes (no live symlink), once their backoff window has
// passed: a route was just minted, or its stage failed; the sweep
// picks it up as soon as there is something to stage.
+// - routes waiting for a dev proof (D3), each time the proof gate's
+// cached miss runs out: they have a live version, but their head is
+// held until its proof arrives.
//
// Computed BEFORE any store work so an empty set costs zero store reads —
// the idle box pays one serial GET per tick and nothing else. A registry
@@ -1553,6 +1580,20 @@ func (d *fastDeps) fastMembers(now time.Time) []string {
members = append(members, rt.Host)
continue
}
+ // D3 (spec §4.5, codex 14): a listed route waiting for a dev proof
+ // stays in the sweep although it has a live version, whenever its
+ // 30 s miss has run out, so it adopts soon after the proof arrives
+ // rather than at the next serial bump or the hourly floor. A wait the
+ // last sweep left standing (fastTick) is inside a backoff window, and
+ // sits it out like an unstaged route.
+ if d.proof.WaitingDue(rt.Host) {
+ if nb, ok := d.notBefore[rt.Host]; ok && now.Before(nb) {
+ continue
+ }
+ seen[rt.Host] = true
+ members = append(members, rt.Host)
+ continue
+ }
// A LiveVersion error reads as unstaged: the pass is the thing that
// can repair a broken link, and the backoff bounds the retry cost.
if v, err := release.LiveVersion(d.optDir, rt.Host); err == nil && v != "" {
@@ -1801,8 +1842,9 @@ func logSlow(what string, start time.Time, detail string) {
// move, last, for both of the same reasons — see the call site.
// 2. Fast sweep: fastMembers, and only when non-empty one PassFilter over
// exactly those hosts — an empty sweep costs zero store reads. After
-// the sweep, members still unstaged back off; staged members clear
-// their backoff state.
+// the sweep, members still unstaged back off, and so do members whose
+// proof wait the sweep left standing; other staged members clear their
+// backoff state.
func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
doc, _, err := release.ReadSerial(ctx, d.store)
switch {
@@ -1864,6 +1906,13 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
d.systemLeg.tick(ctx, now)
}
+ // D3's mint asker: on a proving box, ask for a root pass every 2 minutes
+ // while a listed build has baked with no proof. Elsewhere it reads one
+ // absent file and returns.
+ if d.mintLeg != nil {
+ d.mintLeg(ctx)
+ }
+
members := d.fastMembers(now)
if len(members) == 0 {
return // zero store reads: no PassFilter, no bake-clock write
@@ -1878,11 +1927,19 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
log.Printf("agent: fast converge sweep: %v", err)
}
for _, h := range members {
- if v, err := release.LiveVersion(d.optDir, h); err == nil && v != "" {
+ switch v, err := release.LiveVersion(d.optDir, h); {
+ case err != nil || v == "":
+ d.noteUnstaged(h, now)
+ case d.proof.WaitingDue(h):
+ // The sweep neither renewed this host's proof wait (which would
+ // cache a fresh 30 s miss) nor cleared it: adoptHost returned
+ // before the gate, say on a channel with no pointer. Nothing will
+ // change that by the next tick, so it backs off like an unstaged
+ // route instead of being swept every 2 s (plan 3 ruling 5).
+ d.noteUnstaged(h, now)
+ default:
delete(d.notBefore, h)
delete(d.step, h)
- } else {
- d.noteUnstaged(h, now)
}
}
}
@@ -1936,6 +1993,14 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
// The rollback's restart (D1): checked like the forward one, and a replace
// rather than a start, so a rejected build left up is cycled out.
opts = append(opts, release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv)))
+ // D3: the process's one proof gate rides every pass this loop runs, and
+ // fastMembers reads its waiting list. The mint asker has its own
+ // requester, so its 2-minute asks never spend the stage budget and its
+ // journal line does not claim a stage.
+ opts = append(opts, release.WithProofGate(agentProofGate()))
+ d.proof = agentProofGate()
+ d.mintLeg = (&mintAsker{reg: reg, store: store, sys: systemdUnits{}, optDir: optDir,
+ policyPath: release.ProofPolicyPath, request: mintRequester(dd), now: time.Now}).tick
// The restart check's memory (issue #113, narrowed requests added by
// discussion #16), seeded ONCE here — two reads per app at arming (the
// app-wide generation and the narrowed request log) and never again except
diff --git a/internal/host/proofask.go b/internal/host/proofask.go
new file mode 100644
index 000000000..0c2648aca
--- /dev/null
+++ b/internal/host/proofask.go
@@ -0,0 +1,139 @@
+// The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md
+// §4.3, §4.5).
+//
+// agentProofGate is the one release.ProofGate this process passes to every
+// pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss
+// cache, the waiting list fastMembers reads and the status line all agree.
+//
+// mintAsker runs on the proving box's fast loop. Minting needs the root-only
+// key, and root passes run hourly or on request, so when a listed app's live
+// build has run for the policy's after with no proof in the bucket, the asker
+// requests a root pass through the stage-request relay. It asks again every
+// mintAskEvery until it sees the proof (codex 5): the relay can miss a request
+// written while carlos-stage.service is running, and a mint write can fail.
+// It also leaves a pending note the first time it sees a build live, which is
+// how `carlos proof get` can say how long the build has had.
+//
+// It cannot check everything a mint needs (it cannot read another unit's
+// /proc/<pid>/exe), and need not: the root pass checks every condition again.
+// When systemctl show is denied here, it asks anyway and root decides.
+package host
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ proofGateOnce sync.Once
+ proofGateInst *release.ProofGate
+)
+
+// agentProofGate's bake directory is dataDir(), the same directory every
+// agent pass hands release.BakeDir, so the gate's healthy-history reads and
+// not-proven refusal records land where the pass's own refusal gate looks.
+func agentProofGate() *release.ProofGate {
+ proofGateOnce.Do(func() { proofGateInst = release.NewProofGate(release.ProofPolicyPath, dataDir()) })
+ return proofGateInst
+}
+
+const mintAskEvery = 2 * time.Minute
+
+type mintAsker struct {
+ reg *registry.Registry
+ store release.Store
+ sys unitSystem
+ optDir string
+ policyPath string
+ request func(host, ver string, err error) // mintRequester in production
+ now func() time.Time
+
+ last map[string]time.Time // host -> when it was last looked at
+ proven map[string]string // host -> sha256 seen proven
+ pending map[string]string // host -> sha256 whose pending note is written
+ shaByPath map[string]string // staged file -> sha256 (version dirs are immutable)
+}
+
+func (a *mintAsker) tick(ctx context.Context) {
+ // A policy file that does not parse is closed (Task 2's carry): root
+ // mints nothing until it parses (proofroot.go says so on every pass, and
+ // the gate once a minute), so there is nothing to ask root for. An
+ // absent file, or one with no prove block, is a box that proves nothing.
+ pol, err := release.LoadProofPolicy(a.policyPath)
+ if err != nil || pol == nil || pol.Prove == nil {
+ return
+ }
+ routes, err := a.reg.List()
+ if err != nil {
+ return
+ }
+ if a.last == nil {
+ a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[string]string{}, map[string]string{}, map[string]string{}
+ }
+ now := a.now()
+ after := pol.ProveAfter()
+ prover := pol.Prove.Prover
+ for _, rt := range routes {
+ if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) {
+ continue
+ }
+ if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery {
+ continue
+ }
+ a.last[rt.Host] = now
+ target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host))
+ if err != nil {
+ continue
+ }
+ sha, ok := a.shaByPath[target]
+ if !ok {
+ if sha, err = release.FileSHA256(target); err != nil {
+ continue
+ }
+ a.shaByPath[target] = sha
+ }
+ if a.proven[rt.Host] == sha {
+ continue
+ }
+ ver, _ := release.LiveVersion(a.optDir, rt.Host)
+ acct := release.RouteAccount(rt)
+ if p, err := a.sys.Show(rt.Unit); err == nil {
+ if p.ActiveState != "active" {
+ continue
+ }
+ if !p.ActiveEnterTimestamp.IsZero() {
+ if a.pending[rt.Host] != sha {
+ if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App,
+ Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover,
+ LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil {
+ log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err)
+ } else {
+ a.pending[rt.Host] = sha
+ }
+ }
+ if now.Sub(p.ActiveEnterTimestamp) < after {
+ continue
+ }
+ }
+ }
+ _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover)
+ switch {
+ case err == nil:
+ a.proven[rt.Host] = sha
+ continue
+ case !errors.Is(err, os.ErrNotExist):
+ log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err)
+ continue
+ }
+ log.Printf("agent: %s has run %s for %s with no proof in the bucket; asking for a root pass to mint one", rt.Host, ver, after)
+ a.request(rt.Host, ver, nil)
+ }
+}
diff --git a/internal/host/proofask_test.go b/internal/host/proofask_test.go
new file mode 100644
index 000000000..d1fa0488b
--- /dev/null
+++ b/internal/host/proofask_test.go
@@ -0,0 +1,237 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func mintAskerFixture(t *testing.T, activeFor time.Duration) (*mintAsker, *int, *time.Time, release.Store, string) {
+ t.Helper()
+ opt, etc := t.TempDir(), t.TempDir()
+ liveTo(t, opt, observedHost, "v1")
+ reg := restartTestRegistry(t)
+ if err := reg.Upsert(registry.Route{Host: observedHost, Kind: "instance", App: "console",
+ Channel: "canary/console", Unit: "console.service", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(etc, "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(provePolicy("/etc/carlos/proving.key")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t0 := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ clock := t0
+ asked := 0
+ store := release.NewDirStore(t.TempDir())
+ a := &mintAsker{reg: reg, store: store, optDir: opt, policyPath: policy,
+ sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7,
+ ActiveEnterTimestamp: t0.Add(-activeFor)}}},
+ request: func(string, string, error) { asked++ },
+ now: func() time.Time { return clock }}
+ sha, err := release.FileSHA256(filepath.Join(opt, "versions", "v1", "console"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return a, &asked, &clock, store, sha
+}
+
+// Review focus 5: the relay can drop a request and a mint write can fail, so
+// the agent asks every 2 minutes until it sees the proof, then stops.
+func TestMintAskerReasksUntilTheProofAppears(t *testing.T) {
+ a, asked, clock, store, sha := mintAskerFixture(t, 20*time.Minute)
+ ctx := context.Background()
+ t0 := *clock
+ at := func(d time.Duration) { *clock = t0.Add(d); a.tick(ctx) }
+ at(0)
+ if *asked != 1 {
+ t.Fatalf("asked %d times at first sight, want 1", *asked)
+ }
+ at(time.Minute)
+ if *asked != 1 {
+ t.Fatalf("asked %d times inside 2 minutes, want 1", *asked)
+ }
+ at(2 * time.Minute)
+ if *asked != 2 {
+ t.Fatalf("asked %d times after 2 minutes, want 2", *asked)
+ }
+ seed, _, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := release.SignProof(key, release.Proof{App: "console", Platform: release.AdoptPlatform(), SHA256: sha,
+ Version: "v1", Prover: "dev", ActiveSince: t0.Add(-20 * time.Minute), ProvenAt: t0})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PutProof(ctx, store, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ at(4 * time.Minute)
+ at(6 * time.Minute)
+ if *asked != 2 {
+ t.Fatalf("asked %d times after the proof appeared, want 2", *asked)
+ }
+ pend, err := release.GetProofPending(ctx, store, "bab", "console", sha, "dev")
+ if err != nil || !pend.LiveSince.Equal(t0.Add(-20*time.Minute)) {
+ t.Fatalf("pending = %+v, %v", pend, err)
+ }
+}
+
+func TestMintAskerWaitsOutTheBake(t *testing.T) {
+ a, asked, _, store, sha := mintAskerFixture(t, 5*time.Minute)
+ a.tick(context.Background())
+ if *asked != 0 {
+ t.Fatalf("asked %d times five minutes into a fifteen-minute bake", *asked)
+ }
+ if _, err := release.GetProofPending(context.Background(), store, "bab", "console", sha, "dev"); err != nil {
+ t.Fatalf("no pending note for a baking build: %v", err)
+ }
+}
+
+// Task 2's carry: a policy file that does not parse is closed. Root mints
+// nothing until it parses, so the asker has nothing to ask for and stays
+// quiet rather than reading the broken file as "no prove block" and guessing.
+func TestMintAskerAsksNothingOnABrokenPolicy(t *testing.T) {
+ a, asked, _, store, sha := mintAskerFixture(t, 20*time.Minute)
+ if err := os.WriteFile(a.policyPath, []byte(`{"schema":1,"prove":`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ a.tick(context.Background())
+ if *asked != 0 {
+ t.Fatalf("asked %d times with a broken policy, want 0", *asked)
+ }
+ if _, err := release.GetProofPending(context.Background(), store, "bab", "console", sha, "dev"); err == nil {
+ t.Fatal("pending note written under a broken policy")
+ }
+}
+
+// Spec §4.5, codex 14: a non-canary listed route with a live version stays in
+// the fast set while it waits (at the gate's 30 s cadence), and leaves once
+// it adopts.
+func TestFastMembersKeepsAWaitingRoute(t *testing.T) {
+ f := newFastFixture(t)
+ ctx := context.Background()
+ host := "edge.example.com"
+ f.addRoute(t, host, "edge")
+ f.stageLive(t, host, "v1")
+ plat := release.AdoptPlatform()
+ for ver, body := range map[string]string{"v1": "staged build", "v2": "build two"} {
+ m := release.Manifest{App: "fastapp", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}
+ if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte(body)}); err != nil {
+ t.Fatalf("Ship %s: %v", ver, err)
+ }
+ }
+ if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ prover, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(t.TempDir(), "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ clock := time.Now()
+ gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock })
+ d := f.deps(nil)
+ d.passOpts = append(d.passOpts, release.WithProofGate(gate))
+ d.proof = gate
+
+ if got := d.fastMembers(clock); len(got) != 0 {
+ t.Fatalf("members = %v before anything waits", got)
+ }
+ // The serial-triggered full pass sees v2 and finds no proof.
+ if err := release.Pass(ctx, f.reg, f.store, f.pub, f.optDir, d.restart, d.passOpts...); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := release.LiveVersion(f.optDir, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 while unproven", v)
+ }
+ clock = clock.Add(31 * time.Second)
+ if got := d.fastMembers(clock); len(got) != 1 || got[0] != host {
+ t.Fatalf("members = %v, want the waiting non-canary route", got)
+ }
+ sum := sha256.Sum256([]byte("build two"))
+ p, err := release.SignProof(prover, release.Proof{App: "fastapp", Platform: plat, SHA256: hex.EncodeToString(sum[:]),
+ Version: "v2", Prover: "dev", ActiveSince: clock.Add(-time.Hour).UTC().Truncate(time.Second),
+ ProvenAt: clock.UTC().Truncate(time.Second)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PutProof(ctx, f.store, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, clock)
+ if v, _ := release.LiveVersion(f.optDir, host); v != "v2" {
+ t.Fatalf("live = %q, want v2 once proven", v)
+ }
+ clock = clock.Add(31 * time.Second)
+ if got := d.fastMembers(clock); len(got) != 0 {
+ t.Fatalf("members = %v after adopting, want none", got)
+ }
+}
+
+// Ruling 5's purpose, seen from the fast loop: a wait the sweep neither
+// renews nor clears (here the route moved to a channel with no pointer, so
+// adoptHost returns before the gate) must not keep its host in the sweep
+// every tick. It backs off like an unstaged route.
+func TestFastMembersBacksOffAWaitTheSweepLeftStanding(t *testing.T) {
+ f := newFastFixture(t)
+ ctx := context.Background()
+ host := "edge.example.com"
+ f.addRoute(t, host, "edge")
+ f.stageLive(t, host, "v1")
+ plat := release.AdoptPlatform()
+ m := release.Manifest{App: "fastapp", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}
+ if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte("build two")}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(t.TempDir(), "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ clock := time.Now()
+ gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock })
+ d := f.deps(nil)
+ d.passOpts = append(d.passOpts, release.WithProofGate(gate))
+ d.proof = gate
+ if err := release.Pass(ctx, f.reg, f.store, f.pub, f.optDir, d.restart, d.passOpts...); err != nil {
+ t.Fatal(err)
+ }
+ f.addRoute(t, host, "nowhere") // no pointer: the pass never reaches the gate
+ clock = clock.Add(31 * time.Second)
+ if got := d.fastMembers(clock); len(got) != 1 {
+ t.Fatalf("members = %v, want the waiting route", got)
+ }
+ fastTick(ctx, d, clock)
+ if got := d.fastMembers(clock.Add(time.Second)); len(got) != 0 {
+ t.Fatalf("members = %v a second after a sweep that left the wait standing, want none", got)
+ }
+ if d.backoffFor(host) == 0 {
+ t.Fatal("no backoff recorded for the standing wait")
+ }
+}