carlos / platform Public

region HA 2a: box and group roles in the signed roster

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a2789b7a05494475bd2f5aa30055bfd12910d985 parent c3783b1
8 files changed, +427 −2
  • internal/hostactors/enroll.go +9 −1
  • internal/hostactors/enrollment_confirmation.go +3 −0
  • internal/hostactors/roles.go +143 −0
  • internal/hostactors/roles_test.go +250 −0
  • internal/hostactors/roster.go +18 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +2 −0
  • region_ha_boundary_test.go +1 −0
diff --git a/internal/hostactors/enroll.go b/internal/hostactors/enroll.go
index 6f5e401b9..7f5681fba 100644
--- a/internal/hostactors/enroll.go
+++ b/internal/hostactors/enroll.go
@@ -68,7 +68,15 @@ func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v
a.Consumed = true
r.EnrollmentAttempts[in.Reference] = a
}
- r.Boxes[in.Reference] = Box{Enrollment: enrollment, Namespace: in.Namespace, BoxID: in.Reference, Label: in.Label, Region: in.Region, Pool: in.Pool, Cloud: cloud, EvidenceSHA256: evidence.RecordSHA256, State: "active", LossIDs: []string{}}
+ box := Box{Enrollment: enrollment, Namespace: in.Namespace, BoxID: in.Reference, Label: in.Label, Region: in.Region, Pool: in.Pool, Cloud: cloud, EvidenceSHA256: evidence.RecordSHA256, State: "active", LossIDs: []string{}}
+ if automatic {
+ // A box the group launched takes the group's role in this same
+ // write (region HA spec 2 §7, A11-1): a replacement for an
+ // excluded group is never placeable, even for a moment.
+ g := r.Regions[in.Region]
+ box.Group, box.Role = g.GroupName, g.Role
+ }
+ r.Boxes[in.Reference] = box
return d.Replace(ctx, sg, o, r)
}
diff --git a/internal/hostactors/enrollment_confirmation.go b/internal/hostactors/enrollment_confirmation.go
index 326d0bb66..52a5d57c3 100644
--- a/internal/hostactors/enrollment_confirmation.go
+++ b/internal/hostactors/enrollment_confirmation.go
@@ -18,6 +18,9 @@ type Region struct {
AWSRegion string `json:"aws_region"`
GroupName string `json:"group_name"`
GroupARN string `json:"group_arn"`
+ // Role is the role every box automatic enrollment admits from the
+ // group takes (roles.go): "" (default) or legacy.
+ Role string `json:"role,omitempty"`
}
type TerminatedEnrollment struct {
PassID string `json:"pass_id"`
diff --git a/internal/hostactors/roles.go b/internal/hostactors/roles.go
new file mode 100644
index 000000000..c457eaa41
--- /dev/null
+++ b/internal/hostactors/roles.go
@@ -0,0 +1,143 @@
+package hostactors
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Box and group roles (region HA spec 2 §7, A11-1). A box's role says
+// whether placement may choose it: the default role may be chosen, and
+// legacy and platform never are, for a create, a relocation target or a
+// move target. The box keeps the instances it has (H25).
+//
+// The region's group entry carries a role too. Every box automatic
+// enrollment admits from the group takes the group's role in the same
+// roster write, and changing the group's role changes the role of every
+// box enrolled from it, so a replacement launched after the group was
+// excluded is never placeable, even for a moment.
+//
+// The roster stores the default role as "" so a roster written before
+// roles existed reads as all-default.
+const (
+ RoleDefault = ""
+ RoleLegacy = "legacy"
+ RolePlatform = "platform"
+)
+
+// ErrRole is a role name a box or group cannot take.
+var ErrRole = errors.New("hostactors: not a role this box or group can take")
+
+// ParseBoxRole reads an operator's box role: default, legacy or platform.
+func ParseBoxRole(s string) (string, error) {
+ switch strings.TrimSpace(s) {
+ case "default":
+ return RoleDefault, nil
+ case RoleLegacy:
+ return RoleLegacy, nil
+ case RolePlatform:
+ return RolePlatform, nil
+ }
+ return "", ErrRole
+}
+
+// ParseGroupRole reads an operator's group role: default or legacy. A
+// group is never the platform box.
+func ParseGroupRole(s string) (string, error) {
+ switch strings.TrimSpace(s) {
+ case "default":
+ return RoleDefault, nil
+ case RoleLegacy:
+ return RoleLegacy, nil
+ }
+ return "", ErrRole
+}
+
+// RoleName is the operator's word for a stored role.
+func RoleName(role string) string {
+ if role == RoleDefault {
+ return "default"
+ }
+ return role
+}
+
+func validBoxRole(role string) bool {
+ return role == RoleDefault || role == RoleLegacy || role == RolePlatform
+}
+
+func validGroupRole(role string) bool { return role == RoleDefault || role == RoleLegacy }
+
+// SetBoxRole sets one box's role in a fresh roster write over o. The box
+// must be enrolled and not retired. Setting the role it already has
+// changes nothing and returns o.
+func (d Directory) SetBoxRole(ctx context.Context, sg release.Signer, o Observed, ref, role string) (Observed, error) {
+ if !validBoxRole(role) {
+ return Observed{}, ErrRole
+ }
+ b, ok := o.Roster.Boxes[ref]
+ if !ok || b.State == "retired" {
+ return Observed{}, ErrRoster
+ }
+ if b.Role == role {
+ return o, nil
+ }
+ r := Clone(o.Roster)
+ b = r.Boxes[ref]
+ b.Role = role
+ r.Boxes[ref] = b
+ return d.Replace(ctx, sg, o, r)
+}
+
+// GroupMembers returns, sorted, the boxes enrolled from region's group
+// that are not retired: the boxes a group role change applies to.
+func GroupMembers(r Roster, region string) []string {
+ g, ok := r.Regions[region]
+ if !ok || g.GroupName == "" {
+ return nil
+ }
+ var out []string
+ for ref, b := range r.Boxes {
+ if b.Region == region && b.Group == g.GroupName && b.State != "retired" {
+ out = append(out, ref)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// SetGroupRole sets region's group role and, in the same roster write, the
+// role of every box enrolled from that group (GroupMembers). The region
+// must have a group. It returns the roster and the boxes it set.
+func (d Directory) SetGroupRole(ctx context.Context, sg release.Signer, o Observed, region, role string) (Observed, []string, error) {
+ if !validGroupRole(role) {
+ return Observed{}, nil, ErrRole
+ }
+ g, ok := o.Roster.Regions[region]
+ if !ok || g.GroupName == "" {
+ return Observed{}, nil, ErrRoster
+ }
+ members := GroupMembers(o.Roster, region)
+ changed := g.Role != role
+ for _, ref := range members {
+ changed = changed || o.Roster.Boxes[ref].Role != role
+ }
+ if !changed {
+ return o, members, nil
+ }
+ r := Clone(o.Roster)
+ g.Role = role
+ r.Regions[region] = g
+ for _, ref := range members {
+ b := r.Boxes[ref]
+ b.Role = role
+ r.Boxes[ref] = b
+ }
+ next, err := d.Replace(ctx, sg, o, r)
+ if err != nil {
+ return Observed{}, nil, err
+ }
+ return next, members, nil
+}
diff --git a/internal/hostactors/roles_test.go b/internal/hostactors/roles_test.go
new file mode 100644
index 000000000..2d0b0c507
--- /dev/null
+++ b/internal/hostactors/roles_test.go
@@ -0,0 +1,250 @@
+package hostactors
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestParseRoles(t *testing.T) {
+ for in, want := range map[string]string{"default": RoleDefault, "legacy": RoleLegacy, "platform": RolePlatform, " legacy ": RoleLegacy} {
+ if got, err := ParseBoxRole(in); err != nil || got != want {
+ t.Fatalf("ParseBoxRole(%q) = %q, %v", in, got, err)
+ }
+ }
+ for _, in := range []string{"", "Legacy", "draining", "none"} {
+ if _, err := ParseBoxRole(in); !errors.Is(err, ErrRole) {
+ t.Fatalf("ParseBoxRole(%q) accepted", in)
+ }
+ }
+ if _, err := ParseGroupRole("platform"); !errors.Is(err, ErrRole) {
+ t.Fatal("a group took the platform role")
+ }
+ if got, err := ParseGroupRole("legacy"); err != nil || got != RoleLegacy {
+ t.Fatalf("ParseGroupRole(legacy) = %q, %v", got, err)
+ }
+ if RoleName(RoleDefault) != "default" || RoleName(RolePlatform) != "platform" {
+ t.Fatal("RoleName")
+ }
+}
+
+func TestBoxRoleIsSignedAndPersists(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ next, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ read, err := d.Read(ctx)
+ if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy || read.ETag != next.ETag {
+ t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err)
+ }
+ // The same role again writes nothing.
+ same, err := d.SetBoxRole(ctx, sg, next, "box", RoleLegacy)
+ if err != nil || same.ETag != next.ETag {
+ t.Fatalf("idempotent set wrote: %v", err)
+ }
+ // A stale observation loses the CAS.
+ if _, err = d.SetBoxRole(ctx, sg, o, "box", RolePlatform); err == nil {
+ t.Fatal("stale roster accepted")
+ }
+ back, err := d.SetBoxRole(ctx, sg, next, "box", RoleDefault)
+ if err != nil || back.Roster.Boxes["box"].Role != RoleDefault {
+ t.Fatalf("back to default: %+v %v", back.Roster.Boxes["box"], err)
+ }
+ if _, err = d.SetBoxRole(ctx, sg, back, "nobody", RoleLegacy); !errors.Is(err, ErrRoster) {
+ t.Fatalf("unknown box: %v", err)
+ }
+ if _, err = d.SetBoxRole(ctx, sg, back, "box", "boss"); !errors.Is(err, ErrRole) {
+ t.Fatalf("unknown role: %v", err)
+ }
+ // A retired box keeps no role.
+ retired := Clone(back.Roster)
+ b := retired.Boxes["box"]
+ b.State = "retired"
+ retired.Boxes["box"] = b
+ gone, err := d.Replace(ctx, sg, back, retired)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = d.SetBoxRole(ctx, sg, gone, "box", RoleLegacy); !errors.Is(err, ErrRoster) {
+ t.Fatalf("retired box: %v", err)
+ }
+}
+
+func TestRosterRefusesUnknownRolesAndAGroupChange(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ bad := Clone(o.Roster)
+ b := bad.Boxes["box"]
+ b.Role = "boss"
+ bad.Boxes["box"] = b
+ if _, err := d.Replace(ctx, sg, o, bad); !errors.Is(err, ErrRoster) {
+ t.Fatalf("unknown box role written: %v", err)
+ }
+ moved := Clone(o.Roster)
+ b = moved.Boxes["box"]
+ b.Group = "routers"
+ moved.Boxes["box"] = b
+ if _, err := d.Replace(ctx, sg, o, moved); !errors.Is(err, ErrRoster) {
+ t.Fatalf("group rewritten: %v", err)
+ }
+ noGroup := Clone(o.Roster)
+ noGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", Role: RoleLegacy}}
+ if _, err := d.Replace(ctx, sg, o, noGroup); !errors.Is(err, ErrRoster) {
+ t.Fatalf("a role on a region with no group: %v", err)
+ }
+ platformGroup := Clone(o.Roster)
+ platformGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", Role: RolePlatform}}
+ if _, err := d.Replace(ctx, sg, o, platformGroup); !errors.Is(err, ErrRoster) {
+ t.Fatalf("a platform group: %v", err)
+ }
+}
+
+// groupFixture is fixture plus a group in ie and two boxes it enrolled.
+func groupFixture(t *testing.T) (Directory, release.Signer, Observed) {
+ t.Helper()
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ next := Clone(o.Roster)
+ next.Regions = map[string]Region{
+ "ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}},
+ "uk": {Account: "123", AWSRegion: "eu-west-2"},
+ }
+ for i, ref := range []string{"g1", "g2"} {
+ next.Boxes[ref] = Box{Enrollment: strings.Repeat(string(rune('c'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", Cloud: Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}, Group: "routers"}
+ }
+ o, err := d.Replace(ctx, sg, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return d, sg, o
+}
+
+func TestGroupRoleSetsEveryMemberInOneWrite(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := groupFixture(t)
+ next, set, err := d.SetGroupRole(ctx, sg, o, "ie", RoleLegacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(set, []string{"g1", "g2"}) {
+ t.Fatalf("set %v", set)
+ }
+ if next.Roster.Revision != o.Roster.Revision+1 {
+ t.Fatalf("revision %d, want one write", next.Roster.Revision)
+ }
+ r := next.Roster
+ if r.Regions["ie"].Role != RoleLegacy || r.Boxes["g1"].Role != RoleLegacy || r.Boxes["g2"].Role != RoleLegacy {
+ t.Fatalf("roles: %+v %+v %+v", r.Regions["ie"], r.Boxes["g1"], r.Boxes["g2"])
+ }
+ // The operator-enrolled box in the same region is not the group's.
+ if r.Boxes["box"].Role != RoleDefault {
+ t.Fatalf("operator box took the group's role: %+v", r.Boxes["box"])
+ }
+ again, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleLegacy)
+ if err != nil || again.ETag != next.ETag {
+ t.Fatalf("idempotent group set wrote: %v", err)
+ }
+ back, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleDefault)
+ if err != nil || back.Roster.Boxes["g1"].Role != RoleDefault || back.Roster.Regions["ie"].Role != RoleDefault {
+ t.Fatalf("back to default: %v", err)
+ }
+ if _, _, err = d.SetGroupRole(ctx, sg, back, "uk", RoleLegacy); !errors.Is(err, ErrRoster) {
+ t.Fatalf("a region with no group: %v", err)
+ }
+ if _, _, err = d.SetGroupRole(ctx, sg, back, "ie", RolePlatform); !errors.Is(err, ErrRole) {
+ t.Fatalf("a platform group: %v", err)
+ }
+}
+
+// Automatic enrollment gives the new box the group's role in the same
+// roster write (A11-1): a replacement for an excluded group is never
+// placeable, even for a moment.
+func TestAutomaticEnrollmentInheritsTheGroupRole(t *testing.T) {
+ for _, role := range []string{RoleDefault, RoleLegacy} {
+ t.Run(RoleName(role), func(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`)
+ sum := sha256.Sum256(doc)
+ sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)})
+ v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
+ in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
+ next := Clone(o.Roster)
+ next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}}
+ if o, err = d.Replace(ctx, sg, o, next); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = d.Invite(ctx, sg, in.Reference, raw); err != nil {
+ t.Fatal(err)
+ }
+ o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new")
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: now, RequestIDs: []string{"asg", "ec2"}}
+ c.Confirmer.Box, c.Confirmer.Enrollment, c.Confirmer.Region = "box", o.Roster.Boxes["box"].Enrollment, "ie"
+ wire, _ := json.Marshal(c)
+ if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
+ t.Fatal(err)
+ }
+ in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
+ admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := admitted.Roster.Boxes["new"]
+ if b.Group != "routers" || b.Role != role {
+ t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role)
+ }
+ if admitted.Roster.Revision != o.Roster.Revision+1 {
+ t.Fatal("the role was not set in the enrolling write")
+ }
+ })
+ }
+}
+
+// Every other roster write keeps the role: a quarantine, for one, rewrites
+// the box's entry and must not reopen placement on it.
+func TestOtherRosterWritesKeepTheRole(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ o, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ q, err := d.Quarantine(ctx, sg, o, "box", o.Roster.Boxes["box"].Enrollment, "loss-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b := q.Roster.Boxes["box"]; b.Role != RoleLegacy || b.State != "quarantined" {
+ t.Fatalf("after a quarantine: %+v", b)
+ }
+ read, err := d.Read(ctx)
+ if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy {
+ t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err)
+ }
+}
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go
index 3d6ff913c..38abe806c 100644
--- a/internal/hostactors/roster.go
+++ b/internal/hostactors/roster.go
@@ -39,6 +39,14 @@ type Box struct {
State string `json:"state"`
QuarantineRevision uint64 `json:"quarantine_revision"`
LossIDs []string `json:"loss_ids"`
+ // Group is the Auto Scaling group automatic enrollment admitted the box
+ // from (the region's GroupName then); "" for a box an operator
+ // enrolled. It never changes.
+ Group string `json:"group,omitempty"`
+ // Role is the box's placement role (roles.go): "" (default), legacy or
+ // platform. It is the one field besides the state fields a roster
+ // write may change on an enrolled box.
+ Role string `json:"role,omitempty"`
}
type Console struct {
EnrolledAt time.Time `json:"enrolled_at,omitzero"`
@@ -114,12 +122,20 @@ func (r Roster) Validate(bucket string) error {
if b.State != "active" && b.State != "quarantined" && b.State != "retired" {
return ErrRoster
}
+ if !validBoxRole(b.Role) {
+ return ErrRoster
+ }
if b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) {
return ErrRoster
}
ids[b.Enrollment] = true
clouds[b.Cloud] = true
}
+ for _, g := range r.Regions {
+ if !validGroupRole(g.Role) || (g.Role != RoleDefault && g.GroupName == "") {
+ return ErrRoster
+ }
+ }
for id, c := range r.Consoles {
if !segment(id) || !token.MatchString(c.Enrollment) || ids[c.Enrollment] || c.InstanceID != id || c.DeploymentBucket != bucket || len(c.Namespaces) == 0 {
return ErrRoster
@@ -246,6 +262,8 @@ func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observ
z.QuarantineRevision = 0
a.LossIDs = nil
z.LossIDs = nil
+ a.Role = ""
+ z.Role = ""
if !reflect.DeepEqual(a, z) || b.QuarantineRevision < old.QuarantineRevision {
return Observed{}, ErrRoster
}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 8af5ccb11..593038ff4 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -806,7 +806,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"},
{"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},
{"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"},
- {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "677bd94746cc5d3a5cd966d221f80b509ae096083a8ff4a0ee817d4c12c4a75f"},
+ {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"},
{"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"},
{"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"},
{"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"},
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 d98906ccd..34d954cab 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv
@@ -1,3 +1,5 @@
# Plan 14 census overlay region-ha-2a (region HA spec 2, plan 2a: docs/superpowers/plans/2026-10-16-region-ha-2a-core.md), loaded last.
# Task 2: the new internal/lbhealth package (the load balancer health state). Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added, so the legacy seal stands.
+# Task 3: box and group roles in the signed roster (internal/hostactors roles.go; Box.Role and Box.Group, Region.Role, Replace lets the role change, automatic enrollment inherits the group's role). Envelope-only: Directory.Replace, Roster.Validate and Directory.enroll are not root-read units, no root-read unit changed or was added, and the legacy seal stands.
@coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124
+@coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go
index 2499e2ef4..7f68ca3fc 100644
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -20,6 +20,7 @@ import (
// an AWS package.
var regionHACoreFiles = []string{
"internal/lbhealth/lbhealth.go",
+ "internal/hostactors/roles.go",
}
// regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so