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>
5 files changed,
+143
−10
approved_region_ha_copy_test.go+7 −6docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md+3 −0internal/console/apiboxroles.go+26 −2internal/console/apiboxroles_test.go+106 −1internal/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 |