carlos / platform Public

release: refusal records for builds a box rolled back (console defences D1)

Task 1 of Plan 1: durable records saying a box refused a promotion, with operations to write, read, list, and clear refusals. The record blocks a specific promotion (identified by version and PromotedAt timestamp) and clears when the pointer changes.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cc42b5223d6835d28aa6a144cf3efdbb6fd9cedb parent 5594bde
2 files changed, +233 −0
  • internal/release/refusal.go +147 −0
  • internal/release/refusal_test.go +86 −0
diff --git a/internal/release/refusal.go b/internal/release/refusal.go
new file mode 100644
index 000000000..d9804aab3
--- /dev/null
+++ b/internal/release/refusal.go
@@ -0,0 +1,147 @@
+// Refusal records — D1 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §2.5).
+//
+// When a named unit does not come up on a new build, the root pass rolls it
+// back and writes one of these per host in the rolled-back process group.
+// Every later pass, the agent's fast loop and root alike, reads it before
+// staging and skips the promotion it names. Without it the very next pass
+// would adopt the same build and bounce the service again.
+//
+// The record blocks a PROMOTION, not a version name: it matches while the
+// channel pointer still has the version and PromotedAt it had when the build
+// was refused. `carlos promote --retry` re-signs the pointer with a fresh
+// PromotedAt, and so does any move to another version; either clears it.
+//
+// Root writes these under the bake directory, which the sandboxed agent can
+// read. A file that does not parse reads as absent: a torn write must never
+// become a permanent block.
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+const refusalSchema = 1
+
+type Refusal struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ App string `json:"app"`
+ Version string `json:"version"`
+ SHA256 string `json:"sha256,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ PointerPromotedAt time.Time `json:"pointer_promoted_at"`
+ RefusedAt time.Time `json:"refused_at"`
+ Reason string `json:"reason"`
+}
+
+func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") }
+
+func refusalPath(bakeDir, host string) (string, error) {
+ if host == "" || strings.ContainsAny(host, `/\`) || host == "." || host == ".." {
+ return "", fmt.Errorf("refusal: invalid host %q", host)
+ }
+ return filepath.Join(RefusalDir(bakeDir), host+".json"), nil
+}
+
+// WriteRefusal writes atomically (temp file + rename) and world-readable,
+// because the reader is the sandboxed agent.
+func WriteRefusal(bakeDir string, r Refusal) error {
+ p, err := refusalPath(bakeDir, r.Host)
+ if err != nil {
+ return err
+ }
+ r.Schema = refusalSchema
+ b, err := json.MarshalIndent(r, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(p), ".refusal-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(append(b, '\n')); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(0o644); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), p)
+}
+
+func ReadRefusal(bakeDir, host string) (*Refusal, error) {
+ p, err := refusalPath(bakeDir, host)
+ if err != nil {
+ return nil, err
+ }
+ b, err := os.ReadFile(p)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var r Refusal
+ if err := json.Unmarshal(b, &r); err != nil || r.Schema != refusalSchema {
+ log.Printf("refusal: %s does not parse (%v), treating it as absent", p, err)
+ return nil, nil
+ }
+ return &r, nil
+}
+
+func ListRefusals(bakeDir string) ([]Refusal, error) {
+ ents, err := os.ReadDir(RefusalDir(bakeDir))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var out []Refusal
+ for _, e := range ents {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, ".") {
+ continue
+ }
+ r, err := ReadRefusal(bakeDir, strings.TrimSuffix(name, ".json"))
+ if err != nil || r == nil {
+ continue
+ }
+ out = append(out, *r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out, nil
+}
+
+func ClearRefusal(bakeDir, host string) error {
+ p, err := refusalPath(bakeDir, host)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return nil
+}
+
+// Blocks reports whether this record still refuses the promotion p names.
+func (r *Refusal) Blocks(p *ChannelPointer) bool {
+ return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt)
+}
diff --git a/internal/release/refusal_test.go b/internal/release/refusal_test.go
new file mode 100644
index 000000000..12cbdcd9c
--- /dev/null
+++ b/internal/release/refusal_test.go
@@ -0,0 +1,86 @@
+package release
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestRefusalRoundTripAndBlocks(t *testing.T) {
+ dir := t.TempDir()
+ at := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC)
+ r := Refusal{Host: "console.example.com", App: "console", Version: "v2",
+ SHA256: "abc", Platform: "linux-arm64", PointerPromotedAt: at,
+ RefusedAt: at.Add(10 * time.Second), Reason: "exited: status=2"}
+ if err := WriteRefusal(dir, r); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadRefusal(dir, "console.example.com")
+ if err != nil || got == nil {
+ t.Fatalf("ReadRefusal = %+v, %v", got, err)
+ }
+ if got.Schema != 1 || got.Version != "v2" || !got.PointerPromotedAt.Equal(at) {
+ t.Fatalf("round trip = %+v", got)
+ }
+ if !got.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at}) {
+ t.Fatal("the same promotion is not blocked")
+ }
+ if got.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at.Add(time.Second)}) {
+ t.Fatal("a re-signed pointer (retry) is still blocked")
+ }
+ if got.Blocks(&ChannelPointer{Version: "v3", PromotedAt: at}) {
+ t.Fatal("a different version is blocked")
+ }
+ var nilR *Refusal
+ if nilR.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at}) {
+ t.Fatal("a nil refusal blocks")
+ }
+}
+
+func TestReadRefusalAbsentIsNil(t *testing.T) {
+ got, err := ReadRefusal(t.TempDir(), "nobody.example.com")
+ if got != nil || err != nil {
+ t.Fatalf("absent = %+v, %v; want nil, nil", got, err)
+ }
+}
+
+func TestReadRefusalCorruptIsAbsent(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(RefusalDir(dir), "console.example.com.json")
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(`{"schema":1,"host":`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadRefusal(dir, "console.example.com")
+ if got != nil || err != nil {
+ t.Fatalf("corrupt = %+v, %v; want nil, nil (a torn file must never block)", got, err)
+ }
+}
+
+func TestRefusalRejectsAPathInTheHost(t *testing.T) {
+ if err := WriteRefusal(t.TempDir(), Refusal{Host: "../etc/x"}); err == nil {
+ t.Fatal("a host with a path separator was written")
+ }
+}
+
+func TestListAndClearRefusals(t *testing.T) {
+ dir := t.TempDir()
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := WriteRefusal(dir, Refusal{Host: h, Version: "v2"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := ClearRefusal(dir, "a.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := ClearRefusal(dir, "never.example.com"); err != nil {
+ t.Fatalf("clearing an absent record: %v", err)
+ }
+ all, err := ListRefusals(dir)
+ if err != nil || len(all) != 1 || all[0].Host != "b.example.com" {
+ t.Fatalf("ListRefusals = %+v, %v", all, err)
+ }
+}