carlos / platform Public

region HA 2a: box and group role doors and the carlos system verbs

POST /api/cli/system/boxes/{box}/role and /groups/{region}/role (operator-only,
host claims on, 404 unless CARLOS_REGION_HA), Server.boxRoster, and carlos
system box role / group role. Copy rows RHA-C20 to RHA-C22 added (pending);
census: new accepted-legacy unit Server.boxRoster, seal and coverage re-pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 851b3db435f46bbe992eb68db616f01bb17bb023 parent ff18a45
17 files changed, +634 −8
  • approved_region_ha_copy_test.go +6 −5
  • docs/site/cli.md +13 −0
  • docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md +14 −0
  • internal/console/apiboxroles.go +176 −0
  • internal/console/apiboxroles_test.go +155 −0
  • internal/console/authcli.go +4 −0
  • internal/console/claim_invariant_fixtures_test.go +2 −0
  • internal/console/claim_invariant_probes_nonapp_test.go +48 −0
  • internal/console/svcroutes_test.go +4 −0
  • internal/console/testdata/claim-routes.txt +2 −0
  • internal/provision/plan14_root_reads_test.go +1 −1
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +8 −0
  • region_ha_boundary_test.go +2 −0
  • system.go +13 −1
  • system_roles.go +118 −0
  • system_roles_test.go +67 −0
