carlos / platform Public

hostactors, status: the signed roster, verified enrollment, census and reservations (claim Plan 8b Task 2)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 8e0153d7bdfc825a2e829f3941c2f38d7ec7620d parent f84cebb
21 files changed, +2407 −0
  • internal/hostactors/activation_complete.go +50 −0
  • internal/hostactors/activation_publication.go +51 −0
  • internal/hostactors/attempt_regression_test.go +103 −0
  • internal/hostactors/authority_regression_test.go +285 −0
  • internal/hostactors/census.go +442 −0
  • internal/hostactors/census_test.go +103 −0
  • internal/hostactors/enroll.go +106 −0
  • internal/hostactors/enroll_test.go +126 −0
  • internal/hostactors/enrollment_confirmation.go +157 −0
  • internal/hostactors/fence_authority.go +51 −0
  • internal/hostactors/invitation.go +94 −0
  • internal/hostactors/lifecycle.go +56 −0
  • internal/hostactors/membership.go +64 −0
  • internal/hostactors/private.go +29 −0
  • internal/hostactors/publication.go +79 −0
  • internal/hostactors/references.go +68 −0
  • internal/hostactors/reservations.go +124 −0
  • internal/hostactors/roster.go +291 −0
  • internal/hostactors/roster_test.go +78 −0
  • internal/status/console_process.go +12 −0
  • internal/status/snapshot.go +38 −0

CI passed — run details

