carlos / platform Public

plan: claim Plan 8b rev 2, answers the review (one P1, six P2), aligned with claim rev 6.30

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 408bcb3e0a0a3c0d480611c87519a1eb82639427 parent 6df728c
1 file changed, +1543 −179
  • docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md +1543 −179

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md b/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md
index 5e3f6ab3c..81c0162ba 100644
--- a/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md
+++ b/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md
@@ -1,5 +1,7 @@
# Host lifecycle claim, Plan 8b: the floor, the roster and live fencing
+Rev 2 answers the review (one P1, six P2) and aligns with claim rev 6.30.
+
Implementation plan, 2026-10-06. Branch to implement: `feat/hostclaim-floor`,
from `feat/hostclaim-leases` at
`ca5a79d8a3821172f5829231014969f106bc33e9`. This document is the deliverable.
@@ -7,13 +9,13 @@ Do not implement it in the design worktree. Authoring this plan creates no
implementation branch, stages nothing and commits nothing.
The floor is the **first fully integrated tagged release**, as Paul ruled in
-claim rev 6.27 §1.1 P1. It includes the finished claim and moves integrations,
+claim rev 6.30 §1.1 P1. It includes the finished claim and moves integrations,
box identity, this census, and guarded boot and recovery. This branch is not
that release by itself. No release tag in a test is an activation choice.
## Sources and boundaries
-- Claim rev 6.27, in this worktree:
+- Claim rev 6.30, in this worktree:
`docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`.
§1.1 takes precedence over the older wording in §7.3 and §10.1. Their
surviving references to awaiting rulings are history, not a reason to keep
@@ -48,16 +50,21 @@ git show ca5a79d8a3821172f5829231014969f106bc33e9:internal/hostexec/controller.g
git show 0c8ad11baa87c35bfca264c65fabfc67b9b178a4:docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
```
-### What the unadopted region HA requests do not authorize
+### Adopted contracts and integration dependencies
+
+Claim rev 6.30 includes the L1-L20 amendments adopted in rev 6.28. Its
+normative sections govern this plan; the mapping is in
+`docs/superpowers/specs/CODEX-ANALYSIS-REGION-HA-L1-L20.md`. The remaining
+boundaries are implementation dependencies, not requests for new rulings.
-| Requests | Work here | Boundary until adopted |
+| Contracts | Work here | Required integration |
| --- | --- | --- |
-| L1, L9-L13, L16-L18 | Preserve existing claim kinds, admission provenance and owner checks. | No `relocating` operation, `released` coverage, files publisher, new drain protocol or relaxation of park-generation barriers. |
-| L2, L3, L4 | P2 permits a region worker to persist exact terminated evidence without the console. Implement the worker and a separate destroyed-local predicate. | No invented quarantine revision, operator attestation or root signature on worker evidence. L4's zero-revision claim representation and L3's operation-specific discharge CAS still need adoption. Existing owner transitions must consume the evidence under their amended contract before recovery is enabled. |
-| L5, L20 | Ordinary D7 census is complete and fresh; independent consoles are members. | No exclusion solely on a worker fence, no inferred console-to-box `compute` binding, and no stale-console exception in ordinary starts. H18's exception is only for future box-started relocation after L20, never a general freshness relaxation. |
-| L6 | Build H14's read-only activation check and both sides of the durable pending/update-begun interlock. | A future box-started owner records `directory_revision` and uses this interlock. It never gets write authority to the roster. No new box-started operation is introduced here. |
-| L7, L19 | P3 evidence-based roster enrollment; H16/H17 membership-confirmation consumer and readiness invitation. | Spec 4 owns the ASG confirmer/producer and its automatic enrollment loop. Cross-region confirmation must positively account for every active or quarantined target-region enrollment. It is not a termination fence. |
-| L8, L14, L15 | Preserve the once-recorded executor and the existing operation-owner seam. | Do not substitute a target, weaken moves' commit boundary, or implement source-loss recovery in this plan. |
+| L1, L9-L13, L16-L18 | Preserve the existing owner checks and expose the directory and census contracts. | Region HA's owner implements `relocating`, released coverage, drain, files publication and generation barriers. Its two-box model and rev 6.30's prepared-manifest, flip and validation milestones must pass before relocation is enabled. |
+| L2-L4 | Persist immutable enrollment-qualified termination evidence and return its complete reference. | Each operation owner applies D3's exact effects in a claim CAS that retains D4's automatic loss consequences, writes both journal citations, and preserves loss evidence until console materialization. The parent's operator-only loss validator must gain the automatic authority variant in that integration. No synthetic quarantine revision or operator attestation stands in for it. |
+| L5, L20 | Read fences directly for census and eligibility, validate console compute bindings, and implement the narrow box-started relocation freshness exception. | Root reporting must bind the actual console process, machine, boot and every replacement/bootstrap path. Independent or unbound consoles require fresh reports. A malformed newest report never permits fallback to an older report. |
+| L6 | Implement both sides of the durable pending/update-begun interlock and running-image revalidation before acknowledgement. | The regional relocation owner calls `CheckBoxStart`, records `directory_revision`, and checks reservations for the exact source and once-selected target. Every production controller uses the running-image verifier. |
+| L7, L19 | Consume fresh confirmations bound to distinct immutable attempt and confirmation IDs. | Region HA spec 4 produces these confirmations from verified ASG membership. Its cross-region pass positively accounts for every active or quarantined target-region enrollment. This grants enrollment only, never cross-region fencing. |
+| L8, L14, L15 | Preserve the once-recorded executor and expose evidence without choosing a replacement. | Moves and relocation owners retain their source-loss rules, exact recovery intent and record-commit CAS boundaries. |
H19 is operative for the evidence producer: a verified `terminated` read
alone is sufficient. Disk evidence must never gate fencing, census readiness,
@@ -101,7 +108,9 @@ nothing about disks.
and retain both sets of artifacts in the immutable snapshot.
7. Reports hash `/proc/self/exe`, not a pathname subsequently replaced on
disk. They include boot and enrollment identity. Every required private
- report is at most 15 minutes old and at most five seconds in the future.
+ report is at most 15 minutes old, except L20's verified guarded-compute
+ console exception for box-started relocation. All reports retain the
+ five-second future bound.
The local console process is checked directly too. Unsupported platforms
refuse protected image execution, but all packages must cross-compile.
8. Lifecycle and executable reservations are root-signed roster entries with
@@ -999,6 +1008,15 @@ go test ./internal/hostfloor -run 'TestFloorPair|TestPendingAndLost|TestCanonica
Build the exact-object ActorDirectory and enrollment protocol. Invite first, install readiness under that nonce, then admit. Production enrollment uses EnrollAWS and EmbeddedCerts; the Verifier argument on the lower-level method is a test seam. H16/H17 confirmation consumers take group/account/region configuration from the signed roster. Existing claims and edges are scanned completely for legacy references. Bootstrap Initialize creates no actors and cannot pass publication or a lifecycle census. Initial inventory reconciliation remains a required input to Paul's activation, not a heartbeat inference.
+
+The signed region configuration names its permitted namespaces and pools. Unknown placement values refuse census before actor selection. `CensusBoxRelocation` is the only entry point that enables L20: it checks starter and participant eligibility, reads the matching directory revision, verifies the local running router, then reads the latest exact console objects. `ReserveLifecycle` cannot opt into this exception.
+
+L5 uses the exact `control/box-fences/<enrollment>.json` object independently of roster state. The bucket policy admits writes only from the authenticated regional producer in Task 4; malformed, mismatched or unreadable authority refuses a start. Retained writer identity and regional scope are checked without requiring the historical writer to remain active. The producer checks current worker eligibility before writing. A console's optional signed `compute` binding must agree with the trusted box report's unique process attestation, actual executable digest and boot. A fenced starter or explicit participant is refused; other fenced actors are excluded. An independent console remains required.
+
+The private box reporter must populate `ConsoleProcesses` from trusted local process measurements, not from console-supplied claims. It verifies the actual executable plus every root-owned replacement and bootstrap path before setting `AllLaunchPathsGuarded` and `GuardFloorSHA256`. The console reporter binds `Compute` and `BootID` to that same enrolled machine. Enrollment/publication and census enforce these assertions; until the reporter integration supplies them, bound consoles fail closed. L20 relaxes only console report age, never future-time, floor, identity, process binding or guard checks.
+
+Automatic enrollment first calls `BeginEnrollmentAttempt`, then the authenticated spec-4 worker writes one create-only `ConfirmationKey(reference, attemptID, confirmationID)` object. Both IDs and the boxid digest are bound to the signed current attempt and configured group. Expiry requires a new attempt and confirmation identity. Admission marks that attempt consumed in the same roster CAS as the box enrollment; stale snapshots, superseded attempts and consumed replay cannot grant authority. Spec 4 must authenticate the worker and constrain evidence writes to the current assigned attempt. The original immutable evidence remains available for audit.
+
**Step 1: add the failing tests.**
### Create `internal/hostactors/census_test.go`
@@ -1017,6 +1035,7 @@ import (
)
type reportsFixture struct {
+ d Directory
box, console Report
wire []byte
}
@@ -1027,6 +1046,9 @@ func (e reportsFixture) ConsoleReport(context.Context, string, Console) (Report,
}
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()
@@ -1042,9 +1064,17 @@ func censusFixture(t *testing.T) (Directory, Observed, reportsFixture, hostfloor
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}
@@ -1056,10 +1086,10 @@ func censusFixture(t *testing.T) (Directory, Observed, reportsFixture, hostfloor
console.Enrollment = c.Enrollment
console.InstanceID = "console-1"
console.ProcessID = "process-1"
- return d, o, reportsFixture{box, console, wire}, f, Scope{Namespace: "platform", Regions: []string{"ie"}, Pools: []string{"default"}, ConsoleID: "console-1"}, now
+ 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"} {
+ 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 {
@@ -1079,6 +1109,12 @@ func TestCensusRejectsMissingStaleAndWrongImages(t *testing.T) {
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"}
}
@@ -1248,6 +1284,339 @@ func TestReservationsExcludeBothOrders(t *testing.T) {
}
```
+### Create `internal/hostactors/authority_regression_test.go`
+
+```go
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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")
+ }
+ })
+ }
+}
+```
+
+### Create `internal/hostactors/attempt_regression_test.go`
+
+```go
+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)
+ }
+}
+```
+
Run this now and expect failure before the implementation is present:
```sh
@@ -1283,7 +1652,7 @@ func (d Directory) ReleaseActivation(ctx context.Context, sg release.Signer, id
if err != nil {
return err
}
- evidence := ExactEvidence{Store: d.Store, Boxes: d.PrivateBoxReport}
+ 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 {
@@ -1323,32 +1692,45 @@ import (
"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 {
- 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"`
+ 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
@@ -1360,11 +1742,15 @@ type Evidence interface {
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() || 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 {
+ 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)
@@ -1384,9 +1770,12 @@ func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey,
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.ConsoleID == "" || e == nil {
+ 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 {
@@ -1399,6 +1788,16 @@ func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey,
}
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
@@ -1454,11 +1853,61 @@ func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey,
if r.Component != "console" || r.Actor != id || r.ProcessID == "" || !r.HostClaimEnabled {
return nil, ErrRoster
}
- if err = VerifyReport(ctx, e, pub, f, r, c.Enrollment, id, now); err != nil {
+ 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
@@ -1471,6 +1920,9 @@ func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey,
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
}
@@ -1525,11 +1977,18 @@ func (d Directory) CheckBoxStart(ctx context.Context, refs ...string) (uint64, e
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 {
@@ -1544,9 +2003,10 @@ func (d Directory) CheckBoxStart(ctx context.Context, refs ...string) (uint64, e
// ExactEvidence adapts the existing private status object without changing
// the public status shape. The supplied readers must use exact private keys.
type ExactEvidence struct {
- Store release.Store
- Boxes func(context.Context, string, Box) (Report, error)
- Self func(context.Context) (Report, error)
+ 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) {
@@ -1583,6 +2043,82 @@ func (e ExactEvidence) Identity(ctx context.Context, tag string) ([]byte, error)
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)
+}
```
### Create `internal/hostactors/enroll.go`
@@ -1600,6 +2136,8 @@ import (
)
type EnrollmentRequest struct {
+ AttemptID string
+ ConfirmationID string
Reference string
Namespace string
Label string
@@ -1665,6 +2203,14 @@ func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v
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)
}
@@ -1674,6 +2220,9 @@ func (d Directory) EnrollAWS(ctx context.Context, sg release.Signer, o Observed,
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
@@ -1690,19 +2239,25 @@ 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 {
- Account string `json:"account"`
- AWSRegion string `json:"aws_region"`
- GroupName string `json:"group_name"`
- GroupARN string `json:"group_arn"`
+ 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"`
@@ -1711,6 +2266,11 @@ type TerminatedEnrollment struct {
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"`
@@ -1733,16 +2293,23 @@ type MembershipConfirmation struct {
// 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
}
- body, _, err := d.Store.Get(ctx, "control/box-enrollment/"+in.Reference+"/"+instance+".json")
+ 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 || 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 {
+ 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 {
@@ -1754,11 +2321,15 @@ func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Obs
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 || 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)) {
+ 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
@@ -1777,9 +2348,53 @@ func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Obs
func freshConfirmation(at, now time.Time) bool {
return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second))
}
-```
-### Create `internal/hostactors/invitation.go`
+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
+}
+```
+
+### Create `internal/hostactors/invitation.go`
```go
package hostactors
@@ -1862,6 +2477,9 @@ import (
)
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
@@ -1942,7 +2560,7 @@ func (d Directory) checkMembership(ctx context.Context, old, next Roster) error
if err != nil || f.Digest != old.FloorSHA256 {
return ErrRoster
}
- e := ExactEvidence{Store: d.Store, Boxes: d.PrivateBoxReport}
+ 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") {
@@ -1964,6 +2582,9 @@ func (d Directory) checkMembership(ctx context.Context, old, next Roster) error
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
}
@@ -2002,7 +2623,7 @@ func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Rep
return Report{}, ErrRoster
}
m := box.Metrics
- return Report{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
+ 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
}
```
@@ -2065,6 +2686,9 @@ func (d Directory) PublishFloor(ctx context.Context, sg release.Signer, floor ho
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
}
@@ -2282,6 +2906,7 @@ import (
"regexp"
"slices"
"strings"
+ "time"
)
const Key = "control/host-actors.json"
@@ -2309,10 +2934,11 @@ type Box struct {
LossIDs []string `json:"loss_ids"`
}
type Console struct {
- Enrollment string `json:"enrollment"`
- InstanceID string `json:"instance_id"`
- Namespaces []string `json:"namespaces"`
- DeploymentBucket string `json:"deployment_bucket"`
+ 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 {
ID string `json:"id"`
@@ -2325,16 +2951,17 @@ type Reservation struct {
Hashes map[string][]string `json:"hashes,omitempty"`
}
type Roster struct {
- 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"`
+ 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
@@ -2378,6 +3005,12 @@ func (r Roster) Validate(bucket string) error {
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} {
@@ -2431,6 +3064,10 @@ func (d Directory) Lookup(ctx context.Context, box string) (hostquarantine.Actor
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) {
@@ -2542,12 +3179,86 @@ func (d Directory) Initialize(ctx context.Context, sg release.Signer) error {
}
```
+### Create `internal/hostactors/fence_authority.go`
+
+```go
+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
+}
+```
+
+### Create `internal/status/console_process.go`
+
+```go
+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"`
+}
+```
+
**Step 3: apply the exact existing-file patch.**
```diff
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
-@@ -41,0 +42,12 @@
+@@ -41,0 +42,13 @@
++ ConsoleProcesses []ConsoleProcess
+ ActivationID string
+ ActivationComplete bool
+ Enrollment string
@@ -2560,21 +3271,23 @@ func (d Directory) Initialize(ctx context.Context, sg release.Signer) error {
+ HostClaimEnabled bool
+ RetirementReceiptSHA256 string
+
-@@ -213,0 +226,12 @@
-+ 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"`
+@@ -213,0 +227,13 @@
++ 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"`
+
-@@ -702,0 +727,11 @@
-+ ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete,
+@@ -702,0 +729,12 @@
++ ConsoleProcesses: p.ConsoleProcesses,
++ ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete,
+ Enrollment: p.Enrollment,
+ MovesProtocol: p.MovesProtocol,
+ ReleaseTag: p.ReleaseTag,
@@ -2597,6 +3310,22 @@ go test ./internal/hostactors -count=1
Install the persistent checker explicitly before activation. Ordinary bootstrap does not install a missing policy as unpublished. Guard-arm holds the existing update lock, validates current and recovery bytes and rejects an outstanding journal before arming. The drop-ins are separate from the unit files copied or restored by system apply. Console deployments pass --console with a root-owned current image and its .prev; a console cannot report guard readiness without the matching persistent drop-in. System apply persists its signed activation snapshot, checks every selected image before running it and keeps rejected recovery evidence. CheckLocal and the existing controller lifetime/live-lock rules stay in force.
+The update lock owns recovery of both durable records. If activation was saved
+before the marker, or the marker survives without a journal, verify the signed
+transaction and selected current image and close that transaction. A journal
+requires its matching marker and verifies the selected recovery image before
+any executable action. Journal removal is checked and synced. Completion is
+written and synced before marker removal; recovery retries either write. Report
+completion only when both marker and journal are absent. A failed staging or
+no-op return runs the same no-journal cleanup before releasing the update lock.
+Missing policy and invalid authorization retain denial and recovery evidence.
+The explicit unpublished policy exempts both begin and completion.
+
+After persisting pending, controllers verify the executing inode against its
+signed identity and the current floor, then repeat that check before lease
+acknowledgement and publication. Reading only the replaceable binary path would
+incorrectly authorize an old process paused across a completed update.
+
**Step 1: add the failing tests.**
### Create `internal/host/guardtransaction_test.go`
@@ -2606,9 +3335,13 @@ package host
import (
"context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -2633,12 +3366,367 @@ func TestFloorRefusalKeepsRecoveryJournalAndExecutesNothing(t *testing.T) {
})
}
}
+
+func TestUnpublishedCompletionAndRecovery(t *testing.T) {
+ f := newApplyFixture(t, "stable", "old-build")
+ f.a.floor = &hostfloor.Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: f.pub}
+ raw, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "unpublished"})
+ if err := hostfloor.Durable(filepath.Join(f.a.floor.Dir, "policy.json"), raw, 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.beginGuard(t.Context(), nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.endGuard(); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.checkGuardRecovery(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(f.a.floor.Dir, "activation.json")); !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+}
+
+func guardRecoveryFixture(t *testing.T) (*applyFixture, string) {
+ t.Helper()
+ f := newApplyFixture(t, "stable", "old-build")
+ a := f.a
+ a.floor = &hostfloor.Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: f.pub}
+ a.guardOpen = os.Open // Isolate the state machine from root-owned path checks.
+ sg := release.RootSigner(f.priv)
+ floor, _ := json.Marshal(hostfloor.Floor{Min: 1, MinRelease: "v1.2.3"})
+ auth, err := release.SignRootEnvelope(sg, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: "bucket", Key: hostfloor.Key, SHA256: hostfloor.Digest(floor)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: floor, Authorization: auth})
+ if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "policy.json"), policy, 0644); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(systemBinaryPath(a.optDir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ hash := hostfloor.Digest(body)
+ rel := release.SystemRelease{Version: "old-build", ReleaseTag: "v1.2.3", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{a.platform: {Key: release.SystemArtifactKey("old-build", a.platform), SHA256: hash}}}
+ if _, err := release.PromoteSystem(t.Context(), f.store, sg, "router", a.channel, rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ identity, _, err := f.store.Get(t.Context(), release.SystemPointerKey("router", a.channel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(a.floor.Dir, "identities"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "identities", "v1.2.3.json"), identity, 0644); err != nil {
+ t.Fatal(err)
+ }
+ id := strings.Repeat("a", 32)
+ enrollment := strings.Repeat("b", 32)
+ box := hostactors.Box{Enrollment: enrollment, Namespace: "test", BoxID: "box", Label: "box", Region: "region", Pool: "pool", Cloud: hostactors.Cloud{Provider: "aws", Account: "account", Region: "region", InstanceID: "instance"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
+ h := hostactors.Reservation{ID: id, FloorSHA256: hostfloor.Digest(floor), Actors: map[string]string{"box:box": enrollment}, Releases: map[string]hostactors.Report{"box:box": {Enrollment: enrollment}}, Hashes: map[string][]string{"box:box": {hash, hash, hash}}}
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": box}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{id: h}}
+ if err := roster.Validate("bucket"); err != nil {
+ t.Fatal(err)
+ }
+ wire, err := release.SignRootEnvelope(sg, roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tx, _ := json.Marshal(guardTransaction{Roster: wire, ID: id, Actor: "box:box", Enrollment: enrollment})
+ if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "activation.json"), tx, 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(a.dataDir, ".hostexec"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ return f, id
+}
+func TestGuardRecoveryEveryTerminalCrashState(t *testing.T) {
+ for _, state := range []string{"prepared", "staging-failed", "journal-removed", "completion-written", "marker-removed"} {
+ t.Run(state, func(t *testing.T) {
+ f, id := guardRecoveryFixture(t)
+ a := f.a
+ marker := filepath.Join(a.floor.Dir, "update-begun.json")
+ completed := filepath.Join(a.floor.Dir, "completed.json")
+ if state != "prepared" {
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if state == "journal-removed" {
+ if err := a.journal(systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if state == "completion-written" || state == "marker-removed" {
+ if err := hostfloor.Durable(completed, []byte(id), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if state == "marker-removed" {
+ if err := hostfloor.RemoveDurable(marker); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal("replay", err)
+ }
+ raw, err := os.ReadFile(completed)
+ if err != nil || string(raw) != id {
+ t.Fatal("completion absent", err, string(raw))
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("marker stranded", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatal("recovery executed a binary", f.calls)
+ }
+ if err := a.floor.Begin("next", a.dataDir); err != nil {
+ t.Fatal("subsequent update blocked", err)
+ }
+ })
+ }
+}
+func TestGuardRecoveryRetainsMarkerOnInvalidImageOrMarker(t *testing.T) {
+ for _, bad := range []string{"image", "marker", "completion-io"} {
+ t.Run(bad, func(t *testing.T) {
+ f, id := guardRecoveryFixture(t)
+ a := f.a
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ marker := filepath.Join(a.floor.Dir, "update-begun.json")
+ switch bad {
+ case "image":
+ if err := os.WriteFile(systemBinaryPath(a.optDir), []byte("wrong executable"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ case "marker":
+ if err := os.WriteFile(marker, []byte(`{"id":"other"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ case "completion-io":
+ if err := os.Mkdir(filepath.Join(a.floor.Dir, "completed.json"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := a.Run(t.Context(), true); err == nil {
+ t.Fatal("unsafe recovery succeeded")
+ }
+ if _, err := os.Stat(marker); err != nil {
+ t.Fatal("denial erased", err)
+ }
+ if bad == "completion-io" {
+ if err := os.Remove(filepath.Join(a.floor.Dir, "completed.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal("retry failed", err)
+ }
+ }
+ })
+ }
+}
```
-Run this now and expect failure before the implementation is present:
+### Create `internal/hostexec/updateidentity_test.go`
+
+```go
+//go:build linux
+
+package hostexec
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostfloor"
+)
+
+func TestPendingRevalidatesRunningImageAfterCompletedUpdate(t *testing.T) {
+ cfg, _, _, store, _ := fixture(t)
+ cfg.UpdateGuardDir = t.TempDir()
+ cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json")
+ var oldImage atomic.Bool
+ oldImage.Store(true)
+ var checked atomic.Int32
+ cfg.verifyRunning = func(context.Context) error {
+ checked.Add(1)
+ if oldImage.Load() {
+ return hostfloor.ErrFloor
+ }
+ return nil
+ }
+ // Suspend precisely after pending persistence. Root finished its update by
+ // the time the controller resumes, so marker-only checking would acknowledge.
+ cfg.writeLocal = func(d *localDir, s State) error {
+ if err := d.write(s); err != nil {
+ return err
+ }
+ marker := filepath.Join(cfg.UpdateGuardDir, "update-begun.json")
+ if err := hostfloor.Durable(marker, []byte(`{"id":"update"}`), 0644); err != nil {
+ return err
+ }
+ if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "completed.json"), []byte("update"), 0644); err != nil {
+ return err
+ }
+ return hostfloor.RemoveDurable(marker)
+ }
+ c, err := Open(t.Context(), cfg)
+ if c != nil || !errors.Is(err, hostfloor.ErrFloor) {
+ t.Fatal("old executing image acknowledged", err)
+ }
+ if checked.Load() == 0 {
+ t.Fatal("running-image verifier skipped")
+ }
+ store.mu.Lock()
+ calls := store.calls
+ store.mu.Unlock()
+ if calls != 0 {
+ t.Fatal("acknowledged before identity check", calls)
+ }
+}
+
+func TestUpdateIdentityRecheckedBeforeLeaseAcknowledgement(t *testing.T) {
+ cfg, _, _, store, _ := fixture(t)
+ cfg.UpdateGuardDir = t.TempDir()
+ cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json")
+ var checked atomic.Int32
+ cfg.verifyRunning = func(context.Context) error {
+ if checked.Add(1) > 1 {
+ return hostfloor.ErrFloor
+ }
+ return nil
+ }
+ c, err := Open(t.Context(), cfg)
+ if c != nil || !errors.Is(err, hostfloor.ErrFloor) {
+ t.Fatal("identity change during cleanup accepted", err)
+ }
+ store.mu.Lock()
+ calls := store.calls
+ store.mu.Unlock()
+ if calls != 0 {
+ t.Fatal("lease written", calls)
+ }
+}
+func TestUpdateMarkerBlocksBeforeIdentityCheck(t *testing.T) {
+ cfg, _, _, _, _ := fixture(t)
+ cfg.UpdateGuardDir = t.TempDir()
+ cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json")
+ cfg.verifyRunning = func(context.Context) error { t.Fatal("verification ran despite update"); return nil }
+ for _, path := range []string{filepath.Join(cfg.UpdateGuardDir, "update-begun.json"), cfg.UpdateJournal} {
+ if err := os.WriteFile(path, []byte("present"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := cfg.checkUpdateIdentity(t.Context()); err == nil {
+ t.Fatal("update accepted")
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestRunningIdentityUsesExecutingImageAndCurrentFloor(t *testing.T) {
+ oldBuild := version.Build
+ version.Build = "v1.2.3"
+ t.Cleanup(func() { version.Build = oldBuild })
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "bucket")
+ cfg := Config{UpdateGuardDir: t.TempDir(), UpdateJournal: filepath.Join(t.TempDir(), "journal.json"), Store: release.NewDirStore(t.TempDir())}
+ sg := release.RootSigner(priv)
+ if err := os.Mkdir(filepath.Join(cfg.UpdateGuardDir, "identities"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(cfg.UpdateGuardDir, "carlos"), []byte("persistent checker"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeFloor := func(min string) {
+ floor, _ := json.Marshal(hostfloor.Floor{Min: 1, MinRelease: min})
+ auth, err := release.SignRootEnvelope(sg, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: "bucket", Key: hostfloor.Key, SHA256: hostfloor.Digest(floor)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: floor, Authorization: auth})
+ if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "policy.json"), policy, 0644); err != nil {
+ t.Fatal(err)
+ }
+ for key, wire := range map[string][]byte{hostfloor.Key: floor, hostfloor.AuthKey: auth} {
+ _, etag, _ := cfg.Store.Get(t.Context(), key)
+ if _, err := cfg.Store.Put(t.Context(), key, wire, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ writeIdentity := func(hash string) {
+ platform := runtime.GOOS + "-" + runtime.GOARCH
+ rel := release.SystemRelease{Version: version.Build, ReleaseTag: "v1.2.3", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version.Build, platform), SHA256: hash}}}
+ store := release.NewDirStore(t.TempDir())
+ if _, err := release.PromoteSystem(t.Context(), store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ wire, _, err := store.Get(t.Context(), release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "identities", "v1.2.3.json"), wire, 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ writeFloor("v1.2.3")
+ hash, err := hostfloor.ExecutingHash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeIdentity(hash)
+ if err := cfg.checkUpdateIdentity(t.Context()); err != nil {
+ t.Fatal("valid running image refused", err)
+ }
+ writeIdentity(hostfloor.Digest([]byte("replacement at current binary path")))
+ if err := cfg.checkUpdateIdentity(t.Context()); err == nil {
+ t.Fatal("replacement identity authorized stale executing inode")
+ }
+ writeIdentity(hash)
+ writeFloor("v1.2.4")
+ if err := cfg.checkUpdateIdentity(t.Context()); err == nil {
+ t.Fatal("resumed image below current floor authorized")
+ }
+}
+```
+
+Run these before Step 2. The missing-policy guard test fails before Task 3;
+the revision regressions also fail against rev 1: unpublished completion asks
+for activation.json, prepared/staging/journal-removed transactions lack
+completion, completed transactions retain their marker, and resumed old
+controllers acknowledge. Private file-open and verifier seams isolate the
+root-owned filesystem requirement and the pause schedule. The production-path
+test independently verifies real executing bytes and a raised signed floor.
```sh
-go test ./internal/host -run 'TestFloorRefusal|TestApply|TestRecovery' -count=1
+go test ./internal/host -run 'TestGuardRecovery|TestUnpublishedCompletion|TestFloorRefusal|TestApply|TestRecovery' -count=1
go test ./internal/hostexec -count=1
```
@@ -2692,7 +3780,11 @@ func (a *systemApplier) guardImage(path string) error {
if !a.guarded() {
return nil
}
- f, err := hostfloor.OpenProtected(path)
+ open := hostfloor.OpenProtected
+ if a.guardOpen != nil {
+ open = a.guardOpen
+ }
+ f, err := open(path)
if err != nil {
return err
}
@@ -2791,44 +3883,98 @@ func (a *systemApplier) beginGuard(ctx context.Context, target *release.SystemPo
}
return a.floor.Begin(tx.ID, a.dataDir)
}
+
+// checkGuardRecovery runs with the apply lock held. No-journal markers can
+// only precede the first destructive step or follow durable journal removal.
+// Both are terminal after the selected current image has been verified.
func (a *systemApplier) checkGuardRecovery() error {
if !a.guarded() {
return nil
}
p, _, err := a.floor.Load()
- if err != nil {
+ if err != nil || p.Mode == "unpublished" {
return err
}
- if p.Mode == "unpublished" {
- return nil
- }
j, err := readSystemJournal(a.optDir)
if err != nil {
return err
}
- if j == nil {
- return nil
+ marker := filepath.Join(a.floor.Dir, "update-begun.json")
+ raw, err := os.ReadFile(marker)
+ if errors.Is(err, os.ErrNotExist) && j == nil {
+ tx, txErr := a.guardTransaction()
+ if errors.Is(txErr, os.ErrNotExist) {
+ return nil
+ }
+ if txErr != nil {
+ return txErr
+ }
+ completed, readErr := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json"))
+ if readErr == nil && string(completed) == tx.ID {
+ return nil
+ }
+ if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
+ return readErr
+ }
+ return a.endGuard()
}
- if _, err = a.guardTransaction(); err != nil {
+ if err != nil {
return err
}
- if _, err = os.Stat(filepath.Join(a.floor.Dir, "update-begun.json")); err != nil {
+ tx, err := a.guardTransaction()
+ if err != nil {
return err
}
+ var begun struct {
+ ID string `json:"id"`
+ }
+ if json.Unmarshal(raw, &begun) != nil || begun.ID != tx.ID {
+ return hostfloor.ErrFloor
+ }
+ if j == nil {
+ return a.endGuard()
+ }
+ if j.Phase == systemPhaseBackingUp {
+ return a.guardImage(systemBinaryPath(a.optDir))
+ }
return a.guardImage(systemPrevPath(a.optDir))
}
func (a *systemApplier) endGuard() error {
if !a.guarded() {
return nil
}
+ p, _, err := a.floor.Load()
+ if err != nil || p.Mode == "unpublished" {
+ return err
+ }
tx, err := a.guardTransaction()
if err != nil {
return err
}
- if err = hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json")); err != nil {
+ if j, err := readSystemJournal(a.optDir); err != nil {
return err
+ } else if j != nil {
+ return hostfloor.ErrFloor
}
- return hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(tx.ID), 0644)
+ if err = a.guardImage(systemBinaryPath(a.optDir)); err != nil {
+ return err
+ }
+ // Completion is durable before the marker disappears. Replaying either
+ // write is harmless; readers never report completion with a marker present.
+ if err = hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(tx.ID), 0644); err != nil {
+ return err
+ }
+ return hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json"))
+}
+func (a *systemApplier) closeGuardJournal() error {
+ if !a.guarded() {
+ a.removeJournal()
+ return nil
+ }
+ if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil {
+ return err
+ }
+ return a.endGuard()
}
func (a *systemApplier) guardRun(ctx context.Context, wait time.Duration, name string, args ...string) (string, error) {
if !a.guarded() {
@@ -3215,7 +4361,7 @@ func claimReport(p *status.Probes) {
if err != nil {
return
}
- if completed, err := os.ReadFile(filepath.Join(d.Dir, "completed.json")); err == nil && len(completed) == 32 {
+ if completed, err := os.ReadFile(filepath.Join(d.Dir, "completed.json")); err == nil && len(completed) == 32 && hostfloor.UpdateIdle(d.Dir, filepath.Join(systemRootDir(release.OptDir()), "journal.json")) == nil {
p.ActivationID = string(completed)
p.ActivationComplete = true
}
@@ -3241,11 +4387,15 @@ func claimReport(p *status.Probes) {
+ }
+ if err := installFloorHook(a); err != nil {
+ log.Fatal(err)
-@@ -178 +182,2 @@
+@@ -178 +182,3 @@
- ops sysOps
-+ floor *hostfloor.Disk
-+ ops sysOps
-@@ -232 +237,9 @@
++ floor *hostfloor.Disk
++ guardOpen func(string) (*os.File, error)
++ ops sysOps
+@@ -206 +212 @@
+-func (a *systemApplier) Run(ctx context.Context, recover bool) error {
++func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) {
+@@ -232 +238,13 @@
- defer os.RemoveAll(a.stagingDir())
+ // Rejected floor recovery must retain staging and the journal.
+ if err := a.checkGuardRecovery(); err != nil {
@@ -3253,60 +4403,80 @@ func claimReport(p *status.Probes) {
+ }
+ defer func() {
+ if j, e := readSystemJournal(a.optDir); e == nil && j == nil {
++ if err := a.checkGuardRecovery(); err != nil {
++ retErr = errors.Join(retErr, err)
++ return
++ }
+ _ = os.RemoveAll(a.stagingDir())
+ }
+ }()
-@@ -286,0 +300,3 @@
+@@ -286,0 +305,3 @@
+ }
+ if err := a.beginGuard(ctx, p); err != nil {
+ return err
-@@ -330,0 +347,3 @@
+@@ -330,0 +352,3 @@
+ if err := a.guardImage(staged); err != nil {
+ return err
+ }
-@@ -420 +439 @@
+@@ -419,2 +443 @@
+- a.removeJournal()
- return nil
-+ return a.endGuard()
-@@ -450,0 +470,3 @@
++ return a.closeGuardJournal()
+@@ -450,0 +474,3 @@
+ if err := a.guardImage(systemBinaryPath(a.optDir)); err != nil {
+ return false, err
+ }
-@@ -458,0 +481,5 @@
+@@ -458,0 +485,5 @@
+ if prevErr == nil {
+ if err := a.guardImage(prev); err != nil {
+ return false, err
+ }
+ }
-@@ -468 +495 @@
+@@ -468 +499 @@
- out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version")
+ out, err := a.guardRun(ctx, systemVersionWait, systemPrevPath(a.optDir), "version")
-@@ -484 +511 @@
+@@ -484 +515 @@
- out, err := a.ops.run(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json")
+ out, err := a.guardRun(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json")
-@@ -797,0 +825,3 @@
-+ if err := a.endGuard(); err != nil {
+@@ -788 +819,3 @@
+- a.removeJournal()
++ if closeErr := a.closeGuardJournal(); closeErr != nil {
++ return errors.Join(err, closeErr)
++ }
+@@ -797 +830,3 @@
+- a.removeJournal()
++ if err := a.closeGuardJournal(); err != nil {
+ return err
+ }
-@@ -808,0 +839,3 @@
+@@ -808,0 +844,3 @@
+ if err := a.guardImage(prev); err != nil {
+ return bootstrapPlan{}, err
+ }
-@@ -967 +1000 @@
+@@ -924,2 +962 @@
+- a.removeJournal()
+- return false, nil
++ return false, a.closeGuardJournal()
+@@ -966,2 +1003 @@
+- a.removeJournal()
- return true, nil
-+ return true, a.endGuard()
-@@ -1048 +1081 @@
++ return true, a.closeGuardJournal()
+@@ -1048 +1084 @@
- out, err := a.ops.run(ctx, systemVersionWait, systemBinaryPath(a.optDir), "version")
+ out, err := a.guardRun(ctx, systemVersionWait, systemBinaryPath(a.optDir), "version")
-@@ -1113 +1146 @@
+@@ -1113 +1149 @@
- out, err := a.ops.run(ctx, systemVersionWait, staged, "version")
+ out, err := a.guardRun(ctx, systemVersionWait, staged, "version")
--- a/internal/hostexec/controller.go
+++ b/internal/hostexec/controller.go
-@@ -14,0 +15 @@
+@@ -8,0 +9 @@
++ "github.com/carlosframework/platform/internal/version"
+@@ -9,0 +11 @@
++ "runtime"
+@@ -14,0 +17 @@
+ "github.com/carlosframework/platform/internal/hostfloor"
-@@ -17,0 +19 @@
+@@ -17,0 +21 @@
+ "path/filepath"
-@@ -21,6 +23,8 @@
+@@ -21,6 +25,9 @@
- Binding Binding
- Store release.Store
- Guard hostguard.Guard
@@ -3320,18 +4490,80 @@ func claimReport(p *status.Probes) {
+ Clock Clock
+ UpdateGuardDir string
+ UpdateJournal string
++ verifyRunning func(context.Context) error
+ LockDir string
-@@ -64,0 +69,4 @@
+@@ -64,0 +72,4 @@
+ if cfg.UpdateGuardDir == "" {
+ cfg.UpdateGuardDir = "/opt/carlos/system/guard"
+ cfg.UpdateJournal = filepath.Join("/opt/carlos/system/router", "journal.json")
+ }
-@@ -108,0 +117,5 @@
-+ }
+@@ -109,0 +121,5 @@
+ if cfg.UpdateGuardDir != "" {
-+ if err := hostfloor.UpdateIdle(cfg.UpdateGuardDir, cfg.UpdateJournal); err != nil {
++ if err := cfg.checkUpdateIdentity(ctx); err != nil {
+ return err
+ }
++ }
+@@ -182,0 +199,6 @@
++ if err := c.cfg.checkUpdateIdentity(ctx); err != nil {
++ return err
++ }
++ if err := c.cfg.checkRemoteUpdateIdentity(ctx); err != nil {
++ return err
++ }
+@@ -626,0 +649,53 @@
++
++// checkUpdateIdentity reads the executing inode, never the replaceable current
++// path. Run it after pending is durable and before each acknowledgement.
++func (cfg Config) checkUpdateIdentity(ctx context.Context) error {
++ if cfg.UpdateGuardDir == "" {
++ return nil
++ }
++ if err := hostfloor.UpdateIdle(cfg.UpdateGuardDir, cfg.UpdateJournal); err != nil {
++ return err
++ }
++ if cfg.verifyRunning != nil {
++ return cfg.verifyRunning(ctx)
++ }
++ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
++ if err != nil {
++ return err
++ }
++ d := hostfloor.Disk{Dir: cfg.UpdateGuardDir, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), PublicKey: pub}
++ running, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH)
++ if err != nil || !running.GuardReady {
++ if err != nil {
++ return err
++ }
++ return hostfloor.ErrFloor
++ }
++ _, floor, err := d.Load()
++ if err != nil || running.FloorSHA256 != floor.Digest {
++ return hostfloor.ErrFloor
++ }
++ return hostfloor.UpdateIdle(cfg.UpdateGuardDir, cfg.UpdateJournal)
++}
++
++// authority calls this outside the controller mutex and host guard. Recheck
++// the executing identity against the exact local digest compared remotely.
++func (cfg Config) checkRemoteUpdateIdentity(ctx context.Context) error {
++ if cfg.UpdateGuardDir == "" || cfg.verifyRunning != nil {
++ return nil
++ }
++ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
++ if err != nil {
++ return err
++ }
++ d := hostfloor.Disk{Dir: cfg.UpdateGuardDir, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), PublicKey: pub}
++ running, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH)
++ if err != nil {
++ return err
++ }
++ _, floor, err := d.Load()
++ if err != nil || !running.GuardReady || running.FloorSHA256 != floor.Digest {
++ return hostfloor.ErrFloor
++ }
++ return hostfloor.CheckRemote(ctx, cfg.Store, pub, d.Bucket, floor)
++}
--- a/system.go
+++ b/system.go
@@ -50 +50 @@
@@ -3348,13 +4580,13 @@ func claimReport(p *status.Probes) {
**Step 4: verify the task.**
```sh
-go test ./internal/host -run 'TestFloorRefusal|TestApply|TestRecovery' -count=1
+go test ./internal/host -run 'TestGuardRecovery|TestUnpublishedCompletion|TestFloorRefusal|TestApply|TestRecovery' -count=1
go test ./internal/hostexec -count=1
```
## Task 4: A live regional termination-evidence producer
-The box-side pass, behind CARLOS_HOST_CLAIM, uses read-only EC2 and the roster's cloud binding. It polls in-region and creates immutable worker evidence. It never calls the console for a signature and never stops or terminates compute. An EventBridge consumer can use Event only after validating the configured queue/rule provenance; region HA spec 3 owns that queue loop. Activity-history evidence stays unsupported. The complete running-time volume history is an input only to DestroyedLocal; the fence producer does not call it. Until L3/L4's claim representation is adopted, this task writes the verified fence but does not fabricate a claim loss record or discharge a chore.
+The box-side pass, behind CARLOS_HOST_CLAIM, uses read-only EC2 and the roster's cloud binding. It polls in-region and creates immutable worker evidence. It never calls the console for a signature and never stops or terminates compute. An EventBridge consumer can use Event only after validating the configured queue/rule provenance; region HA spec 3 owns that queue loop. Activity-history evidence stays unsupported. The complete running-time volume history is an input only to DestroyedLocal; the fence producer does not call it. The adopted D2-D4 contract requires the owning claim integration described below. This task supplies the evidence; the operation owner must persist its exact consequences and journal citations before effects. The pinned parent's operator-only loss validator is not an automatic-loss implementation.
**Step 1: add the failing tests.**
@@ -3374,6 +4606,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/carlosframework/platform/internal/awsinv"
"github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/release"
"strings"
"testing"
@@ -3476,6 +4709,58 @@ func TestDestroyedLocalIsIndependentAndNeedsHistory(t *testing.T) {
}
}
+func TestFencedWorkerCannotProduceNewEvidence(t *testing.T) {
+ w, s := terminationFixture(t)
+ o, err := w.Directory.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ self := o.Roster.Boxes["worker"]
+ dead := o.Roster.Boxes["dead"]
+ fence := Termination{V: 1, Box: "worker", Enrollment: self.Enrollment, Cloud: self.Cloud, State: "terminated", ObservedAt: w.Now(), Via: "describe", RequestID: "request", WrittenBy: Worker{Box: "dead", Enrollment: dead.Enrollment}}
+ raw, _ := json.Marshal(fence)
+ if _, err := s.Put(context.Background(), FenceKey(self.Enrollment), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ w.Reader = func(context.Context, string) (awsinv.ExactEC2API, error) {
+ return terminatedEC2{state: types.InstanceStateNameTerminated, owner: "123"}, nil
+ }
+ if _, err := w.Describe(context.Background(), "dead"); err == nil {
+ t.Fatal("fenced worker produced new execution authority")
+ }
+ keys, err := s.List(context.Background(), "control/box-fences/")
+ if err != nil || len(keys) != 1 {
+ t.Fatal("refused worker wrote another fence", keys, err)
+ }
+}
+
+func TestTerminationReferenceIncludesExactEnrollmentAndBytes(t *testing.T) {
+ w, s := terminationFixture(t)
+ w.Reader = func(context.Context, string) (awsinv.ExactEC2API, error) {
+ return terminatedEC2{state: types.InstanceStateNameTerminated, owner: "123"}, nil
+ }
+ ref, err := w.Describe(context.Background(), "dead")
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(context.Background(), ref.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fence Termination
+ if json.Unmarshal(raw, &fence) != nil || ref.Enrollment != fence.Enrollment || ref.Key != FenceKey(fence.Enrollment) || ref.SHA256 != hostfloor.Digest(raw) {
+ t.Fatal("reference is not bound to the verified enrollment and bytes", ref)
+ }
+ wire, err := json.Marshal(ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]string
+ if json.Unmarshal(wire, &fields) != nil || len(fields) != 3 || fields["enrollment"] != fence.Enrollment || fields["key"] != ref.Key || fields["sha256"] != ref.SHA256 {
+ t.Fatal("journal reference lacks the adopted D2 wire contract", string(wire))
+ }
+}
+
func TestTerminationFenceTimeIsAfterTheVerifiedRead(t *testing.T) {
w, s := terminationFixture(t)
start := w.Now()
@@ -3542,8 +4827,9 @@ type Termination struct {
WrittenBy Worker `json:"written_by"`
}
type EvidenceRef struct {
- Key string `json:"key"`
- SHA256 string `json:"sha256"`
+ Enrollment string `json:"enrollment"`
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
}
type TerminationWorker struct {
Enabled bool
@@ -3566,6 +4852,10 @@ func (w TerminationWorker) binding(ctx context.Context, box string) (hostactors.
if !ok || self.State != "active" || self.Enrollment != w.Self.Enrollment {
return hostactors.Box{}, ErrObservation
}
+ eligible, err := w.Directory.Lookup(ctx, w.Self.Box)
+ if err != nil || eligible.State != "active" || eligible.Enrollment != w.Self.Enrollment {
+ return hostactors.Box{}, ErrObservation
+ }
target, ok := o.Roster.Boxes[box]
if !ok || target.Region != self.Region || target.Cloud.Region != self.Cloud.Region || target.Cloud.Account != self.Cloud.Account {
return hostactors.Box{}, ErrObservation
@@ -3638,7 +4928,7 @@ func (w TerminationWorker) persist(ctx context.Context, f Termination) (Evidence
}
raw = saved
}
- return EvidenceRef{key, hostfloor.Digest(raw)}, nil
+ return EvidenceRef{Enrollment: f.Enrollment, Key: key, SHA256: hostfloor.Digest(raw)}, nil
}
type BlockDevice struct {
@@ -4195,7 +5485,7 @@ func (s Service) Readmit(ctx context.Context, box, enrollment string, revision u
if err != nil {
return err
}
- evidence := hostactors.ExactEvidence{Store: s.Directory.Store, Boxes: s.Directory.PrivateBoxReport}
+ evidence := hostactors.ExactEvidence{PublicKey: s.Directory.PublicKey, Store: s.Directory.Store, Boxes: s.Directory.PrivateBoxReport}
if err = hostactors.VerifyReport(ctx, evidence, s.Directory.PublicKey, floor, report, enrollment, b.Cloud.InstanceID, s.Now()); err != nil {
return err
}
@@ -4319,12 +5609,19 @@ func (s *Server) localHostReport(ctx context.Context) (hostactors.Report, error)
if !ok {
return hostactors.Report{}, hostactors.ErrRoster
}
+ if c.Compute != nil {
+ local, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json")
+ box, exists := roster.Roster.Boxes[c.Compute.Box]
+ if err != nil || !exists || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment || box.Enrollment != local.Enrollment || local.InstanceID != box.Cloud.InstanceID {
+ return hostactors.Report{}, hostactors.ErrRoster
+ }
+ }
disk := hostfloor.Disk{Dir: filepath.Join(release.OptDir(), "system", "guard"), Bucket: s.cfg.Bucket, PublicKey: s.cfg.ReleasePub}
running, err := disk.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH, "console")
if err != nil {
return hostactors.Report{}, err
}
- return hostactors.Report{Component: "console", GeneratedAt: time.Now().UTC(), Actor: id, Enrollment: c.Enrollment, InstanceID: id, ProcessID: fmt.Sprintf("%s:%d", running.BootID, os.Getpid()), Release: version.Build, ReleaseTag: running.ReleaseTag, Platform: runtime.GOOS + "-" + runtime.GOARCH, BinarySHA256: running.Hash, MovesProtocol: running.Protocol, BootID: running.BootID, FloorSHA256: running.FloorSHA256, FloorGuardReady: running.GuardReady, HostClaimEnabled: s.cfg.HostClaim}, nil
+ return hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: time.Now().UTC(), Actor: id, Enrollment: c.Enrollment, InstanceID: id, ProcessID: fmt.Sprintf("%s:%d", running.BootID, os.Getpid()), Release: version.Build, ReleaseTag: running.ReleaseTag, Platform: runtime.GOOS + "-" + runtime.GOARCH, BinarySHA256: running.Hash, MovesProtocol: running.Protocol, BootID: running.BootID, FloorSHA256: running.FloorSHA256, FloorGuardReady: running.GuardReady, HostClaimEnabled: s.cfg.HostClaim}, nil
}
func (s *Server) StartHostReports(ctx context.Context) {
// Reporting before the flag is enabled is harmless and needed for arming.
@@ -4458,7 +5755,7 @@ func (s *Server) reserveHostUpdate(ctx context.Context, rel *release.SystemRelea
}
id := hex.EncodeToString(nonce[:])
h := hostactors.Reservation{ID: id, FloorSHA256: floor.Digest, Actors: map[string]string{}, Releases: map[string]hostactors.Report{}, Hashes: map[string][]string{}}
- e := hostactors.ExactEvidence{Store: s.cfg.Releases, Boxes: d.PrivateBoxReport}
+ e := hostactors.ExactEvidence{PublicKey: d.PublicKey, Store: s.cfg.Releases, Boxes: d.PrivateBoxReport}
for ref, b := range o.Roster.Boxes {
if b.State != "active" {
continue
@@ -4771,7 +6068,7 @@ the floor tag.
`ReleaseFinishedHold` only after the operation ends. It must never fall
through to legacy cleanup after beginning a claim operation.
- The first-phase owner calls `Directory.CheckHold` before accepting the
- execution acknowledgement. Box-started owners, once adopted, use
+ execution acknowledgement. Box-started relocation owners use
`CheckBoxStart` and persist `directory_revision`; they do not write a
console reservation or add themselves to the roster.
- Every Plan 6 admission and guarded host-state write supplies this concrete
@@ -4784,6 +6081,17 @@ the floor tag.
Receipt publication failure leaves readmission refused. Do not derive a
successful receipt from an empty claim inventory or an empty local route
table. A fresh reboot must recheck and report that boot's receipt.
+- The trusted box reporter populates `ConsoleProcesses` only after measuring
+ the actual console ID, enrollment, process ID and executable hash on that
+ boot. It records `AllLaunchPathsGuarded` and `GuardFloorSHA256` only after
+ verifying that
+ the console executable and every replacement/bootstrap path use the root
+ guard for that digest. The console report includes its signed roster
+ compute binding only when the root-installed local box identity agrees.
+ A console's self-report cannot supply the box's process attestation.
+ The deployment's process-measurement and replacement-path integration must
+ land before enrolling bound consoles or enabling L20; missing evidence
+ refuses those paths. Independently hosted consoles retain fresh reporting.
- The release workflow approves both router and console artifact hashes,
publishes their immutable signed snapshot, and passes it to root before
any candidate inspection. Normal uploads never mint a new tag mapping.
@@ -4792,13 +6100,36 @@ the floor tag.
changing their protected paths. They retain authorization through rollback.
A script that still activates an app-owned symlink or invokes an unchecked
candidate is not eligible for the first integrated release.
-- A region HA worker consumes the stored termination reference by key and
- digest under the owning claim transition only after L3/L4 are adopted.
- It preserves holder stamp/session, exact local chore identity and every
- remote retirement scope. Do not turn `ObservedAt` into the cleanup time.
- The complete volume recorder/history producer belongs beside that consumer;
- absent enrollment-time evidence permanently prevents automatic local-chore
- discharge for that enrollment. The execution fence remains usable.
+- An operation owner consumes `EvidenceRef{Enrollment, Key, SHA256}` only
+ after reading the exact immutable object and verifying its digest, roster
+ identity and authenticated worker authority. In the same claim CAS that
+ relies on it, the owner writes `op.journal["fence.<box>"]` as the JSON string
+ `{"enrollment":"<enrollment>","key":"control/box-fences/<enrollment>.json","sha256":"<digest>"}`.
+ The same CAS writes `op.journal["loss.<id>"]` with recovery intent citing
+ that identical reference. A retry cannot rebind either key to another box,
+ enrollment or digest. Move return and relocation recovery accept the fence
+ through this journal reference and reverify it; a loose box name is insufficient.
+- That CAS also retains D4's exact host-specific consequences, loss identity,
+ timer knowledge, write declarations and pending alerts before any effect.
+ These survive operation replacement, `free` and holder replacement until
+ the console materializes the automatic authority variant of the loss record.
+ The console then retires the roster entry idempotently. Automatic recovery
+ does not wait for console availability, a root signature on worker evidence,
+ an operator attestation or a quarantine revision. Preserve holder stamp and
+ session, exact local chore identity and every remote retirement scope.
+ Retries use the original evidence time, never processing time.
+ Existing retirement scopes retain their earlier evidence bounds.
+- The owning-integration regression suite must reject missing enrollment,
+ wrong key or digest, mismatched `fence.<box>` and `loss.<id>` citations, and
+ an attempt to rebind a journal key. Crash after the CAS and before effects,
+ replace the operation or holder, then retry materialization and prove that
+ loss identity, declarations, unknown timers and pending alerts survive.
+ A failure to retain either citation or the complete loss evidence refuses
+ the transition. These tests land with the owner, before relocation activation.
+- The complete volume recorder/history producer belongs beside the exact
+ local-chore consumer. Missing enrollment-time history prevents automatic
+ local-chore discharge for that enrollment. It never prevents fencing,
+ census readiness, admission or recovery.
- Preserve the P7 cold-only rule. This plan provides no warm-admission escape
and issues no clean-close certificate for the unresolved Litestream case.
@@ -4843,6 +6174,12 @@ Rehearse failures as well as successes:
4. Interrupt arming, each update journal phase, rollback and boot recovery.
Verify rejection occurs before any candidate's `version` or bootstrap
executes, preserves the journal, and prevents an unsafe edge start.
+ Include activation saved before the marker, staging failure before the
+ journal exists, journal removal before completion, completion publication
+ before marker removal, and retry after each state. Explicit unpublished
+ completion succeeds without an activation transaction. Resume a controller
+ paused across a completed update and reject its obsolete running image
+ or changed floor before acknowledgement.
Unset the flag, remove network access and remove the remote floor;
none lowers the armed local policy.
5. Terminated without volume evidence writes execution evidence. Stopped,
@@ -4854,7 +6191,9 @@ Rehearse failures as well as successes:
6. Exercise same-region ASG confirmation and H17 with one dead enrollment,
then with a second enrollment appearing before the console CAS. The latter
must refuse unless the confirmation positively accounts for it too.
- A missing EC2 result never establishes an empty region.
+ A missing EC2 result never establishes an empty region. Refresh expired
+ confirmation evidence with new immutable attempt and confirmation IDs;
+ replay the superseded pair and prove it cannot enroll.
7. Kill operator resolution after quarantine, after prepared loss persistence,
after claim CAS and before committed bookkeeping. Retry with the same
declaration ID. A second loss must defeat an older readmission's final
@@ -4863,6 +6202,16 @@ Rehearse failures as well as successes:
private reporting. Enrollment, cloud identity, boot IDs and artifact hashes
must stay private. Independently running console replicas each need their
own configured `CARLOS_CONSOLE_ID` and roster enrollment.
+9. Keep a fenced box active in the roster and verify direct fence exclusion
+ from census, Lookup and CheckBoxStart. Exclude only the console whose
+ process and boot are attested by that compute enrollment; an unrelated
+ console remains required. Invalid or unreadable fence authority refuses.
+ Reject unknown regions and pools before selecting any required actors.
+10. L20 accepts only the newest stale console report for box-started
+ relocation with exact compute binding and same-floor root guard coverage.
+ A newer malformed, mismatched or incompatible report refuses. Ordinary
+ lifecycle starts, unrelated processes and future-dated reports never
+ receive the exception.
Have Opus review the authority changes and the combined branch before landing.
Use amadan, never GitHub PRs: push the reviewed branch, describe the actual
@@ -4888,8 +6237,9 @@ The order is:
claim reference and independently running console against deployment
configuration. Include offline machines. Resolve missing registries,
ambiguous namespaces and unexplained actors before freezing the roster.
- Validate the region map against deployment account, AWS region and, where
- used, the exact ASG name/ARN. Enroll existing labels only with boxid evidence
+ Validate the signed region map's namespaces and pools against deployment
+ placement authority, including account, AWS region and, where used, the
+ exact ASG name/ARN. Enroll existing labels only with boxid evidence
and the approved tofu instance ID. Heartbeat expiry removes nobody.
2. Ship the integrated release with the flag off. Produce the immutable signed
router/console snapshot through the approved release workflow. Provision
@@ -4915,10 +6265,12 @@ The order is:
with the flag enabled before protected starts. Keep cold-only admission.
Test the operator path with mocks first. Live EC2/ASG/DNS/IAM experiments
require separately scoped production approval or the designated sandbox.
-7. Keep region HA relocation and the L5/L20 census exceptions disabled until
- their claim amendments and owner integrations land. Observe automatic
- termination evidence independently; do not equate a persisted fence with
- completed host relocation or disk retirement.
+7. Enable region HA relocation only after its owner, automatic-loss
+ persistence, process-bound compute reports and two-box model are integrated.
+ L5 fence exclusion is part of the protected census from activation. L20
+ applies only to box-started relocation with the newest console report and
+ verified same-floor guard coverage of its exact compute enrollment. A
+ persisted fence alone does not complete relocation or disk retirement.
IAM changes are deployment work. The console keeps no EC2 permissions. Region
workers receive only the scoped describes needed for their placement, plus
@@ -4956,31 +6308,30 @@ or cloud resource was changed while writing this plan.
- The live worker only reports cloud observations it made for roster-bound
instances in its region. It cannot author roster membership or operator
attestations. Volume evidence has no influence on its execution fence.
-- This branch deliberately does not claim that L1-L20 have been adopted.
- There is no synthetic zero-revision loss, no stale-console admission, no
- new relocation kind, no target substitution and no fake disk destruction.
+- L1-L20 are adopted. The remaining gates name the operation owners,
+ automatic-loss materializer, process-bound guard reports and the region HA
+ model that must be integrated. Stale console reports are allowed only by
+ L20's narrow box-started relocation rule; no other census gets that exception.
- Compilation is necessary but does not prove the complete release's
admission, bootstrap, retirement, deployment-script or IAM integration.
Those are explicit activation/review gates above, not hidden success flags.
## Authoring verification
-Verification results are recorded below after reconstructing these exact
-files and patches in a disposable export of the pinned parent. No Go source,
-branch or implementation change is written into the design worktree.
+Rev 2 was reconstructed from this document's 42 complete Go files and five
+patches covering 12 existing files in a fresh disposable export of
+`ca5a79d8a3821172f5829231014969f106bc33e9`. Every printed Go file matched
+its compiled source byte for byte, and every patch applied to that parent.
+No implementation files or branch were created in the design worktree.
-The author reconstructed all 37 complete Go files and all five patches
-covering 12 existing files in a fresh export of
-`ca5a79d8a3821172f5829231014969f106bc33e9`. Every reconstructed source byte
-matched the compiled working export. The following checks passed on that
-reconstruction:
+These checks passed on the final reconstruction:
```sh
go build -buildvcs=false ./...
go test -buildvcs=false -exec=/bin/true ./...
go vet -buildvcs=false ./...
go test -buildvcs=false -race -count=1 ./internal/hostactors ./internal/hostfloor ./internal/hostfence ./internal/hostresolve ./internal/hostexec
-go test -buildvcs=false ./internal/host -run 'TestFloorRefusal|TestApply|TestRecovery' -count=1
+go test -buildvcs=false ./internal/host -run 'TestFloorRefusal|TestGuardRecovery|TestUnpublishedCompletion|TestApply|TestRecovery' -count=1
go test -buildvcs=false ./internal/console -run '^TestHostClaimRoutesArePersonOperatorFlagGatedAndInert$' -count=1
go test -buildvcs=false . -run 'TestHostClaimFlag|TestParseHostResolve' -count=1
GOOS=darwin GOARCH=amd64 go build -buildvcs=false ./...
@@ -4989,28 +6340,41 @@ GOOS=linux GOARCH=arm64 go build -buildvcs=false ./...
gofmt -l .
```
-`-exec=/bin/true` compiles every test package; it does not execute that test
-suite. The runtime checks above are targeted. An earlier broader console
-selection reached the existing `TestCensusPassRefusesMissingRegistry`, which
-could not create its loopback test listener in this sandbox. The full runtime
-gate must still run on the implementation branch. No full-suite pass is
-claimed here.
-
-Four deliberate regressions were watched fail their behavioral assertions:
-accepting `stopped` as termination; ignoring pending, active, lost and malformed
-controller files during update exclusion; expiring an already accepted loss
-retry; and recording termination before the verified response completed.
-Each mutation was removed. The final source retains all four protections.
-
-The default Go cache was tried first and was read-only in this tool sandbox.
-Verification reused the existing shared on-disk
-`/home/paulca/.cache/tmp/gocache`; it did not create a per-task Go cache or use
-RAM-backed `/tmp`. The disposable source exports and authoring scratch were
-removed after verification. The shared cache was retained.
-
-An explicitly requested Opus review process timed out without a verdict.
-No independent authority review is claimed; the Opus review remains a gate
-before implementation lands. Cross-branch activation and deployment checks
-remain the explicit gates above. Only this Markdown plan was added to the
-design worktree. Nothing was staged or committed, and no production resource,
-floor, feature flag or service was changed.
+`-exec=/bin/true` compiles every test package without executing its tests.
+The runtime checks above are targeted; the full repository runtime gate
+remains an implementation-branch requirement. The revised Markdown also
+passes `git diff --check`, has balanced code fences and contains no em dashes.
+
+Regression tests cover active-roster fence exclusion from census and
+eligibility, invalid or unreadable fence authority, exact console process
+and compute binding, unknown placement scope, expired and superseded
+confirmation refresh, replay after consumption, every retained update-guard
+crash state, unpublished completion, and a controller resumed after an
+update with the wrong running image or floor. L20 tests include the newest
+stale console report, changed floor, missing guard, incompatible image,
+malformed newest evidence, future timestamps and ordinary-start refusal.
+The fence-reference test checks enrollment, exact key and byte digest.
+
+Deliberate regressions were watched fail their behavioral assertions:
+ignoring a valid termination fence, omitting placement-scope validation,
+omitting enrollment from the returned evidence reference, restoring the old
+update recovery/completion bodies, and restoring marker-only controller
+checks. Each mutation was removed before final verification. The paired
+journal and automatic-loss persistence tests remain explicit requirements
+of the operation-owner integration described above.
+
+The default Go cache was attempted and was read-only in this sandbox.
+Verification reused the existing shared on-disk cache by setting
+`GOCACHE=/home/paulca/.cache/tmp/gocache`; it created no per-task Go cache
+and used no RAM-backed `/tmp` scratch. Disposable exports and authoring
+scratch were removed after verification. The shared cache was retained.
+
+An independent GPT-6 Astra review checked the revised roster, update guard
+and automatic-fence integration contracts. Its console report identity
+finding was fixed and covered by regression tests; no findings remained in
+that reviewed scope. Opus was unavailable in this session, so this is not an
+Opus review. The repository's required authority review before implementation
+lands, and the named cross-branch activation dependencies, remain gates.
+This task revised only the requested Markdown plan, staged nothing and
+committed nothing. No production resource, floor, feature flag or service
+was changed.