diff --git a/approved_region_ha_copy_test.go b/approved_region_ha_copy_test.go
index 744d17db7..5210315a6 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-C19;
+// RHA-C22;
// - TestRegionHACopyHasNoEmDash;
// - TestRegionHABuiltCopyIsApproved: an id in regionHACopyBuilt is an
// approved row. No pending string reaches code, CLI help or docs;
@@ -24,10 +24,11 @@ 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-C19, pinned.
+// regionHACopyIDs is the record's inventory: RHA-C1 to RHA-C22, pinned
+// (RHA-C20 to RHA-C22 are Task 10's further rows).
var regionHACopyIDs = func() []string {
- ids := make([]string, 0, 19)
- for i := 1; i <= 19; i++ {
+ ids := make([]string, 0, 22)
+ for i := 1; i <= 22; i++ {
ids = append(ids, "RHA-C"+strconv.Itoa(i))
}
return ids
@@ -36,7 +37,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{}
+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"}
func mustParseRegionHARecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 07c4d2939..5c09b4231 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2200,6 +2200,19 @@ carlos system legacy-routes assign says which account and app the routes an oper
carlos system legacy-routes assign --name shop --to bac/shop
```
+#### Box and group roles
+
+carlos system box role says whether new instances may be placed on a box. A box with the default role can take them. A legacy or platform box keeps the instances it has and takes no new ones: no new instance, relocation or move lands on it. carlos system group role sets the role of every box a region's group has enrolled, and of every box it enrolls later. Both work only on a console with region HA on.
+
+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.
+
+```sh
+carlos system box role i-0123456789abcdef0 legacy
+carlos system group role ie default
+```
+
+Ops account owners and `CARLOS_SYSTEM_OPERATORS`.
+
### carlos move
Moves one instance to another region. The instance keeps serving while its
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 cb94e5c3c..0eb368371 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
@@ -60,3 +60,17 @@ domain attach, standalone sidecar create and the move door's destination.
| RHA-C17 | internal/console/nocapacity.go | No box in <region> has room right now. Try again in a few minutes. | pending |
| RHA-C18 | internal/console/nocapacity.go (the operator alert's subject) | No box in <region> has room | pending |
| RHA-C19 | internal/console/nocapacity.go (the operator alert's body) | A request in <region> was refused because no box there can take it. Every box is full, stale or excluded by its role. Add a box, or free memory or disk on one, and check the eligible-capacity alarm for <region>. | pending |
+
+## More Task 10 rows
+
+Added at Task 10's build (controller ruling): every sentence a person reads
+that the role doors, the CLI and the docs section carry and the rows above
+do not. Command syntax (the usage synopses and the docs example's commands)
+is not copy. The numbering follows Task 11's, which is why these start at
+RHA-C20.
+
+| id | file | text | status |
+|---|---|---|---|
+| 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 |
diff --git a/internal/console/apiboxroles.go b/internal/console/apiboxroles.go
new file mode 100644
index 000000000..32a829ba3
--- /dev/null
+++ b/internal/console/apiboxroles.go
@@ -0,0 +1,176 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Box and group roles (region HA spec 2 §7, A11-1): `carlos system box role
+// <box> <role>` and `carlos system group role <region> <role>`. A legacy or
+// platform box takes no new instance, relocation or move; it keeps the
+// ones it has (H25). A group's role is every box it enrolled, now and
+// later. The roles live in the signed roster, so the console, the only
+// holder of the root key, is the only writer.
+//
+// Operator-only (apiSystemRoller, the update doors' authority), registered
+// only with host claims on, and answering 404 unless CARLOS_REGION_HA is on
+// too: with region HA off nothing reads a role.
+
+const (
+ boxRoleRefusal = "role must be default, legacy or platform"
+ groupRoleRefusal = "a group's role must be default or legacy"
+ 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."
+)
+
+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) }
+
+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,
+// for the role doors alone, which have already let only an operator in
+// (roleOperator). It is a read, of the one roster every reader reads
+// (control/host-actors.json through hostactors.Directory). Census ruling
+// region-ha-2a's conditions hold it to that: operator-only, read-only, the
+// same roster, and no role it reads feeds root's claim authority (roles
+// are read by the placement chooser alone).
+func (s *Server) boxRoster(ctx context.Context) (hostactors.Directory, release.Signer, hostactors.Observed, error) {
+ if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.Key) == 0 {
+ return hostactors.Directory{}, release.Signer{}, hostactors.Observed{}, errors.New("console: no deployment bucket or root key")
+ }
+ signer := release.RootSigner(s.cfg.Key)
+ dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket}
+ o, err := dir.Read(ctx)
+ return dir, signer, o, err
+}
+
+// rosterRef finds a box by reference, or by the one label no retired box
+// shares (hostactors.Directory.Resolve's rule, over a roster in hand).
+func rosterRef(r hostactors.Roster, name string) (string, bool) {
+ if _, ok := r.Boxes[name]; ok {
+ return name, true
+ }
+ found := ""
+ for ref, b := range r.Boxes {
+ if b.Label == name && b.State != "retired" {
+ if found != "" {
+ return "", false
+ }
+ found = ref
+ }
+ }
+ return found, found != ""
+}
+
+// roleOperator is the role doors' gate: an operator, with region HA on.
+func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
+ addr, _, ok := s.apiSystemRoller(w, r)
+ if !ok {
+ return "", false
+ }
+ if !s.cfg.RegionHA {
+ http.NotFound(w, r)
+ return "", false
+ }
+ return addr, true
+}
+
+// roleWriteError answers a roster write that did not land.
+func roleWriteError(w http.ResponseWriter, what string, err error) {
+ if errors.Is(err, release.ErrCASConflict) || errors.Is(err, hostactors.ErrRoster) {
+ jsonError(w, http.StatusConflict, boxRosterChanged)
+ return
+ }
+ log.Printf("console: %s: %v", what, err)
+ jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure)
+}
+
+func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request) {
+ by, ok := s.roleOperator(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ Role string `json:"role"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ role, err := hostactors.ParseBoxRole(req.Role)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, boxRoleRefusal)
+ return
+ }
+ dir, signer, o, err := s.boxRoster(r.Context())
+ if err != nil {
+ log.Printf("console: box role: roster: %v", err)
+ jsonError(w, http.StatusServiceUnavailable, boxRosterUnread)
+ return
+ }
+ name := r.PathValue("box")
+ ref, ok := rosterRef(o.Roster, name)
+ if !ok {
+ jsonError(w, http.StatusNotFound, noEnrolledBox(name))
+ return
+ }
+ if o.Roster.Boxes[ref].State == "retired" {
+ jsonError(w, http.StatusConflict, retiredBoxRole(ref))
+ return
+ }
+ next, err := dir.SetBoxRole(r.Context(), signer, o, ref, role)
+ if err != nil {
+ roleWriteError(w, "box role "+ref, err)
+ return
+ }
+ log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision)
+ writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)})
+}
+
+func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request) {
+ by, ok := s.roleOperator(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ Role string `json:"role"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ role, err := hostactors.ParseGroupRole(req.Role)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, groupRoleRefusal)
+ return
+ }
+ dir, signer, o, err := s.boxRoster(r.Context())
+ if err != nil {
+ log.Printf("console: group role: roster: %v", err)
+ jsonError(w, http.StatusServiceUnavailable, boxRosterUnread)
+ return
+ }
+ region := r.PathValue("region")
+ g, ok := o.Roster.Regions[region]
+ if !ok || g.GroupName == "" {
+ jsonError(w, http.StatusNotFound, noGroupIn(region))
+ return
+ }
+ next, boxes, err := dir.SetGroupRole(r.Context(), signer, o, region, role)
+ if err != nil {
+ roleWriteError(w, "group role "+region, err)
+ 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 boxes == nil {
+ boxes = []string{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"region": region, "group": g.GroupName, "role": hostactors.RoleName(role), "boxes": boxes})
+}
diff --git a/internal/console/apiboxroles_test.go b/internal/console/apiboxroles_test.go
new file mode 100644
index 000000000..da9d498dc
--- /dev/null
+++ b/internal/console/apiboxroles_test.go
@@ -0,0 +1,155 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// roleFixture is a console with region HA on and a signed roster: in ie,
+// an operator-enrolled box ie-old and two boxes the group "routers"
+// enrolled; uk has no group.
+func roleFixture(t *testing.T) *consoleFixture {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.RegionHA = true
+ old := moveRosterBox(ieBox("ie-old"))
+ g1, g2 := moveRosterBox(ieBox("ie-g1")), moveRosterBox(ieBox("ie-g2"))
+ g1.Group, g2.Group = "routers", "routers"
+ putMoveRoster(t, fx, hostactors.Roster{
+ Boxes: map[string]hostactors.Box{"ie-old": old, "ie-g1": g1, "ie-g2": g2},
+ Regions: map[string]hostactors.Region{
+ "ie": {Account: moveTestAccount, AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "arn:group"},
+ "uk": {Account: moveTestAccount, AWSRegion: "eu-west-2"},
+ },
+ })
+ return fx
+}
+
+// roleCall drives one role door as addr, past cliRoute.
+func roleCall(t *testing.T, h http.HandlerFunc, addr, pathKey, pathValue, body string) (int, map[string]any) {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodPost, "/api/cli/system/x/y/role", bytes.NewBufferString(body))
+ r.Header.Set("Content-Type", "application/json")
+ r.SetPathValue(pathKey, pathValue)
+ r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, Principal{Kind: PrincipalUser, Address: addr}))
+ w := httptest.NewRecorder()
+ h(w, r)
+ var out map[string]any
+ _ = json.Unmarshal(w.Body.Bytes(), &out)
+ return w.Code, out
+}
+
+func rosterNow(t *testing.T, fx *consoleFixture) hostactors.Roster {
+ t.Helper()
+ _, _, o, err := fx.srv.boxRoster(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return o.Roster
+}
+
+func TestBoxRoleDoor(t *testing.T) {
+ fx := roleFixture(t)
+ h := fx.srv.handleAPISystemBoxRole
+ if st, _ := roleCall(t, h, "someone@keymail.dev", "box", "ie-old", `{"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", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound {
+ t.Fatalf("region HA off = %d, want 404", st)
+ }
+ fx.srv.cfg.RegionHA = true
+ if st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`); st != http.StatusBadRequest || out["error"] != boxRoleRefusal {
+ t.Fatalf("unknown role = %d %v", st, out)
+ }
+ if st, out := roleCall(t, h, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noEnrolledBox("nobody") {
+ t.Fatalf("unknown box = %d %v", st, out)
+ }
+ // By label, as an operator names it.
+ st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old-label", `{"role":"legacy"}`)
+ if st != http.StatusOK || out["box"] != "ie-old" || out["role"] != "legacy" {
+ t.Fatalf("set = %d %v", st, out)
+ }
+ if got := rosterNow(t, fx).Boxes["ie-old"].Role; got != hostactors.RoleLegacy {
+ t.Fatalf("the roster says %q", got)
+ }
+ st, out = roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`)
+ if st != http.StatusOK || out["role"] != "default" || rosterNow(t, fx).Boxes["ie-old"].Role != hostactors.RoleDefault {
+ t.Fatalf("back to default = %d %v", st, out)
+ }
+}
+
+func TestGroupRoleDoor(t *testing.T) {
+ fx := roleFixture(t)
+ h := fx.srv.handleAPISystemGroupRole
+ 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)
+ }
+ if st, out := roleCall(t, h, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noGroupIn("uk") {
+ t.Fatalf("no group = %d %v", st, out)
+ }
+ st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`)
+ if st != http.StatusOK || out["group"] != "routers" || out["role"] != "legacy" || !reflect.DeepEqual(out["boxes"], []any{"ie-g1", "ie-g2"}) {
+ t.Fatalf("set = %d %v", st, out)
+ }
+ r := rosterNow(t, fx)
+ if r.Regions["ie"].Role != hostactors.RoleLegacy || r.Boxes["ie-g1"].Role != hostactors.RoleLegacy || r.Boxes["ie-g2"].Role != hostactors.RoleLegacy || r.Boxes["ie-old"].Role != hostactors.RoleDefault {
+ t.Fatalf("roster after the group role: %+v", r.Boxes)
+ }
+}
+
+func TestRoleWriteErrors(t *testing.T) {
+ for _, c := range []struct {
+ err error
+ code int
+ msg string
+ }{
+ {release.ErrCASConflict, http.StatusConflict, boxRosterChanged},
+ {hostactors.ErrRoster, http.StatusConflict, boxRosterChanged},
+ {errors.New("SlowDown"), http.StatusServiceUnavailable, boxRoleWriteFailure},
+ } {
+ w := httptest.NewRecorder()
+ roleWriteError(w, "test", c.err)
+ var out map[string]string
+ _ = json.Unmarshal(w.Body.Bytes(), &out)
+ if w.Code != c.code || out["error"] != c.msg {
+ t.Fatalf("%v = %d %v", c.err, w.Code, out)
+ }
+ }
+}
+
+// The doors exist only on a host-claim console, operator-only and 404
+// with region HA off.
+func TestRoleDoorsAreRegisteredWithHostClaims(t *testing.T) {
+ st := openGatedStore(t)
+ rel := release.NewDirStore(t.TempDir())
+ path := "/api/cli/system/boxes/ie-old/role"
+ off, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ghostStatus, ghostBody := serveHostClaim(t, off, "/unknown-role-route", "")
+ if status, body := serveHostClaim(t, off, path, ""); status != ghostStatus || !bytes.Equal(body, ghostBody) {
+ t.Fatalf("claims off = %d %q, want the unknown route's %d %q", status, body, ghostStatus, ghostBody)
+ }
+ token := mintHostClaimToken(t, st, "paul@keymail.dev")
+ on, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, HostClaim: true, SystemOperators: []string{"paul@keymail.dev"}}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized {
+ t.Fatalf("unsigned = %d", status)
+ }
+ if status, _ := serveHostClaim(t, on, path, token); status != http.StatusNotFound {
+ t.Fatalf("region HA off = %d, want 404", status)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9a0b20ed7..fb22494bb 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -512,6 +512,10 @@ func (s *Server) registerCLIRoutes() {
// ActorDirectory both routes stop at the same inert refusal.
s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect)
s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve)
+ // Box and group roles (region HA spec 2 §7, apiboxroles.go):
+ // operator-only, and 404 unless CARLOS_REGION_HA is on too.
+ s.cliRoute("POST /api/cli/system/boxes/{box}/role", svcrole.Set{}, s.handleAPISystemBoxRole)
+ s.cliRoute("POST /api/cli/system/groups/{region}/role", svcrole.Set{}, s.handleAPISystemGroupRole)
}
// carlos status --layout (plan 4, task 5): which layout each instance is
// on, box by box. Same posture as the door above — an empty role set, the
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 215960ec9..53770c0b8 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1129,6 +1129,8 @@ var claimFixtures = map[string]claimFixture{
}},
"route POST /api/cli/ops/hosts/{host}/resolve/inspect": claimRead("operator host-loss inspection: reads the claim, the record and the roster and signs a challenge; writes nothing"),
"route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"),
+ "route POST /api/cli/system/boxes/{box}/role": claimNonApp("operator box role (region HA spec 2 §7): one signed roster write to control/host-actors.json; names no app"),
+ "route POST /api/cli/system/groups/{region}/role": claimNonApp("operator group role (region HA spec 2 §7): one signed roster write to control/host-actors.json, the group and the boxes it enrolled; names no app"),
"route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"),
"route POST /api/cli/system/activation/cancel": claimNonApp("cancels an unpublished system activation"),
"route POST /api/cli/system/pointer-digests": claimNonApp("operator switch: writes or removes each account's root-signed pointer digest marker (releases/<acct>/pointer-digests), classified exactly by the mutation boundary"),
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go
index d9aa1c8ae..6a8ffad45 100644
--- a/internal/console/claim_invariant_probes_nonapp_test.go
+++ b/internal/console/claim_invariant_probes_nonapp_test.go
@@ -674,6 +674,54 @@ var claimNonAppProbes = map[string]claimProbe{
return ""
},
},
+ // Box and group roles (region HA spec 2 §7): one signed roster write,
+ // only with region HA on.
+ "route POST /api/cli/system/boxes/{box}/role": {
+ setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.RegionHA = true },
+ do: claimAPIReply(http.MethodPost, "/api/cli/system/boxes/box-ie-default/role", `{"role":"legacy"}`),
+ body: []string{`"role":"legacy"`},
+ effect: func(t *testing.T, cx *claimCase, r claimReply) string {
+ o, err := cx.claimFixtureDirectory().Read(context.Background())
+ if err != nil || o.Roster.Boxes["box-ie-default"].Role != hostactors.RoleLegacy {
+ return "box-ie-default does not have the legacy role in the signed roster"
+ }
+ return ""
+ },
+ },
+ "route POST /api/cli/system/groups/{region}/role": {
+ setup: func(t *testing.T, cx *claimCase) {
+ cx.srv.cfg.RegionHA = true
+ ctx := context.Background()
+ o, err := cx.claimFixtureDirectory().Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ next := hostactors.Clone(o.Roster)
+ g := next.Regions["ie"]
+ g.GroupName, g.GroupARN = "routers", "arn:aws:autoscaling:eu-west-1:123456789012:autoScalingGroup:routers"
+ next.Regions["ie"] = g
+ b := next.Boxes["box-ie-default"]
+ b.Group = "routers"
+ next.Boxes["box-ie-default"] = b
+ next.Revision++
+ raw, err := release.SignRootEnvelope(release.RootSigner(cx.priv), next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = cx.rel.Put(ctx, hostactors.Key, raw, o.ETag); err != nil {
+ t.Fatal(err)
+ }
+ },
+ do: claimAPIReply(http.MethodPost, "/api/cli/system/groups/ie/role", `{"role":"legacy"}`),
+ body: []string{`"boxes":["box-ie-default"]`},
+ effect: func(t *testing.T, cx *claimCase, r claimReply) string {
+ o, err := cx.claimFixtureDirectory().Read(context.Background())
+ if err != nil || o.Roster.Regions["ie"].Role != hostactors.RoleLegacy || o.Roster.Boxes["box-ie-default"].Role != hostactors.RoleLegacy {
+ return "the group in ie and its box do not have the legacy role in the signed roster"
+ }
+ return ""
+ },
+ },
"route POST /api/cli/system/seeds-floor": {
do: claimAPIReply(http.MethodPost, "/api/cli/system/seeds-floor", `{"min_release":"v0.27.0"}`),
body: []string{`"min_release":"v0.27.0"`},
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 89c5b7b93..262a15ad8 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -286,6 +286,10 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) {
hostClaimDoors := []string{
"POST /api/cli/ops/hosts/{host}/resolve/inspect",
"POST /api/cli/ops/hosts/{host}/resolve",
+ // Box and group roles (region HA spec 2 §7): operator-only, and 404
+ // unless CARLOS_REGION_HA is on as well.
+ "POST /api/cli/system/boxes/{box}/role",
+ "POST /api/cli/system/groups/{region}/role",
}
movesDoors := []string{
"POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve",
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index fc667b937..7e71c4dc8 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -237,6 +237,8 @@ route POST /api/cli/services/{account}/{name}/revoke
route POST /api/cli/services/{account}/{name}/rotate
route POST /api/cli/system/activation/cancel
route POST /api/cli/system/antivirus
+route POST /api/cli/system/boxes/{box}/role
+route POST /api/cli/system/groups/{region}/role
route POST /api/cli/system/layout
route POST /api/cli/system/legacy-routes
route POST /api/cli/system/pointer-digests
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 4c5bc4c89..cad6a66a5 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -797,7 +797,7 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
- seal = "5d1b9b6addf6c9964ed18b417ee5b3d0d49fd43e1acabb775e054bba4b6e314e"
+ seal = "4f1164be6f79275a71801f5309a77b6e773fe2b4343820106ce8e3ab8e6c2a2a"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 593038ff4..daff9cc12 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -692,7 +692,7 @@ var plan9B8CensusInventory = []censusEntry{
// Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers.
// Re-reviewed at the merge of main into carlos-run (2026-10-13): main's routes (the retry door among them) with the five run doors registered beside them; record writes stay in the reviewed handlers.
// Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's routes and the run doors with the four import doors, the seeds-floor door and the legacy-routes door registered beside them; record writes stay in the reviewed handlers.
- {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "bb32a85461c3253b2b20f4191ae5fb1f2aa26e5e6c038bc338b6d16a14e7301a"},
+ {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "b55987735c833b952a7ef835302dba728c803379ecb474c3d465bb268cf4c781"},
{"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"},
// Builds the instance page path and picks the confirmation line (a declined box's own line); the only write is the reviewed retry door (requestHostRetry), no record write here.
{"internal/console/browserretry.go", "Server.handleInstanceRetry", "key-site", "lit:instances", "4d85d392f3660373f3c282c6391f58d8e25b6df738257ccc067b816e24dfe7ff"},
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 98c76e40f..b5c86aa93 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
@@ -7,6 +7,8 @@
# Task 7: the box's own addresses (internal/host boxaddress.go, the provider-neutral source over CARLOS_PRIVATE_IP with CARLOS_PRIVATE_CIDR, and CARLOS_PUBLIC_IP, all from root-owned host.env; awsaddr.go, the IMDS adapter, which reads the VPC's CIDR blocks too) and the edges file's private_ip and private_cidr, validated before they are written by edgesfile.PrivateAddress (census ruling region-ha-2a, condition 1). edgesFileWriteClaims (authority, F,J1) is re-digested with its class, task, basis and sites unchanged: with region HA on it reads the address source before every publication and no longer holds a blank EIP. It adds no registry, /data, release or claim read, and nothing runs as root that did not before; the legacy seal stands.
# Task 8: with the reader's own region HA on, forwarding dials a same-region box's private address when it passes edgesfile.PrivateAddress against the reader's own published network, and every other box at its public address (internal/edge homemap.go: dialAddress, regionHAFromEnv, edgesFileView's private_ip, private_cidr, region and placement). The home map is not a root-read unit. Envelope-only, and the legacy seal stands.
# Task 9: the load balancer health answer (internal/edge lbhealth.go, answered in httpFallbackHandler; the home map and the certificate mirror latch their first loads, Edge.Watch beats, the activator's sweeper beats through Service.LastSweep, and ClaimWiring.startRegional marks the box hosting). The only root-read unit that moved is the activator's authority row type Service, which gains one atomic.Int64 field (lastSweep) written after each finished sweep and read by LastSweep: no read, write or decision of the seed rows' authority changes, so its row is re-pinned to the new digest with its class, owner and basis unchanged. The answer reads only in-memory state. Envelope-only, and the legacy seal stands.
+# Task 10: the operator's box and group role doors (internal/console apiboxroles.go, registered with host claims on in registerCLIRoutes) and the CLI (system_roles.go). Census ruling region-ha-2a (platform-1e, approved with conditions) covers one accepted-legacy unit, Server.boxRoster: the role doors' read of the signed roster (Directory.Read), operator-only (it is reached only past apiSystemRoller), read-only, the same roster every reader reads, and no role it reads feeds root's claim authority. Its basis is main's for a console root input (J4). The seal moves.
+# Task 10 (system.go): the box and group verbs add `box` and `group` to systemVerb's verb list and two cases to runSystem's dispatch, calling runSystemBoxVerb and runSystemGroupVerb (system_roles.go). The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added, and the verbs reach the console over the operator's CLI session.
internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative be4ca3a3ea2bd768e7e52c1056513d4fef1c1702d2e04656c540cdcbb858865c
@coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124
@coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab
@@ -17,3 +19,9 @@ 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 . 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
+system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 529ab54816bd15161c4f57e2fcb5899581ee7098a465cd3d53b070bde8ddf415
+system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 7067c6024d46c6da98f6db1276aff895b41c0d00939e101ccf70cff8b691fd27
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go
index 4336d209e..67f9946de 100644
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -25,6 +25,8 @@ var regionHACoreFiles = []string{
"internal/host/boxaddress.go",
"internal/edgesfile/private.go",
"internal/edge/lbhealth.go",
+ "internal/console/apiboxroles.go",
+ "system_roles.go",
}
// regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
diff --git a/system.go b/system.go
index 761e5bc12..4a52915ca 100644
--- a/system.go
+++ b/system.go
@@ -49,7 +49,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) {
// untouched in rest for runSystemAntivirusVerb to read.
// layout is a family too: migrate|revert|status stay in rest for
// runSystemLayout.
- case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "help", "-h", "--help":
+ case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help":
return args[0], args[1:], component
}
return "", args, component
@@ -90,6 +90,10 @@ func runSystem(args []string) {
runSystemSeedsFloorVerb(rest)
case "legacy-routes":
runSystemLegacyRoutesVerb(rest)
+ case "box":
+ runSystemBoxVerb(rest)
+ case "group":
+ runSystemGroupVerb(rest)
case "help", "-h", "--help":
systemUsage()
default:
@@ -124,6 +128,8 @@ func systemUsage() {
carlos system geoip <publish|status|off> [flags]
carlos system seeds-floor publish --min-release <release>
carlos system legacy-routes assign --name <name> --to <account>/<app>
+ carlos system box role <box> <default|legacy|platform>
+ carlos system group role <region> <default|legacy>
The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
@@ -140,6 +146,12 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
the first release whose boxes can place a seed, such as v0.27.0
legacy-routes assign --name <name> --to <account>/<app>
`+systemLegacyRoutesVerb+`
+ box role <box> <default|legacy|platform>
+ whether new instances may be placed on a box. A legacy or
+ platform box keeps the instances it has and takes no new ones
+ group role <region> <default|legacy>
+ the same for every box a region's group has enrolled, and for
+ every box it enrolls later
Deployment-wide settings (CARLOS_STORE_OPERATORS):
diff --git a/system_roles.go b/system_roles.go
new file mode 100644
index 000000000..487d52bc5
--- /dev/null
+++ b/system_roles.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+)
+
+// carlos system box role <box> <role> and carlos system group role
+// <region> <role> (region HA spec 2 §7, A11-1). A box's role says whether
+// placement may choose it: legacy and platform boxes take no new
+// instances and keep the ones they have. A group's role is every box it
+// has enrolled and every box it enrolls later. Operators only, and only on
+// a console with region HA on.
+
+// rolePost is the one console call a role change makes.
+type rolePost func(ctx context.Context, path string, in any) (int, []byte, error)
+
+// boxRoleLine is what the CLI says once a box has its role.
+func boxRoleLine(box, role string) string { return fmt.Sprintf("%s now has the %s role", box, role) }
+
+// groupRoleLine is what it says once a group has its role.
+func groupRoleLine(region, role string) string {
+ return fmt.Sprintf("the group in %s now has the %s role", region, role)
+}
+
+// setBoxRole asks the console to give box the role, and returns the line
+// to print.
+func setBoxRole(ctx context.Context, post rolePost, box, role string) (string, error) {
+ status, body, err := post(ctx, "/api/cli/system/boxes/"+url.PathEscape(box)+"/role", map[string]string{"role": role})
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Box string `json:"box"`
+ Role string `json:"role"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", fmt.Errorf("unreadable response: %w", err)
+ }
+ return boxRoleLine(out.Box, out.Role), nil
+}
+
+// setGroupRole asks the console to give region's group the role, and
+// returns the lines to print: the group's, then one per box it set.
+func setGroupRole(ctx context.Context, post rolePost, region, role string) ([]string, error) {
+ status, body, err := post(ctx, "/api/cli/system/groups/"+url.PathEscape(region)+"/role", map[string]string{"role": role})
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Region string `json:"region"`
+ Role string `json:"role"`
+ Boxes []string `json:"boxes"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("unreadable response: %w", err)
+ }
+ lines := []string{groupRoleLine(out.Region, out.Role)}
+ for _, b := range out.Boxes {
+ lines = append(lines, boxRoleLine(b, out.Role))
+ }
+ return lines, nil
+}
+
+// roleArgs reads `role <name> <role>` with an optional --console flag
+// after them, and exits with the usage on anything else.
+func roleArgs(rest []string) (name, role, console string) {
+ if len(rest) < 3 || rest[0] != "role" {
+ systemUsage()
+ os.Exit(2)
+ }
+ name, role = rest[1], rest[2]
+ tail := rest[3:]
+ switch {
+ case len(tail) == 0:
+ case len(tail) == 2 && (tail[0] == "--console" || tail[0] == "-console"):
+ console = tail[1]
+ default:
+ systemUsage()
+ os.Exit(2)
+ }
+ return name, role, console
+}
+
+func runSystemBoxVerb(rest []string) {
+ box, role, consoleFlag := roleArgs(rest)
+ sess := requireDeployAPISession(consoleFlag)
+ printUsing(stdout, sess)
+ line, err := setBoxRole(context.Background(), sess.postJSON, box, role)
+ if err != nil {
+ log.Fatalf("carlos system box role: %v", err)
+ }
+ fmt.Fprintln(stdout, line)
+}
+
+func runSystemGroupVerb(rest []string) {
+ region, role, consoleFlag := roleArgs(rest)
+ sess := requireDeployAPISession(consoleFlag)
+ printUsing(stdout, sess)
+ lines, err := setGroupRole(context.Background(), sess.postJSON, region, role)
+ if err != nil {
+ log.Fatalf("carlos system group role: %v", err)
+ }
+ for _, l := range lines {
+ fmt.Fprintln(stdout, l)
+ }
+}
diff --git a/system_roles_test.go b/system_roles_test.go
new file mode 100644
index 000000000..f8fc0383f
--- /dev/null
+++ b/system_roles_test.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "reflect"
+ "testing"
+)
+
+func TestSystemVerbKnowsBoxAndGroup(t *testing.T) {
+ for _, verb := range []string{"box", "group"} {
+ got, rest, _ := systemVerb([]string{verb, "role", "x", "legacy"})
+ if got != verb || len(rest) != 3 || rest[0] != "role" {
+ t.Fatalf("systemVerb(%s) = %q %q", verb, got, rest)
+ }
+ }
+}
+
+// fakeRolePost answers one role call with status and body, recording what
+// was asked.
+type fakeRolePost struct {
+ status int
+ body string
+ path string
+ in any
+}
+
+func (f *fakeRolePost) post(_ context.Context, path string, in any) (int, []byte, error) {
+ f.path, f.in = path, in
+ return f.status, []byte(f.body), nil
+}
+
+func TestSetBoxRole(t *testing.T) {
+ f := &fakeRolePost{status: http.StatusOK, body: `{"box":"i-0abc","role":"legacy"}`}
+ line, err := setBoxRole(context.Background(), f.post, "old box", "legacy")
+ if err != nil || line != "i-0abc now has the legacy role" {
+ t.Fatalf("line %q, %v", line, err)
+ }
+ if f.path != "/api/cli/system/boxes/old%20box/role" || !reflect.DeepEqual(f.in, map[string]string{"role": "legacy"}) {
+ t.Fatalf("asked %s %v", f.path, f.in)
+ }
+ f = &fakeRolePost{status: http.StatusBadRequest, body: `{"error":"role must be default, legacy or platform"}`}
+ if _, err := setBoxRole(context.Background(), f.post, "x", "boss"); err == nil || err.Error() != "role must be default, legacy or platform" {
+ t.Fatalf("refusal: %v", err)
+ }
+ failing := func(context.Context, string, any) (int, []byte, error) { return 0, nil, errors.New("offline") }
+ if _, err := setBoxRole(context.Background(), failing, "x", "legacy"); err == nil {
+ t.Fatal("a failed call printed a line")
+ }
+}
+
+func TestSetGroupRole(t *testing.T) {
+ f := &fakeRolePost{status: http.StatusOK, body: `{"region":"ie","group":"routers","role":"legacy","boxes":["i-1","i-2"]}`}
+ lines, err := setGroupRole(context.Background(), f.post, "ie", "legacy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"the group in ie now has the legacy role", "i-1 now has the legacy role", "i-2 now has the legacy role"}
+ if !reflect.DeepEqual(lines, want) || f.path != "/api/cli/system/groups/ie/role" {
+ t.Fatalf("lines %q path %s", lines, f.path)
+ }
+ f = &fakeRolePost{status: http.StatusOK, body: `{"region":"ie","group":"routers","role":"default","boxes":[]}`}
+ if lines, _ := setGroupRole(context.Background(), f.post, "ie", "default"); len(lines) != 1 {
+ t.Fatalf("an empty group: %q", lines)
+ }
+}