carlos / platform Public

release: a not-proven refusal stands only while the box still requires a proof (H3)

Break glass (removing the require entry or the policy file) could not override a stored RefusalNotProven: the refusal gate returned before the proof gate was asked. The gate now asks ProofGate.Requires: a clean policy with no require entry for the app ignores and best-effort clears the D3 record; a policy load error keeps it blocking. D1 and D2 records are untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e97a50688756a43438af701060c3a4179b7a2e89 parent 7e032fe
3 files changed, +149 −1
  • internal/release/adopt.go +19 −1
  • internal/release/proofgate.go +20 −0
  • internal/release/proofgate_test.go +110 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 09fa8461b..49521e241 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -1014,8 +1014,26 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// D1's refusal (refusal.go): this box rolled this exact promotion back
// because the unit did not come up. Checked before ensureStaged so a
// refused build is neither downloaded nor relayed to a root pass.
+ //
+ // A D3 refusal (RefusalNotProven) is the one kind that depends on the
+ // policy: it stands only while this pass's policy still requires a proof
+ // for the app, or failed to load (closed). Once the require entry or the
+ // whole file is gone, the record is ignored and best-effort cleared, so
+ // removing the requirement is a break glass that works after a binding
+ // refusal too (final review H3). The agent cannot remove root's file;
+ // that failure is ignored and the next root pass clears it. D1 and D2
+ // records never depend on the policy.
if cfg.bakeDir != "" {
- if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err != nil {
+ r, err := ReadRefusal(cfg.bakeDir, rt.Host)
+ if err == nil && r.Blocks(ptr) && r.Kind == RefusalNotProven {
+ if required, _ := cfg.proof.Requires(rt.App); !required {
+ if ClearRefusal(cfg.bakeDir, rt.Host) == nil {
+ fmt.Printf(" %s: cleared the not-proven refusal of %s: this box no longer requires a proof for %s\n", rt.Host, ver, rt.App)
+ }
+ r = nil
+ }
+ }
+ if err != nil {
fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err)
} else if r.Blocks(ptr) {
switch r.Kind {
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go
index 803415ffb..2245770c4 100644
--- a/internal/release/proofgate.go
+++ b/internal/release/proofgate.go
@@ -119,6 +119,26 @@ func (g *ProofGate) state() (*ProofPolicy, error) {
return g.policy, g.policyErr
}
+// Requires reports what this pass's policy says about app: required when a
+// require entry lists it, and policyErr when the policy failed to load, which
+// the gate reads as required (closed) because the box cannot tell which apps
+// were listed. A nil gate requires nothing.
+//
+// adoptHost's refusal gate asks it about a RefusalNotProven record: that
+// record stands only while the box still requires a proof for the app, so
+// removing the require entry (or the policy file) is the break glass it is
+// meant to be (final review H3).
+func (g *ProofGate) Requires(app string) (required bool, policyErr bool) {
+ if g == nil {
+ return false, false
+ }
+ pol, err := g.state()
+ if err != nil {
+ return true, true
+ }
+ return len(pol.Requires(app)) > 0, false
+}
+
// Waits is the waiting list, sorted by host.
func (g *ProofGate) Waits() []ProofWait {
if g == nil {
diff --git a/internal/release/proofgate_test.go b/internal/release/proofgate_test.go
index 4d4552ac9..0621cdaf1 100644
--- a/internal/release/proofgate_test.go
+++ b/internal/release/proofgate_test.go
@@ -492,3 +492,113 @@ func TestProofGateNilIsOff(t *testing.T) {
t.Fatal("a nil gate refused the staged bytes")
}
}
+
+// bindingRefusal is TestProofBindingRefusesStagedBytesThatDiffer's first
+// pass: a proven manifest, other bytes staged, a RefusalNotProven written.
+func bindingRefusal(t *testing.T) *proofEnv {
+ t.Helper()
+ e := newProofEnv(t)
+ e.requireDev(t)
+ if err := StageArtifact(e.opt, "bab", "new", "console", []byte("not the shipped bytes")); err != nil {
+ t.Fatal(err)
+ }
+ e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev"))
+ e.pass(t, e.store)
+ if r, _ := ReadRefusal(e.bake, proofHost); r == nil || r.Kind != RefusalNotProven || e.live() != "old" {
+ t.Fatalf("refusal = %+v, live = %q; want a not-proven refusal and old", r, e.live())
+ }
+ return e
+}
+
+// Final review H3: removing the require entry, or the whole policy file, is
+// the break glass, and it works after a binding refusal as well as after a
+// missing-proof wait. The stored D3 record stops blocking and is cleared.
+func TestProofBreakGlassOverridesABindingRefusal(t *testing.T) {
+ for name, lift := range map[string]func(t *testing.T, e *proofEnv){
+ "require entry removed": func(t *testing.T, e *proofEnv) { e.writePolicy(t, `{"schema":1}`) },
+ "policy file removed": func(t *testing.T, e *proofEnv) {
+ if err := os.Remove(e.policy); err != nil {
+ t.Fatal(err)
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ e := bindingRefusal(t)
+ lift(t, e)
+ out := e.pass(t, e.store)
+ if e.live() != "new" {
+ t.Fatalf("live = %q, printed %q; want new once the box no longer requires a proof", e.live(), out)
+ }
+ if r, _ := ReadRefusal(e.bake, proofHost); r != nil {
+ t.Fatalf("refusal = %+v, want it cleared", r)
+ }
+ if strings.Contains(out, "refused:") {
+ t.Fatalf("printed %q: the D3 refusal still gated the pass", out)
+ }
+ })
+ }
+}
+
+// A policy that will not load is closed: the D3 record keeps blocking, even
+// on a route with no unit, which admit alone would let through.
+func TestProofBrokenPolicyKeepsABindingRefusal(t *testing.T) {
+ e := bindingRefusal(t)
+ e.writePolicy(t, `{"schema":1}}`)
+ out := e.pass(t, e.store)
+ if e.live() != "old" || !strings.Contains(out, "refused: new's staged bytes do not match dev's proof") {
+ t.Fatalf("live = %q, printed %q; want old and the not-proven gate line", e.live(), out)
+ }
+ if r, _ := ReadRefusal(e.bake, proofHost); r == nil || r.Kind != RefusalNotProven {
+ t.Fatalf("refusal = %+v, want the not-proven record kept", r)
+ }
+}
+
+// D1 and D2 records never depend on the policy: lifting the requirement
+// leaves them blocking.
+func TestProofBreakGlassLeavesOtherRefusals(t *testing.T) {
+ for _, kind := range []string{RefusalNotUp, RefusalWrongProgram} {
+ t.Run("kind "+kind, func(t *testing.T) {
+ e := bindingRefusal(t)
+ r, _ := ReadRefusal(e.bake, proofHost)
+ r.Kind = kind
+ if err := WriteRefusal(e.bake, *r); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(e.policy); err != nil {
+ t.Fatal(err)
+ }
+ out := e.pass(t, e.store)
+ if e.live() != "old" || !strings.Contains(out, "refused: new") {
+ t.Fatalf("live = %q, printed %q; want old and the refusal gate", e.live(), out)
+ }
+ if got, _ := ReadRefusal(e.bake, proofHost); got == nil || got.Kind != kind {
+ t.Fatalf("refusal = %+v, want the %q record kept", got, kind)
+ }
+ })
+ }
+}
+
+func TestProofGateRequires(t *testing.T) {
+ var nilGate *ProofGate
+ if req, perr := nilGate.Requires("console"); req || perr {
+ t.Fatalf("nil gate Requires = %v, %v; want false, false", req, perr)
+ }
+ e := newProofEnv(t)
+ e.gate.BeginPass()
+ if req, perr := e.gate.Requires("console"); req || perr {
+ t.Fatalf("no policy: Requires = %v, %v; want false, false", req, perr)
+ }
+ e.requireDev(t)
+ e.gate.BeginPass()
+ if req, perr := e.gate.Requires("console"); !req || perr {
+ t.Fatalf("listed: Requires = %v, %v; want true, false", req, perr)
+ }
+ if req, perr := e.gate.Requires("other"); req || perr {
+ t.Fatalf("unlisted: Requires = %v, %v; want false, false", req, perr)
+ }
+ e.writePolicy(t, `{"schema":1,"require":[`)
+ e.gate.BeginPass()
+ if req, perr := e.gate.Requires("other"); !req || !perr {
+ t.Fatalf("broken policy: Requires = %v, %v; want true, true", req, perr)
+ }
+}