diff --git a/internal/hostactors/activation_complete.go b/internal/hostactors/activation_complete.go
new file mode 100644
index 000000000..5955eaa1f
--- /dev/null
+++ b/internal/hostactors/activation_complete.go
@@ -0,0 +1,50 @@
+package hostactors
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "slices"
+ "strings"
+ "time"
+)
+
+func (d Directory) ReleaseActivation(ctx context.Context, sg release.Signer, id string, now time.Time) error {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ h, ok := o.Roster.Activations[id]
+ if !ok {
+ return nil
+ }
+ floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ if err != nil {
+ return err
+ }
+ evidence := ExactEvidence{PublicKey: d.PublicKey, Store: d.Store, Boxes: d.PrivateBoxReport}
+ for actor, enrollment := range h.Actors {
+ ref, ok := strings.CutPrefix(actor, "box:")
+ if !ok {
+ return ErrRoster
+ }
+ box, ok := o.Roster.Boxes[ref]
+ if !ok || box.Enrollment != enrollment {
+ return ErrRoster
+ }
+ r, err := d.PrivateBoxReport(ctx, ref, box)
+ if err != nil {
+ return err
+ }
+ if err = VerifyReport(ctx, evidence, d.PublicKey, floor, r, enrollment, box.Cloud.InstanceID, now); err != nil {
+ return err
+ }
+ if r.ActivationID != id || !r.ActivationComplete || !slices.Contains(h.Hashes[actor], r.BinarySHA256) {
+ return ErrRoster
+ }
+ }
+ next := Clone(o.Roster)
+ delete(next.Activations, id)
+ _, err = d.Replace(ctx, sg, o, next)
+ return err
+}
diff --git a/internal/hostactors/activation_publication.go b/internal/hostactors/activation_publication.go
new file mode 100644
index 000000000..dd563375a
--- /dev/null
+++ b/internal/hostactors/activation_publication.go
@@ -0,0 +1,51 @@
+package hostactors
+
+import (
+ "context"
+ "reflect"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func (d Directory) PublishActivation(ctx context.Context, id string) (*release.SystemPointer, error) {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return nil, err
+ }
+ h, ok := o.Roster.Activations[id]
+ if !ok || h.Publication == nil || h.Publication.ID != id {
+ return nil, ErrRoster
+ }
+ return release.PublishSystemPublication(ctx, d.Store, d.PublicKey, *h.Publication)
+}
+
+func (d Directory) CancelActivation(ctx context.Context, sg release.Signer, id string) error {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ h, ok := o.Roster.Activations[id]
+ if !ok {
+ return nil
+ }
+ if h.Publication == nil || h.Publication.ID != id {
+ return ErrRoster
+ }
+ if err := release.CancelSystemPublication(ctx, d.Store, sg, *h.Publication); err != nil {
+ return err
+ }
+ fresh, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ if _, ok := fresh.Roster.Activations[id]; !ok {
+ return nil
+ }
+ if !reflect.DeepEqual(h, fresh.Roster.Activations[id]) {
+ return ErrRoster
+ }
+ next := Clone(fresh.Roster)
+ delete(next.Activations, id)
+ _, err = d.Replace(ctx, sg, fresh, next)
+ return err
+}
diff --git a/internal/hostactors/attempt_regression_test.go b/internal/hostactors/attempt_regression_test.go
new file mode 100644
index 000000000..cd6c8c364
--- /dev/null
+++ b/internal/hostactors/attempt_regression_test.go
@@ -0,0 +1,103 @@
+package hostactors
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/release"
+ "testing"
+ "time"
+)
+
+func TestEnrollmentRefreshRejectsExpiredSupersededAndConsumedEvidence(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)
+ }
+ rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}
+ raw, _ := json.Marshal(rec)
+ 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"}}}
+ o, err = d.Replace(ctx, sg, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = d.Invite(ctx, sg, in.Reference, raw); err != nil {
+ t.Fatal(err)
+ }
+ o, first, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new")
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ confirmation := func(a EnrollmentAttempt, at time.Time) MembershipConfirmation {
+ 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: at, RequestIDs: []string{"asg", "ec2"}}
+ c.Confirmer.Box = "box"
+ c.Confirmer.Enrollment = o.Roster.Boxes["box"].Enrollment
+ c.Confirmer.Region = "ie"
+ return c
+ }
+ save := func(a EnrollmentAttempt, c MembershipConfirmation) {
+ t.Helper()
+ wire, _ := json.Marshal(c)
+ if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
+ t.Fatal(err)
+ }
+ }
+ save(first, confirmation(first, now.Add(-2*time.Minute)))
+ in.AttemptID = first.AttemptID
+ in.ConfirmationID = first.ConfirmationID
+ if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
+ t.Fatal("expired evidence admitted")
+ }
+ staleRoster := o
+ o, second, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.AttemptID == second.AttemptID || first.ConfirmationID == second.ConfirmationID {
+ t.Fatal("immutable identity reused")
+ }
+ save(second, confirmation(second, now))
+ if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
+ t.Fatal("superseded request admitted")
+ }
+ if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
+ t.Fatal("superseded IDs admitted while old evidence was still fresh")
+ }
+ // Even a still-fresh old confirmation cannot win the stale roster CAS.
+ if _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
+ t.Fatal("superseded snapshot admitted")
+ }
+ in.AttemptID = second.AttemptID
+ in.ConfirmationID = second.ConfirmationID
+ admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !admitted.Roster.EnrollmentAttempts[in.Reference].Consumed {
+ t.Fatal("attempt not consumed with roster admission")
+ }
+ if _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", now); err == nil {
+ t.Fatal("consumed confirmation replayed")
+ }
+ if _, _, err = d.Store.Get(ctx, ConfirmationKey(in.Reference, first.AttemptID, first.ConfirmationID)); err != nil {
+ t.Fatal("old immutable evidence removed", err)
+ }
+}
diff --git a/internal/hostactors/authority_regression_test.go b/internal/hostactors/authority_regression_test.go
new file mode 100644
index 000000000..e612cfeca
--- /dev/null
+++ b/internal/hostactors/authority_regression_test.go
@@ -0,0 +1,285 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "strings"
+ "testing"
+ "time"
+)
+
+func writeFence(t *testing.T, d Directory, o Observed, ref string, now time.Time, invalid bool) {
+ t.Helper()
+ b := o.Roster.Boxes[ref]
+ state := "terminated"
+ if invalid {
+ state = "stopped"
+ }
+ f := map[string]any{"v": 1, "box": ref, "enrollment": b.Enrollment, "cloud": b.Cloud, "state": state, "observed_at": now, "via": "describe", "request_id": "request", "written_by": map[string]string{"box": ref, "enrollment": b.Enrollment}}
+ raw, _ := json.Marshal(f)
+ if err := release.CreateExact(context.Background(), d.Store, "control/box-fences/"+b.Enrollment+".json", raw); err != nil {
+ t.Fatal(err)
+ }
+}
+func TestTerminationFenceExcludesActiveBeforeRosterUpdate(t *testing.T) {
+ for _, invalid := range []bool{false, true} {
+ t.Run(map[bool]string{false: "valid", true: "invalid"}[invalid], func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+ writeFence(t, d, o, "box", now, invalid)
+ reports, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now)
+ if invalid {
+ if err == nil {
+ t.Fatal("invalid fence treated as absence or exclusion")
+ }
+ } else if err != nil || len(reports) != 1 {
+ t.Fatal(reports, err)
+ }
+ if _, err := d.Lookup(context.Background(), "box"); err == nil {
+ t.Fatal("fenced or invalid actor eligible")
+ }
+ if _, err := d.CheckBoxStart(context.Background(), "box"); err == nil {
+ t.Fatal("fenced or invalid start allowed")
+ }
+ scope.Participants = []string{"box"}
+ if _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now); err == nil {
+ t.Fatal("fenced participant allowed")
+ }
+ })
+ }
+}
+func boundConsole(o *Observed, e *reportsFixture) {
+ c := o.Roster.Consoles["console-1"]
+ c.Compute = &ComputeBinding{Box: "box", Enrollment: e.box.Enrollment}
+ o.Roster.Consoles["console-1"] = c
+ e.console.Compute = c.Compute
+ e.box.ConsoleProcesses = []ConsoleProcess{{ConsoleID: "console-1", Enrollment: c.Enrollment, ProcessID: e.console.ProcessID, BinarySHA256: e.console.BinarySHA256, GuardFloorSHA256: e.box.FloorSHA256, AllLaunchPathsGuarded: true}}
+}
+func TestConsoleFenceRequiresExactProcessMachineAndBoot(t *testing.T) {
+ for _, fault := range []string{"good", "missing", "boot", "enrollment", "process", "ambiguous", "console-enrollment", "console-instance"} {
+ t.Run(fault, func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+ boundConsole(&o, &e)
+ // The starter lives elsewhere; only the bound console can be excluded.
+ scope.ConsoleID = "starter"
+ c := o.Roster.Consoles["console-1"]
+ c.Compute = nil
+ c.InstanceID = "starter"
+ c.Enrollment = "dddddddddddddddddddddddddddddddd"
+ o.Roster.Consoles["starter"] = c
+ switch fault {
+ case "missing":
+ c = o.Roster.Consoles["console-1"]
+ c.Compute = nil
+ o.Roster.Consoles["console-1"] = c
+ case "console-enrollment":
+ e.console.Enrollment = "wrong"
+ case "console-instance":
+ e.console.InstanceID = "wrong"
+ case "boot":
+ e.console.BootID = "other"
+ case "enrollment":
+ e.console.Compute = &ComputeBinding{Box: "box", Enrollment: "other"}
+ case "process":
+ e.box.ConsoleProcesses[0].ProcessID = "other"
+ case "ambiguous":
+ e.box.ConsoleProcesses = append(e.box.ConsoleProcesses, e.box.ConsoleProcesses[0])
+ }
+ // Validate the binding independently, then exercise full census below with exact objects.
+ _, err := verifyCompute(context.Background(), o, e, "console-1", o.Roster.Consoles["console-1"], e.console)
+ if (err == nil) != (fault == "good") {
+ t.Fatal(fault, err)
+ }
+ if fault != "good" {
+ return
+ }
+ writeFence(t, d, o, "box", now, false)
+ for id, cc := range o.Roster.Consoles {
+ r := e.console
+ if id == "starter" {
+ r.Actor = id
+ r.Enrollment = cc.Enrollment
+ r.InstanceID = id
+ r.Compute = nil
+ }
+ raw, _ := json.Marshal(r)
+ if err := release.CreateExact(context.Background(), d.Store, "status/consoles/"+id+".json", raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ local := e.console
+ local.Actor = "starter"
+ local.Enrollment = c.Enrollment
+ local.InstanceID = "starter"
+ local.Compute = nil
+ exact := ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: e.BoxReport, Self: func(context.Context) (Report, error) { return local, nil }}
+ reports, err := Census(context.Background(), o, exact, d.PublicKey, f, scope, now)
+ if err != nil || len(reports) != 1 || reports["console:starter"].Actor != "starter" {
+ t.Fatal(reports, err)
+ }
+ })
+ }
+}
+func TestRelocationOnlyNewestGuardedConsoleMayBeStale(t *testing.T) {
+ for _, fault := range []string{"good", "ordinary", "guard", "floor", "binding", "future", "newer-malformed", "newer-image", "other-operation"} {
+ t.Run(fault, func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+ boundConsole(&o, &e)
+ e.console.GeneratedAt = now.Add(-time.Hour)
+ scope.boxRelocation = true
+ scope.StarterBox = "box"
+ scope.ConsoleID = ""
+ switch fault {
+ case "ordinary", "other-operation":
+ scope.boxRelocation = false
+ scope.ConsoleID = "console-1"
+ case "guard":
+ e.box.ConsoleProcesses[0].AllLaunchPathsGuarded = false
+ case "floor":
+ e.box.ConsoleProcesses[0].GuardFloorSHA256 = "wrong"
+ case "binding":
+ e.console.Compute = nil
+ case "future":
+ e.console.GeneratedAt = now.Add(time.Minute)
+ case "newer-image":
+ e.console.BinarySHA256 = "wrong"
+ }
+ raw, _ := json.Marshal(e.console)
+ if fault == "newer-malformed" {
+ raw = []byte("{")
+ }
+ if err := release.CreateExact(context.Background(), d.Store, "status/consoles/console-1.json", raw); err != nil {
+ t.Fatal(err)
+ }
+ exact := ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: e.BoxReport, Self: func(context.Context) (Report, error) { return e.box, nil }}
+ _, err := Census(context.Background(), o, exact, d.PublicKey, f, scope, now)
+ if (err == nil) != (fault == "good") {
+ t.Fatal(fault, err)
+ }
+ })
+ }
+}
+
+type brokenFenceStore struct{ release.Store }
+
+func (s brokenFenceStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasPrefix(key, "control/box-fences/") {
+ return nil, "", errors.New("fence read unavailable")
+ }
+ return s.Store.Get(ctx, key)
+}
+func TestFenceAuthorityErrorsAndMismatchesRefuse(t *testing.T) {
+ for _, fault := range []string{"read", "malformed", "enrollment", "cloud", "writer", "future"} {
+ t.Run(fault, func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+ if fault == "read" {
+ d.Store = brokenFenceStore{d.Store}
+ e.d = d
+ } else {
+ writeFence(t, d, o, "box", now, false)
+ key := "control/box-fences/" + o.Roster.Boxes["box"].Enrollment + ".json"
+ raw, etag, err := d.Store.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var record map[string]any
+ if err := json.Unmarshal(raw, &record); err != nil {
+ t.Fatal(err)
+ }
+ switch fault {
+ case "enrollment":
+ record["enrollment"] = "wrong"
+ case "cloud":
+ record["cloud"] = Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "wrong"}
+ case "writer":
+ record["written_by"] = map[string]string{"box": "unknown", "enrollment": "wrong"}
+ case "future":
+ record["observed_at"] = now.Add(time.Minute)
+ }
+ raw, _ = json.Marshal(record)
+ if fault == "malformed" {
+ raw = []byte("{")
+ }
+ // Test-only mutation creates corrupt authority without allowing production overwrite.
+ if _, err = d.Store.Put(context.Background(), key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now); err == nil {
+ t.Fatal("bad authority admitted census")
+ }
+ if _, err := d.Lookup(context.Background(), "box"); err == nil {
+ t.Fatal("bad authority granted eligibility")
+ }
+ if _, err := d.CheckBoxStart(context.Background(), "box"); err == nil {
+ t.Fatal("bad authority granted start")
+ }
+ })
+ }
+}
+
+func TestStartRevalidatesReservedActorEligibility(t *testing.T) {
+ for _, fault := range []string{"good", "quarantined", "retired", "fenced", "malformed", "unreadable", "console-quarantined", "console-fenced"} {
+ t.Run(fault, func(t *testing.T) {
+ ctx := t.Context()
+ d, sg, o := fixture(t)
+ floor := hostfloor.Floor{Min: 1, MinRelease: "v1.2.3"}
+ if err := hostfloor.Publish(ctx, d.Store, sg, d.Bucket, floor); err != nil {
+ t.Fatal(err)
+ }
+ f, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := Clone(o.Roster)
+ r.Publication, r.FloorSHA256 = "published", f.Digest
+ actor, enrollment := "box:box", r.Boxes["box"].Enrollment
+ if strings.HasPrefix(fault, "console-") {
+ actor, enrollment = "console:console-1", strings.Repeat("c", 32)
+ r.Consoles["console-1"] = Console{Enrollment: enrollment, InstanceID: "console-1", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket, Compute: &ComputeBinding{Box: "box", Enrollment: r.Boxes["box"].Enrollment}}
+ }
+ o, err = d.Replace(ctx, sg, o, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, err := hostclaim.Read(ctx, d.Store, "shop.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := Reservation{ID: "hold", Host: before.Host, OpID: "operation", ExpectedClaimETag: before.ETag, FloorSHA256: f.Digest, Actors: map[string]string{actor: enrollment}, Releases: map[string]Report{actor: {Enrollment: enrollment, GeneratedAt: time.Now()}}}
+ o, err = d.Reserve(ctx, sg, o, h, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch fault {
+ case "quarantined", "console-quarantined":
+ _, err = d.Quarantine(ctx, sg, o, "box", o.Roster.Boxes["box"].Enrollment, "loss")
+ case "retired":
+ r = Clone(o.Roster)
+ b := r.Boxes["box"]
+ b.State = "retired"
+ r.Boxes["box"] = b
+ _, err = d.Replace(ctx, sg, o, r)
+ case "fenced", "console-fenced", "malformed":
+ writeFence(t, d, o, "box", time.Now(), fault == "malformed")
+ case "unreadable":
+ d.Store = brokenFenceStore{d.Store}
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ called := false
+ _, err = d.Start(ctx, before, h.ID, func(*hostclaim.Claim) error { called = true; return errors.New("stop before writing") })
+ if err == nil || called != (fault == "good") {
+ t.Fatalf("transition called=%v, error=%v", called, err)
+ }
+ after, err := hostclaim.Read(ctx, d.Store, before.Host)
+ if err != nil || before.ETag != after.ETag || before.Absent != after.Absent {
+ t.Fatal("refusal changed claim", err)
+ }
+ })
+ }
+}
diff --git a/internal/hostactors/census.go b/internal/hostactors/census.go
new file mode 100644
index 000000000..83d870cee
--- /dev/null
+++ b/internal/hostactors/census.go
@@ -0,0 +1,442 @@
+package hostactors
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+ "reflect"
+ "slices"
+ "time"
+)
+
+type ComputeBinding struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+}
+type ConsoleProcess = status.ConsoleProcess
+type Report struct {
+ Compute *ComputeBinding `json:"compute,omitempty"`
+ ConsoleProcesses []ConsoleProcess `json:"console_processes,omitempty"`
+ Component string `json:"component"`
+ SystemChannel string `json:"system_channel,omitempty"`
+ ActivationID string `json:"activation_id,omitempty"`
+ ActivationComplete bool `json:"activation_complete,omitempty"`
+ GeneratedAt time.Time `json:"generated_at"`
+ Actor string `json:"actor"`
+ Enrollment string `json:"enrollment"`
+ InstanceID string `json:"instance_id"`
+ ProcessID string `json:"process_id"`
+ Release string `json:"release"`
+ ReleaseTag string `json:"release_tag"`
+ Platform string `json:"platform"`
+ BinarySHA256 string `json:"binary_sha256"`
+ MovesProtocol int `json:"moves_protocol"`
+ BootID string `json:"boot_id"`
+ FloorSHA256 string `json:"floor_sha256"`
+ FloorGuardReady bool `json:"floor_guard_ready"`
+ HostClaimEnabled bool `json:"host_claim_enabled"`
+ RetirementReceiptSHA256 string `json:"retirement_receipt_sha256,omitempty"`
+}
+type Scope struct {
+ // Only CensusBoxRelocation sets this private permission after eligibility checks.
+ boxRelocation bool
+ StarterBox string
+
+ Namespace string
+ Regions []string
+ Pools []string
+ Participants []string
+ ConsoleID string
+}
+type Evidence interface {
+ BoxReport(context.Context, string, Box) (Report, error)
+ ConsoleReport(context.Context, string, Console) (Report, error)
+ LocalConsole(context.Context) (Report, error)
+ Identity(context.Context, string) ([]byte, error)
+ Fence(context.Context, Observed, string, time.Time) (bool, error)
+}
+
+func VerifyReport(ctx context.Context, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, r Report, enrollment, instance string, now time.Time) error {
+ return verifyReport(ctx, e, pub, f, r, enrollment, instance, now, false)
+}
+func verifyReport(ctx context.Context, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, r Report, enrollment, instance string, now time.Time, allowStale bool) error {
+ age := now.Sub(r.GeneratedAt)
+ if r.GeneratedAt.IsZero() || (!allowStale && age > 15*time.Minute) || age < -5*time.Second || r.Enrollment != enrollment || r.InstanceID != instance || r.BootID == "" || !r.FloorGuardReady || r.FloorSHA256 != f.Digest || r.MovesProtocol < f.Floor.Min {
+ return ErrRoster
+ }
+ wire, err := e.Identity(ctx, r.ReleaseTag)
+ if err != nil {
+ return err
+ }
+ p, err := release.VerifySystemIdentity(pub, wire)
+ if err != nil {
+ return err
+ }
+ if p.Current.ReleaseTag != r.ReleaseTag || p.Current.MovesProtocol != r.MovesProtocol {
+ return ErrRoster
+ }
+ return hostfloor.CheckComponent(pub, f, wire, r.Release, r.Platform, r.BinarySHA256, r.Component)
+}
+func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, scope Scope, now time.Time) (map[string]Report, error) {
+ if o.Roster.Publication != "published" || o.Roster.FloorSHA256 != f.Digest {
+ return nil, ErrRoster
+ }
+ if scope.Namespace == "" || len(scope.Regions) == 0 || len(scope.Pools) == 0 || (!scope.boxRelocation && scope.ConsoleID == "") || e == nil {
+ return nil, ErrRoster
+ }
+ if err := validateScope(o.Roster, scope); err != nil {
+ return nil, err
+ }
+ reports := map[string]Report{}
+ participants := map[string]bool{}
+ for _, p := range scope.Participants {
+ participants[p] = true
+ }
+ for ref, b := range o.Roster.Boxes {
+ required := participants[ref] || (b.Namespace == scope.Namespace && slices.Contains(scope.Regions, b.Region) && slices.Contains(scope.Pools, b.Pool))
+ if !required {
+ continue
+ }
+ requiredParticipant := participants[ref]
+ delete(participants, ref)
+ fenced, err := e.Fence(ctx, o, ref, now)
+ if err != nil {
+ return nil, err
+ }
+ if fenced {
+ if requiredParticipant {
+ return nil, ErrRoster
+ }
+ continue
+ }
+ if b.State != "active" {
+ if requiredParticipant {
+ return nil, ErrRoster
+ }
+ if len(b.LossIDs) == 0 || b.QuarantineRevision == 0 {
+ return nil, ErrRoster
+ }
+ exact, ok := e.(ExactEvidence)
+ if !ok {
+ return nil, ErrRoster
+ }
+ found := false
+ for _, id := range b.LossIDs {
+ loss, err := hostclaim.ReadLossRecord(ctx, exact.Store, pub, id)
+ if err != nil {
+ return nil, err
+ }
+ if loss.Record.Box != ref || loss.Record.Enrollment != b.Enrollment {
+ return nil, ErrRoster
+ }
+ if loss.Record.QuarantineRevision == b.QuarantineRevision {
+ found = true
+ }
+ }
+ if !found {
+ return nil, ErrRoster
+ }
+ continue
+ }
+ r, err := e.BoxReport(ctx, ref, b)
+ if err != nil {
+ return nil, err
+ }
+ if r.Component != "router" || r.Actor != ref || !r.HostClaimEnabled {
+ return nil, ErrRoster
+ }
+ if err = VerifyReport(ctx, e, pub, f, r, b.Enrollment, b.Cloud.InstanceID, now); err != nil {
+ return nil, err
+ }
+ reports["box:"+ref] = r
+ }
+ if len(participants) != 0 {
+ return nil, ErrRoster
+ }
+ for id, c := range o.Roster.Consoles {
+ if !slices.Contains(c.Namespaces, scope.Namespace) && id != scope.ConsoleID {
+ continue
+ }
+ r, err := e.ConsoleReport(ctx, id, c)
+ if err != nil {
+ return nil, err
+ }
+ if r.Component != "console" || r.Actor != id || r.ProcessID == "" || !r.HostClaimEnabled {
+ return nil, ErrRoster
+ }
+ bound, err := verifyCompute(ctx, o, e, id, c, r)
+ if err != nil {
+ return nil, err
+ }
+ if c.Compute != nil {
+ fenced, err := e.Fence(ctx, o, c.Compute.Box, now)
+ if err != nil {
+ return nil, err
+ }
+ if fenced {
+ if id == scope.ConsoleID {
+ return nil, ErrRoster
+ }
+ continue
+ }
+ }
+ allowStale := false
+ if scope.boxRelocation && c.Compute != nil {
+ // Re-read the one current private key. No caller-selected historical report.
+ exact, ok := e.(ExactEvidence)
+ if ok {
+ newest, readErr := exact.ConsoleReport(ctx, id, c)
+ if readErr != nil {
+ return nil, readErr
+ }
+ if !reflect.DeepEqual(newest, r) {
+ return nil, ErrRoster
+ }
+ box := o.Roster.Boxes[c.Compute.Box]
+ if VerifyReport(ctx, e, pub, f, bound, box.Enrollment, box.Cloud.InstanceID, now) == nil {
+ for _, p := range bound.ConsoleProcesses {
+ if p.ConsoleID == id && p.GuardFloorSHA256 == f.Digest && p.AllLaunchPathsGuarded {
+ allowStale = true
+ }
+ }
+ }
+ }
+ }
+ if err = verifyReport(ctx, e, pub, f, r, c.Enrollment, id, now, allowStale); err != nil {
+ return nil, err
+ }
+ reports["console:"+id] = r
+ }
+ if scope.boxRelocation {
+ local, err := e.LocalConsole(ctx) // box relocation supplies its actual running router here.
+ published, ok := reports["box:"+scope.StarterBox]
+ if err != nil || !ok || local.Component != "router" || !local.HostClaimEnabled || local.Actor != scope.StarterBox || local.BootID != published.BootID {
+ return nil, ErrRoster
+ }
+ box := o.Roster.Boxes[scope.StarterBox]
+ if err := VerifyReport(ctx, e, pub, f, local, box.Enrollment, box.Cloud.InstanceID, now); err != nil {
+ return nil, err
+ }
+ return reports, nil
+ }
+ own, ok := o.Roster.Consoles[scope.ConsoleID]
+ if !ok {
+ return nil, ErrRoster
+ }
+ local, err := e.LocalConsole(ctx)
+ if err != nil {
+ return nil, err
+ }
+ published, ok := reports["console:"+scope.ConsoleID]
+ if !ok || local.Component != "console" || !local.HostClaimEnabled || local.Actor != scope.ConsoleID || local.ProcessID == "" || local.ProcessID != published.ProcessID {
+ return nil, ErrRoster
+ }
+ if _, err := verifyCompute(ctx, o, e, scope.ConsoleID, own, local); err != nil {
+ return nil, err
+ }
+ if err = VerifyReport(ctx, e, pub, f, local, own.Enrollment, scope.ConsoleID, now); err != nil {
+ return nil, err
+ }
+ return reports, nil
+}
+func (d Directory) Reserve(ctx context.Context, sg release.Signer, o Observed, h Reservation, activation bool) (Observed, error) {
+ r := Clone(o.Roster)
+ if h.ID == "" || len(h.Actors) == 0 || len(h.Releases) != len(h.Actors) {
+ return Observed{}, ErrRoster
+ }
+ if _, exists := r.Holds[h.ID]; exists {
+ return Observed{}, ErrRoster
+ }
+ if _, exists := r.Activations[h.ID]; exists {
+ return Observed{}, ErrRoster
+ }
+ if activation {
+ if len(h.Hashes) != len(h.Actors) {
+ return Observed{}, ErrRoster
+ }
+ for key := range h.Actors {
+ if len(h.Hashes[key]) != 3 {
+ return Observed{}, ErrRoster
+ }
+ }
+ for _, other := range r.Holds {
+ if overlaps(h, other) {
+ return Observed{}, ErrRoster
+ }
+ }
+ for _, other := range r.Activations {
+ if h.Publication != nil && other.Publication != nil && h.Publication.Component == other.Publication.Component && h.Publication.Channel == other.Publication.Channel {
+ return Observed{}, ErrRoster
+ }
+ if overlaps(h, other) {
+ return Observed{}, ErrRoster
+ }
+ }
+ r.Activations[h.ID] = h
+ } else {
+ if h.Host == "" || h.OpID == "" {
+ return Observed{}, ErrRoster
+ }
+ for _, other := range r.Activations {
+ if h.Publication != nil && other.Publication != nil && h.Publication.Component == other.Publication.Component && h.Publication.Channel == other.Publication.Channel {
+ return Observed{}, ErrRoster
+ }
+ if overlaps(h, other) {
+ return Observed{}, ErrRoster
+ }
+ }
+ r.Holds[h.ID] = h
+ }
+ return d.Replace(ctx, sg, o, r)
+}
+func (d Directory) CheckBoxStart(ctx context.Context, refs ...string) (uint64, error) {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return 0, err
+ }
+ if len(refs) == 0 {
+ return 0, ErrRoster
+ }
+ for _, ref := range refs {
+ b, ok := o.Roster.Boxes[ref]
+ if !ok || b.State != "active" {
+ return 0, ErrRoster
+ }
+ fenced, err := d.TerminationFence(ctx, o, ref, time.Now())
+ if err != nil || fenced {
+ return 0, ErrRoster
+ }
+ for _, a := range o.Roster.Activations {
+ for _, enrollment := range a.Actors {
+ if enrollment == b.Enrollment {
+ return 0, ErrRoster
+ }
+ }
+ }
+ }
+ return o.Roster.Revision, nil
+}
+
+// ExactEvidence adapts the existing private status object without changing
+// the public status shape. The supplied readers must use exact private keys.
+type ExactEvidence struct {
+ PublicKey ed25519.PublicKey
+ Store release.Store
+ Boxes func(context.Context, string, Box) (Report, error)
+ Self func(context.Context) (Report, error)
+}
+
+func (e ExactEvidence) BoxReport(ctx context.Context, id string, b Box) (Report, error) {
+ if e.Boxes == nil {
+ return Report{}, ErrRoster
+ }
+ return e.Boxes(ctx, id, b)
+}
+func (e ExactEvidence) LocalConsole(ctx context.Context) (Report, error) {
+ if e.Self == nil {
+ return Report{}, ErrRoster
+ }
+ return e.Self(ctx)
+}
+func (e ExactEvidence) ConsoleReport(ctx context.Context, id string, c Console) (Report, error) {
+ if !segment(id) {
+ return Report{}, ErrRoster
+ }
+ raw, _, err := e.Store.Get(ctx, "status/consoles/"+id+".json")
+ if err != nil {
+ return Report{}, err
+ }
+ var r Report
+ if err = json.Unmarshal(raw, &r); err != nil {
+ return Report{}, err
+ }
+ return r, nil
+}
+func (e ExactEvidence) Identity(ctx context.Context, tag string) ([]byte, error) {
+ key, err := release.IdentityKey(tag)
+ if err != nil {
+ return nil, err
+ }
+ raw, _, err := e.Store.Get(ctx, key)
+ return raw, err
+}
+
+func (e ExactEvidence) Fence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) {
+ return (Directory{Store: e.Store, PublicKey: e.PublicKey, Bucket: o.Roster.DeploymentBucket}).TerminationFence(ctx, o, ref, now)
+}
+func validateScope(r Roster, scope Scope) error {
+ for _, name := range scope.Regions {
+ region, ok := r.Regions[name]
+ if !ok || region.Account == "" || region.AWSRegion == "" || !slices.Contains(region.Namespaces, scope.Namespace) {
+ return ErrRoster
+ }
+ for _, pool := range scope.Pools {
+ if !slices.Contains(region.Pools, pool) {
+ return ErrRoster
+ }
+ }
+ }
+ return nil
+}
+func verifyCompute(ctx context.Context, o Observed, e Evidence, id string, c Console, r Report) (Report, error) {
+ if r.Component != "console" || r.Actor != id || r.Enrollment != c.Enrollment || r.InstanceID != id || r.ProcessID == "" {
+ return Report{}, ErrRoster
+ }
+ if c.Compute == nil {
+ if r.Compute != nil {
+ return Report{}, ErrRoster
+ }
+ return Report{}, nil
+ }
+ binding := *c.Compute
+ box, ok := o.Roster.Boxes[binding.Box]
+ if !ok || binding.Enrollment != box.Enrollment || r.Compute == nil || *r.Compute != binding {
+ return Report{}, ErrRoster
+ }
+ br, err := e.BoxReport(ctx, binding.Box, box)
+ if err != nil {
+ return Report{}, err
+ }
+ if br.Component != "router" || br.Actor != binding.Box || br.Enrollment != box.Enrollment || br.InstanceID != box.Cloud.InstanceID || br.BootID == "" || br.BootID != r.BootID {
+ return Report{}, ErrRoster
+ }
+ matches := 0
+ for _, p := range br.ConsoleProcesses {
+ if p.ConsoleID != id {
+ continue
+ }
+ matches++
+ if p.Enrollment != c.Enrollment || p.ProcessID == "" || p.ProcessID != r.ProcessID || p.BinarySHA256 != r.BinarySHA256 {
+ return Report{}, ErrRoster
+ }
+ }
+ if matches != 1 {
+ return Report{}, ErrRoster
+ }
+ return br, nil
+}
+
+// CensusBoxRelocation is the only entry that grants L20's freshness exception.
+// The caller must be the local relocation controller; it cannot accept a member request.
+func (d Directory) CensusBoxRelocation(ctx context.Context, e Evidence, f hostfloor.Verified, scope Scope, now time.Time) (map[string]Report, error) {
+ if scope.StarterBox == "" {
+ return nil, ErrRoster
+ }
+ refs := append([]string{scope.StarterBox}, scope.Participants...)
+ revision, err := d.CheckBoxStart(ctx, refs...)
+ if err != nil {
+ return nil, err
+ }
+ o, err := d.Read(ctx)
+ if err != nil || o.Roster.Revision != revision {
+ return nil, ErrRoster
+ }
+ scope.boxRelocation = true
+ scope.ConsoleID = ""
+ scope.Participants = refs
+ return Census(ctx, o, e, d.PublicKey, f, scope, now)
+}
diff --git a/internal/hostactors/census_test.go b/internal/hostactors/census_test.go
new file mode 100644
index 000000000..dfa98acb8
--- /dev/null
+++ b/internal/hostactors/census_test.go
@@ -0,0 +1,103 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "strings"
+ "testing"
+ "time"
+)
+
+type reportsFixture struct {
+ d Directory
+ box, console Report
+ wire []byte
+}
+
+func (e reportsFixture) BoxReport(context.Context, string, Box) (Report, error) { return e.box, nil }
+func (e reportsFixture) ConsoleReport(context.Context, string, Console) (Report, error) {
+ return e.console, nil
+}
+func (e reportsFixture) LocalConsole(context.Context) (Report, error) { return e.console, nil }
+func (e reportsFixture) Identity(context.Context, string) ([]byte, error) { return e.wire, nil }
+func (e reportsFixture) Fence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) {
+ return e.d.TerminationFence(ctx, o, ref, now)
+}
+func censusFixture(t *testing.T) (Directory, Observed, reportsFixture, hostfloor.Verified, Scope, time.Time) {
+ t.Helper()
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ now := time.Now().UTC()
+ tag := "v1.2.3"
+ hash := strings.Repeat("a", 64)
+ rel := release.SystemRelease{ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: "bootstrap/console/build123/carlos-linux-amd64", SHA256: hash}}, Version: "build123", ReleaseTag: tag, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("build123", "linux-amd64"), SHA256: hash}}}
+ if _, err := release.PromoteSystem(ctx, d.Store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ wire, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := release.IdentityKey(tag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.CreateExact(ctx, d.Store, key, wire); err != nil {
+ t.Fatal(err)
+ }
+ floor := hostfloor.Floor{Min: 1, MinRelease: tag}
+ raw, _ := json.Marshal(floor)
+ f := hostfloor.Verified{Floor: floor, Digest: hostfloor.Digest(raw)}
+ o.Roster.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"default"}}}
+ o.Roster.Publication = "published"
+ o.Roster.FloorSHA256 = f.Digest
+ c := Console{Enrollment: strings.Repeat("c", 32), InstanceID: "console-1", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket}
+ o.Roster.Consoles["console-1"] = c
+ box := Report{Component: "router", GeneratedAt: now, Actor: "box", Enrollment: o.Roster.Boxes["box"].Enrollment, InstanceID: "i-box", Release: "build123", ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: f.Digest, FloorGuardReady: true, HostClaimEnabled: true}
+ console := box
+ console.Component = "console"
+ console.Actor = "console-1"
+ console.Enrollment = c.Enrollment
+ console.InstanceID = "console-1"
+ console.ProcessID = "process-1"
+ return d, o, reportsFixture{d: d, box: box, console: console, wire: wire}, f, Scope{Namespace: "platform", Regions: []string{"ie"}, Pools: []string{"default"}, ConsoleID: "console-1"}, now
+}
+func TestCensusRejectsMissingStaleAndWrongImages(t *testing.T) {
+ for _, field := range []string{"good", "old-box", "old-console", "future", "image", "floor", "flag", "enrollment", "scope", "unknown-participant", "unknown-region", "unknown-pool", "unknown-namespace"} {
+ t.Run(field, func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+ switch field {
+ case "old-box":
+ e.box.GeneratedAt = now.Add(-16 * time.Minute)
+ case "old-console":
+ e.console.GeneratedAt = now.Add(-16 * time.Minute)
+ case "future":
+ e.box.GeneratedAt = now.Add(6 * time.Second)
+ case "image":
+ e.box.BinarySHA256 = strings.Repeat("b", 64)
+ case "floor":
+ e.console.FloorSHA256 = ""
+ case "flag":
+ e.box.HostClaimEnabled = false
+ case "enrollment":
+ e.box.Enrollment = "another"
+ case "scope":
+ scope.Regions = nil
+ case "unknown-region":
+ scope.Regions = []string{"nowhere"}
+ case "unknown-pool":
+ scope.Pools = []string{"nowhere"}
+ case "unknown-namespace":
+ scope.Namespace = "unknown"
+ case "unknown-participant":
+ scope.Participants = []string{"unknown"}
+ }
+ _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now)
+ if (err == nil) != (field == "good") {
+ t.Fatal(field, err)
+ }
+ })
+ }
+}
diff --git a/internal/hostactors/enroll.go b/internal/hostactors/enroll.go
new file mode 100644
index 000000000..8db7fd244
--- /dev/null
+++ b/internal/hostactors/enroll.go
@@ -0,0 +1,106 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type EnrollmentRequest struct {
+ AttemptID string
+ ConfirmationID string
+ Reference string
+ Namespace string
+ Label string
+ Region string
+ Pool string
+ AWSRegion string
+ TofuInstanceID string
+ ExistingReference bool
+ Operator string
+}
+
+// Enroll uses operator-approved configuration, not values in the identity
+// request. H16/H17's worker confirmation producer belongs to region HA spec 4.
+func (d Directory) Enroll(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte) (Observed, error) {
+ return d.enroll(ctx, sg, o, v, in, raw, false)
+}
+func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, automatic bool) (Observed, error) {
+ if (!automatic && in.Operator == "") || in.AWSRegion == "" || in.Reference == "" {
+ return Observed{}, ErrRoster
+ }
+ referenced, err := d.Referenced(ctx, in.Reference)
+ if err != nil {
+ return Observed{}, err
+ }
+ in.ExistingReference = in.ExistingReference || referenced
+ if automatic && referenced {
+ return Observed{}, ErrRoster
+ }
+ var rec boxid.Record
+ if json.Unmarshal(raw, &rec) != nil || rec.V != 1 || rec.Box != in.Reference {
+ return Observed{}, ErrRoster
+ }
+ // Verify the IMDS signature without asking the legacy bind object to grant
+ // enrollment. The label is bound by this root-signed roster write.
+ var doc struct {
+ InstanceID string `json:"instanceId"`
+ }
+ decoded, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
+ if err != nil {
+ return Observed{}, err
+ }
+ if json.Unmarshal(decoded, &doc) != nil || doc.InstanceID == "" {
+ return Observed{}, ErrRoster
+ }
+ proof := rec
+ proof.Box = doc.InstanceID
+ identity, err := v.Verify(proof, raw, nil)
+ if err != nil {
+ return Observed{}, err
+ }
+ if identity.Region != in.AWSRegion || (in.ExistingReference && in.TofuInstanceID != identity.InstanceID) {
+ return Observed{}, ErrRoster
+ }
+ cloud := Cloud{"aws", identity.AccountID, identity.Region, identity.InstanceID}
+ if old, exists := o.Roster.Boxes[in.Reference]; exists {
+ if old.Cloud == cloud && old.EvidenceSHA256 == hostfloor.Digest(raw) && old.Namespace == in.Namespace && old.Region == in.Region && old.Pool == in.Pool {
+ return o, nil
+ }
+ return Observed{}, ErrRoster
+ }
+ enrollment, err := d.enrollmentNonce(ctx, in, raw)
+ if err != nil {
+ return Observed{}, err
+ }
+ r := Clone(o.Roster)
+ if automatic {
+ a, ok := r.EnrollmentAttempts[in.Reference]
+ if !ok || a.Consumed || a.AttemptID != in.AttemptID || a.ConfirmationID != in.ConfirmationID {
+ return Observed{}, ErrRoster
+ }
+ 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: hostfloor.Digest(raw), State: "active", LossIDs: []string{}}
+ return d.Replace(ctx, sg, o, r)
+}
+
+func (d Directory) EnrollAWS(ctx context.Context, sg release.Signer, o Observed, in EnrollmentRequest, raw []byte) (Observed, error) {
+ region, ok := o.Roster.Regions[in.Region]
+ if !ok || region.Account == "" || region.AWSRegion == "" {
+ return Observed{}, ErrRoster
+ }
+ if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil {
+ return Observed{}, err
+ }
+ certs, err := boxid.EmbeddedCerts()
+ if err != nil {
+ return Observed{}, err
+ }
+ in.AWSRegion = region.AWSRegion
+ return d.Enroll(ctx, sg, o, boxid.Verifier{Account: region.Account, Certs: certs}, in, raw)
+}
diff --git a/internal/hostactors/enroll_test.go b/internal/hostactors/enroll_test.go
new file mode 100644
index 000000000..6e16251ae
--- /dev/null
+++ b/internal/hostactors/enroll_test.go
@@ -0,0 +1,126 @@
+package hostactors
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/boxid"
+ "testing"
+)
+
+func TestEnrollmentUsesSignedCloudAndRefusesRebinding(t *testing.T) {
+ d, sg, o := fixture(t)
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cert := &x509.Certificate{PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}
+ document := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`)
+ sum := sha256.Sum256(document)
+ signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := boxid.Record{V: 1, Box: "new-label", IdentityDocument: base64.StdEncoding.EncodeToString(document), IdentitySignature: base64.StdEncoding.EncodeToString(signature)}
+ raw, _ := json.Marshal(rec)
+ v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": cert}}
+ in := EnrollmentRequest{Reference: rec.Box, Namespace: "platform", Label: rec.Box, Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator", ExistingReference: true, TofuInstanceID: "i-new"}
+ for _, wrong := range []string{"region", "tofu", "operator", "signature"} {
+ request := in
+ body := raw
+ switch wrong {
+ case "region":
+ request.AWSRegion = "us-east-1"
+ case "tofu":
+ request.TofuInstanceID = "i-other"
+ case "operator":
+ request.Operator = ""
+ case "signature":
+ bad := rec
+ bad.IdentitySignature = base64.StdEncoding.EncodeToString([]byte("wrong"))
+ body, _ = json.Marshal(bad)
+ }
+ if _, err = d.Enroll(context.Background(), sg, o, v, request, body); err == nil {
+ t.Fatal(wrong)
+ }
+ }
+ if _, err = d.Invite(context.Background(), sg, in.Reference, raw); err != nil {
+ t.Fatal(err)
+ }
+ next, err := d.Enroll(context.Background(), sg, o, v, in, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := next.Roster.Boxes[rec.Box]
+ if got.Cloud.InstanceID != "i-new" || got.BoxID != rec.Box || got.Enrollment == "" {
+ t.Fatal(got)
+ }
+ in.Reference = "box"
+ rec.Box = "box"
+ raw, _ = json.Marshal(rec)
+ if _, err = d.Enroll(context.Background(), sg, next, v, in, raw); err == nil {
+ t.Fatal("reference rebound")
+ }
+}
+
+func TestFleetInvitationBindsReferenceToEnrollment(t *testing.T) {
+ d, sg, o := fixture(t)
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cert := &x509.Certificate{PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}
+ document := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-fleet"}`)
+ sum := sha256.Sum256(document)
+ signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := boxid.Record{V: 1, IdentityDocument: base64.StdEncoding.EncodeToString(document), IdentitySignature: base64.StdEncoding.EncodeToString(signature)}
+ raw, _ := json.Marshal(rec)
+ invite, err := d.Invite(context.Background(), sg, "", raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if invite.Reference != "fleet-"+invite.Enrollment {
+ t.Fatalf("reference = %q, enrollment = %q", invite.Reference, invite.Enrollment)
+ }
+ rec.Box = invite.Reference
+ bound, _ := json.Marshal(rec)
+ retry, err := d.Invite(context.Background(), sg, invite.Reference, bound)
+ if err != nil || retry != invite {
+ t.Fatalf("retry = %+v, %v", retry, err)
+ }
+ if _, err := d.Invite(context.Background(), sg, "fleet-00000000000000000000000000000000", bound); err == nil {
+ t.Fatal("caller-chosen fleet reference accepted")
+ }
+ v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": cert}}
+ ordinary := rec
+ ordinary.Box = "ordinary-label"
+ ordinaryRaw, _ := json.Marshal(ordinary)
+ if _, err := d.Invite(context.Background(), sg, ordinary.Box, ordinaryRaw); err != nil {
+ t.Fatal(err)
+ }
+ badFleet := EnrollmentRequest{Reference: ordinary.Box, Namespace: "fleet:workers", Label: "fleet", Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator"}
+ if _, err := d.Enroll(context.Background(), sg, o, v, badFleet, ordinaryRaw); err == nil {
+ t.Fatal("fleet namespace accepted arbitrary reference")
+ }
+ in := EnrollmentRequest{Reference: invite.Reference, Namespace: "fleet:workers", Label: "fleet", Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator"}
+ next, err := d.Enroll(context.Background(), sg, o, v, in, bound)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := next.Roster.Boxes[invite.Reference]; got.Enrollment != invite.Enrollment || got.BoxID != invite.Reference {
+ t.Fatal(got)
+ }
+ rec.Box = "fleet-ffffffffffffffffffffffffffffffff"
+ mismatched, _ := json.Marshal(rec)
+ if _, err := d.Enroll(context.Background(), sg, o, v, in, mismatched); err == nil {
+ t.Fatal("mismatched fleet evidence accepted")
+ }
+}
diff --git a/internal/hostactors/enrollment_confirmation.go b/internal/hostactors/enrollment_confirmation.go
new file mode 100644
index 000000000..326d0bb66
--- /dev/null
+++ b/internal/hostactors/enrollment_confirmation.go
@@ -0,0 +1,157 @@
+package hostactors
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "time"
+)
+
+type Region struct {
+ Namespaces []string `json:"namespaces"`
+ Pools []string `json:"pools"`
+ Account string `json:"account"`
+ AWSRegion string `json:"aws_region"`
+ GroupName string `json:"group_name"`
+ GroupARN string `json:"group_arn"`
+}
+type TerminatedEnrollment struct {
+ PassID string `json:"pass_id"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ InstanceID string `json:"instance_id"`
+ State string `json:"state"`
+ ObservedAt time.Time `json:"observed_at"`
+ RequestID string `json:"request_id"`
+}
+type MembershipConfirmation struct {
+ PassID string `json:"pass_id"`
+ AttemptID string `json:"attempt_id"`
+ ConfirmationID string `json:"confirmation_id"`
+ EvidenceSHA256 string `json:"evidence_sha256"`
+
+ TargetPlacement string `json:"target_placement"`
+ AWSRegion string `json:"aws_region"`
+ GroupName string `json:"group_name"`
+ GroupARN string `json:"group_arn"`
+ InstanceID string `json:"instance_id"`
+ Account string `json:"account"`
+ AvailabilityZoneRegion string `json:"availability_zone_region"`
+ LifecycleState string `json:"lifecycle_state"`
+ InstanceState string `json:"instance_state"`
+ ObservedAt time.Time `json:"observed_at"`
+ RequestIDs []string `json:"request_ids"`
+ Confirmer struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ Region string `json:"region"`
+ } `json:"confirmer"`
+ TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"`
+}
+
+// Confirmers write this create-only evidence with read-only ASG/EC2 results.
+// This consumer gives H16/H17 their admission boundary; spec 4 owns producing it.
+func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, now time.Time) (Observed, error) {
+ if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil {
+ return Observed{}, err
+ }
+ region, ok := o.Roster.Regions[in.Region]
+ if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference {
+ return Observed{}, ErrRoster
+ }
+ attempt, ok := o.Roster.EnrollmentAttempts[in.Reference]
+ if !ok || attempt.Consumed || !token.MatchString(in.AttemptID) || !token.MatchString(in.ConfirmationID) || in.AttemptID == in.ConfirmationID || attempt.AttemptID != in.AttemptID || attempt.ConfirmationID != in.ConfirmationID || attempt.InstanceID != instance || attempt.EvidenceSHA256 != hostfloor.Digest(raw) || attempt.Region != in.Region || attempt.Account != region.Account || attempt.AWSRegion != region.AWSRegion || attempt.GroupARN != region.GroupARN || attempt.GroupName != region.GroupName {
+ return Observed{}, ErrRoster
+ }
+ body, _, err := d.Store.Get(ctx, ConfirmationKey(in.Reference, in.AttemptID, in.ConfirmationID))
+ if err != nil {
+ return Observed{}, err
+ }
+ var c MembershipConfirmation
+ if json.Unmarshal(body, &c) != nil || !segment(c.PassID) || c.AttemptID != attempt.AttemptID || c.ConfirmationID != attempt.ConfirmationID || c.EvidenceSHA256 != attempt.EvidenceSHA256 || c.TargetPlacement != in.Region || c.AWSRegion != region.AWSRegion || c.Account != region.Account || c.GroupARN != region.GroupARN || c.GroupName != region.GroupName || c.InstanceID != instance || c.AvailabilityZoneRegion != region.AWSRegion || (c.LifecycleState != "Pending" && c.LifecycleState != "InService") || (c.InstanceState != "pending" && c.InstanceState != "running") || !freshConfirmation(c.ObservedAt, now) || len(c.RequestIDs) < 2 {
+ return Observed{}, ErrRoster
+ }
+ for _, id := range c.RequestIDs {
+ if id == "" {
+ return Observed{}, ErrRoster
+ }
+ }
+ confirmer, ok := o.Roster.Boxes[c.Confirmer.Box]
+ if !ok || confirmer.State != "active" || confirmer.Enrollment != c.Confirmer.Enrollment || confirmer.Region != c.Confirmer.Region {
+ return Observed{}, ErrRoster
+ }
+ fenced, err := d.TerminationFence(ctx, o, c.Confirmer.Box, now)
+ if err != nil || fenced {
+ return Observed{}, ErrRoster
+ }
+ if confirmer.Region != in.Region {
+ seen := map[string]bool{}
+ for _, read := range c.TerminatedEnrollments {
+ b, ok := o.Roster.Boxes[read.Box]
+ if !ok || read.PassID != c.PassID || seen[read.Box] || b.Region != in.Region || b.Enrollment != read.Enrollment || b.Cloud.InstanceID != read.InstanceID || read.State != "terminated" || read.RequestID == "" || !freshConfirmation(read.ObservedAt, now) || read.ObservedAt.Before(c.ObservedAt.Add(-time.Minute)) || read.ObservedAt.After(c.ObservedAt) {
+ return Observed{}, ErrRoster
+ }
+ seen[read.Box] = true
+ }
+ for ref, b := range o.Roster.Boxes {
+ if b.Region == in.Region && (b.State == "active" || b.State == "quarantined") && !seen[ref] {
+ return Observed{}, ErrRoster
+ }
+ }
+ }
+ // Pin the verified IMDS identity to the instance that membership proved.
+ in.TofuInstanceID = instance
+ in.ExistingReference = true
+ return d.enroll(ctx, sg, o, v, in, raw, true)
+}
+func freshConfirmation(at, now time.Time) bool {
+ return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second))
+}
+
+type EnrollmentAttempt struct {
+ AttemptID string `json:"attempt_id"`
+ ConfirmationID string `json:"confirmation_id"`
+ InstanceID string `json:"instance_id"`
+ EvidenceSHA256 string `json:"evidence_sha256"`
+ Region string `json:"region"`
+ Account string `json:"account"`
+ AWSRegion string `json:"aws_region"`
+ GroupName string `json:"group_name"`
+ GroupARN string `json:"group_arn"`
+ Consumed bool `json:"consumed"`
+}
+
+func ConfirmationKey(ref, attempt, confirmation string) string {
+ return "control/box-enrollment/" + ref + "/" + attempt + "/" + confirmation + ".json"
+}
+
+// BeginEnrollmentAttempt supersedes evidence by CAS in the signed authority.
+// A retry after expiry obtains new IDs; old immutable confirmations remain.
+func (d Directory) BeginEnrollmentAttempt(ctx context.Context, sg release.Signer, o Observed, in EnrollmentRequest, evidence []byte, instance string) (Observed, EnrollmentAttempt, error) {
+ region, ok := o.Roster.Regions[in.Region]
+ if !ok || !segment(in.Reference) || !segment(instance) || region.Account == "" || region.AWSRegion == "" || region.GroupName == "" || region.GroupARN == "" {
+ return Observed{}, EnrollmentAttempt{}, ErrRoster
+ }
+ if _, exists := o.Roster.Boxes[in.Reference]; exists {
+ return Observed{}, EnrollmentAttempt{}, ErrRoster
+ }
+ var nonce [32]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return Observed{}, EnrollmentAttempt{}, err
+ }
+ a := EnrollmentAttempt{AttemptID: hex.EncodeToString(nonce[:16]), ConfirmationID: hex.EncodeToString(nonce[16:]), InstanceID: instance, EvidenceSHA256: hostfloor.Digest(evidence), Region: in.Region, Account: region.Account, AWSRegion: region.AWSRegion, GroupName: region.GroupName, GroupARN: region.GroupARN}
+ if a.AttemptID == a.ConfirmationID {
+ return Observed{}, EnrollmentAttempt{}, ErrRoster
+ }
+ next := Clone(o.Roster)
+ if next.EnrollmentAttempts == nil {
+ next.EnrollmentAttempts = map[string]EnrollmentAttempt{}
+ }
+ next.EnrollmentAttempts[in.Reference] = a
+ updated, err := d.Replace(ctx, sg, o, next)
+ return updated, a, err
+}
diff --git a/internal/hostactors/fence_authority.go b/internal/hostactors/fence_authority.go
new file mode 100644
index 000000000..685d0d218
--- /dev/null
+++ b/internal/hostactors/fence_authority.go
@@ -0,0 +1,51 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "time"
+)
+
+// TerminationFence reads authenticated worker-owned, create-only evidence.
+// Object access failures and invalid bindings are errors, never exclusions.
+func (d Directory) TerminationFence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) {
+ b, ok := o.Roster.Boxes[ref]
+ if !ok || !token.MatchString(b.Enrollment) || d.Store == nil {
+ return false, ErrRoster
+ }
+ raw, _, err := d.Store.Get(ctx, "control/box-fences/"+b.Enrollment+".json")
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ var f struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ Cloud Cloud `json:"cloud"`
+ State string `json:"state"`
+ ObservedAt time.Time `json:"observed_at"`
+ Via string `json:"via"`
+ RequestID string `json:"request_id"`
+ EventID string `json:"event_id"`
+ WrittenBy struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ } `json:"written_by"`
+ }
+ if json.Unmarshal(raw, &f) != nil || f.V != 1 || f.Box != ref || f.Enrollment != b.Enrollment || f.Cloud != b.Cloud || f.State != "terminated" || f.ObservedAt.IsZero() || f.ObservedAt.After(now.Add(5*time.Second)) {
+ return false, ErrRoster
+ }
+ if (f.Via != "describe" || f.RequestID == "") && (f.Via != "event" || f.EventID == "") {
+ return false, ErrRoster
+ }
+ writer, ok := o.Roster.Boxes[f.WrittenBy.Box]
+ if !ok || writer.Enrollment != f.WrittenBy.Enrollment || writer.Region != b.Region || writer.Cloud.Account != b.Cloud.Account || writer.Cloud.Region != b.Cloud.Region {
+ return false, ErrRoster
+ }
+ return true, nil
+}
diff --git a/internal/hostactors/invitation.go b/internal/hostactors/invitation.go
new file mode 100644
index 000000000..90aa21d6f
--- /dev/null
+++ b/internal/hostactors/invitation.go
@@ -0,0 +1,94 @@
+package hostactors
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type Invitation struct {
+ V int `json:"v"`
+ Bucket string `json:"bucket"`
+ Reference string `json:"reference"`
+ Enrollment string `json:"enrollment"`
+ EvidenceSHA256 string `json:"evidence_sha256"`
+}
+
+// Invite grants no membership. It gives an enrolled-identity candidate a
+// stable nonce with which to install its root guard and publish readiness.
+// A fleet caller passes an empty reference, then changes only boxid.Record.Box
+// to the returned Reference and marshals it again before publishing or Enroll.
+// That field is outside the AWS-signed identity document and signature.
+func (d Directory) Invite(ctx context.Context, sg release.Signer, reference string, evidence []byte) (Invitation, error) {
+ fleet := reference == ""
+ if !fleet && !segment(reference) {
+ return Invitation{}, ErrRoster
+ }
+ if !fleet {
+ key := "control/host-actor-invitations/" + reference + ".json"
+ raw, _, err := d.Store.Get(ctx, key)
+ if err == nil {
+ var invite Invitation
+ if release.VerifyRootEnvelope(d.PublicKey, raw, &invite) != nil || invite.V != 1 || invite.Bucket != d.Bucket || invite.Reference != reference || !token.MatchString(invite.Enrollment) || invite.EvidenceSHA256 != hostfloor.Digest(evidence) || (strings.HasPrefix(reference, "fleet-") && reference != "fleet-"+invite.Enrollment) {
+ return Invitation{}, ErrRoster
+ }
+ return invite, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return Invitation{}, err
+ }
+ // Fleet references are minted from their enrollment nonce. A caller may
+ // retry one returned by this function, but cannot choose a fresh one.
+ if strings.HasPrefix(reference, "fleet-") {
+ return Invitation{}, ErrRoster
+ }
+ }
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ return Invitation{}, err
+ }
+ enrollment := hex.EncodeToString(id[:])
+ if fleet {
+ reference = "fleet-" + enrollment
+ var rec boxid.Record
+ if json.Unmarshal(evidence, &rec) != nil || rec.V != 1 {
+ return Invitation{}, ErrRoster
+ }
+ rec.Box = reference
+ var err error
+ evidence, err = json.Marshal(rec)
+ if err != nil {
+ return Invitation{}, err
+ }
+ }
+ invite := Invitation{1, d.Bucket, reference, enrollment, hostfloor.Digest(evidence)}
+ raw, err := release.SignRootEnvelope(sg, invite)
+ if err != nil {
+ return Invitation{}, err
+ }
+ key := "control/host-actor-invitations/" + reference + ".json"
+ if _, err = d.Store.Put(ctx, key, raw, ""); err != nil {
+ return Invitation{}, err
+ }
+ return invite, nil
+}
+func (d Directory) enrollmentNonce(ctx context.Context, in EnrollmentRequest, evidence []byte) (string, error) {
+ key := "control/host-actor-invitations/" + in.Reference + ".json"
+ raw, _, err := d.Store.Get(ctx, key)
+ if err != nil {
+ return "", err
+ }
+ var invite Invitation
+ if release.VerifyRootEnvelope(d.PublicKey, raw, &invite) != nil || invite.V != 1 || invite.Bucket != d.Bucket || invite.Reference != in.Reference || !token.MatchString(invite.Enrollment) || ((strings.HasPrefix(in.Namespace, "fleet:") || strings.HasPrefix(in.Reference, "fleet-")) && in.Reference != "fleet-"+invite.Enrollment) || invite.EvidenceSHA256 != hostfloor.Digest(evidence) {
+ return "", ErrRoster
+ }
+ return invite.Enrollment, nil
+}
diff --git a/internal/hostactors/lifecycle.go b/internal/hostactors/lifecycle.go
new file mode 100644
index 000000000..7bf5c49ce
--- /dev/null
+++ b/internal/hostactors/lifecycle.go
@@ -0,0 +1,56 @@
+package hostactors
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "time"
+)
+
+func (d Directory) ReserveLifecycle(ctx context.Context, sg release.Signer, e Evidence, scope Scope, claim hostclaim.Observed, id, op string, now time.Time) (Observed, error) {
+ if scope.boxRelocation || scope.StarterBox != "" {
+ return Observed{}, ErrRoster
+ }
+ o, err := d.Read(ctx)
+ if err != nil {
+ return Observed{}, err
+ }
+ floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ if err != nil {
+ return Observed{}, err
+ }
+ reports, err := Census(ctx, o, e, d.PublicKey, floor, scope, now)
+ if err != nil {
+ return Observed{}, err
+ }
+ actors := map[string]string{}
+ for key, r := range reports {
+ actors[key] = r.Enrollment
+ }
+ return d.Reserve(ctx, sg, o, Reservation{ID: id, Host: claim.Host, OpID: op, ExpectedClaimETag: claim.ETag, FloorSHA256: floor.Digest, Actors: actors, Releases: reports}, false)
+}
+func (d Directory) ReleaseFinishedHold(ctx context.Context, sg release.Signer, id string) error {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ h, ok := o.Roster.Holds[id]
+ if !ok {
+ return nil
+ }
+ claim, err := hostclaim.Read(ctx, d.Store, h.Host)
+ if err != nil {
+ return err
+ }
+ if claim.Absent || claim.ETag == h.ExpectedClaimETag {
+ return ErrRoster
+ }
+ if !claim.Claim.Free() && claim.Claim.Op.Kind != hostclaim.KindServing {
+ return ErrRoster
+ }
+ next := Clone(o.Roster)
+ delete(next.Holds, id)
+ _, err = d.Replace(ctx, sg, o, next)
+ return err
+}
diff --git a/internal/hostactors/membership.go b/internal/hostactors/membership.go
new file mode 100644
index 000000000..8867ed132
--- /dev/null
+++ b/internal/hostactors/membership.go
@@ -0,0 +1,64 @@
+package hostactors
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "reflect"
+ "time"
+)
+
+func (d Directory) checkMembership(ctx context.Context, old, next Roster) error {
+ if old.Publication == "" {
+ return nil
+ }
+ if next.FloorSHA256 != old.FloorSHA256 || next.Publication == "" {
+ return ErrRoster
+ }
+ changed := !reflect.DeepEqual(old.Boxes, next.Boxes) || !reflect.DeepEqual(old.Consoles, next.Consoles) || !reflect.DeepEqual(old.Regions, next.Regions)
+ if old.Publication == "preparing" {
+ if changed {
+ return ErrRoster
+ }
+ return nil
+ }
+ if old.Publication != "published" || next.Publication != "published" {
+ return ErrRoster
+ }
+ if !changed {
+ return nil
+ }
+ f, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ if err != nil || f.Digest != old.FloorSHA256 {
+ return ErrRoster
+ }
+ e := ExactEvidence{PublicKey: d.PublicKey, Store: d.Store, Boxes: d.PrivateBoxReport}
+ for ref, b := range next.Boxes {
+ previous, exists := old.Boxes[ref]
+ if exists && !(previous.State != "active" && b.State == "active") {
+ continue
+ }
+ report, err := d.PrivateBoxReport(ctx, ref, b)
+ if err != nil {
+ return err
+ }
+ if err = VerifyReport(ctx, e, d.PublicKey, f, report, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil {
+ return err
+ }
+ }
+ for id, c := range next.Consoles {
+ if previous, exists := old.Consoles[id]; exists && reflect.DeepEqual(previous, c) {
+ continue
+ }
+ report, err := e.ConsoleReport(ctx, id, c)
+ if err != nil {
+ return err
+ }
+ if _, err := verifyCompute(ctx, Observed{Roster: next}, e, id, c, report); err != nil {
+ return err
+ }
+ if err = VerifyReport(ctx, e, d.PublicKey, f, report, c.Enrollment, id, time.Now()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/hostactors/private.go b/internal/hostactors/private.go
new file mode 100644
index 000000000..ed2e11ec5
--- /dev/null
+++ b/internal/hostactors/private.go
@@ -0,0 +1,29 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/status"
+ "strings"
+)
+
+func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) {
+ key := "status/boxes/" + ref + ".json"
+ if strings.HasPrefix(b.Namespace, "fleet:") {
+ fleet := strings.TrimPrefix(b.Namespace, "fleet:")
+ if !segment(fleet) || !segment(b.Label) {
+ return Report{}, ErrRoster
+ }
+ key = "fleets/" + fleet + "/status/boxes/" + b.Label + ".json"
+ }
+ raw, _, err := d.Store.Get(ctx, key)
+ if err != nil {
+ return Report{}, err
+ }
+ var box status.Box
+ if json.Unmarshal(raw, &box) != nil || box.Metrics == nil {
+ return Report{}, ErrRoster
+ }
+ m := box.Metrics
+ return Report{ConsoleProcesses: m.ConsoleProcesses, Component: "router", SystemChannel: m.SystemChannel, ActivationID: m.ActivationID, ActivationComplete: m.ActivationComplete, GeneratedAt: box.GeneratedAt, Actor: ref, Enrollment: m.Enrollment, InstanceID: m.InstanceID, Release: m.Release, ReleaseTag: m.ReleaseTag, Platform: m.Platform, BinarySHA256: m.BinarySHA256, MovesProtocol: m.MovesProtocol, BootID: m.BootID, FloorSHA256: m.FloorSHA256, FloorGuardReady: m.FloorGuardReady, HostClaimEnabled: m.HostClaimEnabled, RetirementReceiptSHA256: m.RetirementReceiptSHA256}, nil
+}
diff --git a/internal/hostactors/publication.go b/internal/hostactors/publication.go
new file mode 100644
index 000000000..0592c003e
--- /dev/null
+++ b/internal/hostactors/publication.go
@@ -0,0 +1,79 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "time"
+)
+
+// PublishFloor is called by the approved release workflow after the initial
+// deployment inventory has been reconciled and every guarded launch path
+// installed. It never discovers membership from reports.
+func (d Directory) PublishFloor(ctx context.Context, sg release.Signer, floor hostfloor.Floor, e Evidence, now time.Time) error {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ if len(o.Roster.Boxes) == 0 || len(o.Roster.Consoles) == 0 || len(o.Roster.Holds) != 0 || len(o.Roster.Activations) != 0 {
+ return ErrRoster
+ }
+ raw, err := json.Marshal(floor)
+ if err != nil {
+ return err
+ }
+ prospective := hostfloor.Verified{Floor: floor, Digest: hostfloor.Digest(raw)}
+ if o.Roster.Publication == "" {
+ next := Clone(o.Roster)
+ next.Publication = "preparing"
+ next.FloorSHA256 = prospective.Digest
+ o, err = d.Replace(ctx, sg, o, next)
+ if err != nil {
+ return err
+ }
+ } else if o.Roster.FloorSHA256 != prospective.Digest {
+ return ErrRoster
+ }
+ for ref, b := range o.Roster.Boxes {
+ // Initial publication requires every enrollment. A retained quarantine
+ // is resolved by the activation runbook rather than skipped here.
+ if b.State != "active" {
+ return ErrRoster
+ }
+ r, err := e.BoxReport(ctx, ref, b)
+ if err != nil {
+ return err
+ }
+ if err = VerifyReport(ctx, e, d.PublicKey, prospective, r, b.Enrollment, b.Cloud.InstanceID, now); err != nil {
+ return err
+ }
+ }
+ for id, c := range o.Roster.Consoles {
+ r, err := e.ConsoleReport(ctx, id, c)
+ if err != nil {
+ return err
+ }
+ if _, err := verifyCompute(ctx, o, e, id, c, r); err != nil {
+ return err
+ }
+ if err = VerifyReport(ctx, e, d.PublicKey, prospective, r, c.Enrollment, id, now); err != nil {
+ return err
+ }
+ }
+ // An inventory move during the scan is a failed publication attempt.
+ fresh, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ if fresh.ETag != o.ETag {
+ return ErrRoster
+ }
+ if err = hostfloor.Publish(ctx, d.Store, sg, d.Bucket, floor); err != nil {
+ return err
+ }
+ next := Clone(fresh.Roster)
+ next.Publication = "published"
+ _, err = d.Replace(ctx, sg, fresh, next)
+ return err
+}
diff --git a/internal/hostactors/references.go b/internal/hostactors/references.go
new file mode 100644
index 000000000..2bd440064
--- /dev/null
+++ b/internal/hostactors/references.go
@@ -0,0 +1,68 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "slices"
+ "strings"
+)
+
+func (d Directory) Referenced(ctx context.Context, reference string) (bool, error) {
+ keys, err := d.Store.List(ctx, "control/hosts/")
+ if err != nil {
+ return false, err
+ }
+ for _, key := range keys {
+ name := strings.TrimPrefix(key, "control/hosts/")
+ if strings.Contains(name, "/") {
+ continue
+ }
+ host, ok := strings.CutSuffix(name, ".json")
+ if !ok {
+ return false, ErrRoster
+ }
+ observed, err := hostclaim.Read(ctx, d.Store, host)
+ if err != nil {
+ return false, err
+ }
+ if observed.Absent {
+ return false, ErrRoster
+ }
+ c := observed.Claim
+ _, inventory := c.Inventory[reference]
+ if inventory || c.Op.Exec == reference || slices.Contains(c.Op.Participants, reference) || slices.Contains(c.Touched.Boxes, reference) || slices.Contains(c.Lost, reference) {
+ return true, nil
+ }
+ if c.Holder != nil {
+ if c.Holder.WriterBox == reference {
+ return true, nil
+ }
+ for _, a := range c.Holder.Admissions {
+ if a.Box == reference {
+ return true, nil
+ }
+ }
+ }
+ }
+ keys, err = d.Store.List(ctx, "control/edges/")
+ if err != nil {
+ return false, err
+ }
+ for _, key := range keys {
+ raw, _, err := d.Store.Get(ctx, key)
+ if err != nil {
+ return false, err
+ }
+ var edge struct {
+ BoxID string `json:"box_id"`
+ }
+ if json.Unmarshal(raw, &edge) != nil {
+ return false, ErrRoster
+ }
+ if edge.BoxID == reference || key == "control/edges/"+reference+".json" {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/internal/hostactors/reservations.go b/internal/hostactors/reservations.go
new file mode 100644
index 000000000..44b98e368
--- /dev/null
+++ b/internal/hostactors/reservations.go
@@ -0,0 +1,124 @@
+package hostactors
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "reflect"
+ "strings"
+ "time"
+)
+
+// Start attaches the reservation in the same claim CAS as the start intent.
+func (d Directory) Start(ctx context.Context, o hostclaim.Observed, id string, tr func(*hostclaim.Claim) error) (hostclaim.Observed, error) {
+ roster, err := d.Read(ctx)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ h, ok := roster.Roster.Holds[id]
+ if !ok || h.Host != o.Host || h.ExpectedClaimETag != o.ETag || roster.Roster.Publication != "published" || roster.Roster.FloorSHA256 != h.FloorSHA256 {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ if err != nil || floor.Digest != h.FloorSHA256 {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ for _, report := range h.Releases {
+ if report.GeneratedAt.IsZero() || time.Since(report.GeneratedAt) > 15*time.Minute || report.GeneratedAt.After(time.Now().Add(5*time.Second)) {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ }
+ // Reservations pin identity, but never preserve an actor's eligibility.
+ for actor, enrollment := range h.Actors {
+ ref, boxActor := strings.CutPrefix(actor, "box:")
+ if !boxActor {
+ id, consoleActor := strings.CutPrefix(actor, "console:")
+ console, exists := roster.Roster.Consoles[id]
+ if !consoleActor || !exists || console.Enrollment != enrollment {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ if console.Compute == nil {
+ continue
+ }
+ ref, enrollment = console.Compute.Box, console.Compute.Enrollment
+ }
+ box, exists := roster.Roster.Boxes[ref]
+ if !exists || box.Enrollment != enrollment || box.State != "active" {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ fenced, err := d.TerminationFence(ctx, roster, ref, time.Now())
+ if err != nil || fenced {
+ return hostclaim.Observed{}, ErrRoster
+ }
+ }
+ return hostclaim.Update(ctx, d.Store, o, func(c *hostclaim.Claim) error {
+ if err := tr(c); err != nil {
+ return err
+ }
+ if c.Op.ID != h.OpID || c.Op.Intent == nil {
+ return ErrRoster
+ }
+ c.Op.Intent["actors_hold"] = id
+ return nil
+ })
+}
+
+// CancelHold invalidates the claim ETag before releasing a never-started hold.
+// If a delayed start won its CAS, the reread leaves its reservation held.
+func (d Directory) CancelHold(ctx context.Context, sg release.Signer, id string) error {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ h, ok := o.Roster.Holds[id]
+ if !ok {
+ return nil
+ }
+ claim, err := hostclaim.Read(ctx, d.Store, h.Host)
+ if err != nil {
+ return err
+ }
+ if !claim.Absent && claim.Claim.Op.Intent["actors_hold"] == id {
+ return ErrRoster
+ }
+ if claim.ETag == h.ExpectedClaimETag {
+ _, err = hostclaim.Update(ctx, d.Store, claim, func(*hostclaim.Claim) error { return nil })
+ if err != nil {
+ return err
+ }
+ }
+ // Reread both records after the invalidating CAS. No timeout releases a hold.
+ fresh, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ if !reflect.DeepEqual(fresh.Roster.Holds[id], h) {
+ return ErrRoster
+ }
+ claim, err = hostclaim.Read(ctx, d.Store, h.Host)
+ if err != nil {
+ return err
+ }
+ if !claim.Absent && claim.Claim.Op.Intent["actors_hold"] == id {
+ return ErrRoster
+ }
+ r := Clone(fresh.Roster)
+ delete(r.Holds, id)
+ _, err = d.Replace(ctx, sg, fresh, r)
+ return err
+}
+func (d Directory) CheckHold(ctx context.Context, c *hostclaim.Claim, box, enrollment string) error {
+ if c == nil {
+ return ErrRoster
+ }
+ o, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ h, ok := o.Roster.Holds[c.Op.Intent["actors_hold"]]
+ if !ok || h.Host != c.Host || h.OpID != c.Op.ID || h.Actors["box:"+box] != enrollment {
+ return ErrRoster
+ }
+ return nil
+}
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go
new file mode 100644
index 000000000..e81f069ef
--- /dev/null
+++ b/internal/hostactors/roster.go
@@ -0,0 +1,291 @@
+package hostactors
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostquarantine"
+ "github.com/carlosframework/platform/internal/release"
+ "math"
+ "reflect"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+)
+
+const Key = "control/host-actors.json"
+
+var ErrRoster = errors.New("hostactors: roster authority refused")
+var token = regexp.MustCompile(`^[0-9a-f]{32}$`)
+
+type Cloud struct {
+ Provider string `json:"provider"`
+ Account string `json:"account"`
+ Region string `json:"region"`
+ InstanceID string `json:"instance_id"`
+}
+type Box struct {
+ Enrollment string `json:"enrollment"`
+ Namespace string `json:"namespace"`
+ BoxID string `json:"box_id"`
+ Label string `json:"label"`
+ Region string `json:"region"`
+ Pool string `json:"pool"`
+ Cloud Cloud `json:"cloud"`
+ EvidenceSHA256 string `json:"evidence_sha256"`
+ State string `json:"state"`
+ QuarantineRevision uint64 `json:"quarantine_revision"`
+ LossIDs []string `json:"loss_ids"`
+}
+type Console struct {
+ Compute *ComputeBinding `json:"compute,omitempty"`
+ Enrollment string `json:"enrollment"`
+ InstanceID string `json:"instance_id"`
+ Namespaces []string `json:"namespaces"`
+ DeploymentBucket string `json:"deployment_bucket"`
+}
+type Reservation struct {
+ Publication *release.SystemPublication `json:"publication,omitempty"`
+ RequestSHA256 string `json:"request_sha256,omitempty"`
+ UpdateMode string `json:"update_mode,omitempty"`
+ ID string `json:"id"`
+ Host string `json:"host"`
+ OpID string `json:"op_id"`
+ ExpectedClaimETag string `json:"expected_claim_etag"`
+ FloorSHA256 string `json:"floor_sha256"`
+ Actors map[string]string `json:"actors"`
+ Releases map[string]Report `json:"releases"`
+ Hashes map[string][]string `json:"hashes,omitempty"`
+}
+type Roster struct {
+ EnrollmentAttempts map[string]EnrollmentAttempt `json:"enrollment_attempts,omitempty"`
+ Publication string `json:"publication,omitempty"`
+ FloorSHA256 string `json:"floor_sha256,omitempty"`
+ Regions map[string]Region `json:"regions,omitempty"`
+ V int `json:"v"`
+ DeploymentBucket string `json:"deployment_bucket"`
+ Revision uint64 `json:"revision"`
+ Boxes map[string]Box `json:"boxes"`
+ Consoles map[string]Console `json:"consoles"`
+ Holds map[string]Reservation `json:"holds"`
+ Activations map[string]Reservation `json:"activations"`
+}
+type Observed struct {
+ Roster Roster
+ ETag string
+}
+type Directory struct {
+ Store release.Store
+ PublicKey ed25519.PublicKey
+ Bucket string
+}
+
+func segment(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
+}
+func (r Roster) Validate(bucket string) error {
+ if r.V != 1 || bucket == "" || r.DeploymentBucket != bucket || r.Revision == 0 || r.Boxes == nil || r.Consoles == nil || r.Holds == nil || r.Activations == nil {
+ return ErrRoster
+ }
+ if r.Publication != "" && r.Publication != "preparing" && r.Publication != "published" {
+ return ErrRoster
+ }
+ if (r.Publication == "") != (r.FloorSHA256 == "") {
+ return ErrRoster
+ }
+ ids := map[string]bool{}
+ clouds := map[Cloud]bool{}
+ for key, b := range r.Boxes {
+ if !segment(key) || b.BoxID != key || !token.MatchString(b.Enrollment) || ids[b.Enrollment] || b.Namespace == "" || b.Label == "" || b.Region == "" || b.Pool == "" || b.Cloud.Provider != "aws" || b.Cloud.Account == "" || b.Cloud.Region == "" || b.Cloud.InstanceID == "" || len(b.EvidenceSHA256) != 64 || clouds[b.Cloud] {
+ return ErrRoster
+ }
+ if b.State != "active" && b.State != "quarantined" && b.State != "retired" {
+ return ErrRoster
+ }
+ if b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) {
+ return ErrRoster
+ }
+ ids[b.Enrollment] = true
+ clouds[b.Cloud] = true
+ }
+ 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
+ }
+ if c.Compute != nil {
+ b, ok := r.Boxes[c.Compute.Box]
+ if !ok || b.Enrollment != c.Compute.Enrollment {
+ return ErrRoster
+ }
+ }
+ ids[c.Enrollment] = true
+ }
+ for _, reservations := range []map[string]Reservation{r.Holds, r.Activations} {
+ for id, h := range reservations {
+ if !segment(id) || h.ID != id || len(h.FloorSHA256) != 64 || len(h.Actors) == 0 || len(h.Releases) != len(h.Actors) {
+ return ErrRoster
+ }
+ if h.Publication != nil && (h.Publication.ID != id || len(h.RequestSHA256) != 64 || (h.UpdateMode != "promote" && h.UpdateMode != "rollback")) {
+ return ErrRoster
+ }
+ for key, enrollment := range h.Actors {
+ expected := ""
+ if ref, ok := strings.CutPrefix(key, "box:"); ok {
+ expected = r.Boxes[ref].Enrollment
+ }
+ if ref, ok := strings.CutPrefix(key, "console:"); ok {
+ expected = r.Consoles[ref].Enrollment
+ }
+ if expected != enrollment || !ids[enrollment] || h.Releases[key].Enrollment != enrollment {
+ return ErrRoster
+ }
+ }
+ }
+ }
+ for _, h := range r.Holds {
+ for _, a := range r.Activations {
+ if overlaps(h, a) {
+ return ErrRoster
+ }
+ }
+ }
+ return nil
+}
+func (d Directory) Read(ctx context.Context) (Observed, error) {
+ if d.Store == nil {
+ return Observed{}, ErrRoster
+ }
+ raw, etag, err := d.Store.Get(ctx, Key)
+ if err != nil {
+ return Observed{}, err
+ }
+ var r Roster
+ if etag == "" || release.VerifyRootEnvelope(d.PublicKey, raw, &r) != nil || r.Validate(d.Bucket) != nil {
+ return Observed{}, ErrRoster
+ }
+ return Observed{r, etag}, nil
+}
+func (d Directory) Lookup(ctx context.Context, box string) (hostquarantine.Actor, error) {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return hostquarantine.Actor{}, err
+ }
+ b, ok := o.Roster.Boxes[box]
+ if !ok {
+ return hostquarantine.Actor{}, ErrRoster
+ }
+ fenced, err := d.TerminationFence(ctx, o, box, time.Now())
+ if err != nil || fenced {
+ return hostquarantine.Actor{}, ErrRoster
+ }
+ return hostquarantine.Actor{Enrollment: b.Enrollment, State: b.State, QuarantineRevision: b.QuarantineRevision}, nil
+}
+func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observed, next Roster) (Observed, error) {
+ if before.ETag == "" || before.Roster.Revision == math.MaxUint64 {
+ return Observed{}, ErrRoster
+ }
+ if err := d.checkMembership(ctx, before.Roster, next); err != nil {
+ return Observed{}, err
+ }
+ next.Revision = before.Roster.Revision + 1
+ for ref, old := range before.Roster.Boxes {
+ b, ok := next.Boxes[ref]
+ if !ok {
+ return Observed{}, ErrRoster
+ }
+ a, z := old, b
+ a.State = ""
+ z.State = ""
+ a.QuarantineRevision = 0
+ z.QuarantineRevision = 0
+ a.LossIDs = nil
+ z.LossIDs = nil
+ if !reflect.DeepEqual(a, z) || b.QuarantineRevision < old.QuarantineRevision {
+ return Observed{}, ErrRoster
+ }
+ if old.State == "retired" && b.State != "retired" {
+ return Observed{}, ErrRoster
+ }
+ }
+ if next.Validate(d.Bucket) != nil {
+ return Observed{}, ErrRoster
+ }
+ wire, err := release.SignRootEnvelope(sg, next)
+ if err != nil {
+ return Observed{}, err
+ }
+ etag, err := d.Store.Put(ctx, Key, wire, before.ETag)
+ if err != nil {
+ return Observed{}, err
+ }
+ return Observed{next, etag}, nil
+}
+func Clone(r Roster) Roster {
+ b, _ := json.Marshal(r)
+ var out Roster
+ _ = json.Unmarshal(b, &out)
+ return out
+}
+func (d Directory) Quarantine(ctx context.Context, sg release.Signer, o Observed, box, enrollment, loss string) (Observed, error) {
+ r := Clone(o.Roster)
+ b, ok := r.Boxes[box]
+ if !ok || b.Enrollment != enrollment || b.State == "retired" || loss == "" || b.QuarantineRevision == math.MaxUint64 {
+ return Observed{}, ErrRoster
+ }
+ if slices.Contains(b.LossIDs, loss) {
+ return o, nil
+ }
+ b.State = "quarantined"
+ b.QuarantineRevision++
+ b.LossIDs = append(b.LossIDs, loss)
+ r.Boxes[box] = b
+ return d.Replace(ctx, sg, o, r)
+}
+func (d Directory) Resolve(ctx context.Context, name string) (string, Box, error) {
+ o, err := d.Read(ctx)
+ if err != nil {
+ return "", Box{}, err
+ }
+ if b, ok := o.Roster.Boxes[name]; ok {
+ return name, b, nil
+ }
+ var ref string
+ var result Box
+ for key, b := range o.Roster.Boxes {
+ if b.Label == name && b.State != "retired" {
+ if ref != "" {
+ return "", Box{}, ErrRoster
+ }
+ ref = key
+ result = b
+ }
+ }
+ if ref == "" {
+ return "", Box{}, ErrRoster
+ }
+ return ref, result, nil
+}
+func overlaps(a, b Reservation) bool {
+ for _, x := range a.Actors {
+ for _, y := range b.Actors {
+ if x == y {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (d Directory) Initialize(ctx context.Context, sg release.Signer) error {
+ r := Roster{V: 1, DeploymentBucket: d.Bucket, Revision: 1, Boxes: map[string]Box{}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}}
+ if err := r.Validate(d.Bucket); err != nil {
+ return err
+ }
+ wire, err := release.SignRootEnvelope(sg, r)
+ if err != nil {
+ return err
+ }
+ return release.CreateExact(ctx, d.Store, Key, wire)
+}
diff --git a/internal/hostactors/roster_test.go b/internal/hostactors/roster_test.go
new file mode 100644
index 000000000..b772f61a1
--- /dev/null
+++ b/internal/hostactors/roster_test.go
@@ -0,0 +1,78 @@
+package hostactors
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "github.com/carlosframework/platform/internal/release"
+ "strings"
+ "testing"
+)
+
+func fixture(t *testing.T) (Directory, release.Signer, Observed) {
+ t.Helper()
+ pub, key, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := release.NewDirStore(t.TempDir())
+ sg := release.RootSigner(key)
+ r := Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]Box{"box": {Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "box", Label: "label", Region: "ie", Pool: "default", Cloud: Cloud{"aws", "123", "eu-west-1", "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}}
+ raw, err := release.SignRootEnvelope(sg, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ etag, err := s.Put(context.Background(), Key, raw, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return Directory{s, pub, "bucket"}, sg, Observed{r, etag}
+}
+func TestDirectoryFreshQuarantineAndCAS(t *testing.T) {
+ ctx := context.Background()
+ d, sg, o := fixture(t)
+ first, err := d.Lookup(ctx, "box")
+ if err != nil || first.State != "active" {
+ t.Fatal(first, err)
+ }
+ changed, err := d.Quarantine(ctx, sg, o, "box", first.Enrollment, "loss-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := d.Lookup(ctx, "box")
+ if err != nil || got.State != "quarantined" || got.QuarantineRevision != 1 {
+ t.Fatal(got, err)
+ }
+ if _, err = d.Quarantine(ctx, sg, o, "box", first.Enrollment, "loss-2"); err == nil {
+ t.Fatal("stale CAS accepted")
+ }
+ next := Clone(changed.Roster)
+ b := next.Boxes["box"]
+ b.Cloud.InstanceID = "i-other"
+ next.Boxes["box"] = b
+ if _, err = d.Replace(ctx, sg, changed, next); err == nil {
+ t.Fatal("cloud binding changed")
+ }
+}
+func TestReservationsExcludeBothOrders(t *testing.T) {
+ ctx := context.Background()
+ for _, activationFirst := range []bool{true, false} {
+ d, sg, o := fixture(t)
+ en := o.Roster.Boxes["box"].Enrollment
+ h := Reservation{ID: "hold", Host: "app.example", OpID: "op", ExpectedClaimETag: "etag", FloorSHA256: strings.Repeat("f", 64), Actors: map[string]string{"box:box": en}, Releases: map[string]Report{"box:box": {Enrollment: en}}}
+ a := h
+ a.ID = "activation"
+ a.Hashes = map[string][]string{"box:box": {strings.Repeat("a", 64), strings.Repeat("b", 64), strings.Repeat("c", 64)}}
+ first, second := h, a
+ if activationFirst {
+ first, second = a, h
+ }
+ next, err := d.Reserve(ctx, sg, o, first, activationFirst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = d.Reserve(ctx, sg, next, second, !activationFirst); err == nil {
+ t.Fatal("overlap accepted")
+ }
+ }
+}
diff --git a/internal/status/console_process.go b/internal/status/console_process.go
new file mode 100644
index 000000000..5950af0f6
--- /dev/null
+++ b/internal/status/console_process.go
@@ -0,0 +1,12 @@
+package status
+
+// ConsoleProcess is emitted by the trusted local box reporter after measuring
+// the running process and checking all root-owned launch and replacement paths.
+type ConsoleProcess struct {
+ ConsoleID string `json:"console_id"`
+ Enrollment string `json:"enrollment"`
+ ProcessID string `json:"process_id"`
+ BinarySHA256 string `json:"binary_sha256"`
+ GuardFloorSHA256 string `json:"guard_floor_sha256"`
+ AllLaunchPathsGuarded bool `json:"all_launch_paths_guarded"`
+}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index e1b76eaeb..021ebafe8 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -39,6 +39,19 @@ import (
// Probes carries everything CollectBox may not read from the registry —
// each nil means "unknown", and unknown publishes as null, never a guess.
type Probes struct {
+ ConsoleProcesses []ConsoleProcess
+ ActivationID string
+ ActivationComplete bool
+ Enrollment string
+ MovesProtocol int
+ ReleaseTag string
+ BinarySHA256 string
+ BootID string
+ FloorSHA256 string
+ FloorGuardReady bool
+ HostClaimEnabled bool
+ RetirementReceiptSHA256 string
+
MachineID string // hashed into the label; never serialized
// InstanceID is the cloud machine this box runs on (status.InstanceID),
// or "" for hardware no cloud owns. It is the console architecture
@@ -211,6 +224,19 @@ type SystemState struct {
// publicly served fleet.json keeps the banded-words-only commitment. See the
// package doc comment for why this block is safe to exist at all.
type BoxMetrics struct {
+ ConsoleProcesses []ConsoleProcess `json:"console_processes,omitempty"`
+ ActivationID string `json:"activation_id,omitempty"`
+ ActivationComplete bool `json:"activation_complete,omitempty"`
+ Enrollment string `json:"enrollment,omitempty"`
+ MovesProtocol int `json:"moves_protocol,omitempty"`
+ ReleaseTag string `json:"release_tag,omitempty"`
+ BinarySHA256 string `json:"binary_sha256,omitempty"`
+ BootID string `json:"boot_id,omitempty"`
+ FloorSHA256 string `json:"floor_sha256,omitempty"`
+ FloorGuardReady bool `json:"floor_guard_ready,omitempty"`
+ HostClaimEnabled bool `json:"host_claim_enabled,omitempty"`
+ RetirementReceiptSHA256 string `json:"retirement_receipt_sha256,omitempty"`
+
MemTotalBytes *int64 `json:"mem_total_bytes,omitempty"`
MemAvailBytes *int64 `json:"mem_avail_bytes,omitempty"`
Load1 *float64 `json:"load1,omitempty"`
@@ -700,6 +726,18 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
len(routeDBBytes) > 0 || len(p.ConfigRefusals) > 0 || p.ConfigGuard != "" ||
p.Alerts != nil {
box.Metrics = &BoxMetrics{
+ ConsoleProcesses: p.ConsoleProcesses,
+ ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete,
+ Enrollment: p.Enrollment,
+ MovesProtocol: p.MovesProtocol,
+ ReleaseTag: p.ReleaseTag,
+ BinarySHA256: p.BinarySHA256,
+ BootID: p.BootID,
+ FloorSHA256: p.FloorSHA256,
+ FloorGuardReady: p.FloorGuardReady,
+ HostClaimEnabled: p.HostClaimEnabled,
+ RetirementReceiptSHA256: p.RetirementReceiptSHA256,
+
MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes,
Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes,
Release: p.Release, Tunnel: p.Tunnel,