release: adoptHost requires a dev proof for listed apps, checked before staging and on the staged bytes
D3's ProofGate (plan 3 task 4): admit before ensureStaged with a 30 s miss cache and a waiting list, bind on the staged bytes before PointLive. A staged-bytes mismatch is a RefusalNotProven record (recovered, still on the old build) with its own gate line, alert and status row; the wait is cleared on every early return that stops the gate being asked (ruling 5); any policy load error holds every named-unit route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+962
−4
cli.go+4 −0internal/host/alertsink.go+16 −0internal/host/refusalwatch_test.go+37 −0internal/release/adopt.go+46 −2internal/release/proofgate.go+336 −0internal/release/proofgate_test.go+494 −0internal/release/refusal.go+6 −2statuslive_test.go+23 −0
diff --git a/cli.go b/cli.go| index ffeeab224..fb212becf 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -3460,6 +3460,10 @@ func printLiveBoxes(w io.Writer, boxes []status.Box, now time.Time) { |
| fmt.Fprintf(w, " refused %s %s since %s: wrong program (%s)\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), strings.TrimPrefix(r.Reason, "wrong program: ")) |
| continue |
| } |
| + if r.Kind == release.RefusalNotProven { |
| + fmt.Fprintf(w, " refused %s %s since %s: staged bytes do not match dev's proof (%s)\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), strings.TrimPrefix(r.Reason, "not proven: ")) |
| + continue |
| + } |
| failed := "" |
| if r.RecoveryError != "" { |
| failed = " (rollback failed: " + r.RecoveryError + ")" |
diff --git a/internal/host/alertsink.go b/internal/host/alertsink.go| index f3fde47ad..6000c57aa 100644 |
| --- a/internal/host/alertsink.go |
| +++ b/internal/host/alertsink.go |
| @@ -174,6 +174,22 @@ func formatReleaseAlert(r release.Refusal) (subject, msg string) { |
| r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), r.Reason) |
| return subject, msg |
| } |
| + if r.Kind == release.RefusalNotProven { |
| + // D3's binding check: the same shape as the wrong program. Nothing |
| + // started and nothing was rolled back, and "--retry" would re-sign |
| + // the pointer over the same staged bytes. |
| + still := r.RestoredVersion |
| + if still == "" { |
| + still = "nothing" |
| + } |
| + subject = fmt.Sprintf("%s: %s refused, staged bytes do not match dev's proof (still on %s)", r.Host, r.Version, still) |
| + if len(subject) > 100 { // SNS subject limit |
| + subject = subject[:100] |
| + } |
| + msg = fmt.Sprintf("%s refused %s at %s: %s\n\nThe staged bytes do not match dev's proof, so %s was never pointed live. The box is still on %s. Roll the exact bytes dev proved.", |
| + r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), strings.TrimPrefix(r.Reason, "not proven: "), r.Version, still) |
| + return subject, msg |
| + } |
| if r.Recovered { |
| subject = fmt.Sprintf("%s: %s did not start, rolled back to %s", r.Host, r.Version, r.RestoredVersion) |
| } else { |
diff --git a/internal/host/refusalwatch_test.go b/internal/host/refusalwatch_test.go| index 419712022..8aa25967a 100644 |
| --- a/internal/host/refusalwatch_test.go |
| +++ b/internal/host/refusalwatch_test.go |
| @@ -122,3 +122,40 @@ func TestRefusalWatchReportsAWrongProgram(t *testing.T) { |
| t.Fatalf("rows = %+v", rows) |
| } |
| } |
| + |
| +// D3's staged-bytes refusal is not a rollback either (plan 3 ruling 3): the |
| +// box never left its old build. The alert says the staged bytes do not match |
| +// dev's proof, names the build the box is still on, and asks for the exact |
| +// bytes dev proved; never --retry, never a rollback. |
| +func TestRefusalWatchReportsANotProven(t *testing.T) { |
| + bake := t.TempDir() |
| + now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC) |
| + rec := release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute), |
| + Reason: "not proven: staged sha256 aaaa, dev proved bbbb", Kind: release.RefusalNotProven, Recovered: true, RestoredVersion: "v1"} |
| + if err := release.WriteRefusal(bake, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + subject, msg := formatReleaseAlert(rec) |
| + if subject != "console.example.com: v2 refused, staged bytes do not match dev's proof (still on v1)" { |
| + t.Fatalf("subject = %q", subject) |
| + } |
| + for _, want := range []string{"staged sha256 aaaa, dev proved bbbb", "still on v1", "Roll the exact bytes dev proved"} { |
| + if !strings.Contains(msg, want) { |
| + t.Fatalf("message = %q, want %q in it", msg, want) |
| + } |
| + } |
| + for _, never := range []string{"--retry", "rolled back", "rollback"} { |
| + if strings.Contains(msg, never) || strings.Contains(subject, never) { |
| + t.Fatalf("subject %q, message %q: %q has no place in a not-proven alert", subject, msg, never) |
| + } |
| + } |
| + var posts []string |
| + sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{}) |
| + rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now) |
| + if len(posts) != 1 || !strings.Contains(posts[0], `"refusal_kind":"not-proven"`) { |
| + t.Fatalf("posts = %v", posts) |
| + } |
| + if len(rows) != 1 || rows[0].Kind != release.RefusalNotProven || rows[0].RecoveryError != "" { |
| + t.Fatalf("rows = %+v", rows) |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 17f858825..09fa8461b 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -72,6 +72,10 @@ type passConfig struct { |
| // running (reset-failed + restart), because a deadline failure can leave |
| // the rejected build running unready and `start` is a no-op on it. |
| rollbackRestart func(host string) error |
| + |
| + // proof is D3's gate (WithProofGate, proofgate.go). nil, the default for |
| + // every existing caller and test, means no proof is read or required. |
| + proof *ProofGate |
| } |
| // NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one |
| @@ -259,6 +263,14 @@ func WithRollbackRestart(fn func(host string) error) PassOption { |
| return func(c *passConfig) { c.rollbackRestart = fn } |
| } |
| +// WithProofGate arms D3 (proofgate.go): a build of an app the policy file |
| +// lists is adopted only with a verified proof for its bytes, or from this |
| +// box's healthy history. Pass the SAME gate to every pass of a process, so |
| +// its 30 s miss cache and its waiting list survive between ticks. |
| +func WithProofGate(g *ProofGate) PassOption { |
| + return func(c *passConfig) { c.proof = g } |
| +} |
| + |
| // stageBlocked reports whether err is the version store refusing a write to |
| // THIS uid, rather than any of the many other ways staging fails. errors.Is |
| // unwraps the *os.PathError that mkdir/openat return. |
| @@ -317,6 +329,9 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| for _, opt := range opts { |
| opt(&cfg) |
| } |
| + // D3: re-read the proof policy at the start of every pass, so an edit |
| + // reaches the agent and root alike without a restart (spec §4.5). |
| + cfg.proof.BeginPass() |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| @@ -957,6 +972,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| backfillManifest(ctx, s, optDir, RouteAccount(rt), rt.App, cur, rt.Host) |
| } |
| if cur == ver { |
| + cfg.proof.clear(rt.Host) // live on its head: nothing left to wait for |
| // A refusal that no longer blocks the pointer is stale even though |
| // nothing moves: v2 was refused and rolled back to v1, then the |
| // channel went back to v1. Without this every later pass returns |
| @@ -979,12 +995,20 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| // deliberately unratcheted (pipelines spec §3 amendment: ratcheting it |
| // would make it wait out the very window it exists to skip). Either one |
| // alone releases the head. |
| + // |
| + // Each return from here to admit stops D3's gate being asked about this |
| + // head, so each one also takes the host off the proof waiting list |
| + // (cfg.proof.clear). A wait left behind for a head the channel has moved |
| + // off would stay due for ever, and the fast loop would sweep the host |
| + // every tick for the whole hold (plan 3 ruling 5). |
| if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, holds, ignore || ptr.FastTrack, now); hold != "" { |
| fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold) |
| + cfg.proof.clear(rt.Host) |
| return |
| } |
| if poisoned[ver] { |
| fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver) |
| + cfg.proof.clear(rt.Host) |
| return |
| } |
| // D1's refusal (refusal.go): this box rolled this exact promotion back |
| @@ -994,19 +1018,33 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err != nil { |
| fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err) |
| } else if r.Blocks(ptr) { |
| - if r.Kind == RefusalWrongProgram { |
| + switch r.Kind { |
| + case RefusalWrongProgram: |
| // --retry would re-stage the same bytes and be refused again, |
| // and re-shipping this version cannot help: its manifest is |
| // immutable, so the consent has to ride on a new version. |
| fmt.Printf(" %s: refused: %s is the wrong program for this route (%s); still on %s; ship a new version with carlos ship --program-change and promote it, if the change is intended\n", |
| rt.Host, ver, r.Reason, r.RestoredVersion) |
| - } else { |
| + case RefusalNotProven: |
| + // D3's binding check (proofgate.go bind). Nothing was started |
| + // or rolled back, and --retry would re-sign the pointer over |
| + // the same staged bytes and be refused again. |
| + fmt.Printf(" %s: refused: %s's staged bytes do not match dev's proof (%s); still on %s; roll the exact bytes dev proved\n", |
| + rt.Host, ver, strings.TrimPrefix(r.Reason, "not proven: "), orNothing(r.RestoredVersion)) |
| + default: |
| fmt.Printf(" %s: refused: %s did not come up on this box at %s (%s); retry with: carlos promote --retry %s %s\n", |
| rt.Host, ver, r.RefusedAt.Format(time.RFC3339), r.Reason, ver, channel) |
| } |
| + cfg.proof.clear(rt.Host) |
| return |
| } |
| } |
| + // D3's cheap gate (proofgate.go, spec §4.5 check 1): before ensureStaged, |
| + // so an unproven build is neither downloaded nor relayed to root. |
| + verdict, admitted := cfg.proof.admit(ctx, s, optDir, rt, ptr, cur) |
| + if !admitted { |
| + return |
| + } |
| m, err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host) |
| if err != nil { |
| fmt.Printf(" %s: %v\n", rt.Host, err) |
| @@ -1061,6 +1099,12 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| } |
| } |
| + // D3's binding check (spec §4.5 check 2): the staged bytes themselves |
| + // must be the proven ones, or a build this box has run well. |
| + if !cfg.proof.bind(optDir, rt, ptr, verdict, cur, now) { |
| + return |
| + } |
| + |
| // The capability converge for the NEW version, BEFORE PointLive (spec |
| // §4d). m is the manifest ensureStaged has in hand — never a re-read of |
| // the staged sibling, whose write is best-effort: a sibling that failed |
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go| new file mode 100644 |
| index 000000000..803415ffb |
| --- /dev/null |
| +++ b/internal/release/proofgate.go |
| @@ -0,0 +1,336 @@ |
| +// The dev-proof gate, the requiring half of D3 (spec |
| +// 2026-10-06-console-release-defences-design.md §4.5). |
| +// |
| +// adoptHost asks it twice. admit runs before ensureStaged, so an unproven |
| +// build is neither downloaded nor relayed to a root pass: it takes the |
| +// manifest's sha256 for this platform and admits the build when that sha256 |
| +// is in this box's healthy history, or a configured prover's proof for it |
| +// verifies. bind runs after ensureStaged and before PointLive and hashes the |
| +// staged file, because a manifest can name bytes the box is not running (a |
| +// reused version name, a stale cache): the staged hash must be the proof's or |
| +// in the healthy history, or the build is refused with a refusal record of |
| +// kind RefusalNotProven. |
| +// |
| +// A missing proof is not a refusal. The box keeps looking, so the build adopts |
| +// soon after its proof arrives. A miss is cached per (route, sha256) for |
| +// ProofMissTTL, and the gate keeps a waiting list that the agent's fast loop |
| +// (fastMembers) and `carlos status --live` read. A host leaves the list as |
| +// soon as the gate stops being asked about it (adoptHost clears it on every |
| +// early return before admit), so the list never holds a head the channel has |
| +// moved off. |
| +// |
| +// One gate lives for the life of its process and is shared by every pass in |
| +// it. BeginPass re-reads the policy file, so an edit reaches the agent and |
| +// root alike without a restart. An absent file is D3 off: nothing is read or |
| +// required. Any other failure to load it (it does not parse, or cannot be |
| +// read) is D3 on and waiting for every named-unit route, because the box |
| +// cannot tell which apps were listed. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const ( |
| + ProofMissTTL = 30 * time.Second |
| + // proofPolicyLogEvery bounds how often a broken policy file is printed |
| + // by a long-lived process. A root pass is a fresh process and prints it |
| + // every pass; the agent's 2-second loop prints it once a minute. |
| + proofPolicyLogEvery = time.Minute |
| +) |
| + |
| +// ProofWait is a host held on its current build because its channel head is |
| +// not proven (or the policy file cannot be loaded). |
| +type ProofWait struct { |
| + Host, App, Version, SHA256, Prover, Reason string |
| + Since time.Time |
| +} |
| + |
| +type promotionKey struct { |
| + acct, app, version string |
| + promotedAt int64 |
| +} |
| + |
| +type missKey struct{ host, sha string } |
| + |
| +type ProofGate struct { |
| + policyPath string |
| + bakeDir string |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + policy *ProofPolicy |
| + policyErr error |
| + lastPolicyErr string |
| + lastPolicyLog time.Time |
| + shas map[promotionKey]string |
| + misses map[missKey]time.Time |
| + waits map[string]ProofWait |
| +} |
| + |
| +func NewProofGate(policyPath, bakeDir string) *ProofGate { |
| + return &ProofGate{policyPath: policyPath, bakeDir: bakeDir, now: time.Now, |
| + shas: map[promotionKey]string{}, misses: map[missKey]time.Time{}, waits: map[string]ProofWait{}} |
| +} |
| + |
| +// WithClock replaces the gate's clock (tests). |
| +func (g *ProofGate) WithClock(now func() time.Time) *ProofGate { |
| + g.now = now |
| + return g |
| +} |
| + |
| +// BeginPass re-reads the policy file. PassFilter calls it once per pass. |
| +func (g *ProofGate) BeginPass() { |
| + if g == nil { |
| + return |
| + } |
| + pol, err := LoadProofPolicy(g.policyPath) |
| + now := g.now() |
| + say := false |
| + g.mu.Lock() |
| + g.policy, g.policyErr = pol, err |
| + if err != nil { |
| + msg := err.Error() |
| + say = msg != g.lastPolicyErr || now.Sub(g.lastPolicyLog) >= proofPolicyLogEvery |
| + if say { |
| + g.lastPolicyErr, g.lastPolicyLog = msg, now |
| + } |
| + } else { |
| + g.lastPolicyErr = "" |
| + } |
| + g.mu.Unlock() |
| + if say { |
| + fmt.Printf(" %v; every named-unit route waits until it is fixed\n", err) |
| + } |
| +} |
| + |
| +func (g *ProofGate) state() (*ProofPolicy, error) { |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + return g.policy, g.policyErr |
| +} |
| + |
| +// Waits is the waiting list, sorted by host. |
| +func (g *ProofGate) Waits() []ProofWait { |
| + if g == nil { |
| + return nil |
| + } |
| + g.mu.Lock() |
| + out := make([]ProofWait, 0, len(g.waits)) |
| + for _, w := range g.waits { |
| + out = append(out, w) |
| + } |
| + g.mu.Unlock() |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + return out |
| +} |
| + |
| +// WaitingDue reports whether host is waiting and its cached miss has run |
| +// out, which is when the fast loop should look at it again. |
| +func (g *ProofGate) WaitingDue(host string) bool { |
| + if g == nil { |
| + return false |
| + } |
| + now := g.now() |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + w, ok := g.waits[host] |
| + if !ok { |
| + return false |
| + } |
| + until, cached := g.misses[missKey{host, w.SHA256}] |
| + return !cached || !now.Before(until) |
| +} |
| + |
| +// clear takes host off the waiting list and forgets its cached misses. A |
| +// miss that outlived its wait would hold the host silently, off the list, |
| +// if its channel came back to the same build inside ProofMissTTL. |
| +func (g *ProofGate) clear(host string) { |
| + if g == nil { |
| + return |
| + } |
| + g.mu.Lock() |
| + delete(g.waits, host) |
| + for k := range g.misses { |
| + if k.host == host { |
| + delete(g.misses, k) |
| + } |
| + } |
| + g.mu.Unlock() |
| +} |
| + |
| +// hold records host as waiting, caches the miss, and prints the line once |
| +// per change of version, sha256 or reason. |
| +func (g *ProofGate) hold(rt registry.Route, ver, sha, prover, reason, cur string) { |
| + cur = orNothing(cur) |
| + now := g.now() |
| + g.mu.Lock() |
| + prev, had := g.waits[rt.Host] |
| + same := had && prev.Version == ver && prev.SHA256 == sha |
| + w := ProofWait{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha, Prover: prover, Reason: reason, Since: now} |
| + if same { |
| + w.Since = prev.Since |
| + } |
| + g.waits[rt.Host] = w |
| + g.misses[missKey{rt.Host, sha}] = now.Add(ProofMissTTL) |
| + g.mu.Unlock() |
| + if same && prev.Reason == reason { |
| + return |
| + } |
| + if sha == "" { |
| + fmt.Printf(" %s: %s is waiting: %s; staying on %s\n", rt.Host, ver, reason, cur) |
| + return |
| + } |
| + fmt.Printf(" %s: %s (sha256 %s) is not proven on %s; staying on %s (%s)\n", rt.Host, ver, sha, prover, cur, reason) |
| +} |
| + |
| +// orNothing names the build a host is on for a "staying on" or "still on" |
| +// line: a first adoption has none. |
| +func orNothing(ver string) string { |
| + if ver == "" { |
| + return "nothing" |
| + } |
| + return ver |
| +} |
| + |
| +// proofVerdict is admit's answer, carried to bind. |
| +type proofVerdict struct { |
| + required bool // the route's app is listed in a require entry |
| + sha string // the manifest's sha256 for this platform |
| + proof *Proof // the verified proof; nil when admitted by the healthy history |
| +} |
| + |
| +// admit is the cheap gate (spec §4.5 check 1). false means adoptHost returns. |
| +func (g *ProofGate) admit(ctx context.Context, s Store, optDir string, rt registry.Route, ptr *ChannelPointer, cur string) (proofVerdict, bool) { |
| + if g == nil { |
| + return proofVerdict{}, true |
| + } |
| + pol, perr := g.state() |
| + if perr != nil { |
| + // Any load error is D3 on, never "no policy" (spec §4.5): every |
| + // named-unit route waits, since the box cannot tell which apps were |
| + // listed. The reason is the load error itself, which names the file |
| + // and says whether it did not parse or could not be read. |
| + if rt.Unit == "" { |
| + return proofVerdict{}, true |
| + } |
| + g.hold(rt, ptr.Version, "", "", perr.Error(), cur) |
| + return proofVerdict{}, false |
| + } |
| + reqs := pol.Requires(rt.App) |
| + if len(reqs) == 0 { |
| + g.clear(rt.Host) |
| + return proofVerdict{}, true |
| + } |
| + acct := RouteAccount(rt) |
| + sha, err := g.manifestSHA(ctx, s, optDir, acct, rt.App, ptr) |
| + if err != nil { |
| + fmt.Printf(" %s: %s: reading its manifest for the proof check: %v\n", rt.Host, ptr.Version, err) |
| + return proofVerdict{}, false |
| + } |
| + v := proofVerdict{required: true, sha: sha} |
| + if sha == "" { |
| + return v, true // no build for this platform: ensureStaged says so |
| + } |
| + if IsHealthy(g.bakeDir, acct, rt.App, sha) { |
| + g.clear(rt.Host) |
| + return v, true |
| + } |
| + g.mu.Lock() |
| + until, cached := g.misses[missKey{rt.Host, sha}] |
| + g.mu.Unlock() |
| + if cached && g.now().Before(until) { |
| + return v, false |
| + } |
| + var reasons, names []string |
| + for _, e := range reqs { |
| + names = append(names, e.Prover) |
| + p, err := GetProof(ctx, s, acct, rt.App, sha, e.Prover) |
| + if errors.Is(err, os.ErrNotExist) { |
| + reasons = append(reasons, "no proof from "+e.Prover) |
| + continue |
| + } |
| + if err == nil { |
| + err = p.Check(e.PublicKey(), rt.App, adoptPlatform, sha, e.Prover) |
| + } |
| + if err != nil { |
| + reasons = append(reasons, fmt.Sprintf("proof from %s: %v", e.Prover, err)) |
| + continue |
| + } |
| + g.clear(rt.Host) |
| + v.proof = p |
| + return v, true |
| + } |
| + g.hold(rt, ptr.Version, sha, strings.Join(names, " or "), strings.Join(reasons, "; "), cur) |
| + return v, false |
| +} |
| + |
| +// manifestSHA is the manifest's sha256 for this platform, from the staged |
| +// sibling when there is one and the bucket otherwise, cached per promotion. |
| +func (g *ProofGate) manifestSHA(ctx context.Context, s Store, optDir, acct, app string, ptr *ChannelPointer) (string, error) { |
| + k := promotionKey{acct, app, ptr.Version, ptr.PromotedAt.Unix()} |
| + g.mu.Lock() |
| + sha, ok := g.shas[k] |
| + g.mu.Unlock() |
| + if ok { |
| + return sha, nil |
| + } |
| + m, err := ReadStagedManifest(optDir, acct, ptr.Version, app) |
| + if err != nil || m == nil { |
| + m, err = GetManifest(ctx, s, acct, app, ptr.Version) |
| + if err != nil { |
| + return "", err |
| + } |
| + } |
| + sha = m.Artifacts[adoptPlatform].SHA256 |
| + g.mu.Lock() |
| + g.shas[k] = sha |
| + g.mu.Unlock() |
| + return sha, nil |
| +} |
| + |
| +// bind is the staged-bytes check (spec §4.5 check 2). false means adoptHost |
| +// returns. A mismatch is a RefusalNotProven record naming both hashes: |
| +// nothing was restarted, so it is Recovered and still on cur, the same shape |
| +// as D2's wrong-program record. |
| +func (g *ProofGate) bind(optDir string, rt registry.Route, ptr *ChannelPointer, v proofVerdict, cur string, now time.Time) bool { |
| + if g == nil || !v.required { |
| + return true |
| + } |
| + acct := RouteAccount(rt) |
| + got, err := FileSHA256(VersionPath(optDir, acct, ptr.Version, rt.App)) |
| + if err != nil { |
| + fmt.Printf(" %s: %s: hashing the staged build for the proof check: %v; not pointed live\n", rt.Host, ptr.Version, err) |
| + return false |
| + } |
| + if v.proof != nil && got == v.proof.SHA256 { |
| + return true |
| + } |
| + if IsHealthy(g.bakeDir, acct, rt.App, got) { |
| + return true |
| + } |
| + reason := fmt.Sprintf("staged sha256 %s, manifest sha256 %s", got, v.sha) |
| + if v.proof != nil { |
| + reason = fmt.Sprintf("staged sha256 %s, %s proved %s", got, v.proof.Prover, v.proof.SHA256) |
| + } |
| + fmt.Printf(" %s: %s: the staged bytes do not match the proof (%s); not pointed live\n", rt.Host, ptr.Version, reason) |
| + if g.bakeDir != "" { |
| + if err := WriteRefusal(g.bakeDir, Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, SHA256: got, |
| + Platform: adoptPlatform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), |
| + Reason: "not proven: " + reason, |
| + Kind: RefusalNotProven, Recovered: true, RestoredVersion: cur}); err != nil { |
| + fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err) |
| + } |
| + } |
| + g.clear(rt.Host) |
| + return false |
| +} |
diff --git a/internal/release/proofgate_test.go b/internal/release/proofgate_test.go| new file mode 100644 |
| index 000000000..4d4552ac9 |
| --- /dev/null |
| +++ b/internal/release/proofgate_test.go |
| @@ -0,0 +1,494 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const proofHost = "console.example.com" |
| + |
| +// proofEnv is startUnitFixture (live "old", channel canary/x promoted to |
| +// "new" whose bytes are "bad build") plus a prover key, a policy path, a bake |
| +// directory and one gate shared by every pass, as the agent shares one. It |
| +// keeps the release key too, so a test can promote again. |
| +type proofEnv struct { |
| + ctx context.Context |
| + reg *registry.Registry |
| + store Store |
| + priv ed25519.PrivateKey |
| + pub ed25519.PublicKey |
| + opt, bake string |
| + policy string |
| + prover ed25519.PrivateKey |
| + proverPub string |
| + newSHA string |
| + clock time.Time |
| + gate *ProofGate |
| +} |
| + |
| +func newProofEnv(t *testing.T) *proofEnv { |
| + t.Helper() |
| + // startUnitFixture's setup, inlined so the release key is kept. |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + shipRelease(t, s, "bab", "console", "old", adoptPlatform, []byte("good build")) |
| + shipRelease(t, s, "bab", "console", "new", adoptPlatform, []byte("bad build")) |
| + if err := StageArtifact(opt, "bab", "old", "console", []byte("good build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, proofHost, "console", "canary/x", false) |
| + if err := PointLive(opt, proofHost, "bab", "old", "console"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil { |
| + t.Fatalf("Promote new: %v", err) |
| + } |
| + prover, _, pubB64 := proverKey(t) |
| + e := &proofEnv{ctx: ctx, reg: reg, store: s, priv: priv, pub: pub, opt: opt, bake: t.TempDir(), |
| + policy: filepath.Join(t.TempDir(), "proof-policy.json"), prover: prover, proverPub: pubB64, |
| + newSHA: etagOf([]byte("bad build")), clock: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)} |
| + e.gate = NewProofGate(e.policy, e.bake).WithClock(func() time.Time { return e.clock }) |
| + return e |
| +} |
| + |
| +func (e *proofEnv) writePolicy(t *testing.T, body string) { |
| + t.Helper() |
| + if err := os.WriteFile(e.policy, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (e *proofEnv) requireDev(t *testing.T) { |
| + e.writePolicy(t, fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"dev","key":%q}]}`, e.proverPub)) |
| +} |
| + |
| +func (e *proofEnv) pass(t *testing.T, s Store) string { |
| + t.Helper() |
| + return e.passWith(t, s, func(string) error { return nil }) |
| +} |
| + |
| +func (e *proofEnv) passWith(t *testing.T, s Store, restart func(string) error) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + if err := Pass(e.ctx, e.reg, s, e.pub, e.opt, restart, |
| + BakeDir(e.bake), WithProofGate(e.gate)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + }) |
| + return out.String() |
| +} |
| + |
| +func (e *proofEnv) live() string { v, _ := LiveVersion(e.opt, proofHost); return v } |
| + |
| +func (e *proofEnv) signed(t *testing.T, key ed25519.PrivateKey, app, sha, prover string) *Proof { |
| + t.Helper() |
| + p, err := SignProof(key, Proof{App: app, Platform: adoptPlatform, SHA256: sha, Version: "new", Prover: prover, |
| + ActiveSince: e.clock.Add(-time.Hour), ProvenAt: e.clock}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// putAt writes p at the dev key for sha, whatever p itself says. |
| +func (e *proofEnv) putAt(t *testing.T, sha string, p *Proof) { |
| + t.Helper() |
| + b, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", sha, "dev"), b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +type proofReadCountingStore struct { |
| + Store |
| + mu sync.Mutex |
| + gets []string |
| +} |
| + |
| +func (c *proofReadCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.mu.Lock() |
| + c.gets = append(c.gets, key) |
| + c.mu.Unlock() |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *proofReadCountingStore) proofGets() int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + n := 0 |
| + for _, k := range c.gets { |
| + if strings.Contains(k, "/proofs/") { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +func TestProofNoPolicyMakesNoProofRead(t *testing.T) { |
| + e := newProofEnv(t) |
| + cs := &proofReadCountingStore{Store: e.store} |
| + e.pass(t, cs) |
| + if e.live() != "new" { |
| + t.Fatalf("live = %q, want new: with no policy file adoption is unchanged", e.live()) |
| + } |
| + if n := cs.proofGets(); n != 0 { |
| + t.Fatalf("%d proof GETs with no policy file, want 0 (spec §4.6)", n) |
| + } |
| +} |
| + |
| +func TestProofMissingStaysAndIsNotARefusal(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + out := e.pass(t, e.store) |
| + if e.live() != "old" { |
| + t.Fatalf("live = %q, want old", e.live()) |
| + } |
| + if !strings.Contains(out, "is not proven on dev; staying on old") { |
| + t.Fatalf("printed %q, want the not-proven line", out) |
| + } |
| + if r, _ := ReadRefusal(e.bake, proofHost); r != nil { |
| + t.Fatalf("a missing proof wrote a refusal: %+v", r) |
| + } |
| + w := e.gate.Waits() |
| + if len(w) != 1 || w[0].Host != proofHost || w[0].SHA256 != e.newSHA || w[0].Prover != "dev" { |
| + t.Fatalf("waits = %+v", w) |
| + } |
| +} |
| + |
| +func TestProofValidAdopts(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev")) |
| + e.pass(t, e.store) |
| + if e.live() != "new" { |
| + t.Fatalf("live = %q, want new", e.live()) |
| + } |
| + if w := e.gate.Waits(); len(w) != 0 { |
| + t.Fatalf("waits = %+v after adopting", w) |
| + } |
| +} |
| + |
| +func TestProofRejectsBadProofs(t *testing.T) { |
| + for name, mk := range map[string]func(t *testing.T, e *proofEnv) *Proof{ |
| + "bad signature": func(t *testing.T, e *proofEnv) *Proof { |
| + other, _, _ := proverKey(t) |
| + return e.signed(t, other, "console", e.newSHA, "dev") |
| + }, |
| + "wrong sha256": func(t *testing.T, e *proofEnv) *Proof { |
| + return e.signed(t, e.prover, "console", etagOf([]byte("other bytes")), "dev") |
| + }, |
| + "wrong app": func(t *testing.T, e *proofEnv) *Proof { |
| + return e.signed(t, e.prover, "other", e.newSHA, "dev") |
| + }, |
| + "wrong prover": func(t *testing.T, e *proofEnv) *Proof { |
| + return e.signed(t, e.prover, "console", e.newSHA, "staging") |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + e.putAt(t, e.newSHA, mk(t, e)) |
| + out := e.pass(t, e.store) |
| + if e.live() != "old" || !strings.Contains(out, "is not proven on dev") { |
| + t.Fatalf("live = %q, printed %q; want old and the not-proven line", e.live(), out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Review focus 3: rolling back to a build this box ran never waits on dev. |
| +func TestProofHealthyHistoryAdmitsWithoutProof(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.newSHA, Version: "new", How: "settle", At: e.clock}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cs := &proofReadCountingStore{Store: e.store} |
| + e.pass(t, cs) |
| + if e.live() != "new" { |
| + t.Fatalf("live = %q, want new from the healthy history", e.live()) |
| + } |
| + if n := cs.proofGets(); n != 0 { |
| + t.Fatalf("%d proof GETs for a build in the healthy history, want 0", n) |
| + } |
| +} |
| + |
| +// Review focus 2: the manifest's sha256 is proven, but the staged file (a |
| +// cache hit, which ensureStaged does not re-hash) holds other bytes. |
| +// |
| +// Ruling 3: the record is its own kind, recovered and still on the old |
| +// build, because nothing was restarted. The next pass's gate line says the |
| +// staged bytes do not match the proof and never suggests --retry, which |
| +// would re-sign the pointer over the same stale bytes and be refused again. |
| +func TestProofBindingRefusesStagedBytesThatDiffer(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + stale := []byte("not the shipped bytes") |
| + if err := StageArtifact(e.opt, "bab", "new", "console", stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev")) |
| + out := e.pass(t, e.store) |
| + if e.live() != "old" || !strings.Contains(out, "not pointed live") { |
| + t.Fatalf("live = %q, printed %q; want old and the binding refusal", e.live(), out) |
| + } |
| + r, _ := ReadRefusal(e.bake, proofHost) |
| + if r == nil || !strings.Contains(r.Reason, etagOf(stale)) || !strings.Contains(r.Reason, e.newSHA) { |
| + t.Fatalf("refusal = %+v, want one naming both hashes", r) |
| + } |
| + if r.Kind != RefusalNotProven || !r.Recovered || r.RestoredVersion != "old" || r.RecoveryError != "" { |
| + t.Fatalf("refusal = %+v, want kind %q, recovered, restored to old", r, RefusalNotProven) |
| + } |
| + out = e.pass(t, e.store) |
| + if e.live() != "old" { |
| + t.Fatalf("live = %q on the second pass, want old", e.live()) |
| + } |
| + want := "refused: new's staged bytes do not match dev's proof" |
| + if !strings.Contains(out, want) || !strings.Contains(out, "still on old") || !strings.Contains(out, "roll the exact bytes dev proved") { |
| + t.Fatalf("second pass printed %q, want the not-proven gate line", out) |
| + } |
| + if strings.Contains(out, "--retry") || strings.Contains(out, "did not come up") { |
| + t.Fatalf("second pass printed %q: a not-proven refusal is not a failed start", out) |
| + } |
| +} |
| + |
| +func TestProofMissIsCachedThirtySeconds(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + cs := &proofReadCountingStore{Store: e.store} |
| + e.pass(t, cs) |
| + e.clock = e.clock.Add(10 * time.Second) |
| + e.pass(t, cs) |
| + if n := cs.proofGets(); n != 1 { |
| + t.Fatalf("%d proof GETs inside 30s, want 1", n) |
| + } |
| + if e.gate.WaitingDue(proofHost) { |
| + t.Fatal("due inside its 30s miss") |
| + } |
| + e.clock = e.clock.Add(21 * time.Second) |
| + if !e.gate.WaitingDue(proofHost) { |
| + t.Fatal("not due after 31s") |
| + } |
| + e.pass(t, cs) |
| + if n := cs.proofGets(); n != 2 { |
| + t.Fatalf("%d proof GETs after 31s, want 2", n) |
| + } |
| +} |
| + |
| +// Review focus 1: a typo keeps the gate closed for a named-unit route. |
| +func TestProofPolicyUnparseableKeepsGateClosed(t *testing.T) { |
| + e := newProofEnv(t) |
| + if err := e.reg.SetUnit(proofHost, "console.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.writePolicy(t, `{"schema":1,"require":[`) |
| + out := e.pass(t, e.store) |
| + if e.live() != "old" { |
| + t.Fatalf("live = %q, want old: an unparseable policy must not open the gate", e.live()) |
| + } |
| + if !strings.Contains(out, "does not parse") { |
| + t.Fatalf("printed %q, want the policy error", out) |
| + } |
| + if w := e.gate.Waits(); len(w) != 1 || !strings.Contains(w[0].Reason, "does not parse") { |
| + t.Fatalf("waits = %+v", w) |
| + } |
| +} |
| + |
| +// The Task 2 carry: an error that is not a parse error (here, a policy path |
| +// that is a directory and cannot be read) is closed too, never "no policy". |
| +func TestProofPolicyUnreadableKeepsGateClosed(t *testing.T) { |
| + e := newProofEnv(t) |
| + if err := e.reg.SetUnit(proofHost, "console.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(e.policy, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.pass(t, e.store) |
| + if e.live() != "old" { |
| + t.Fatalf("live = %q, want old: an unreadable policy must not open the gate", e.live()) |
| + } |
| + if w := e.gate.Waits(); len(w) != 1 || w[0].Host != proofHost { |
| + t.Fatalf("waits = %+v, want the named-unit route waiting", w) |
| + } |
| +} |
| + |
| +// The agent's gate lives across passes: a policy edit is seen on the next |
| +// pass without restarting anything. |
| +func TestProofPolicyChangeSeenNextPass(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + e.pass(t, e.store) |
| + if e.live() != "old" { |
| + t.Fatalf("live = %q, want old while required", e.live()) |
| + } |
| + e.writePolicy(t, `{"schema":1}`) |
| + e.pass(t, e.store) |
| + if e.live() != "new" { |
| + t.Fatalf("live = %q, want new once the require entry is gone", e.live()) |
| + } |
| +} |
| + |
| +// Ruling 5 (preflight 4.6): a host waiting on one head whose channel moves |
| +// to a build that stops before the gate (bake hold, poisoned this pass, a |
| +// refusal record) is no longer waiting. Otherwise WaitingDue stays true and |
| +// the fast loop sweeps it every tick for the whole hold. |
| +func TestProofWaitClearedWhenTheGateIsNotConsulted(t *testing.T) { |
| + assertCleared := func(t *testing.T, e *proofEnv, out string) { |
| + t.Helper() |
| + if w := e.gate.Waits(); len(w) != 0 { |
| + t.Fatalf("waits = %+v, printed %q; want none once the gate is not consulted", w, out) |
| + } |
| + if e.gate.WaitingDue(proofHost) { |
| + t.Fatal("still due once the gate is not consulted") |
| + } |
| + } |
| + waitOnNew := func(t *testing.T) *proofEnv { |
| + t.Helper() |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + e.pass(t, e.store) |
| + if w := e.gate.Waits(); len(w) != 1 || w[0].Version != "new" { |
| + t.Fatalf("waits = %+v, want one on new", w) |
| + } |
| + e.clock = e.clock.Add(ProofMissTTL + time.Second) |
| + return e |
| + } |
| + |
| + t.Run("bake hold", func(t *testing.T) { |
| + e := waitOnNew(t) |
| + // beta has a 24h window, and a production app's windows apply. |
| + if err := SetProduction(e.ctx, e.store, e.priv, "bab", "console", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, e.store, "bab", "console", "newer", adoptPlatform, []byte("newer build")) |
| + if err := Promote(e.ctx, e.store, e.priv, "bab", "console", "beta", "newer"); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, e.reg, proofHost, "console", "beta", false) |
| + out := e.pass(t, e.store) |
| + if !strings.Contains(out, "bake window") { |
| + t.Fatalf("printed %q, want the bake hold", out) |
| + } |
| + assertCleared(t, e, out) |
| + }) |
| + |
| + t.Run("poisoned", func(t *testing.T) { |
| + e := waitOnNew(t) |
| + // A second, hibernating route on the same channel goes after the |
| + // awake one, so it meets the build the awake one poisoned. |
| + const sleeper = "sleeper.example.com" |
| + addRoute(t, e.reg, sleeper, "console", "canary/x", true) |
| + if err := PointLive(e.opt, sleeper, "bab", "old", "console"); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev")) |
| + // The sleeper is the one whose wait must go: make it wait first. |
| + e.gate.hold(registry.Route{Host: sleeper, App: "console"}, "new", e.newSHA, "dev", "no proof from dev", "old") |
| + e.clock = e.clock.Add(ProofMissTTL + time.Second) |
| + out := e.passWith(t, e.store, func(string) error { return errors.New("Job for console.service failed") }) |
| + if !strings.Contains(out, "failed on another host this pass") { |
| + t.Fatalf("printed %q, want the poisoned skip", out) |
| + } |
| + for _, w := range e.gate.Waits() { |
| + if w.Host == sleeper { |
| + t.Fatalf("waits = %+v, printed %q; the poisoned host is still waiting", e.gate.Waits(), out) |
| + } |
| + } |
| + if e.gate.WaitingDue(sleeper) { |
| + t.Fatal("poisoned host still due") |
| + } |
| + }) |
| + |
| + t.Run("refusal gate", func(t *testing.T) { |
| + e := waitOnNew(t) |
| + ptr, err := Channel(e.ctx, e.store, e.pub, "bab", "console", "canary/x") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteRefusal(e.bake, Refusal{Host: proofHost, App: "console", Version: "new", |
| + PointerPromotedAt: ptr.PromotedAt, RefusedAt: e.clock, Reason: "exited: status=2"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := e.pass(t, e.store) |
| + if !strings.Contains(out, "refused: new") { |
| + t.Fatalf("printed %q, want the refusal gate", out) |
| + } |
| + assertCleared(t, e, out) |
| + }) |
| +} |
| + |
| +// A host found live on its head (another process, root say, moved it) is |
| +// not waiting for anything. |
| +func TestProofWaitClearedOnceLiveOnTheHead(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + e.pass(t, e.store) |
| + if len(e.gate.Waits()) != 1 { |
| + t.Fatalf("waits = %+v, want one", e.gate.Waits()) |
| + } |
| + if err := StageArtifact(e.opt, "bab", "new", "console", []byte("bad build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(e.opt, proofHost, "bab", "new", "console"); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.pass(t, e.store) |
| + if w := e.gate.Waits(); len(w) != 0 || e.gate.WaitingDue(proofHost) { |
| + t.Fatalf("waits = %+v once live on the head, want none", w) |
| + } |
| +} |
| + |
| +// A host that stops waiting forgets its cached miss too: when the channel |
| +// comes back to the same build inside 30 s, the gate looks again and the |
| +// host is listed as waiting, rather than held silently off the list. |
| +func TestProofClearDropsTheCachedMiss(t *testing.T) { |
| + e := newProofEnv(t) |
| + e.requireDev(t) |
| + cs := &proofReadCountingStore{Store: e.store} |
| + e.pass(t, cs) |
| + e.gate.clear(proofHost) |
| + e.clock = e.clock.Add(time.Second) |
| + e.pass(t, cs) |
| + if n := cs.proofGets(); n != 2 { |
| + t.Fatalf("%d proof GETs, want 2: a cleared host's miss is not cached", n) |
| + } |
| + if w := e.gate.Waits(); len(w) != 1 { |
| + t.Fatalf("waits = %+v, want the host listed again", w) |
| + } |
| +} |
| + |
| +// Every gate method is nil-safe: no WithProofGate is today's behaviour. |
| +func TestProofGateNilIsOff(t *testing.T) { |
| + var g *ProofGate |
| + g.BeginPass() |
| + g.clear("x") |
| + if g.Waits() != nil || g.WaitingDue("x") { |
| + t.Fatal("a nil gate reports a wait") |
| + } |
| + if _, ok := g.admit(context.Background(), nil, "", registry.Route{Host: "x"}, &ChannelPointer{}, ""); !ok { |
| + t.Fatal("a nil gate refused admission") |
| + } |
| + if !g.bind("", registry.Route{Host: "x"}, &ChannelPointer{}, proofVerdict{required: true}, "", time.Time{}) { |
| + t.Fatal("a nil gate refused the staged bytes") |
| + } |
| +} |
diff --git a/internal/release/refusal.go b/internal/release/refusal.go| index 9a5cbce56..2dc4691b8 100644 |
| --- a/internal/release/refusal.go |
| +++ b/internal/release/refusal.go |
| @@ -35,15 +35,19 @@ const refusalSchema = 1 |
| // before Kind existed reads as one) is D1: the unit did not come up and was |
| // rolled back. RefusalWrongProgram is D2: the staged binary is a different Go |
| // program from any this route has run, so it was never pointed live and |
| -// nothing restarted. A "--retry" cures the first and loops on the second. |
| +// nothing restarted. RefusalNotProven is D3 (proofgate.go): the staged bytes |
| +// are not the ones the proof covers, so they too were never pointed live. A |
| +// "--retry" cures the first and loops on the other two. |
| const ( |
| RefusalNotUp = "" |
| RefusalWrongProgram = "wrong-program" |
| + RefusalNotProven = "not-proven" |
| ) |
| type Refusal struct { |
| Schema int `json:"schema"` |
| - // Kind is RefusalNotUp or RefusalWrongProgram. Omitted when not-up. |
| + // Kind is RefusalNotUp, RefusalWrongProgram or RefusalNotProven. Omitted |
| + // when not-up. |
| Kind string `json:"kind,omitempty"` |
| Host string `json:"host"` |
| App string `json:"app"` |
diff --git a/statuslive_test.go b/statuslive_test.go| index 21a4c0e3f..574666806 100644 |
| --- a/statuslive_test.go |
| +++ b/statuslive_test.go |
| @@ -204,6 +204,29 @@ func TestPrintLiveBoxesShowsAWrongProgramRefusal(t *testing.T) { |
| } |
| } |
| +// A not-proven refusal (D3, plan 3 ruling 3) is neither a failed start nor a |
| +// rollback: the staged bytes did not match dev's proof. |
| +func TestPrintLiveBoxesShowsANotProvenRefusal(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| + var buf bytes.Buffer |
| + printLiveBoxes(&buf, []status.Box{ |
| + {GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| + Tunnel: "connected", |
| + ReleaseRefusals: []status.ReleaseRefusal{{ |
| + Host: "console.example.com", Version: "v2", Kind: "not-proven", |
| + RefusedAt: time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC), Reason: "not proven: staged sha256 aaaa, dev proved bbbb", |
| + }}, |
| + }}, |
| + }, now) |
| + want := "refused console.example.com v2 since 2026-10-06T09:30:40Z: staged bytes do not match dev's proof (staged sha256 aaaa, dev proved bbbb)\n" |
| + if !strings.Contains(buf.String(), want) { |
| + t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| + } |
| + if strings.Contains(buf.String(), "rollback") { |
| + t.Fatalf("a not-proven row mentions a rollback:\n%s", buf.String()) |
| + } |
| +} |
| + |
| func TestHumanBytesAndFloat(t *testing.T) { |
| for _, c := range []struct { |
| in *int64 |