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>
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. |