docs: stage B plan rev 5.4 writes Task 10b in full: with region HA on, the source's row is held for its relocation once this box's admission fence for the host closes, and its park-local ends the hold (R5-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+908
−13
docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md+908 −13
diff --git a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md| index 80840f3a2..e8d0a8b5a 100644 |
| --- a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md |
| +++ b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md |
| @@ -1,4 +1,4 @@ |
| -# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 5.3) |
| +# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 5.4) |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| @@ -20,6 +20,10 @@ |
| --- |
| +## Rev 5.4: Task 10b written |
| + |
| +Task 10b (R5-3, the gate of ruling 79 and checklist item 18) is no longer a stub: its Files, Interfaces, decisions, complete code and test bodies, `-run` filters and commit are written against the branch after c9b51c47, and its code was compiled and its tests run in a throwaway copy (the record in the task). With `CARLOS_REGION_HA` on, the box's reconciler holds the source's row for the op in `relocating` once the host guard's admission fence for the host closes in `releasing`, through the existing `registry.SetHoldIf` under the `hold_op` guard; the completion queues the source's park-local, whose retirement ends the hold; a failover's fenced source is left to its park-local; with the flag off reconcile is main's. No other task changed. The task is written, not landed: the activation gate stands until it lands and its tests pass. |
| + |
| ## Rev 5.3: Astra round 8 |
| | # | Finding | Fix in rev 5.3 | Where | |
| @@ -131,6 +135,7 @@ Each task was committed in a throwaway copy of this branch (code as at b9a753f0; |
| | 8d | clean | clean: host | empty | the owner, wake, attempt and root tests pass, with the moves probe-owner, Plan 9 D5, Plan 11 and claim-launch regressions | |
| | 9 | clean | clean: host, hostbox | empty | hostbox's reconcile tests and host's probe-stop (restart, failed marker write), hold-release, successor, discharge, handover, sweep, Plan 9, Plan 11 and handover regressions pass | |
| | 10 | clean | clean: host, hostrelocate, edgesfile, console | empty | `hostrelocate` and `edgesfile` pass in full; the console's edges, eligibility, placement, region, executor and claim-runtime tests pass; 82 region HA host tests pass with the Plan 9, Plan 11, claim-launch, moves and handover regressions | |
| +| 10b (rev 5.4, at 42093182) | clean | clean: host, hostbox, registry | empty | `hostbox`, `registry` and `host` pass in full; the six Task 10b tests pass; darwin, windows and linux/arm64 builds clean; each guard mutation-checked (the task's record) | |
| At Task 10 the whole `internal/hostclaim`, `internal/hostops`, `internal/hostrelocate`, `internal/hostbox` and `internal/host` (47s) packages also pass, once, outside the sandbox (rev 5.3). The whole `internal/activator` package passed at rev 5.1 and rev 5.2 does not touch it; the console's passed at rev 5 and neither revision since touches it. At Task 10, `GOOS=darwin`, `GOOS=windows` and `GOARCH=arm64` builds of `./...` are clean, and so is `go vet` of host, hostrelocate, hostclaim, hostops and edgesfile for all three (and, at rev 5.1, of activator for darwin). Two windows vets fail at b9a753f0 already and this plan does not touch either cause: `./internal/hostbox/` (`plan9_fix2_minors_test.go:100`, `undefined: plan9D1Probe`) and `./internal/activator/` (`activator_test.go:4528`, `undefined: unix.Mkfifo`). |
| @@ -14823,19 +14828,909 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ### Task 10b: The source's row held at its fence's close (R5-3), mandatory before activation |
| -Astra round 6 accepted R5-3 deferred while relocation is dormant, on one condition: this task lands, with its tests passing, before relocation is switched on anywhere (ruling 79; checklist item 18; the activation line in Global Constraints; "After this plan"). It is not built in rev 5.1 and nothing in Tasks 8a to 11 depends on it. Its code and test bodies are written when it is scheduled, against the code as it then is, and compiled task by task as this plan's tasks were. |
| +Astra round 6 accepted R5-3 deferred while relocation is dormant, on one condition: this task lands, with its tests passing, before relocation is switched on anywhere (ruling 79; checklist item 18; the activation line in Global Constraints; "After this plan"). Nothing in Tasks 8a to 11 depends on it. Rev 5.4 writes it against the branch as it is after c9b51c47; its code was compiled and its tests run in a throwaway copy (the record below). |
| + |
| +**What it does.** Stage A's `hostbox.Reconciler` has two hooks no production assembly set: `FenceClosed(ctx, obs) (bool, error)` and `HoldRow(ctx, host, holdOp) error` (hostbox/reconcile.go). With both nil, reconcile under a relocating claim is main's `Serving` path, which refuses and leaves every row unchanged, so the source's row stayed ready through `releasing` and the edge kept routing to a source whose admission was closed. This task sets both, with `CARLOS_REGION_HA` on only, where the box's reconciler is built (`OpenProductionClaimAssembly`, whose adapter `ClaimAssembly.Build` copies). Stage A's row decision (`RelocationRow`, `rowDecision`, `holdOnly`) is unchanged: the source in `releasing` stays ready until this box's admission fence for the host closes, and then it is held for the op; every other relocating participant keeps an existing row held and mints none. |
| + |
| +- `FenceClosed` is `hostbox.AdmissionFenceClosed(guard)`: the host guard's admission fence for the installation the guard record has active, read exactly as `With` reads it (`hostguard.Guard.AdmissionFenced` over `hostguard.Capture`). An unreadable or missing record, or a claim for another host or incarnation, is an error, never "open"; the reconciler then keeps every row and mints none. |
| +- `HoldRow` is `hostbox.RegistryHoldRow(reg)`: `registry.SetHoldIf(host, "relocating", op, "")`, status and `hold_op` in one statement under the `hold_op` guard. A row already held for the op is a replay and nothing is written; a row another op holds is `ErrChanged` with nothing written; no row is `ErrChanged` and nothing is minted; an unreadable row is its error. |
| + |
| +**Decisions** (each verified against the code at HEAD; none reopens a ruling): |
| + |
| +1. **The fence is the host guard's durable admission fence, not the activator's.** Root writes it with `Guard.BeginFence` before any privileged stop work when it serves the source's `relocation-release` stop (`ClaimStopCoordinator.FenceAndStop`, claimstop.go); it survives an agent restart, and a completed stop still reads closed. `FenceAndStopAs` is the target's probe stop (ruling 75) and never runs on the source, and the activator's fences live only in its process, so after a restart an activator reading would say "unfenced": absence taken as evidence. Stage A's reconcile tests already used this observer. Under ruling 28's order root's fence comes after the writers are stopped (root's writers-stopped observation installs no fence, `TestWritersStoppedRootObservesWithItsOwnPeer`) and after the flush, so the hold lands then, not at step 1. *Cost if wrong:* for the flush and the queue (seconds, bounded by `ReleaseDeadline`) the source's row is still ready while its process tree is already stopped, so the edge sends requests to a source whose wake is refused (a relocating claim admits no serving wake), as it does today for the whole release. The hold is not what keeps writers out: the claim kind and the attempt's fence already do. |
| +2. **No new registry write: `SetHoldIf` is the write the stub describes.** It sets a hold status and `hold_op` together, only while `hold_op` is the expected value, refuses a takeover, and is already moves' hold for an existing row (`holdExisting`). `SetClaimHold` does not fit: it sets `hold_op` alone and would leave the status `ready`, which the edge serves. The write is not bound to the row's claim ownership: a hold grants nothing and only stops serving, and the park-local retires by ownership, which the write leaves as it was. *Cost if wrong:* a row for the host that belonged to some other installation on the source would be held too, and stay unserved until its own retirement; nothing could serve from such a row during a relocation anyway. |
| +3. **Nothing releases the source's hold.** The fenced installation never serves again. The completion (`hostclaim.CompleteRelocation`) queues the live source's `park-local`, and the park-local's typed retirement (`hostbox.RegistryRows` to `registry.RetireClaimRows`) deletes the row by ownership whether it is held or not; that ends the hold. `relocationReleaseHold` is unchanged: it releases the completion box's own row (the target's), the source is never the completion box (`StartRelocation` and `replaceTargetSlot` refuse a target that is the source), and a source branch there would be a second writer releasing a relocating hold, which ruling 80's argument relies on not existing. After a failover from a fenced source the source is on `lost`, so `RelocationRow` never gives it a held row: reconcile neither asks its fence nor writes its row, before or after the completion, and the row is left to the park-local. *Cost if wrong:* until the source's park-local runs (it waits on M2 after a converted landing, checklist item 8), the source keeps a held, unserved row while the host serves on the target, as a move's source does. |
| +4. **The stage-requested marker is not touched.** `SetHold`'s comment asks every caller to touch it so root re-judges a held release stage (moves' G10). Relocation stages nothing under the source's hold, and Task 8c's held mint does not touch it either. *Cost if wrong:* root re-judges the source's staged release at its next timer pass rather than at once; the source serves nothing meanwhile. |
| +5. **The hooks follow `CARLOS_REGION_HA` alone**, not whether the relocation owners are bound (ruling 53). With the flag on and C8 unbound this box starts nothing, but a worker elsewhere may still drive a relocation it is the source of, and holding its row then is right. Every assembly mode gets them, but only the agent runs provision's pass with the adapter (agent.go, `instancesLoop` and `fastConvergeLoop`), so root's assemblies never call them. *Cost if wrong:* if the flag is switched off in the one provision pass between the source's fence closing and the hold landing, the row stays ready until the op completes and the park-local retires it; with the flag off the source never runs its release, so its fence does not close then. |
| + |
| +**Files:** |
| +- Create: `internal/hostbox/relocationsource.go`, `internal/host/relocationsourcehold.go`, `internal/hostbox/reconcile_relocation_source_test.go`, `internal/host/relocationsourcehold_test.go`, `internal/host/relocationsourcehold_linux_test.go` |
| +- Modify: `internal/host/claimconstruction.go` (the call), `internal/hostbox/reconcile.go` (comments only: who sets the hooks and which write `holdOnly` makes), `internal/registry/relocating_test.go` (the `SetHoldIf` contract this task relies on) |
| +- Not modified, and why: `internal/registry/registry.go` (decision 2), `internal/host/relocationowners.go` (decision 3). |
| + |
| +**Interfaces:** |
| +- Consumes: `hostbox.Reconciler` (`FenceClosed`, `HoldRow`, `Install`, `holdOnly`, `rowDecision`, `RelocationRow`), `hostbox.ReconcileAdapter`, `hostbox.RegistryRows`, `hostguard.Guard.AdmissionFenced`, `hostguard.Capture`, `hostguard.Guard.BeginFence`, `registry.SetHoldIf`, `registry.Get`, `registry.IsHold`, `registry.RetireClaimRows`, `hostclaim.CompleteRelocation`, `hostclaim.OwesLocal`, `regionHAEnabled`, `claimEntryEnv`, `OpenProductionClaimAssembly`; the test fixture `fix4JournalFixture` (plan9fix4_claimjournal_migration_linux_test.go). |
| +- Produces: `hostbox.AdmissionFenceClosed`, `hostbox.RegistryHoldRow`, `relocationSourceRows`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Four required tests, one per stub item, plus the registry contract and the call site: |
| -What it does. Stage A's `hostbox.Reconciler` has two hooks no production assembly sets: `FenceClosed(ctx, obs) (bool, error)` and `HoldRow(ctx, host, holdOp) error` (hostbox/reconcile.go:33-61, 250-260). Until they are set, reconcile under a relocating claim is main's `Serving` path, which refuses and leaves every row unchanged, so the source's row is not held during `releasing`. |
| +1. `TestReconcileHoldsTheSourcesRowAtItsFencesClose` (hostbox): before the fence closes the row is unchanged (dry run and real); an unreadable fence is the read's error with the row unchanged; a dry run after the close writes nothing; then the row is `relocating`, held for `rel-1`, every other column as it was. |
| +2. `TestReconcileSourceHoldNeverTakesAnotherOpsRow` (hostbox) and `TestSetHoldIfRelocatingNeverTakesAnotherOpsRow` (registry): another op's row is never taken, a replay writes nothing, no row is minted, an unreadable row is an error; the held row keeps its ownership and the park-local's `RetireClaimRows` deletes it. |
| +3. `TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal` (host): the held row through the real `CompleteRelocation`, reconcile leaving it, then the park-local's retirement; a failover from a fenced source, with the row ready or already held when fenced, left as it was during the failover and after its completion, then retired. |
| +4. `TestRelocationSourceHoldFollowsTheFlag` (host): with `CARLOS_REGION_HA` unset, `0`, `false`, `off` or unparseable, no hook is set and a fenced source in `releasing` is refused with the row, the claim and the guard record unchanged, while the serving executor still writes; with `1` or `true` the same box holds the row. `TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA` (host, linux) opens the production assembly and checks the hooks are set with the flag on and not with it off. |
| -**Files (expected):** |
| -- Modify: `internal/registry/registry.go` (a hold write that sets the row's status to one the edge does not serve together with `hold_op`, under the existing `hold_op` guard; `SetClaimHold` sets only `hold_op`), `internal/host/claimassembly*.go` or wherever the box's `Reconciler` is built (set `FenceClosed` from this box's activator fence for the host and `HoldRow` from the new registry write, only with `CARLOS_REGION_HA` on), `internal/host/relocationowners.go` (`relocationReleaseHold` and the source's park-local release the source's hold). |
| -- Test: `internal/hostbox/reconcile_relocation_source_test.go`, `internal/registry/relocating_test.go`, `internal/host/relocationsourcehold_test.go`. |
| +The registry test passes before this task's code (it pins the existing `SetHoldIf`); the others fail to compile. |
| + |
| +`internal/hostbox/reconcile_relocation_source_test.go` (new): |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| -**Tests it must have, each asserting behaviour:** |
| -1. During `releasing`, once this box's admission fence for the host is closed, reconcile holds the source's row for the op with a status the edge does not serve; before the fence closes, the row is unchanged. |
| -2. The hold write never takes a row another op holds, and a replay is a no-op. |
| -3. The completion (or the source's park-local) ends the source's hold; a failover from a fenced source leaves the row to the park-local. |
| -4. With `CARLOS_REGION_HA` off, reconcile is exactly main's. |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// sourceRows is relocationReconciler with Task 10b's production hooks: the |
| +// host guard's admission fence (AdmissionFenceClosed) and the registry |
| +// hold write (RegistryHoldRow), over a registry holding the source's ready |
| +// row, owned by the installation the guard has active. |
| +func sourceRows(t *testing.T) (Reconciler, RecordSelection, *registry.Registry, hostguard.Guard) { |
| + t.Helper() |
| + r, selected, _, guard := relocationReconciler(t) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + captured, err := hostguard.Capture(guard.DataDir, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := registry.ClaimOwnership{ClaimIncarnation: captured.Incarnation, ClaimStamp: captured.Stamp, ClaimOwner: "owner-1", ClaimRowID: "row-1"} |
| + if err := reg.Upsert(registry.Route{Host: selected.Host, Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.FenceClosed, r.HoldRow = AdmissionFenceClosed(guard), RegistryHoldRow(reg) |
| + return r, selected, reg, guard |
| +} |
| + |
| +// closeSourceFence is root beginning the source's stop for the op: the |
| +// durable admission fence in the guard record (BeginFence). |
| +func closeSourceFence(t *testing.T, r Reconciler, selected RecordSelection, guard hostguard.Guard) { |
| + t.Helper() |
| + ctx := context.Background() |
| + captured, err := hostguard.Capture(guard.DataDir, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session, |
| + Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"} |
| + if _, err := guard.BeginFence(ctx, target); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func rowOf(t *testing.T, reg *registry.Registry, host string) *registry.Route { |
| + t.Helper() |
| + row, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return row |
| +} |
| + |
| +// Task 10b test 1 (R5-3): during releasing the source's row is unchanged |
| +// until this box's admission fence for the host closes; reconcile then |
| +// holds it for the op in "relocating", a status the edge never serves, |
| +// with every other column as it was, its claim ownership included. A dry |
| +// run writes nothing, and an unreadable fence is an error with the row |
| +// left as it was, never "open". |
| +func TestReconcileHoldsTheSourcesRowAtItsFencesClose(t *testing.T) { |
| + r, selected, reg, guard := sourceRows(t) |
| + ctx := context.Background() |
| + obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| + before := *rowOf(t, reg, selected.Host) |
| + writes := 0 |
| + for _, dry := range []bool{true, false} { |
| + sel := selected |
| + sel.DryRun = dry |
| + if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("the source before its fence closed (dry run %v): %v", dry, err) |
| + } |
| + } |
| + if got := rowOf(t, reg, selected.Host); writes != 0 || *got != before { |
| + t.Fatalf("before the fence closed: writes %d row %+v (want %+v)", writes, got, before) |
| + } |
| + if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag { |
| + t.Fatalf("the claim was written before the fence closed: %v", err) |
| + } |
| + // An unreadable fence is an error, never "open": the row stays. |
| + record := filepath.Join(guard.DataDir, "hostguard", selected.Host+".json") |
| + if err := os.Rename(record, record+".away"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.Install(ctx, selected, func() error { writes++; return nil }); err == nil || errors.Is(err, ErrChanged) { |
| + t.Fatalf("an unreadable fence: %v (want the read's error)", err) |
| + } |
| + if err := os.Rename(record+".away", record); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := rowOf(t, reg, selected.Host); writes != 0 || *got != before { |
| + t.Fatalf("an unreadable fence changed the row: %+v", got) |
| + } |
| + closeSourceFence(t, r, selected, guard) |
| + dry := selected |
| + dry.DryRun = true |
| + if err := r.Install(ctx, dry, func() error { writes++; return nil }); err != nil { |
| + t.Fatalf("the fenced source's dry run: %v", err) |
| + } |
| + if got := rowOf(t, reg, selected.Host); *got != before { |
| + t.Fatalf("a dry run held the row: %+v", got) |
| + } |
| + if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil { |
| + t.Fatalf("the fenced source's hold: %v", err) |
| + } |
| + got := rowOf(t, reg, selected.Host) |
| + if writes != 0 || got.Status != "relocating" || got.HoldOp != "rel-1" || !registry.IsHold(got.Status) { |
| + t.Fatalf("after the fence closed: writes %d row %+v", writes, got) |
| + } |
| + unheld := *got |
| + unheld.Status, unheld.HoldOp = before.Status, before.HoldOp |
| + if unheld != before { |
| + t.Fatalf("the hold changed more than status and hold_op: %+v (was %+v)", got, before) |
| + } |
| +} |
| + |
| +// Task 10b test 2: the hold write never takes a row another op holds, a |
| +// replay changes nothing, a missing row is not minted, and an unreadable |
| +// row is an error, never "held". Through the reconciler, a fenced source |
| +// whose row another op holds is refused with the row as it was. |
| +func TestReconcileSourceHoldNeverTakesAnotherOpsRow(t *testing.T) { |
| + r, selected, reg, guard := sourceRows(t) |
| + ctx := context.Background() |
| + hold := RegistryHoldRow(reg) |
| + relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| + closeSourceFence(t, r, selected, guard) |
| + |
| + if ok, err := reg.SetHoldIf(selected.Host, "moving", "mv-9", ""); err != nil || !ok { |
| + t.Fatalf("another op's hold: %v %v", ok, err) |
| + } |
| + other := *rowOf(t, reg, selected.Host) |
| + if err := hold(ctx, selected.Host, "rel-1"); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("another op's row: %v", err) |
| + } |
| + if err := r.Install(ctx, selected, func() error { return nil }); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("reconcile over another op's row: %v", err) |
| + } |
| + if got := rowOf(t, reg, selected.Host); *got != other { |
| + t.Fatalf("another op's row was taken: %+v (was %+v)", got, other) |
| + } |
| + if ok, err := reg.ReleaseHold(selected.Host, "mv-9"); err != nil || !ok { |
| + t.Fatalf("release the other op's hold: %v %v", ok, err) |
| + } |
| + |
| + if err := r.Install(ctx, selected, func() error { return nil }); err != nil { |
| + t.Fatalf("the hold: %v", err) |
| + } |
| + held := *rowOf(t, reg, selected.Host) |
| + for i := 0; i < 2; i++ { |
| + if err := hold(ctx, selected.Host, "rel-1"); err != nil { |
| + t.Fatalf("a replay of the write: %v", err) |
| + } |
| + if err := r.Install(ctx, selected, func() error { return nil }); err != nil { |
| + t.Fatalf("a replay through reconcile: %v", err) |
| + } |
| + if got := rowOf(t, reg, selected.Host); *got != held { |
| + t.Fatalf("a replay changed the row: %+v (was %+v)", got, held) |
| + } |
| + } |
| + |
| + if err := hold(ctx, "absent.example", "rel-1"); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("no row: %v", err) |
| + } |
| + if got := rowOf(t, reg, "absent.example"); got != nil { |
| + t.Fatalf("the hold minted a row: %+v", got) |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hold(ctx, selected.Host, "rel-1"); err == nil || errors.Is(err, ErrChanged) { |
| + t.Fatalf("an unreadable row: %v (want the read's error)", err) |
| + } |
| +} |
| +``` |
| + |
| +`internal/registry/relocating_test.go`: add `"context"` to the imports, and append: |
| + |
| +```go |
| +// Region HA Task 10b (R5-3): the source's row is held with SetHoldIf |
| +// expecting it unheld. That write never takes a row another op holds, a |
| +// replay of it matches nothing and changes nothing, it mints no row, and |
| +// it leaves the row's claim ownership as it was, so the source's |
| +// park-local retires the held row by ownership (RetireClaimRows), which |
| +// ends the hold. |
| +func TestSetHoldIfRelocatingNeverTakesAnotherOpsRow(t *testing.T) { |
| + ctx := context.Background() |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + owner := ClaimOwnership{ClaimIncarnation: "inc-1", ClaimStamp: 7, ClaimOwner: "owner-1", ClaimRowID: "row-1"} |
| + if err := reg.Upsert(Route{Host: "shop.example", Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if held, err := reg.SetHoldIf("shop.example", "relocating", "rel-1", ""); err != nil || !held { |
| + t.Fatalf("the hold: %v %v", held, err) |
| + } |
| + held, err := reg.Get("shop.example") |
| + if err != nil || held.Status != "relocating" || held.HoldOp != "rel-1" || held.ClaimOwnership != owner { |
| + t.Fatalf("held row %+v %v", held, err) |
| + } |
| + // A replay matches nothing and changes nothing. |
| + if again, err := reg.SetHoldIf("shop.example", "relocating", "rel-1", ""); err != nil || again { |
| + t.Fatalf("a replay wrote: %v %v", again, err) |
| + } |
| + // Another op neither takes the hold expecting an unheld row nor by |
| + // naming the holder. |
| + if took, err := reg.SetHoldIf("shop.example", "relocating", "rel-2", ""); err != nil || took { |
| + t.Fatalf("another op took the row: %v %v", took, err) |
| + } |
| + if took, err := reg.SetHoldIf("shop.example", "relocating", "rel-2", "rel-1"); err == nil || took { |
| + t.Fatalf("a takeover was accepted: %v %v", took, err) |
| + } |
| + if now, err := reg.Get("shop.example"); err != nil || *now != *held { |
| + t.Fatalf("the row changed: %+v (was %+v) %v", now, held, err) |
| + } |
| + // No row: nothing matches and nothing is minted. |
| + if minted, err := reg.SetHoldIf("absent.example", "relocating", "rel-1", ""); err != nil || minted { |
| + t.Fatalf("a missing row: %v %v", minted, err) |
| + } |
| + if row, err := reg.Get("absent.example"); err != nil || row != nil { |
| + t.Fatalf("a row was minted: %+v %v", row, err) |
| + } |
| + // The park-local's retirement deletes the held row by its ownership. |
| + target := []ClaimRow{{Kind: "route", Host: "shop.example", Ownership: owner}} |
| + if err := reg.RetireClaimRows(ctx, "retire-1", target, func([]ClaimRowArchive) error { return nil }); err != nil { |
| + t.Fatalf("the park-local's retirement of the held row: %v", err) |
| + } |
| + if row, err := reg.Get("shop.example"); err != nil || row != nil { |
| + t.Fatalf("after the retirement: %+v %v", row, err) |
| + } |
| +} |
| +``` |
| + |
| +`internal/host/relocationsourcehold_test.go` (new): |
| + |
| +```go |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const sourceHoldHost = "shop.example" |
| + |
| +// sourceHoldBox is the relocation's source "box" with its installation |
| +// active in the host guard, one cold writer session admitted, a ready row |
| +// owned by that installation, and the reconciler the claim assembly |
| +// builds (the WorkSource selector, park.Local), its hooks set by |
| +// relocationSourceRows under getenv and counted. |
| +type sourceHoldBox struct { |
| + t *testing.T |
| + store release.Store |
| + reg *registry.Registry |
| + guard hostguard.Guard |
| + r hostbox.Reconciler |
| + selected hostbox.RecordSelection |
| + owner registry.ClaimOwnership |
| + asked int // FenceClosed calls |
| + holds int // HoldRow calls |
| + writes int // the activation path's row writes |
| +} |
| + |
| +func newSourceHoldBox(t *testing.T, getenv func(string) string) *sourceHoldBox { |
| + t.Helper() |
| + ctx := t.Context() |
| + b := &sourceHoldBox{t: t, store: release.NewDirStore(t.TempDir())} |
| + dir := t.TempDir() |
| + locks := filepath.Join(dir, "locks") |
| + for _, p := range []string{locks, filepath.Join(dir, "hostguard")} { |
| + if err := os.Mkdir(p, 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + sourceHoldHost + ".json", Account: "acct", App: "app", Host: sourceHoldHost, Region: "ie"}, "box", nil, nil, "adopt", func() string { return "id" })) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b.guard = hostguard.Guard{DataDir: dir, LockDir: locks, Claims: hostguard.StoreReader{Store: b.store}} |
| + if err := b.guard.Activate(ctx, sourceHoldHost, "box", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := hostguard.Capture(dir, sourceHoldHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if b.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { b.reg.Close() }) |
| + b.owner = registry.ClaimOwnership{ClaimIncarnation: captured.Incarnation, ClaimStamp: captured.Stamp, ClaimOwner: "owner-1", ClaimRowID: "row-1"} |
| + if err := b.reg.Upsert(registry.Route{Host: sourceHoldHost, Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: b.owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := func() time.Time { return time.Unix(100, 0) } |
| + source := hostbox.WorkSource{Guard: b.guard, Box: "box", Region: "ie", Now: now, |
| + Policy: hostbox.Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }}.Check, |
| + Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }} |
| + h := obs.Claim.Holder |
| + b.selected = hostbox.RecordSelection{Host: sourceHoldHost, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}} |
| + b.r = hostbox.Reconciler{Claims: b.store, Guard: b.guard, Local: park.Local{Guard: b.guard, Claims: b.store, DataDir: dir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: now, |
| + CheckRecord: func(context.Context, hostbox.RecordSelection) error { return nil }, |
| + CheckLocal: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil }} |
| + relocationSourceRows(getenv, &b.r, b.guard, b.reg) |
| + if fence := b.r.FenceClosed; fence != nil { |
| + b.r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) { b.asked++; return fence(ctx, o) } |
| + } |
| + if hold := b.r.HoldRow; hold != nil { |
| + b.r.HoldRow = func(ctx context.Context, host, op string) error { b.holds++; return hold(ctx, host, op) } |
| + } |
| + return b |
| +} |
| + |
| +func (b *sourceHoldBox) update(change func(*hostclaim.Claim)) hostclaim.Observed { |
| + b.t.Helper() |
| + ctx := b.t.Context() |
| + obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, b.store, obs, func(c *hostclaim.Claim) error { change(c); return nil }) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + return obs |
| +} |
| + |
| +// releasing is a drain from "box" to "dst" in releasing, both |
| +// acknowledged. |
| +func (b *sourceHoldBox) releasing() hostclaim.Observed { |
| + return b.update(func(c *hostclaim.Claim) { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "box", Participants: []string{"box", "dst"}, |
| + Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}} |
| + }) |
| +} |
| + |
| +// closeFence is root beginning the source's release stop: the durable |
| +// admission fence in the guard record (hostguard.Guard.BeginFence). |
| +func (b *sourceHoldBox) closeFence() { |
| + b.t.Helper() |
| + ctx := b.t.Context() |
| + captured, err := hostguard.Capture(b.guard.DataDir, sourceHoldHost) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + target := hostguard.FenceTarget{FenceID: "fence-1", Host: sourceHoldHost, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session, |
| + Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"} |
| + if _, err := b.guard.BeginFence(ctx, target); err != nil { |
| + b.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (b *sourceHoldBox) install(dry bool) error { |
| + sel := b.selected |
| + sel.DryRun = dry |
| + return b.r.Install(b.t.Context(), sel, func() error { b.writes++; return nil }) |
| +} |
| + |
| +func (b *sourceHoldBox) row() *registry.Route { |
| + b.t.Helper() |
| + row, err := b.reg.Get(sourceHoldHost) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + return row |
| +} |
| + |
| +func (b *sourceHoldBox) guardRecord() []byte { |
| + b.t.Helper() |
| + raw, err := os.ReadFile(filepath.Join(b.guard.DataDir, "hostguard", sourceHoldHost+".json")) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +// retireRow is the source's park-local retiring its row: the typed |
| +// retirement's registry step (hostbox.RegistryRows, the one park-local |
| +// row write), by the row's claim ownership, held or not. |
| +func (b *sourceHoldBox) retireRow() { |
| + b.t.Helper() |
| + rows := hostbox.RegistryRows{Registry: b.reg, Inventory: hostbox.RetirementInventory{Host: sourceHoldHost, Captured: hostguard.Captured{Incarnation: b.owner.ClaimIncarnation, Stamp: b.owner.ClaimStamp}, Ownership: b.owner.ClaimOwner}} |
| + route := hostbox.Artifact{Kind: hostbox.ArtifactRoute, Root: "registry", Name: sourceHoldHost, Owner: b.owner.ClaimOwner, RowID: b.owner.ClaimRowID} |
| + if err := rows.RetireRowsJournal(b.t.Context(), "retire-1", b.owner.ClaimOwner, []hostbox.Artifact{route}, func(json.RawMessage) error { return nil }); err != nil { |
| + b.t.Fatalf("the park-local's row retirement: %v", err) |
| + } |
| +} |
| + |
| +func regionHA(on bool) func(string) string { |
| + return func(k string) string { |
| + if k == "CARLOS_REGION_HA" && on { |
| + return "1" |
| + } |
| + return "" |
| + } |
| +} |
| + |
| +// complete is the target's probe passing and root's stop of the probe |
| +// session, then the real serving CAS (hostclaim.CompleteRelocation), |
| +// which queues the source's park-local unless a fence discharged it. |
| +func (b *sourceHoldBox) complete() hostclaim.Observed { |
| + b.t.Helper() |
| + b.update(func(c *hostclaim.Claim) { |
| + h := c.Holder |
| + h.Stamp, h.Session, h.Generation, h.WriterBox = h.Stamp+5, h.Session+1, "P1", "dst" |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Session: h.Session, Box: "dst", Generation: "P1", Predecessor: "g1", Mode: "cold", Region: "ie"}) |
| + probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: h.Session, At: time.Unix(90, 0).UTC()}) |
| + stop, _ := json.Marshal(hostclaim.ProbeStop{Box: "dst", Session: h.Session, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)}) |
| + c.Op.Phase, c.Op.Exec = hostclaim.PhaseRelocatingProbing, "dst" |
| + c.Op.Journal["probe"], c.Op.Journal["probe_stop"] = string(probe), string(stop) |
| + }) |
| + ctx := b.t.Context() |
| + obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost) |
| + if err != nil { |
| + b.t.Fatal(err) |
| + } |
| + n := 0 |
| + obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.CompleteRelocation("rel-1", func() string { n++; return "id-" + strconv.Itoa(n) })) |
| + if err != nil { |
| + b.t.Fatalf("the serving CAS: %v", err) |
| + } |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "dst" || !hostclaim.OwesLocal(obs.Claim, "box") { |
| + b.t.Fatalf("after the completion: op %+v obligations %+v", obs.Claim.Op, obs.Claim.Obligations) |
| + } |
| + return obs |
| +} |
| + |
| +// leaves asserts reconcile, dry run or not, refuses with nothing done: |
| +// the row exactly as before, the fence not asked, nothing held or |
| +// written, and the guard record as it was. |
| +func (b *sourceHoldBox) leaves(what string, before registry.Route, record []byte) { |
| + b.t.Helper() |
| + asked, holds, writes := b.asked, b.holds, b.writes |
| + for _, dry := range []bool{true, false} { |
| + if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) { |
| + b.t.Fatalf("%s (dry run %v): %v", what, dry, err) |
| + } |
| + } |
| + if got := b.row(); got == nil || *got != before || b.asked != asked || b.holds != holds || b.writes != writes || !bytes.Equal(b.guardRecord(), record) { |
| + b.t.Fatalf("%s: row %+v (was %+v) fence asked %d holds %d writes %d", what, got, before, b.asked-asked, b.holds-holds, b.writes-writes) |
| + } |
| +} |
| + |
| +// Task 10b test 3. The completion: the source's row, held at its fence's |
| +// close, stays held through the real serving CAS. Reconcile neither |
| +// renews nor releases it (the source is no longer a participant, and |
| +// reconcile under a serving claim is main's), and the completion queues |
| +// the source's park-local, whose retirement deletes the held row by its |
| +// ownership: that ends the hold, and nothing is minted after. A failover |
| +// from a fenced source: the source is on lost, so reconcile neither asks |
| +// its fence nor holds its row, whether the row was ready or already held |
| +// when the box was fenced, both while the failover runs and after its |
| +// completion queues the park-local; the row is left exactly as it was, |
| +// and the park-local's retirement removes it. |
| +func TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal(t *testing.T) { |
| + t.Run("completion", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + b.releasing() |
| + b.closeFence() |
| + if err := b.install(false); err != nil { |
| + t.Fatalf("the fenced source's hold: %v", err) |
| + } |
| + held := *b.row() |
| + if held.Status != "relocating" || held.HoldOp != "rel-1" || b.holds != 1 { |
| + t.Fatalf("held row %+v holds %d", held, b.holds) |
| + } |
| + b.complete() |
| + b.leaves("the source after the completion", held, b.guardRecord()) |
| + b.retireRow() |
| + holds := b.holds |
| + if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.holds != holds { |
| + t.Fatalf("after the park-local: %v row %+v holds %d", err, b.row(), b.holds-holds) |
| + } |
| + }) |
| + for _, fencedHeld := range []bool{false, true} { |
| + name := "failover, row ready when fenced" |
| + if fencedHeld { |
| + name = "failover, row already held when fenced" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + b.releasing() |
| + if fencedHeld { |
| + b.closeFence() |
| + if err := b.install(false); err != nil { |
| + t.Fatalf("the fenced source's hold: %v", err) |
| + } |
| + } |
| + before, record := *b.row(), b.guardRecord() |
| + // hostclaim.FenceSource on a source terminated before its |
| + // release is journaled: a failover in pinning, the source off |
| + // the participants and on lost, its fence journaled. |
| + b.update(func(c *hostclaim.Claim) { |
| + c.Op.Phase, c.Op.Exec, c.Op.Participants = hostclaim.PhasePinning, "none", []string{"dst"} |
| + c.Op.Journal["reason"], c.Op.Journal["fence.box"] = hostclaim.ReasonFailover, `{"key":"fences/box.json"}` |
| + c.Lost = append(c.Lost, "box") |
| + }) |
| + b.leaves("the fenced source during the failover", before, record) |
| + // No qualified discharge: the completion queues its park-local. |
| + b.complete() |
| + b.leaves("the fenced source after the failover's completion", before, record) |
| + b.retireRow() |
| + if got := b.row(); got != nil { |
| + t.Fatalf("the park-local left the row: %+v", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Task 10b test 4. With CARLOS_REGION_HA off, or any value that is not |
| +// true, relocationSourceRows sets neither hook and reconcile is main's: |
| +// the serving executor writes its row through the activation path, and a |
| +// relocating claim is refused by Serving with the row, the claim and the |
| +// guard record unchanged, even once the source's fence has closed. With |
| +// it on, the same box holds the row at the fence's close. |
| +func TestRelocationSourceHoldFollowsTheFlag(t *testing.T) { |
| + for _, v := range []string{"", "0", "false", "off", "yes please", "1", "true"} { |
| + on := v == "1" || v == "true" |
| + t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) { |
| + b := newSourceHoldBox(t, func(k string) string { |
| + if k == "CARLOS_REGION_HA" { |
| + return v |
| + } |
| + return "" |
| + }) |
| + if (b.r.FenceClosed != nil) != on || (b.r.HoldRow != nil) != on { |
| + t.Fatalf("hooks set %v %v with the flag %q", b.r.FenceClosed != nil, b.r.HoldRow != nil, v) |
| + } |
| + if err := b.install(false); err != nil || b.writes != 1 { |
| + t.Fatalf("the serving executor: %v writes %d", err, b.writes) |
| + } |
| + b.releasing() |
| + b.closeFence() |
| + before, record := *b.row(), b.guardRecord() |
| + obs, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if on { |
| + if err := b.install(false); err != nil { |
| + t.Fatalf("region HA on: %v", err) |
| + } |
| + if got := b.row(); got.Status != "relocating" || got.HoldOp != "rel-1" { |
| + t.Fatalf("region HA on: row %+v", got) |
| + } |
| + return |
| + } |
| + for _, dry := range []bool{true, false} { |
| + if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("region HA off (dry run %v): %v", dry, err) |
| + } |
| + } |
| + now, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := b.row(); *got != before || b.writes != 1 || now.ETag != obs.ETag || !bytes.Equal(b.guardRecord(), record) { |
| + t.Fatalf("region HA off changed something: row %+v (was %+v) writes %d claim %v", got, before, b.writes, now.ETag != obs.ETag) |
| + } |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +`internal/host/relocationsourcehold_linux_test.go` (new): |
| + |
| +```go |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "os" |
| + "testing" |
| +) |
| + |
| +// Task 10b: the box's claim assembly is where its Reconciler is built |
| +// (OpenProductionClaimAssembly, whose adapter ClaimAssembly.Build copies). |
| +// It sets FenceClosed and HoldRow with CARLOS_REGION_HA on, and neither |
| +// with it off, so reconcile there is main's. |
| +func TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA(t *testing.T) { |
| + for _, v := range []string{"", "0", "1"} { |
| + on := v == "1" |
| + t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) { |
| + t.Setenv("CARLOS_REGION_HA", v) |
| + store, resources, owners, legacy := fix4JournalFixture(t) |
| + if err := os.Remove(legacy); err != nil { |
| + t.Fatal(err) |
| + } |
| + assembly, err := OpenProductionClaimAssembly(t.Context(), store, resources, owners) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer assembly.Close(t.Context()) |
| + r := assembly.Reconcile.Reconciler |
| + if (r.FenceClosed != nil) != on || (r.HoldRow != nil) != on { |
| + t.Fatalf("CARLOS_REGION_HA=%q: FenceClosed set %v, HoldRow set %v", v, r.FenceClosed != nil, r.HoldRow != nil) |
| + } |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: |
| + |
| +```bash |
| +nice -n 19 go test ./internal/hostbox/ -count=1 -run '^(TestReconcileHoldsTheSourcesRowAtItsFencesClose|TestReconcileSourceHoldNeverTakesAnotherOpsRow)$' |
| +``` |
| + |
| +Expected: FAIL to compile: `undefined: AdmissionFenceClosed`, `undefined: RegistryHoldRow`. (`./internal/host/` fails to compile the same way, with `undefined: relocationSourceRows`.) |
| + |
| +- [ ] **Step 3: The guard's fence and the hold write** |
| + |
| +`internal/hostbox/relocationsource.go` (new): |
| + |
| +```go |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// AdmissionFenceClosed is Reconciler.FenceClosed over this box's host |
| +// guard (region HA §5.3, R5-3): whether the host's admission is closed |
| +// for the installation the guard record has active, on the claim |
| +// observed, read exactly as With reads it (hostguard.Guard.AdmissionFenced). |
| +// Root writes that fence durably when it begins the source's stop |
| +// (hostguard.Guard.BeginFence, before any privileged stop work), so it |
| +// survives an agent restart and a completed stop still reads closed. It |
| +// is not the activator's in-process fence (FenceAndStopAs), which a |
| +// restart empties. A record that cannot be read, or a claim for another |
| +// host or incarnation, is an error, never "open": absence is never |
| +// evidence, and the Reconciler then keeps every row and mints none. It |
| +// takes no lock: Install asks it outside the host lock and holdOnly |
| +// under it, and the record is replaced atomically. |
| +func AdmissionFenceClosed(g hostguard.Guard) func(context.Context, hostclaim.Observed) (bool, error) { |
| + return func(ctx context.Context, o hostclaim.Observed) (bool, error) { |
| + if err := ctx.Err(); err != nil { |
| + return false, err |
| + } |
| + captured, err := hostguard.Capture(g.DataDir, o.Host) |
| + if err != nil { |
| + return false, err |
| + } |
| + return g.AdmissionFenced(o.Host, o.Claim, captured) |
| + } |
| +} |
| + |
| +// RegistryHoldRow is Reconciler.HoldRow over this box's registry (R5-3): |
| +// it keeps the host's existing row held for op in "relocating", a status |
| +// the edge never serves (registry.HoldStatuses), with registry.SetHoldIf |
| +// expecting the row unheld, the conditional write moves' holdExisting |
| +// makes. A row already held for op is a replay and is left exactly as it |
| +// is: nothing is written. A row another op holds is never taken, and |
| +// neither is one whose hold changed between the read and the write |
| +// (SetHoldIf matches only an unheld row): both are ErrChanged with |
| +// nothing written, and the next pass judges afresh. No row is ErrChanged |
| +// and nothing is minted (ruling 17); a row that cannot be read is its |
| +// error. Neither is ever "held". The write sets status and hold_op only, |
| +// so the row's claim ownership stays as it was and the park-local's |
| +// retirement, which deletes by ownership whatever the hold, still finds |
| +// it. |
| +func RegistryHoldRow(reg *registry.Registry) func(context.Context, string, string) error { |
| + return func(ctx context.Context, host, op string) error { |
| + if reg == nil || host == "" || op == "" { |
| + return ErrUnsupported |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + row, err := reg.Get(host) |
| + if err != nil { |
| + return err |
| + } |
| + switch { |
| + case row == nil: |
| + return fmt.Errorf("%w: %s has no row to hold for %s", ErrChanged, host, op) |
| + case row.HoldOp == op && registry.IsHold(row.Status): |
| + return nil |
| + case row.HoldOp != "": |
| + return fmt.Errorf("%w: %s's row is held by %q, not %s", ErrChanged, host, row.HoldOp, op) |
| + } |
| + held, err := reg.SetHoldIf(host, "relocating", op, "") |
| + if err != nil { |
| + return err |
| + } |
| + if !held { |
| + return fmt.Errorf("%w: %s's row changed before it was held for %s", ErrChanged, host, op) |
| + } |
| + return nil |
| + } |
| +} |
| +``` |
| + |
| +`internal/hostbox/reconcile.go`, comments only. In `Reconciler`, replace the `FenceClosed` and `HoldRow` field comments' last lines: |
| + |
| +```go |
| + // closed, today's behaviour. An error keeps rows and mints none (C6). |
| + // Production: AdmissionFenceClosed, region HA on only (Task 10b). |
| + FenceClosed func(context.Context, hostclaim.Observed) (bool, error) |
| + // HoldRow keeps an existing row held for an operation without |
| + // installation authority (ruling 17). Production: RegistryHoldRow, |
| + // region HA on only (Task 10b). |
| + HoldRow func(ctx context.Context, host, holdOp string) error |
| +``` |
| + |
| +Replace `relocationAware`'s comment with: |
| + |
| +```go |
| +// relocationAware is region HA on: Task 10b sets FenceClosed and HoldRow |
| +// only with CARLOS_REGION_HA (host's relocationSourceRows). With neither, |
| +// reconcile is today's: the serving executor alone (Serving), and no |
| +// hold-only path. |
| +``` |
| + |
| +and `holdOnly`'s with: |
| + |
| +```go |
| +// holdOnly keeps an existing row held for the operation, with no |
| +// installation authority: the write is conditional on the row's observed |
| +// hold (registry.SetHoldIf, through RegistryHoldRow), under the host's |
| +// flock. The decision is remade on a fresh read under that lock. |
| +// ErrChanged when there is no row: nothing is minted. |
| +``` |
| + |
| +- [ ] **Step 4: Wire the hooks where the box's reconciler is built** |
| + |
| +`internal/host/relocationsourcehold.go` (new): |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// relocationSourceRows sets the box's Reconciler hooks for region HA |
| +// §5.3 (R5-3, Task 10b), with CARLOS_REGION_HA on only: FenceClosed reads |
| +// this box's admission fence for the host from the host guard |
| +// (hostbox.AdmissionFenceClosed), and HoldRow holds the row for the op in |
| +// "relocating" (hostbox.RegistryHoldRow). The source in releasing is then |
| +// held at its fence's close, and every other relocating participant keeps |
| +// an existing row held. Nothing here releases a source's hold: the |
| +// fenced installation never serves again, so the completion queues the |
| +// source's park-local (hostclaim.CompleteRelocation), whose retirement |
| +// deletes the row by ownership, held or not. With the flag off both hooks |
| +// stay nil and reconcile is main's: Serving alone, which refuses every |
| +// relocating claim with every row unchanged. |
| +func relocationSourceRows(getenv func(string) string, r *hostbox.Reconciler, guard hostguard.Guard, reg *registry.Registry) { |
| + if r == nil { |
| + return |
| + } |
| + r.FenceClosed, r.HoldRow = nil, nil |
| + if getenv == nil || !regionHAEnabled(getenv) { |
| + return |
| + } |
| + r.FenceClosed = hostbox.AdmissionFenceClosed(guard) |
| + r.HoldRow = hostbox.RegistryHoldRow(reg) |
| +} |
| +``` |
| + |
| +`internal/host/claimconstruction.go`, in `OpenProductionClaimAssembly`, directly after the `ReconcileAdapter` is constructed: |
| + |
| +```go |
| + reconcile := &hostbox.ReconcileAdapter{Registry: r.Registry, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: claimRetireJournalDir(r.Mode, r.DataDir)}} |
| + // Region HA R5-3 (Task 10b): the source's row held at its fence's |
| + // close, with CARLOS_REGION_HA on only. Build copies the adapter and |
| + // sets the reconciler's other fields; it keeps these two. |
| + relocationSourceRows(claimEntryEnv(ctx), &reconcile.Reconciler, guard, r.Registry) |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: |
| + |
| +```bash |
| +nice -n 19 go test ./internal/hostbox/ -count=1 -run '^(TestReconcileHoldsTheSourcesRowAtItsFencesClose|TestReconcileSourceHoldNeverTakesAnotherOpsRow|TestReconcile)' |
| +nice -n 19 go test ./internal/registry/ -count=1 -run '^(TestSetHoldIfRelocatingNeverTakesAnotherOpsRow|TestRelocatingIsAHoldStatus)$' |
| +nice -n 19 go test ./internal/host/ -count=1 -run '^(TestRelocationSourceHold|TestProductionClaimAssemblySetsTheSourceHold)' |
| +``` |
| + |
| +Expected: PASS. The task's tests open no socket and pass inside the sandbox. Then the gate in Global Constraints, with `GOOS=windows go vet` of `./internal/host/` and `./internal/registry/` (hostbox's windows vet fails at the base already, at `plan9_fix2_minors_test.go:100`, which this task does not touch). |
| + |
| +**Compile and test record (rev 5.4).** In a throwaway copy of the branch at 42093182 (code as after c9b51c47; the tip adds only a review brief), with `GOFLAGS=-buildvcs=false`, niced: `go build ./...` clean; `go vet` of host, hostbox and registry clean; `gofmt -l` empty; the six tests above pass; the whole `internal/hostbox`, `internal/registry` and `internal/host` packages pass (host outside the sandbox with `TMPDIR=/tmp/k`); `GOOS=darwin`, `GOOS=windows` and `GOARCH=arm64` builds of `./...` clean; `GOOS=windows go vet` of host and registry clean, and `GOOS=darwin go vet` of all three clean. Each guard was mutated in the copy and the task's tests run again: |
| + |
| +| Mutation | Caught by | |
| +|---|---| |
| +| The replay check removed (`row.HoldOp == op`) | `TestReconcileSourceHoldNeverTakesAnotherOpsRow` | |
| +| The explicit other-op refusal removed and the write made unconditional (`SetHold`) | `TestReconcileSourceHoldNeverTakesAnotherOpsRow` | |
| +| No row answered as held | `TestReconcileSourceHoldNeverTakesAnotherOpsRow` | |
| +| An unreadable row answered as held | `TestReconcileSourceHoldNeverTakesAnotherOpsRow` | |
| +| An unreadable fence answered as open | `TestReconcileHoldsTheSourcesRowAtItsFencesClose` | |
| +| The fence always closed | `TestReconcileHoldsTheSourcesRowAtItsFencesClose` | |
| +| The fence never closed | both hostbox tests, `TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal`, `TestRelocationSourceHoldFollowsTheFlag` | |
| +| The hold status changed (`moving` for `relocating`) | `TestReconcileHoldsTheSourcesRowAtItsFencesClose`, `TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal`, `TestRelocationSourceHoldFollowsTheFlag` | |
| +| The `CARLOS_REGION_HA` gate removed from `relocationSourceRows` | `TestRelocationSourceHoldFollowsTheFlag`, `TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA` | |
| +| The call in `OpenProductionClaimAssembly` removed | `TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA` | |
| +| `SetHoldIf`'s `hold_op` guard dropped from its statement | `TestSetHoldIfRelocatingNeverTakesAnotherOpsRow` | |
| + |
| +One mutation survives, by design: removing only the explicit other-op refusal (`row.HoldOp != ""`) leaves `SetHoldIf`'s own guard, which still refuses, so the behaviour is unchanged; the explicit case gives the clearer error. The second row of the table shows the test catches the takeover once that guard is gone too. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/hostbox/relocationsource.go internal/host/relocationsourcehold.go internal/hostbox/reconcile_relocation_source_test.go internal/host/relocationsourcehold_test.go internal/host/relocationsourcehold_linux_test.go internal/host/claimconstruction.go internal/hostbox/reconcile.go internal/registry/relocating_test.go |
| +git commit -m "host: with region HA on, the source's row is held for its relocation once this box's admission fence for the host closes, and only the source's park-local ends that hold (R5-3) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| **Gate:** relocation is not switched on anywhere until this task has landed and its tests pass. |
| @@ -14875,7 +15770,7 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori |
| ## Unresolved in rev 5.3 |
| - **R5-2.** Rev 3's ported lifecycle property (ruling 63) stays withdrawn. The successor case Astra named is now prevented (ruling 77) and tested through the real start transitions; other interleavings the property explored are covered only by the per-hop tests. |
| -- **R5-3.** Accepted while dormant; Task 10b is written and landed before activation (ruling 79). |
| +- **R5-3.** Accepted while dormant; Task 10b is written (rev 5.4) and must land, with its tests passing, before activation (ruling 79). |
| - **R5-4.** Accepted as an explicit activation gate (checklist item 12), with no claim that the per-hop tests are equivalent integration coverage. |
| - **M-K25** is a new moves ask; N11 is its committed fallback. |
| - **C10** is committed as region HA code; the claim session's confirmation is outstanding. |
| @@ -14885,7 +15780,7 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori |
| ## Self-review |
| - **Spec coverage.** §5.8 release (8a), pin and build (8b), local install and receipt (8c), probe admission with `KeepStamp`, `PredecessorCertified`, P opened once, P's marker (8d), the end of the probe and the serving restart (9), who acts and the passes (10); §5.3 under ruling 31 (reconcile leaves relocating rows, R5-1; the source's hold is Task 10b); §6 and §8 as the checklist (10); L18 (11). |
| -- **Order.** No task uses a symbol a later task introduces: each task's code compiled and its tests passed at its own commit (the rev 5.1 record). Task 10b is not built and nothing depends on it. |
| +- **Order.** No task uses a symbol a later task introduces: each task's code compiled and its tests passed at its own commit (the rev 5.1 record). Task 10b is written (rev 5.4) and checked in a throwaway copy, not yet built on the branch; nothing depends on it. |
| - **Placeholders.** None. Every function has its body and every test asserts behaviour; the withdrawn tests are named in R5-2 and R5-4. |
| - **Type consistency.** `RelocationEffects`' fields are 8a's; `RelocationAssemblyConfig` is 8a's, extended by 8c (`OptDir`, the two seams) and 9 (`Discharge`); `relocationOpDir` names every op journal; `CertifiedFor` is shared by the participant and 8d; `MintHeld(rec, captured, status, op)` is called by 8c only; `ProbeStop` by the participant, 9 and N6; `writerSessionReceipt` by 9's stop and N6. |
| - **Dependencies.** Every open ask is in the dependency table with a fallback or an explicit activation gate. C7 is withdrawn. |