carlos / platform Public

region HA 2a: role doors write an audit row; a non-race roster refusal has its own answer

Fix round 1 for Task 10. Copy rows RHA-C23 to RHA-C25 (pending).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c7484fda1ebba190288dd70dc6c1e069cd092203 parent 851b3db
5 files changed, +143 −10
  • approved_region_ha_copy_test.go +7 −6
  • docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md +3 −0
  • internal/console/apiboxroles.go +26 −2
  • internal/console/apiboxroles_test.go +106 −1
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +1 −1
diff --git a/approved_region_ha_copy_test.go b/approved_region_ha_copy_test.go
index 5210315a6..75bb34b2c 100644
--- a/approved_region_ha_copy_test.go
+++ b/approved_region_ha_copy_test.go
@@ -14,7 +14,7 @@ import (
// mean what they mean there:
//
// - TestRegionHACopyRecordIsComplete: the record names exactly RHA-C1 to
-// RHA-C22;
+// RHA-C25;
// - TestRegionHACopyHasNoEmDash;
// - TestRegionHABuiltCopyIsApproved: an id in regionHACopyBuilt is an
// approved row. No pending string reaches code, CLI help or docs;
@@ -24,11 +24,12 @@ const regionHACopyRecordPath = "docs/superpowers/plans/2026-10-16-region-ha-2a-c
var regionHACopyIDPattern = regexp.MustCompile(`^RHA-C[0-9]+$`)
-// regionHACopyIDs is the record's inventory: RHA-C1 to RHA-C22, pinned
-// (RHA-C20 to RHA-C22 are Task 10's further rows).
+// regionHACopyIDs is the record's inventory: RHA-C1 to RHA-C25, pinned
+// (RHA-C17 to RHA-C19 are Task 11's; RHA-C20 to RHA-C25 are Task 10's
+// further rows).
var regionHACopyIDs = func() []string {
- ids := make([]string, 0, 22)
- for i := 1; i <= 22; i++ {
+ ids := make([]string, 0, 25)
+ for i := 1; i <= 25; i++ {
ids = append(ids, "RHA-C"+strconv.Itoa(i))
}
return ids
@@ -37,7 +38,7 @@ var regionHACopyIDs = func() []string {
// regionHACopyBuilt is every id whose text the code or docs carry. The
// task that writes a row's text adds its id here in the same commit, once
// Paul has approved the row.
-var regionHACopyBuilt = []string{"RHA-C1", "RHA-C2", "RHA-C3", "RHA-C4", "RHA-C5", "RHA-C6", "RHA-C7", "RHA-C8", "RHA-C9", "RHA-C10", "RHA-C11", "RHA-C12", "RHA-C13", "RHA-C14", "RHA-C15", "RHA-C16", "RHA-C20", "RHA-C21", "RHA-C22"}
+var regionHACopyBuilt = []string{"RHA-C1", "RHA-C2", "RHA-C3", "RHA-C4", "RHA-C5", "RHA-C6", "RHA-C7", "RHA-C8", "RHA-C9", "RHA-C10", "RHA-C11", "RHA-C12", "RHA-C13", "RHA-C14", "RHA-C15", "RHA-C16", "RHA-C20", "RHA-C21", "RHA-C22", "RHA-C23", "RHA-C24", "RHA-C25"}
func mustParseRegionHARecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md b/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md
index 0eb368371..6745ea385 100644
--- a/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md
+++ b/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md
@@ -74,3 +74,6 @@ RHA-C20.
| RHA-C20 | system_roles.go | unreadable response: <error> | pending |
| RHA-C21 | docs/site/cli.md | Ops account owners and `CARLOS_SYSTEM_OPERATORS`. | pending |
| RHA-C22 | docs/site/cli.md | Do not set a role until every box and console in the deployment runs a release that knows about roles. A box on an older release cannot read a roster that carries one. | pending |
+| RHA-C23 | internal/console/apiboxroles.go (the ops audit row's detail) | gave <box> the <role> role | pending |
+| RHA-C24 | internal/console/apiboxroles.go (the ops audit row's detail) | gave the group in <region> and its boxes the <role> role | pending |
+| RHA-C25 | internal/console/apiboxroles.go | the box roster can't be changed right now. Try again later. | pending |
diff --git a/internal/console/apiboxroles.go b/internal/console/apiboxroles.go
index 32a829ba3..2fb7f9f0f 100644
--- a/internal/console/apiboxroles.go
+++ b/internal/console/apiboxroles.go
@@ -28,12 +28,21 @@ const (
boxRosterUnread = "could not read the box roster. Try again."
boxRosterChanged = "the box roster changed while the role was being saved, and nothing was changed. Try again."
boxRoleWriteFailure = "could not save the role. Try again."
+ boxRosterBusy = "the box roster can't be changed right now. Try again later."
)
func noEnrolledBox(name string) string { return fmt.Sprintf("no enrolled box is named %s", name) }
func noGroupIn(region string) string { return fmt.Sprintf("%s has no group", region) }
+// boxRoleAudit and groupRoleAudit are the detail of the ops-account audit
+// row a role change writes, as the sibling operator doors write theirs.
+func boxRoleAudit(box, role string) string { return fmt.Sprintf("gave %s the %s role", box, role) }
+
+func groupRoleAudit(region, role string) string {
+ return fmt.Sprintf("gave the group in %s and its boxes the %s role", region, role)
+}
+
func retiredBoxRole(box string) string { return fmt.Sprintf("%s is retired, so it takes no role", box) }
// boxRoster reads the signed roster with the root signer that writes it,
@@ -84,12 +93,21 @@ func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, b
return addr, true
}
-// roleWriteError answers a roster write that did not land.
+// roleWriteError answers a roster write that did not land. Only a lost
+// compare-and-swap is "the roster changed while it was being saved". Any
+// other roster refusal (a membership change in progress, a host floor
+// mismatch, a roster that does not validate) is logged with its real cause
+// and answered as one that cannot be changed right now.
func roleWriteError(w http.ResponseWriter, what string, err error) {
- if errors.Is(err, release.ErrCASConflict) || errors.Is(err, hostactors.ErrRoster) {
+ if errors.Is(err, release.ErrCASConflict) {
jsonError(w, http.StatusConflict, boxRosterChanged)
return
}
+ if errors.Is(err, hostactors.ErrRoster) {
+ log.Printf("console: %s: roster refused: %v", what, err)
+ jsonError(w, http.StatusConflict, boxRosterBusy)
+ return
+ }
log.Printf("console: %s: %v", what, err)
jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure)
}
@@ -132,6 +150,9 @@ func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request)
return
}
log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision)
+ if next.Roster.Revision != o.Roster.Revision {
+ s.auditSystem(by, "box-role", boxRoleAudit(ref, hostactors.RoleName(role)))
+ }
writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)})
}
@@ -169,6 +190,9 @@ func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request
return
}
log.Printf("console: the group in %s (%s) and its boxes %v have the %s role (by %s, roster revision %d)", region, g.GroupName, boxes, hostactors.RoleName(role), by, next.Roster.Revision)
+ if next.Roster.Revision != o.Roster.Revision {
+ s.auditSystem(by, "group-role", groupRoleAudit(region, hostactors.RoleName(role)))
+ }
if boxes == nil {
boxes = []string{}
}
diff --git a/internal/console/apiboxroles_test.go b/internal/console/apiboxroles_test.go
index da9d498dc..c655c2eb0 100644
--- a/internal/console/apiboxroles_test.go
+++ b/internal/console/apiboxroles_test.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"net/http"
"net/http/httptest"
"reflect"
@@ -91,6 +92,17 @@ func TestBoxRoleDoor(t *testing.T) {
func TestGroupRoleDoor(t *testing.T) {
fx := roleFixture(t)
h := fx.srv.handleAPISystemGroupRole
+ if st, _ := roleCall(t, h, "someone@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound {
+ t.Fatalf("a non-operator = %d, want 404", st)
+ }
+ fx.srv.cfg.RegionHA = false
+ if st, _ := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound {
+ t.Fatalf("region HA off = %d, want 404", st)
+ }
+ fx.srv.cfg.RegionHA = true
+ if r := rosterNow(t, fx); r.Regions["ie"].Role != hostactors.RoleDefault || r.Boxes["ie-g1"].Role != hostactors.RoleDefault {
+ t.Fatalf("a refused door changed the roster: %+v", r)
+ }
if st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`); st != http.StatusBadRequest || out["error"] != groupRoleRefusal {
t.Fatalf("platform group = %d %v", st, out)
}
@@ -114,7 +126,9 @@ func TestRoleWriteErrors(t *testing.T) {
msg string
}{
{release.ErrCASConflict, http.StatusConflict, boxRosterChanged},
- {hostactors.ErrRoster, http.StatusConflict, boxRosterChanged},
+ {fmt.Errorf("put: %w", release.ErrCASConflict), http.StatusConflict, boxRosterChanged},
+ {hostactors.ErrRoster, http.StatusConflict, boxRosterBusy},
+ {fmt.Errorf("replace: %w", hostactors.ErrRoster), http.StatusConflict, boxRosterBusy},
{errors.New("SlowDown"), http.StatusServiceUnavailable, boxRoleWriteFailure},
} {
w := httptest.NewRecorder()
@@ -153,3 +167,94 @@ func TestRoleDoorsAreRegisteredWithHostClaims(t *testing.T) {
t.Fatalf("region HA off = %d, want 404", status)
}
}
+
+// roleAudits is the ops account's platform audit rows, oldest first.
+func roleAudits(t *testing.T, fx *consoleFixture) []Event {
+ t.Helper()
+ ev, err := fx.st.Events(OpsAccountID, "platform", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, j := 0, len(ev)-1; i < j; i, j = i+1, j-1 {
+ ev[i], ev[j] = ev[j], ev[i]
+ }
+ return ev
+}
+
+// A successful change writes exactly one audit row, naming the operator;
+// a refused one, or one that changes nothing, writes none.
+func TestRoleDoorsAudit(t *testing.T) {
+ fx := roleFixture(t)
+ box, group := fx.srv.handleAPISystemBoxRole, fx.srv.handleAPISystemGroupRole
+ // Refusals: a non-operator, an unknown role, an unknown box, a region
+ // with no group, and region HA off.
+ roleCall(t, box, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`)
+ roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`)
+ roleCall(t, box, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`)
+ roleCall(t, group, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`)
+ roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`)
+ fx.srv.cfg.RegionHA = false
+ roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`)
+ fx.srv.cfg.RegionHA = true
+ // Changing nothing writes nothing either.
+ if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`); st != http.StatusOK {
+ t.Fatalf("no-op = %d", st)
+ }
+ if ev := roleAudits(t, fx); len(ev) != 0 {
+ t.Fatalf("refusals and a no-op wrote audit rows: %+v", ev)
+ }
+ if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusOK {
+ t.Fatalf("box role = %d", st)
+ }
+ ev := roleAudits(t, fx)
+ if len(ev) != 1 || ev[0].Action != "box-role" || ev[0].Address != "paul@keymail.dev" || ev[0].Detail != "gave ie-old the legacy role" {
+ t.Fatalf("box role audit: %+v", ev)
+ }
+ if st, _ := roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusOK {
+ t.Fatalf("group role = %d", st)
+ }
+ ev = roleAudits(t, fx)
+ if len(ev) != 2 || ev[1].Action != "group-role" || ev[1].Address != "paul@keymail.dev" || ev[1].Detail != "gave the group in ie and its boxes the legacy role" {
+ t.Fatalf("group role audit: %+v", ev)
+ }
+}
+
+// casLossStore loses every write to the roster, as a second writer winning
+// the race does.
+type casLossStore struct{ release.Store }
+
+func (s casLossStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == hostactors.Key {
+ return "", release.ErrCASConflict
+ }
+ return s.Store.Put(ctx, key, body, ifMatch)
+}
+
+// A lost compare-and-swap, through each door, answers 409 with the
+// "changed while saving" sentence, leaves the roster as it was and writes
+// no audit row.
+func TestRoleDoorsLostRaceChangesNothing(t *testing.T) {
+ fx := roleFixture(t)
+ before := rosterNow(t, fx)
+ fx.srv.cfg.Releases = casLossStore{fx.srv.cfg.Releases}
+ for _, c := range []struct {
+ name string
+ h http.HandlerFunc
+ key string
+ val string
+ }{
+ {"box", fx.srv.handleAPISystemBoxRole, "box", "ie-old"},
+ {"group", fx.srv.handleAPISystemGroupRole, "region", "ie"},
+ } {
+ st, out := roleCall(t, c.h, "paul@keymail.dev", c.key, c.val, `{"role":"legacy"}`)
+ if st != http.StatusConflict || out["error"] != boxRosterChanged {
+ t.Fatalf("%s door: %d %v", c.name, st, out)
+ }
+ }
+ if after := rosterNow(t, fx); !reflect.DeepEqual(after, before) {
+ t.Fatalf("a lost race changed the roster:\nbefore %+v\nafter %+v", before, after)
+ }
+ if ev := roleAudits(t, fx); len(ev) != 0 {
+ t.Fatalf("a lost race wrote audit rows: %+v", ev)
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
index b5c86aa93..b5ee6ae87 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
@@ -19,7 +19,7 @@ internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-
@coverage internal/edge 20177c9e7dc961937677bbf2763c73004d7c95f68512591800f6fe841c36152f
internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 root-route;kept=F;scope=protected-decision-only registry:Account@10835,registry:Alias@5297,registry:Alias@5332,registry:Alias@5754,registry:Alias@5911,registry:Alias@5981,registry:Alias@6013,registry:Alias@6482,registry:Alias@8772,registry:App@10847,registry:DNSStatus@5429,registry:DNSStatus@5920,registry:Host@10480,registry:Host@2878,registry:Host@5389,registry:Host@6234,registry:Host@6567,registry:Host@8819,registry:Host@8866,registry:Host@8916,registry:Host@8951,registry:Host@9000,registry:Platform@2921,registry:Provisioned@2935,registry:Replica@2959,registry:Status@877,whole:Route@3212,whole:Route@323,whole:Route@3505,whole:Route@351,whole:RouteAlias@5282,whole:RouteAlias@8725,whole:RoutePath@3520,whole:WildcardDomain@8905 8c7cdffba66886b92f9a2ac663a86dbdd5f80699afd2e6afb4571da916c727ad
@coverage internal/activator fd9cf14712f55895541bb8bfbf8137cfd446298bcaa8634b0aba7b8344c29ec3
-@coverage internal/console ec1a99e1bcdc7c5e5ffc415f1ce2626adbc73494629c3288007cdc2c8843ee50
+@coverage internal/console 1205d53b2e2a2b69a15bfcee5952d4345daf1f133d077b87c9c2d37a118a6dc6
@coverage . 0e6e4542758248000de1882a01f13e3449c880b4ae23e8b2ff0d6dce076b614c
@legacy b02b534b6617f57381ba12e9d9a2bf1bd63f737dc68005de926ff18b7db93dd3 internal/console/apiboxroles.go Server.boxRoster legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2a file:Read@484 9be9925de14dbbd7d86123bf9faf1535c7b116575cadcb8ba983a665af0513f4
internal/console/apiboxroles.go Server.boxRoster legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2a file:Read@484 9be9925de14dbbd7d86123bf9faf1535c7b116575cadcb8ba983a665af0513f4