Region HA 1b-next stage B plan rev 1 (re-planned against claim Plan 11 on main) + Astra round 1 brief
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+2020
−0
docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md+1995 −0docs/superpowers/plans/CODEX-PLAN-REGION-HA-STAGE-B-BRIEF-1.md+25 −0
CI failed — run details
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| new file mode 100644 |
| index 000000000..9308ab565 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md |
| @@ -0,0 +1,1995 @@ |
| +# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 1) |
| + |
| +> **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. |
| + |
| +**Goal:** Bind stage A's relocation (the claim transitions, the worker, the participant, the release effects) to the box assembly as main now has it after claim Plans 5, 6b, 8/8b, 9 and 11 and moves Plans 1 to 5, so that with `CARLOS_HOST_CLAIM`, `CARLOS_MOVES_PHASE1` and `CARLOS_REGION_HA` all on a box releases, builds, installs, probes and reconciles a relocation; and keep every flag off, so nothing changes until the rollout checklist is walked. |
| + |
| +**Architecture:** Stage B replaces the gated Tasks 8a, 8b, 8c, 9 and 10 of `2026-10-07-region-ha-1b-next-relocation.md` (rev 19, lines 11793 to 13557) and absorbs its Task 11. Every task builds on main's types: `ClaimOwnerDispatch.Relocation`, `ClaimRelocationOwners`, `RegisterProductionClaimRelocationSource`, `StopWriters`, `ClaimAssembly.WritersStopped`, `CollectWritersStopped`, `RelocationReleaseReceipts`, `QueueRelocationRelease`, `Policy.WithRefusals` and `PolicyRefusal`. R8's `Extend` hook is gone: the policy owner is `ClaimRelocationOwners.Policy`, refusals go through `WithRefusals`, and the probe runs through main's `OperationAttempts` dispatch and the activator's `ProbeWake`. Seven tasks: 8a the release providers, 8b the remote build of P, 8c the local install with its held row and the probe's inputs, 8d the relocation owner set and its registration, 9 reconcile under a relocating claim, 10 the passes behind the flags with the rollout checklist, 11 the merge gate. Every open ask on amadan #236 is a dependency with a named fallback, never an assumption. |
| + |
| +**Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `release.Store`; `hostclaim`, `hostactors`, `hostfence`, `hostguard`, `hostexec`, `hostbox`, `hostrelocate`, `hostfiles`, `genselect`, `moves`, `lease`, `activator`, `registry`, `appconfig`, `release`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` (rev 5.4 with §24): §5.3, §5.8, §6, §8. The 1b-next plan's rulings 1 to 27 stand; this plan's rulings continue the numbering at 28. Claim Plan 11 (`docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md` on `origin/design-replica-ownership`, "Region HA integration and rollout", items 1 to 7) and the #236 answers (R1 to R10, C1 to C5, M1 to M3, J2, J3) are the contracts. Every signature below was read on this branch at b250a1fc (main merged at dc333fb5); file:line cites are to this branch. |
| + |
| +--- |
| + |
| +## What this plan replaces, and what it keeps |
| + |
| +| 1b-next section | Here | |
| +|---|---| |
| +| Stage B re-plan table (L11793) | "Obligations" below: each met, re-homed or retired | |
| +| Task 8a | Task 8a (rewritten: main's `WritersStopped`, `ReleaseObligation`, `CollectClaimStop`) | |
| +| Task 8b | Task 8b (rewritten against `moves.BuildGeneration` as merged) | |
| +| Task 8c | Tasks 8c and 8d (the local install now mints the held row; the probe goes through `OperationAttempts` and `ProbeWake`, not a bespoke `Probe` method) | |
| +| Task 9 | Task 9 (reconcile never activates under a relocating claim; hold-only and the completion release) | |
| +| Task 10 | Task 10 (the source registration, the passes, the console chooser, the rollout checklist) | |
| +| Task 11 | Task 11, unchanged in substance | |
| + |
| +Stage A (Tasks 1 to 7, 9a), the merge of main (dc333fb5 and its fix round), R9 (ccce3672) and J3 (2ced0c5d, b250a1fc) are done and are not reopened. |
| + |
| +## Obligations from the 1b-next re-plan table |
| + |
| +| Obligation (round) | Disposition | |
| +|---|---| |
| +| Release providers bound to the real attempt: identity, producers, collect, writers-only stop, descriptors recorded at open (1, 2) | **Met, Task 8a.** `localStopIdentity` (claimwakestop.go:79), `ClaimWakeAssembly.Producers` (claimwakeassembly.go:98), `CollectClaimStop` (claimstop_transport.go:121), `ClaimAssembly.WritersStopped` (claimstop_writers.go:458) which runs `StopWriters` (claimwakewriters.go:118) and root's observation with the hints `recordReplicatorHints` took at open (claimwakewriters.go:228). | |
| +| Every stage A provider a test stubs is tested with its production value (10) | **Met across 8a to 10.** Each task names the production value and the test over real evidence; the pinner's `SelectDB` over a versioned fixture is Task 10's `TestRelocationPinnerSelectsOverTheVersionedBucket`. | |
| +| A production probe launches only through the reservation-bearing isolated root launch; refused at `Preflight` without it; passes off until then (12) | **Met by main, re-homed to Task 10's checklist.** R10 is built (Plan 11 Task D, `isolated-unit-v2`; `ClaimRoleAssembly.Preflight`, claimroleassembly.go:60, refuses with `activator.ErrClaimIsolationUnavailable`). Every probe goes through `ClaimWakeAssembly.Build`'s `Prerequisites` (claimwakeassembly.go:211), which calls `Preflight` first. Task 10 never starts a pass on a box whose preflight refuses: `TestRelocatePassStaysOffWhileThePreflightRefuses`. | |
| +| P's prepared files manifest create-only at sequence 0, bound to {location, P, op, stamp}; the first admission takes it by digest (14) | **Met, Tasks 8b and 8c.** `CopyPinnedManifest` (pin.go:355) writes sequence 0; `PreparedFromInstallManifest` reports it; `RelocationWakeInputs.AdmissionEvidence` (8d) refuses a baseline whose files manifest digest is not `prepared_authority.files_manifest_sha256`. | |
| +| `EnsureLocal`'s production body: verify under `install_stamp`, restore a retired install, never reinstall an opened P (14) | **Met, Task 8c.** `VerifyLocal` and `Install` providers; `EnsureLocal` is main's (relocationeffects.go:867). | |
| +| A source's ordinary cold wake in `starting`/`releasing` dispatches while its fence is open (14) | **Retired.** Claim Plan 11 R1 as accepted: `WakePhase` admits no ordinary relocating wake (hostbox/wakepolicy.go:36-42), and the merge aligned `RelocationRow` to it. A source in `starting` keeps serving on the session it already has; nothing new is admitted. | |
| +| `Receipt`'s production body; a missing receipt after P opened marks stuck (15) | **Met, Task 8c.** `relocationReceipt` reads `<DataDir>/relocate/<host>/<op>/install.json`; `VerifyReceipt` is main's (relocationeffects.go:900). | |
| +| Every cold admission writes its files manifest before the app starts (17) | **Re-homed to the claim: main's C8 provider.** `ClaimFilesManifest.Establish` (claimowners.go, `Materialize`) runs before the app executes, on every kind through the dispatcher. Region HA adds no second copy; Task 8d's `Materialize` delegates to it. | |
| +| The producers' drain is idempotent (7) | **Met by main; pinned in Task 8a** through the resumed drain (`releaseDrain.resumed`). | |
| +| The complete writers-stopped scan is root's, authenticated (2) | **Met by main.** `ClaimStopCoordinator.WritersStopped` (claimstop_writers.go:338) and `LinuxStopKernel.VerifyWritersStopped` (claimstop_writers_linux.go:54). | |
| +| `Region` on every stop request; `Recover` after a restart (2, 3, 4) | **Met, Task 8a.** `releaseStopRequest` already sets `Region` (relocationeffects.go:741); `Collect` sets `StopRequest.Recover` (claimstop.go:53) for a `RecoveryReleaseDrain`. | |
| +| The controller's cleanup scoped; `Clear` is `ClearStale` (3, 4) | **Met, Task 8a** (`relocationCleanup`, `execctl.ClearStale` as relocationeffects.go:932 already does). | |
| +| Clearing rests on a cleanup that succeeded now; the journal under `<DataDir>/relocate/participations` (5) | **Met by main** (`RelocationControllers.Finish`, relocationeffects.go:217-302); Task 8a binds `Cleanup` and `Clear` and tests the restart. | |
| +| The env file restored exactly; `ENOENT` means none (5) | **Met, Task 8c** (`ReadPlatform` on `hostfiles.EnvItem`; `StagePlatformDelete` on the flush is main's, relocationflush.go:221). | |
| +| P built under the location lease, authority re-checked at every write (1, 3, 4) | **Met, Task 8b.** | |
| +| The local install under `install_stamp`; destination inventoried first; held staging through root's relay; never remade after P's admission (1, 4) | **Met, Task 8c**, with M3 as a dependency (below). | |
| +| The probe: P opened once, later attempts ordinary, `"probe"` work, one attempt per `BeginProbeAttempt` (1, 2, 3, 4) | **Met, Tasks 8c and 8d.** The action is main's `claimSpawnAction` (claimoperations.go, `"probe"` for relocating); the attempt count is the claim's (`BeginProbeAttempt`, relocation.go:941) and the start count the activator's (`probeMaxStarts`). | |
| +| Reconcile through 6b's adapter: conditional holds, the completed hold cleared, nothing minted for a box with no role (1, 2, 3, 4) | **Met, Task 9**, with ruling 31: reconcile never activates under a relocating claim; the target's row is minted by its install (Task 8c). | |
| +| Production wiring flag-gated; the passes with the sweep; create's chooser only with HA on (2, 3, 4) | **Met, Task 10.** `applyRegionHA` and R8 are gone; the owners are registered through `RegisterProductionClaimRelocationSource` (claimrelocation.go:32) and opened only with `CARLOS_REGION_HA` on (claimrelocation.go:53). | |
| + |
| +## Ledger notes for stage B |
| + |
| +| Note | Disposition | |
| +|---|---| |
| +| Task 10 wires `ConfigureClaimRuntime` and the `RegionChooser` | Task 10 steps 5 and 3. | |
| +| 8a sets `Recover`; `ReleasePositions` is `Record`/`Recorded` | Task 8a; the interface is relocationeffects.go:470-473. | |
| +| 6b's adapter limits `RelocationRow` to relocating claims | **Retired: done.** `Reconciler.relocates` (hostbox/reconcile.go:53) scopes every row decision to `KindRelocating`. | |
| +| IAM allows writes to the evidence prefix | **Rollout checklist (Task 10).** Root writes `control/hosts/<host>/evidence/<op>/<box>/<id>.json` and `.sig` (claimstop.go:490-494). Not checkable in this repository. | |
| +| Root's scan needs the host PID namespace and `CAP_SYS_PTRACE` | **Rollout checklist.** `carlos-instance-control.service` runs as root with no `CapabilityBoundingSet`, no `PrivatePIDs`, no `ProtectProc` (internal/host/units/carlos-instance-control.service), so it has both today; the dev-box check proves it. | |
| +| `PinAfterRelease` and roster-removed refusals have no time bound | **Retired with reason (ruling 36):** no bound; the pass alerts once per host and cause. A refusal is visible and safe; a bound would move a live host's operation to `stuck` on a transient read error. | |
| +| The replicator descriptor is matched by PID, number and path | **Retired: main's shape.** Root matches the authenticated peer's descriptors by `(fd, device, inode)` (`stopevidence.DescriptorHint`, holders.go:26) against its own `/proc` scan. The residual, the replicator closing a descriptor and other agent code reopening the same file at the same number, stands as accepted: no agent code but the replicator opens a tenant database. Raised if the live witness (dependency W1) shows otherwise. | |
| + |
| +## Review findings carried in |
| + |
| +| Finding | Disposition | |
| +|---|---| |
| +| Merge review 5: an owed `relocation-release` has no way out and wedges the host | Dependency C1 with its fallback; ruling 33. | |
| +| Merge review 8: release order versus Plan 11 item 4 | Ruling 28: Plan 11's order. Task 8a restructures `Release`. | |
| +| Merge review 8: Litestream's lazily opened descriptors | Dependency W1 (the live witness); ruling 29. | |
| +| R9-2: `Route.Version` versus `GoodVersion`; the pinner's principal | Ruling 30: the channel pointer, read from the control bucket; Task 10 wires `StorePinner.Release`. | |
| +| J3-3: a conversion waiting for a target, unbounded | Ruling 35; Task 10 step 6 (alert; bound into stuck after `ReleaseDeadline` from the fence). | |
| +| `CommittedLandingGuard`'s production implementation | Task 10 step 6 (`CommittedLandingRecords` over `provision.GetRecord`). | |
| +| Task 8c reads `install_release` and fills `HeldStageRequest.Stamp` | Task 8c; `Stamp` is dependency M3. | |
| + |
| +## Open dependencies |
| + |
| +Each is modelled as a dependency with a fallback. None is assumed. |
| + |
| +| # | To | Ask | Fallback until answered | Where | |
| +|---|---|---|---|---| |
| +| C1 | claim | A discharge for a fenced or stuck source's `relocation-release`, an op-scoped ID or a fence rule, and release authority for a `probe-failed` target | **Drains stay off** (spec §8 item 4 already holds them). Failover never queues the obligation (`Release` runs only for a live executor in `releasing`/`probe-failed`; `QueueRelocationRelease`, relocationrelease.go:7, refuses `probe-failed`), so a failover-only region cannot wedge. A failed probe on a failover's target goes `stuck` at the deadline for an operator. Task 10's checklist marks C1 as the gate for `reason: drain`. | ruling 33, Task 10 | |
| +| C2 | claim | Verify-only `RelocationReleaseReceipts.Verify` | `Complete`'s replay form (claimrelocationrelease.go:75-82) writes nothing; `ReleaseObligation.Verify` (relocationreleases.go:143) keeps using it; its no-write test stands. | none | |
| +| C3 | claim | Four receipt checks in `Complete` | None needed: parity only. | none | |
| +| C4 | claim | `PredecessorCertified` as the relocation release's certification | Kept as built (`relocationProbeOpening`, generation.go:307). If the claim adds a `released` coverage value instead, Task 8d's `Select` sets `Coverage` from the same rule and `PredecessorCertified` false: one function changes. | Task 8d | |
| +| C5 | claim | The claim's kind in `AdmitWriter`'s op match | `RelocationWakeInputs.Select` refuses any kind but relocating, and the dispatcher routes by kind (claimowners.go:208). | none | |
| +| M1 | moves | Close the move record after a converted landing (`released_move`) | Region HA's half is built here (Task 9 step 5): `CompleteRelocation` journals `released_move` when `conversion == committed-landing`. Until moves relaxes `closeMove`, a converted host cannot move again: operator path. | Task 9 | |
| +| M2 | moves | `sourceOf` accepting a converted claim | Nothing to build here. The source's `park-local` waits; the row is held (`RowRetiring`). | none | |
| +| M3 | moves | The held-stage relay's relocating branch, with `Stamp` on `HeldStageRequest` | Task 8c writes the request with `Version` = `op.journal.install_release` and today's fields; `Stamp` is added the day the field lands. Until the branch exists, `heldStageClaim` refuses the request as stale and removes it; the install re-requests on every pass and waits in `installed`. **Activation gate:** the dev-box check must show the relay serving a relocating request. | Task 8c, Task 10 | |
| +| J2 | moves | Plan 5 Task 9's park arming with the files live set | Not stage B's. Task 11's joint round waits for it. | Task 11 | |
| +| W1 | this plan | The live Litestream witness for lazily opened descriptors (Plan 11 item 4) | If root's observation refuses a hint that misses a descriptor the replicator opened after `Replicate`, the drain fails closed and the operation goes `stuck` at `ReleaseDeadline`; failover is unaffected. Then a claim ask: widen the hint to the agent's own descriptors on the database paths at `StopWriters` time (ruling 29). | Task 10 checklist | |
| +| W2 | this plan | Root's writers-stopped scan on the deployed unit (PID namespace, ptrace) | Fails closed; same cost as W1. | Task 10 checklist | |
| +| W3 | infrastructure | The box role may `PutObject` create-only under `control/hosts/*/evidence/` and `control/relocations/` | Root's receipt write fails, the release fails, the operation goes `stuck`; a pin write fails, the worker retries. | Task 10 checklist | |
| + |
| +## Rulings |
| + |
| +28. **The release runs in Plan 11's order** (merge review 8; Plan 11 item 4): writers stopped and observed, the database and files flushed and the positions recorded at the claim as observed; then the obligation queued (`ReleaseObligation.Queue`); then root's full stop with the producers' own drain alone; then `Complete`. Root's `WritersStopped` needs no obligation (`StopAuthorityMatches`, hostguard/fence.go:266-278: a relocating target with an empty obligation id needs only `op.exec == box`), so nothing is asked of root inside its own fence, and the drain callback never makes a root request. The writers are stopped before the claim owes the stop. *Cost if wrong:* if `Queue` then refuses (the operation moved), the source's writers are stopped for an operation that is `releasing` anyway; the next pass retries `Queue` with `StopWriters` answering its cached witness. A stop whose operation goes `stuck` leaves the host down on the source until the operator resolves it, as before. |
| +29. **The replicator hint is main's; the live witness decides whether it is complete** (W1). This plan changes no hint. If the witness shows descriptors opened after `Replicate`, the ask is to re-take the hint from `/proc/self` at `StopWriters` time, once the containment is empty: every descriptor the agent then holds on the database paths is the replicator's, because no other agent code opens a tenant database. *Cost if wrong:* a drain fails closed until the hint is widened. |
| +30. **The pinned release is the channel pointer's current version** (R9-2): `release.Channel(ctx, store, pub, holder.Account, holder.App, record.Channel).Version` (release.go:1220), the deployment's authority for what a host runs, read from the control bucket by the worker. No box's registry row is read: `Route.Version` and `GoodVersion` (registry.go:77-87) are box-local, and a failover's source is dead. Bake holds pace boxes; they are not the authority. *Cost if wrong:* a relocation during a bad deploy installs the bad build; the probe's three attempts catch it, the operation goes `probe-failed`, then nested or `stuck`, never serving a failing build. |
| +31. **Reconcile never activates under a relocating claim.** Plan 11's policy admits only `"probe"` for relocating (hostbox/wakepolicy.go:187-203) and `checkOn` refuses before any write (reconcile.go:147-156). So the target's held row is minted by the target's local install under its own guard at `install_stamp` (Task 8c, `ReconcileAdapter.MintHeld`), every `RowHeld` decision is hold-only, and `RowReady` for the source in `starting`/`releasing` is "leave the row as it is". *Cost if wrong:* the row's authority is the install's stamp rather than a reconcile activation; both are the same stamp. |
| +32. **A relocation ends with one cold restart on the new box.** `CompleteRelocation` (relocation.go:1003) goes straight to kind `serving`; Plan 11 item 2 says the kind change ends the probe's session and requires a separate serving wake. The target releases its row's hold once the claim serves on it (Task 9 step 4); the activator retires the probe entry whose row is no longer held, main's stop ends the session with a receipt, and the next request wakes the host cold. The `relocating/serving` phase (hostclaim/sessionstep.go:39) is not used here. *Cost if wrong:* a few seconds' cut per relocation, which a drain already pays; the zero-downtime variant (phase `serving` with the probe's session kept, the kind change at the next idle) is a follow-up after Task 11. |
| +33. **Drains wait for C1; failovers do not.** With C1 open, `reason: drain` and `rebalance` stay off (spec §8 item 4). A failover never queues `relocation-release`. *Cost if wrong:* none for failover; a drain started before C1 is answered can wedge its host's claim (merge review 5c). |
| +34. **The local install's writes run under `Guard.With` at `install_stamp` with the install's own claim check, not through a `WorkSource` selection.** Plan 11's policy gives relocating no `install` or `held-config` action, so `RenderHeldUnder` (appconfig/renderheld.go:67) is given a pass-through `hostwrite.Work` inside that `With`. *Cost if wrong:* the claim session may want a typed `install` action for relocating; then the pass-through is replaced by `SelectAt(host, "install", captured)`, one line. |
| +35. **A conversion with no target is bounded like a dead target with no spare** (J3-3): `ReleaseDeadline` after the fence's `observed_at`, the worker marks the operation `stuck` with the chooser's reason, and the pass alerts once per host before that. *Cost if wrong:* a region with no spare for ten minutes gets an operator instead of a later automatic start. |
| +36. **Refusals in `pinning`, `installing` and `probing` stay unbounded; the pass alerts once.** The worker's `refused` bounds only `releasing` and `probe-failed` (worker.go). A roster-removed source elsewhere refuses visibly until an operator acts. *Cost if wrong:* an operator instead of a timer. |
| +37. **The owner set is complete or nothing.** `claimRelocationC1` (claimowners.go:87-95) refuses a partial set with `hostwrite.ErrUnavailable`, which refuses the whole assembly. Task 8d's `Open` returns every field or an error; Task 10 registers nothing unless `CARLOS_REGION_HA` is on. *Cost if wrong:* a box with the flag on and a bad source refuses every claim wake at startup, which is loud and safe. |
| +38. **The target's held row has status `relocating`.** `registry.HoldStatuses` (registry.go:1164) gains the entry; the edge, ticks and `ProbeWake`'s `probeHeld` (probewake.go:238) treat it as any hold. *Cost if wrong:* one map entry; `"landing"` would have lied about the operation. |
| +39. **Refusals registered through `WithRefusals` are two:** a relocating selection whose captured stamp is not the holder's (a stale guard record), and any selection on a host whose claim lists this box in `lost`. They can only refuse. *Cost if wrong:* a redundant check. |
| +40. **L21's platform side is its own plan, not stage B** (below). |
| +41. **The relocation's attempt journal lives under `<DataDir>/relocate/<host>/<op>/attempts.json`**, written whole and renamed, one file per operation, modelled on `movesProbeAttempts` (movesprobeattempts.go:57) without the record. The claim's `probe_attempts.<box>` is the record attempt `k`; the journal binds each admitted session to one `k`. *Cost if wrong:* a second attempt journal shape beside moves'; both answer the same `OperationAttempts` contract. |
| + |
| +## L21: where the platform-provisioned `CARLOS_INSTANCE_KEY` belongs |
| + |
| +**Recommendation: its own plan, after rastrillo/rastrillo #66 agrees the contract.** Reasons: it touches create (`appconfig.ApplyInstance`, instances.go:371, the overlay `apps/<acct>/<app>/instances/<host>.json`), the render path (`<host>.config.env`, syncexec.go), a forced publish for every existing host and a rollout note of its own; none of its code is a relocation contract; stage B is correct without it because the pinner's refusal (ruling 25, `ErrFilesUnevidenced`, pin.go:209) is the backstop that keeps a key-less nonempty database from being relocated empty; and the app side is not agreed, so the platform side cannot be tested end to end. Stage B's cost of leaving it out: a host whose app minted `instance.key` after its files manifest and before the next publish goes `stuck` on relocation instead of relocating. That is the spec's ruling 25 behaviour. The L21 plan should land before region HA is enabled on a region with such apps (Task 10's checklist names it). |
| + |
| +## Global Constraints |
| + |
| +- **Dormant.** Nothing changes on a box or console where `CARLOS_REGION_HA` is unset or not true (`regionHAEnabled`, boxrecord.go:25). The relocation owners open only with it on (claimrelocation.go:53); the passes start only with it and `CARLOS_HOST_CLAIM` on; the console's chooser only with `Config.RegionHA`. |
| +- **Activation gate (claim §1.1, L18).** Relocation does not merge until Task 11's joint round passes on the two-box model. |
| +- **One implementation per concept; main's wins.** No second scanner, receipt verifier, roster reader, fence writer, inventory, retirement or marker publisher. The probe is main's attempt path; the attempt journal is region HA's only because `OperationAttempts` is per kind. |
| +- **Absence is never evidence.** A fence, receipt, pin, manifest or inventory that cannot be read is an error. |
| +- **Exact values** as the 1b-next plan: `FailoverPinDelay` 30 s, `ProbeDeadline` and `ReleaseDeadline` 10 min, `MaxTargetReplacements` 2, `MaxProbeAttempts` 3, obligation `relocation-release`, pin key `control/relocations/<host>/<op>/pin-<gen>.json`, journal `<DataDir>/relocate/`. New here: hold status `relocating`; the attempt journal `attempts.json`; the install journal `install.json`. |
| +- **No new module dependencies. Do not add a method to `release.Store`.** Linux-only code in `*_linux.go` with a `!linux` stub; CI cross-compiles darwin, windows and linux/arm64. |
| +- **Copy.** No user-facing copy. Log lines are operator-facing in the surrounding style. |
| +- **Go environment.** `GOFLAGS=-buildvcs=false`; `GOCACHE=$TMPDIR/gocache` only if the sandbox refuses the default; socket tests `TMPDIR=/tmp/k`; tests niced and targeted (`nice -n 19 go test ./pkg -run X -count=1`); the `internal/host` package only outside the sandbox and once per task. No sweeps. |
| +- **Gate before each commit:** `go build ./... && go vet ./<pkgs>/ && gofmt -l <dirs>` (empty), the task's tests, then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. |
| +- Commit messages: lowercase `area:` prefix, a sentence stating the behaviour, a blank line, `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Branch `region-ha-1b-next-build`. Merging is Paul's, through `amadan branch merge`, after Task 11. |
| + |
| +## Review Focus |
| + |
| +1. **A probe admission certifying the wrong predecessor.** `PredecessorCertified` must be set only on P's opening after this operation's verified release. Task 8d `TestRelocationSelectCertifiesOnlyPsOpeningAfterItsRelease`. |
| +2. **The target's row minted or kept for an installation that is not this box's.** The row's ownership digest must be the install stamp's. Task 8c `TestMintHeldBindsTheRowToTheInstallStamp`; Task 9 `TestReconcileLeavesARelocatingTargetsRowToItsInstall`. |
| +3. **A release returned from a writers-stopped witness alone.** Only root's receipt discharges. Task 8a `TestReleaseNeverReturnsBeforeRootsReceipt`. |
| +4. **A later probe attempt reopening P.** Task 8d `TestRelocationSelectNeverReopensP`. |
| +5. **The post-relocation serving reconcile on the target at a later stamp.** After the probe's session ends and a cold serving wake takes a new stamp, reconcile's adapter must find a coherent inventory. Task 9 `TestReconcileAdapterClearsTheHoldAfterCompletion` runs the real adapter and census through it; if the census refuses, that is a claim ask (the admission carries the install's entries forward), not a region HA workaround. |
| + |
| +## File Structure |
| + |
| +| Path | Responsibility | Task | |
| +|---|---|---| |
| +| `internal/host/relocationassembly.go` | `RelocationAssemblyConfig`; `(ClaimAssembly) RelocationEffects`; the release providers' production bodies; `relocationCleanup` | 8a | |
| +| `internal/host/relocationeffects.go` | `Release` in ruling 28's order; the drain is the producers' alone | 8a | |
| +| `internal/host/relocationbuild.go` | P's build under the lease; `PreparedFromInstallManifest` | 8b | |
| +| `internal/host/relocationinstall.go` | The local install under `install_stamp`; the held row; `VerifyLocal`; `Receipt` | 8c | |
| +| `internal/hostbox/reconcile_adapter.go` | `ownedRow` extracted; `MintHeld` | 8c | |
| +| `internal/provision/reconcile.go` | `RouteFor` exported | 8c | |
| +| `internal/registry/registry.go` | `HoldStatuses["relocating"]` | 8c | |
| +| `internal/host/relocationwake.go` | `RelocationWakeInputs`: `Select`, `Materialize`, `EmptyAuthority`, `Publish`, `PublishFenced`, `AdmissionEvidence`, `PublicationEvidence` | 8d | |
| +| `internal/host/relocationattempts.go` | `relocationProbeAttempts`, region HA's `OperationAttempts` | 8d | |
| +| `internal/host/relocationowners.go` | `RelocationSource` (`ClaimProductionRelocationSource`): hooks, policy, refusals, `relocationAdmit` over `ProbeWake` | 8d | |
| +| `internal/hostrelocate/participant.go` | `CertifiedFor` exported | 8d | |
| +| `internal/hostbox/reconcile.go` | Every `RowHeld` hold-only under relocating | 9 | |
| +| `internal/host/relocationeffects.go` | `Finish` releases the completed operation's hold on the serving box | 9 | |
| +| `internal/hostclaim/relocation.go` | `released_move` on a converted landing's completion (M1) | 9 | |
| +| `internal/host/relocatepass.go` | The passes; the worker's and participant's production providers; the source registration | 10 | |
| +| `internal/host/relocationlanding.go` | `CommittedLandingRecords`; the fenced-landing conversion in the pass | 10 | |
| +| `internal/hostrelocate/worker.go` | The no-target bound (ruling 35) | 10 | |
| +| `internal/edgesfile/snapshot.go` | `LiveSnapshot` (moved from console) | 10 | |
| +| `internal/console/claim_runtime.go`, `boxchoice.go` | `Executors` on the create door; console reads `edgesfile.LiveSnapshot` | 10 | |
| +| `internal/host/agent.go` | Start the pass; register the source | 10 | |
| + |
| +--- |
| + |
| +### Task 8a: The release providers on the claim assembly |
| + |
| +**Files:** |
| +- Create: `internal/host/relocationassembly.go`, `internal/host/relocationassembly_test.go` |
| +- Modify: `internal/host/relocationeffects.go:643-833` (`Release`, `releaseDrain`) |
| + |
| +**Interfaces:** |
| +- Consumes: `ClaimAssembly` (claimassembly.go:93-126: `Claims`, `Guard`, `Wake`, `Reconcile`, `RetirementAuthority`); `localStopIdentity` (claimwakestop.go:79); `ClaimWakeAssembly.Producers` (claimwakeassembly.go:98) and `get` (667); `claimWakeAttempt.rep` (137); `ClaimAssembly.WritersStopped` (claimstop_writers.go:458); `CollectClaimStop` (claimstop_transport.go:121); `StopRequest.Recover` (claimstop.go:53); `ReleaseObligation` (relocationreleases.go:94); `ReleaseFlushFiles` (relocationflush.go:176); `ReleasePositionsFile` (252); `RelocationControllers` (relocationeffects.go:61); `OpenRelocationController` (938); `execctl.Config` (hostexec/controller.go:24-43), `execctl.ClearStale`; `hostactors.Directory.Lookup` (roster.go:175) as `hostquarantine.ActorDirectory`; `genselect.Select` (select.go:202), `Plan.MaxTXID`/`MaxLevel0TXID` (86-95); `lease.Replicator.SyncAndWait` (lease.go:391). |
| +- Produces: `RelocationAssemblyConfig`; `(ClaimAssembly) RelocationEffects(cfg) *RelocationEffects` with `BuildP`, `Install`, `VerifyLocal`, `Receipt`, `Admit` nil until Tasks 8b to 8d set them (the methods refuse nil providers: relocationeffects.go:849-920). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +// The flush refuses a host whose admitted attempt has no replicator: |
| +// there is nothing to sync, and N is never guessed. |
| +func TestRelocationFlushRefusesWithoutTheAdmittedReplicator(t *testing.T) { |
| + a := ClaimAssembly{Wake: &ClaimWakeAssembly{}} |
| + _, err := a.relocationFlushDB(RelocationAssemblyConfig{})(context.Background(), |
| + hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Session: 3}}}) |
| + if !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("got %v", err) |
| + } |
| +} |
| + |
| +// Every provider Task 8a owns is bound under the agent's lifetime. |
| +func TestRelocationEffectsAreBoundToTheAssembly(t *testing.T) { |
| + a := ClaimAssembly{Wake: &ClaimWakeAssembly{}} |
| + e := a.RelocationEffects(RelocationAssemblyConfig{Lifetime: context.Background(), ReleaseDir: t.TempDir(), DataDir: t.TempDir()}) |
| + if e.Lifetime == nil || e.Controllers == nil || e.Identity == nil || e.Controller == nil || e.Producers == nil || e.Collect == nil || |
| + e.WritersStopped == nil || e.Releases == nil || e.FlushDB == nil || e.FlushFiles == nil || e.Positions == nil { |
| + t.Fatalf("an unbound provider: %+v", e) |
| + } |
| + if e.Controllers.Journal == "" || e.Controllers.Cleanup == nil || e.Controllers.Clear == nil { |
| + t.Fatal("the controllers are not journaled or cannot be cleaned") |
| + } |
| +} |
| + |
| +// A recovery drain is submitted as a recovery request (6b's Recover), |
| +// under the recorded owner's identity, never as a live stop. |
| +func TestRelocationCollectMarksARecoveryDrain(t *testing.T) { |
| + var seen StopRequest |
| + a := ClaimAssembly{Wake: &ClaimWakeAssembly{}, collectStop: func(_ context.Context, r StopRequest, _ StopDrainer) (StopResult, error) { seen = r; return StopResult{}, nil }} |
| + e := a.RelocationEffects(RelocationAssemblyConfig{Lifetime: context.Background()}) |
| + if _, err := e.Collect(context.Background(), StopRequest{Host: "shop.example"}, RecoveryReleaseDrain{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !seen.Recover { |
| + t.Fatal("a RecoveryReleaseDrain was submitted as a live stop") |
| + } |
| + if _, err := e.Collect(context.Background(), StopRequest{Host: "shop.example"}, &releaseDrain{}); err != nil || seen.Recover { |
| + t.Fatalf("a live drain was marked recovery: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +Three more tests go in `relocationeffects_test.go`, against the restructured `Release` (step 3), using the file's existing fixture (`rootCollect`, main's `ReleaseObligation` over a dir store, a recording `WritersStopped`, a `ReleasePositionsFile`): |
| + |
| +- `TestReleaseStopsWritersAndFlushesBeforeItQueues`: the recorded call order is `writers-stopped, flush-db, flush-files, record, queue, collect(drain), complete`; the claim read by `WritersStopped` has the pre-queue ETag; `rootCollect` sees the post-queue ETag and the obligation owed; the drain it is handed is the resumed form (`DrainClaim` calls only the producers' drain). |
| +- `TestReleaseNeverReturnsBeforeRootsReceipt` (Review Focus 3): a `rootCollect` that refuses leaves the release unreturned and the positions recorded; the next call takes the resumed path and asks root again, flushing nothing twice. |
| +- `TestReleaseAfterARestartRecoversTheRecordedOwner`: the existing "same ids, new process" case is kept; the recovery request still names the recorded boot and controller. |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ -run 'RelocationFlush|RelocationEffectsAreBound|RelocationCollect|TestRelease' -count=1` |
| +Expected: FAIL to compile: `undefined: RelocationAssemblyConfig`. |
| + |
| +- [ ] **Step 3: `Release` in ruling 28's order** |
| + |
| +In `internal/host/relocationeffects.go`, replace the body of `Release` from the `Queue` call to the end, and `releaseDrain.DrainClaim`: |
| + |
| +```go |
| + id, err := e.Identity(ctx) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if id.PID <= 0 || id.ProcessStart == "" { |
| + return hostclaim.Release{}, ErrClaimStopUnavailable |
| + } |
| + if key, digest := obs.Claim.Op.Journal["release_stop_receipt"], obs.Claim.Op.Journal["release_stop_sha256"]; key != "" || digest != "" { |
| + return e.replayDischarged(ctx, obs, id, key, digest) |
| + } |
| + h := obs.Claim.Holder |
| + req := releaseStopRequest(obs, id) |
| + recorded, flushed, err := e.Positions.Recorded(ctx, req) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if !flushed { |
| + // Steps 1 to 3 at the claim as observed, before it owes anything |
| + // (ruling 28, Plan 11 item 4): writers stopped and observed by |
| + // root, the database at N, the files at M, the positions durable. |
| + drained, seen, err := e.WritersStopped(ctx, obs) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if err := drained.For(obs); err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if seen.Host != obs.Host || seen.Incarnation != h.Incarnation || seen.InstallationStamp != h.Stamp || seen.WriterSession != h.Session || |
| + seen.Box != id.Actor.Box || seen.Enrollment != id.Actor.Enrollment || seen.CheckedAt.IsZero() { |
| + return hostclaim.Release{}, fmt.Errorf("%w: writers-stopped evidence names another session", ErrClaimStopIdentity) |
| + } |
| + txid, err := e.FlushDB(ctx, obs) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + seq, err := e.FlushFiles(ctx, obs) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + recorded = ReleasePosition{Request: req, PID: id.PID, ProcessStart: id.ProcessStart, TXID: txid, Seq: seq} |
| + if err := e.Positions.Record(ctx, recorded); err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + } |
| + // Root fences a relocation-release stop only while the claim owes it |
| + // (hostguard.StopAuthorityMatches): queued now, at the flushed claim. |
| + if obs, err = e.Releases.Queue(ctx, obs); err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + req = releaseStopRequest(obs, id) |
| + req.ExpectedDirectoryRevision = recorded.Request.ExpectedDirectoryRevision |
| + var d StopDrainer = RecoveryReleaseDrain{} |
| + if recorded.owner().local(id) { |
| + inner, err := e.Producers(ctx, obs) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + d = &releaseDrain{inner: inner} |
| + } else { |
| + req.BootID, req.ControllerID = recorded.Request.BootID, recorded.Request.ControllerID |
| + } |
| + result, err := e.Collect(ctx, req, d) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if _, err := e.Releases.Complete(ctx, obs, result.Key, result.Digest); err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + return hostclaim.Release{ReleasedBy: id.Actor.Box, Enrollment: id.Actor.Enrollment, Generation: h.Generation, Session: h.Session, |
| + FinalTXID: recorded.TXID, ManifestSeq: recorded.Seq, Receipt: result.Key, ReceiptSHA256: result.Digest}, nil |
| +} |
| + |
| +// releaseDrain is the producers' own drain: steps 1 to 3 ran before the |
| +// obligation was queued (ruling 28), so root's collection closes the |
| +// producers and acknowledges, nothing more. |
| +type releaseDrain struct{ inner StopDrainer } |
| + |
| +func (d *releaseDrain) DrainClaim(ctx context.Context, r StopRequest, fence string) (DrainAcknowledgement, error) { |
| + if d.inner == nil { |
| + return DrainAcknowledgement{}, ErrClaimStopUnavailable |
| + } |
| + return d.inner.DrainClaim(ctx, r, fence) |
| +} |
| +``` |
| + |
| +Delete the old `releaseDrain` fields (`e`, `obs`, `id`, `txid`, `seq`, `done`, `resumed`). The `flushed && !local` branch keeps its meaning: a restarted process asks root for the recorded owner's durable receipt with a refusing `RecoveryReleaseDrain`, and the `Collect` provider marks it `Recover`. |
| + |
| +- [ ] **Step 4: The bindings** |
| + |
| +`internal/host/relocationassembly.go`: |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/controlsock" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RelocationAssemblyConfig is what the relocation providers need beyond |
| +// the assembly itself. Task 10 fills it once per agent. |
| +type RelocationAssemblyConfig struct { |
| + Lifetime context.Context // the agent's context: controllers live under it |
| + // Exec is hostexec's config without Binding and Cleanup, which are set |
| + // per participation: Store, Guard, Directory (hostactors.Directory, |
| + // roster.go:175), Clock, CloseAdmission, CancelWork. LockDir empty |
| + // takes hostexec's production defaults (controller.go:71-76). |
| + Exec execctl.Config |
| + Directory hostactors.Directory // the roster: release receipts verify under the releasing box's census key |
| + Data release.Store // the region's data bucket (files manifests) |
| + Versions lease.VersionedStore // the same bucket, versioned (LTX files, install manifests) |
| + Bucket string |
| + Region string // the lease region (lease.Acquire) |
| + Registry *registry.Registry |
| + FilesDir func(host string) string |
| + EnvPath func(host string) string |
| + DataDir string // hostguard.Guard.DataDir |
| + WorkDir string // <DataDir>/relocate/build |
| + ReleaseDir string // <DataDir>/relocate/release |
| + SecretsKey *ecdh.PrivateKey // CARLOS_SECRETS_KEY, for RenderHeldUnder (Task 8c) |
| + // Probe is the activator's ProbeWake (Task 8d). |
| + Probe func(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) |
| + Now func() time.Time |
| +} |
| + |
| +// RelocationEffects binds every provider to this assembly: one value per |
| +// agent, its controllers outliving every pass. Tasks 8b to 8d set BuildP, |
| +// Install, VerifyLocal, Receipt and Admit. |
| +func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig) *RelocationEffects { |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + receipts := RelocationReleaseReceipts{Store: a.Claims, Directory: cfg.Directory} |
| + e := &RelocationEffects{ |
| + Lifetime: cfg.Lifetime, |
| + Controllers: &RelocationControllers{Journal: filepath.Join(cfg.DataDir, "relocate", "participations"), DataDir: cfg.DataDir, |
| + Cleanup: func(ctx context.Context, b execctl.Binding) error { return a.relocationCleanup(ctx, cfg, b) }, |
| + Clear: func(ctx context.Context, b execctl.Binding) error { c := cfg.Exec; c.Binding = b; return execctl.ClearStale(ctx, c) }}, |
| + Identity: func(ctx context.Context) (RelocationIdentity, error) { return a.localStopIdentity(ctx) }, |
| + Controller: func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + c := cfg.Exec |
| + c.Binding = b |
| + c.Cleanup = func(ctx context.Context) error { return a.relocationCleanup(ctx, cfg, b) } // ruling 20: the relocation's own partial work |
| + return OpenRelocationController(ctx, c) |
| + }, |
| + Producers: func(ctx context.Context, o hostclaim.Observed) (StopDrainer, error) { |
| + if a.Wake == nil { |
| + return nil, ErrClaimStopUnavailable |
| + } |
| + p, err := a.Wake.Producers(ctx, o) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return p, nil |
| + }, |
| + Collect: func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + if _, ok := d.(RecoveryReleaseDrain); ok { |
| + r.Recover = true // 6b's authenticated recovery path: root replays the durable receipt |
| + } |
| + if a.collectStop != nil { |
| + return a.collectStop(ctx, r, d) |
| + } |
| + return CollectClaimStop(ctx, controlsock.Path, r, d) |
| + }, |
| + WritersStopped: func(ctx context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) { |
| + return a.WritersStopped(ctx, o.Host) |
| + }, |
| + Releases: ReleaseObligation{Receipts: receipts}, |
| + FlushDB: a.relocationFlushDB(cfg), |
| + FlushFiles: ReleaseFlushFiles{Data: cfg.Data, Registry: cfg.Registry, FilesDir: cfg.FilesDir, EnvPath: cfg.EnvPath}.Flush, |
| + Positions: ReleasePositionsFile{Dir: cfg.ReleaseDir}, |
| + } |
| + return e |
| +} |
| + |
| +// relocationFlushDB is release step 2 (ruling 8): SyncAndWait with every |
| +// writer stopped, then N is the newest level-0 MaxTXID, which must equal |
| +// the plan's MaxTXID. A listing with no LTX file is the stopped writer |
| +// vouching that it never committed (claim §6.4a "Empty bootstrap"): N is |
| +// 0, which lets the pin be empty (ruling 15). |
| +func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (uint64, error) { |
| + return func(ctx context.Context, o hostclaim.Observed) (uint64, error) { |
| + if a.Wake == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + att := a.Wake.get(o.Host) |
| + if att == nil || att.rep == nil || cfg.Versions == nil || cfg.Bucket == "" { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + if err := att.rep.SyncAndWait(ctx); err != nil { |
| + return 0, err |
| + } |
| + plan, err := genselect.Select(ctx, cfg.Versions, genselect.Request{Bucket: cfg.Bucket, Region: o.Claim.Op.Intent["region"], Host: o.Host, Holder: *o.Claim.Holder}) |
| + if errors.Is(err, genselect.ErrEmpty) { |
| + return 0, nil |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + if plan.MaxLevel0TXID != plan.MaxTXID { |
| + return 0, fmt.Errorf("%w: level 0 at %d, the database at %d", ErrClaimStopUnavailable, plan.MaxLevel0TXID, plan.MaxTXID) |
| + } |
| + return plan.MaxTXID, nil |
| + } |
| +} |
| + |
| +// relocationCleanup is the relocation controller's scoped cleanup (ruling |
| +// 20): it checks the binding is this box's, and retires only this |
| +// operation's unopened local installation of the host through main's |
| +// typed retirement (ReconcileAdapter.Retire over the held row the install |
| +// minted, Task 8c), which asks root for the installation's session-zero |
| +// witness (R4) and parks what the inventory names. It does nothing once |
| +// this box admitted a writer under the operation or serves the host. |
| +func (a ClaimAssembly) relocationCleanup(ctx context.Context, cfg RelocationAssemblyConfig, b execctl.Binding) error { |
| + local, err := a.localStopIdentity(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if b.Box != local.Actor.Box || b.Enrollment != local.Actor.Enrollment { |
| + return fmt.Errorf("%w: participation %s/%s is not this box's", hostrelocate.ErrRefused, b.Host, b.Op) |
| + } |
| + obs, err := hostclaim.Read(ctx, a.Claims, b.Host) |
| + if err != nil { |
| + return err |
| + } |
| + c := obs.Claim |
| + if obs.Absent || c.Holder == nil || c.Holder.Incarnation != b.Incarnation { |
| + return nil |
| + } |
| + if c.Holder.WriterBox == local.Actor.Box || (c.Op.Kind == hostclaim.KindServing && c.Op.Exec == local.Actor.Box) { |
| + return nil |
| + } |
| + for _, adm := range c.Holder.Admissions { |
| + if adm.Box == local.Actor.Box { |
| + return nil |
| + } |
| + } |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Op.ID != b.Op || c.Op.Exec != local.Actor.Box || c.Op.Journal["install_stamp"] == "" || hostclaim.PreparedOpened(c) { |
| + return nil |
| + } |
| + if a.Reconcile == nil || cfg.Registry == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + row, err := cfg.Registry.Get(b.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row == nil || row.HoldOp != c.Op.ID || !row.ClaimOwnership.Valid() { |
| + return nil // nothing of this operation's is installed here yet |
| + } |
| + return a.Reconcile.Retire(ctx, *row) // hostbox/reconcile_adapter.go:160 |
| +} |
| +``` |
| + |
| +`RelocationIdentity` is `claimStopLocal` (relocationeffects.go:25), so `localStopIdentity` returns it directly. `ReleaseObligation`, `ReleaseFlushFiles`, `ReleasePositionsFile`, `OpenRelocationController` and `RelocationControllers` are stage A's as merged. |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ -run 'RelocationFlush|RelocationEffectsAreBound|RelocationCollect|TestRelease|TestADischargedRelease|TestStopReceiptReleases' -count=1` (outside the sandbox, `TMPDIR=/tmp/k`) |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/host/relocationassembly.go internal/host/relocationassembly_test.go internal/host/relocationeffects.go internal/host/relocationeffects_test.go |
| +git commit -m "host: the release stops and flushes at the observed claim, then queues the obligation and asks root; the providers bind to the claim assembly |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8b: Build P and verify it |
| + |
| +**Files:** |
| +- Create: `internal/host/relocationbuild.go`, `internal/host/relocationbuild_test.go` |
| +- Modify: `internal/host/relocationassembly.go` (set `BuildP`) |
| + |
| +**Interfaces:** |
| +- Consumes: `hostrelocate.ReadPin` (pin.go:326), `CopyPinnedManifest` (355), `PinObject` (28), `FilesPin` (46); `moves.BuildGeneration(ctx, BuildInputs) (InstallManifest, error)` (build.go:162), `BuildInputs{Store lease.VersionedStore; Bucket string; Source []SealedObject; N uint64; GenerationPrefix, Op, Host, WorkDir string; Now func() time.Time; Verify func(string) error}` (105-116), `SealedObject{Key, VersionID string; Size int64}` (sealedclient.go:22), `InstallManifestKey`, `SnapshotKey`, `AnchorKey` (build.go:93, 80, 85), `InstallManifest` (install_contract.go:11-25); `lease.Acquire` (lease.go:153), `Held.Lost`/`Release` (222, 270), `lease.CheckDB` (check.go:35); `hostguard.Capture` (record.go:103); `hostfiles.ManifestKey`, `Manifest` (manifest.go:57-64), `ManifestVersion`. |
| +- Produces: `(ClaimAssembly) relocationBuild(cfg)`; `PreparedFromInstallManifest{Claims, Files release.Store; Data lease.VersionedStore; Bucket string}` implementing `hostrelocate.Prepared`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +The tests are the 1b-next plan's (L12206-12250), unchanged in intent: `TestPreparedFromInstallManifestNeedsBothManifests`, `TestPreparedRefusesAPartialBuild` (files manifest only; install manifest only) and `TestRelocationBuildStopsWhenAuthorityChanges` (after the files manifest; after the snapshot PUT; after the stamp is retired). `buildFixture` uses `release.NewDirStore` for files and pins, the in-memory `lease.VersionedStore` fake `internal/moves` tests use (`moves/build_test.go`), a claim at `installing` with a journaled pin and `install_stamp`, a guard record activated at that stamp, and a hook after each durable write. Add `TestRelocationBuildReplaysToTheSameBytes`: a second run after a crash between the two steps reuses the intent, snapshot and anchor (`BuildGeneration`'s `putOrReuse`) and the files manifest copy succeeds on its own earlier copy. |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ -run 'PreparedFromInstallManifest|PreparedRefuses|RelocationBuild' -count=1` |
| +Expected: FAIL to compile: `undefined: PreparedFromInstallManifest`. |
| + |
| +- [ ] **Step 3: The build and the verifier** |
| + |
| +`internal/host/relocationbuild.go` is the 1b-next plan's L12264-12518 with these bindings, every name verified on this branch: |
| + |
| +- `relocationBuild` reads the pin with `hostrelocate.ReadPin(ctx, a.Claims, ref)` (the journaled `RecoveryPin` carries `Release`; `ReadPin` refuses a mismatch); the authority closure re-reads the claim (`op.id`, `PhaseInstalling`, `op.exec` == this box, `prepared`, `install_stamp`, `pin`, no `stuck`) and `hostguard.Capture(a.Guard.DataDir, host)` (`Incarnation`, `Stamp`); `lease.Acquire(ctx, cfg.Bucket, holder.Location, cfg.Region)`; `hostrelocate.CopyPinnedManifest(alive, a.Claims, cfg.Data, pin.Files, location+"/files/"+p+"/")` (`hostfiles.ErrManifestExists` on a replay is success when the bytes match: compare with a `Get` of the manifest and `sameEntries`); `moves.BuildGeneration(alive, moves.BuildInputs{Store: authorityStore{...}, Bucket, Source, N: pin.DB.MaxTXID, GenerationPrefix: location+"/"+p+"/", Op, Host, WorkDir: filepath.Join(cfg.WorkDir, op), Now: cfg.Now, Verify: lease.CheckDB})`. `authorityStore` wraps `PutIfAbsent` and `CopyVersion` as before and refuses `DeleteVersion`; check `lease.VersionedStore`'s method set (versioned.go:45-55) and wrap every write it declares. |
| +- `PreparedFromInstallManifest.Verify` as L12415-12503: P's files manifest at sequence 0 (`m.Seq != 0` refuses; `SourceFilesSeq` is `pin.Files.Seq`), the retained copy's digest and entries, then the install manifest at `moves.InstallManifestKey(prefix)` by its current version, its `Op`, `Host`, `N`, keys, versions and `IntegrityCheck == "ok"`, and both named versions present. It fills `hostclaim.PreparedAuthority{Generation, InstallManifest, SHA256, SnapshotTXID, FilesSeq: 0, SourceFilesSeq: pin.Files.Seq, FilesManifestSHA256}` (relocation.go:131-152; `InstallStamp` is `ToInstalled`'s). |
| +- Leftover `.restore-*.db-wal`/`.db-shm` beside the restored database: main's fix (0d338c14) is in `moves`; `relocationBuild` removes nothing itself and the test asserts `WorkDir/<op>` holds only `<host>.db` after a build. |
| + |
| +In `RelocationEffects` (Task 8a) add `BuildP: a.relocationBuild(cfg)`. |
| + |
| +- [ ] **Step 4: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ -run 'PreparedFromInstallManifest|PreparedRefuses|RelocationBuild' -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/host/relocationbuild.go internal/host/relocationbuild_test.go internal/host/relocationassembly.go |
| +git commit -m "host: P is built under the replica lease from the pinned versions with moves' install routine, files manifest first, and verified by reading both back |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8c: The local install, its held row and the probe's inputs |
| + |
| +**Files:** |
| +- Create: `internal/host/relocationinstall.go`, `internal/host/relocationinstall_test.go` |
| +- Modify: `internal/hostbox/reconcile_adapter.go:40-158` (extract `ownedRow`; add `MintHeld`), `internal/provision/reconcile.go:425` (`RouteFor`), `internal/registry/registry.go:1164` (`HoldStatuses["relocating"]`), `internal/host/relocationassembly.go` (set `Install`, `VerifyLocal`, `Receipt`) |
| +- Test: `internal/hostbox/reconcile_adapter_mint_test.go`, `internal/registry/registry_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `hostguard.Guard.Activate` (guard.go:183), `With` (116), `Capture`; `hostfiles.Restore` (restore.go:59), `ReadPlatform` (203), `LoadWake` (wake.go:125), `ImportWake` (75), `EnvItem` (platform.go:13); `appconfig.RenderHeldUnder` (renderheld.go:67), `HeldRender` (33); `release.MaterializeHeld` (materializeheld.go:137), `ErrStageBlocked` (26), `HeldStageRequest` (451-458), `LiveVersion` (versions.go:308), `OptDir` (37), `NewProofGate`, `ProofPolicyPath`; `writeHeldStageRequest` (movesstage.go:106); `ReconcileAdapter` (reconcile_adapter.go:23-33), `EmptyInstallationCensus.Initialize`; `registry.Upsert` (registry.go:1071), `SetHold` (1176), `IsHold` (1167); `provision.Record` (provision.go:38), `NestedPaths`, `RecordKey`, `ClaimRecord` (claimassembly.go:12); `hostops.ReadRecord` (record.go:43). |
| +- Produces: `(ClaimAssembly) relocationInstall(cfg)`, `relocationVerifyLocal(cfg)`, `relocationReceipt(cfg)`; `(ReconcileAdapter) MintHeld(ctx, rec provision.ClaimRecord, captured hostguard.Captured, status, op string) error`; `provision.RouteFor`; `relocationInstallJournal`; `relocationInstallPath(cfg, host, op)`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package host |
| + |
| +// The install runs only under the stamp the installing CAS issued, and |
| +// never once any admission has named P. |
| +func TestRelocationInstallIsBoundToTheInstallStamp(t *testing.T) { /* guard record at another stamp: hostguard.ErrStampMismatch, nothing written; P opened (prepared_opened journaled): hostrelocate.ErrRefused; happy path: receipt.InstallStamp == journaled, JournalSHA256 == sha256 of install.json */ } |
| + |
| +// A fresh spare: the files directory and the env file are restored from |
| +// P's manifest, the wake state imported, and the row minted held by the |
| +// operation with status relocating, owned by the install stamp. |
| +func TestRelocationInstallRestoresAndMintsTheHeldRow(t *testing.T) { /* dir store with P's manifest (two files, host.env, wake state); after Install: files bytes equal, env equal, timers in the registry, row {Status: "relocating", HoldOp: op, ClaimOwnership.ClaimStamp == install_stamp}; a second Install returns the same receipt and writes nothing new (inventory entry count unchanged) */ } |
| + |
| +// Under the edge unit's restrictions MaterializeHeld is blocked: the |
| +// install leaves a held-stage request naming install_release, never the |
| +// route's version, and reports ErrNotYet until live/<host> names it. |
| +func TestRelocationInstallRequestsRootsHeldStageWithThePinnedRelease(t *testing.T) { /* read-only optDir; journal install_release "v1.5.0" and a route Version "v1.4.0"; after Install: HeldStageDir/<host>.json has Version "v1.5.0", Op, Box; err is ErrNotYet; then write live/<host> -> v1.5.0 (the test's own symlink) and Install returns the receipt */ } |
| + |
| +// VerifyLocal is true only when the files, env and wake state are what P's |
| +// manifest says under the receipt's stamp; a retired tree is false. |
| +func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) { /* true after Install; false after removing one file; false after the guard record moves to another stamp */ } |
| + |
| +// Receipt reads the durable journal; a missing or damaged one is (false, nil). |
| +func TestRelocationReceiptIsTheDurableJournal(t *testing.T) {} |
| + |
| +// MintHeld binds the row to the install stamp (Review Focus 2). |
| +func TestMintHeldBindsTheRowToTheInstallStamp(t *testing.T) { /* in hostbox: a captured stamp that is not the claim holder's: ErrChanged; the route minted equals provision.RouteFor's for the record; ClaimOwner is sha256(host\x00inc\x00stamp\x00box); a second call is idempotent */ } |
| + |
| +// "relocating" is a hold. |
| +func TestRelocatingIsAHoldStatus(t *testing.T) { /* registry.IsHold("relocating") */ } |
| +``` |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ ./internal/hostbox/ ./internal/registry/ -run 'RelocationInstall|RelocationVerifyLocal|RelocationReceipt|MintHeld|RelocatingIsAHold' -count=1` |
| +Expected: FAIL to compile. |
| + |
| +- [ ] **Step 3: `MintHeld`, `RouteFor` and the hold status** |
| + |
| +In `internal/registry/registry.go:1164` add `"relocating": true` to `HoldStatuses`. In `internal/provision/reconcile.go:425` rename `routeFor` to `RouteFor` (and its callers). In `internal/hostbox/reconcile_adapter.go` move the body of `Install`'s closure (lines 83-157: capture check, `ReadInstallation`, `Census.Initialize`, the route entry, `write(owner)`, materialized) into: |
| + |
| +```go |
| +// ownedRow is the typed-inventory half of a row write under the host |
| +// guard at captured: the installation's inventory (initialised by the |
| +// census when none exists), one route entry owned by captured's digest, |
| +// write(owner), and the entry materialized. Install calls it under |
| +// Reconciler.Install; MintHeld under a relocation install's own With. |
| +func (a ReconcileAdapter) ownedRow(ctx context.Context, rec provision.ClaimRecord, inc string, selected RecordSelection, captured hostguard.Captured, write func(registry.ClaimOwnership) error) error { |
| + /* lines 83-157 as they are, with captured and inc as parameters */ |
| +} |
| + |
| +// MintHeld mints or keeps the host's row held by op with status under the |
| +// installation captured, for a relocation target's local install (region |
| +// HA ruling 31): the row reconcile would mint for rec (RouteFor), with its |
| +// ClaimOwnership, status and hold_op set in the same Upsert; an existing |
| +// row must already be this installation's and held by op. |
| +func (a ReconcileAdapter) MintHeld(ctx context.Context, rec provision.ClaimRecord, captured hostguard.Captured, status, op string) error { |
| + if !registry.IsHold(status) || op == "" || a.Registry == nil || a.Inventory == nil { |
| + return ErrUnsupported |
| + } |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + return err |
| + } |
| + if captured.Incarnation != inc { |
| + return ErrChanged |
| + } |
| + layout := "flat" |
| + if rec.Family != "sidecar" && provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) { |
| + layout = "nested" |
| + } |
| + selected := RecordSelection{Host: rec.Host, Key: rec.Key, Incarnation: inc, Family: rec.Family, Layout: layout} |
| + return a.ownedRow(ctx, rec, inc, selected, captured, func(owner registry.ClaimOwnership) error { |
| + row, err := a.Registry.Get(rec.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row != nil { |
| + if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) { |
| + return registry.ErrClaimOwnership |
| + } |
| + return nil |
| + } |
| + rt := rec.Route |
| + rt.ClaimOwnership, rt.Status, rt.HoldOp = owner, status, op |
| + return a.Registry.Upsert(rt) |
| + }) |
| +} |
| +``` |
| + |
| +`ownedRow`'s `ReadInstallation` branch calls `a.Census.Initialize(ctx, observed, selected)` with the claim read fresh, exactly as today; `Install` passes its own `selected` and the captured stamp it captured. |
| + |
| +- [ ] **Step 4: The local install** |
| + |
| +`internal/host/relocationinstall.go`: |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// relocationInstallJournal is the box's durable record of its install for |
| +// one operation (<DataDir>/relocate/<host>/<op>/install.json); its digest |
| +// is the receipt's journal_sha256. |
| +type relocationInstallJournal struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Op string `json:"op"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + InstallStamp uint64 `json:"install_stamp"` |
| + Prepared string `json:"prepared"` |
| + Steps map[string]string `json:"steps"` // files, wake, env, row, render, materialize |
| +} |
| + |
| +func relocationInstallPath(cfg RelocationAssemblyConfig, host, op string) string { |
| + return filepath.Join(cfg.DataDir, "relocate", host, op, "install.json") |
| +} |
| + |
| +// relocationRecord reads the host's authoritative record and the route |
| +// reconcile would mint for it on this box. |
| +func relocationRecord(ctx context.Context, cfg RelocationAssemblyConfig, claims release.Store, o hostclaim.Observed) (provision.ClaimRecord, error) { |
| + rec, err := hostops.ReadRecord(ctx, claims, o.Claim.Holder.Key) |
| + if err != nil { |
| + return provision.ClaimRecord{}, err |
| + } |
| + var record provision.Record |
| + if err := json.Unmarshal(rec.Body, &record); err != nil { |
| + return provision.ClaimRecord{}, err |
| + } |
| + if record.Host != o.Host || record.Kind != "" && record.Kind != "instance" || record.Addr != "" || record.Unit != "" { |
| + return provision.ClaimRecord{}, fmt.Errorf("%w: %s is not a relocatable instance record", hostrelocate.ErrRefused, o.Host) |
| + } |
| + nested := provision.NestedPaths(cfg.DataDir, registry.Route{Host: o.Host}) |
| + rt := provision.RouteFor(record, cfg.DataDir, "", nested) |
| + return provision.ClaimRecord{Host: o.Host, Key: o.Claim.Holder.Key, Family: "instance", Account: record.Account, App: record.App, CreatedAt: record.CreatedAt, Value: record, Route: rt}, nil |
| +} |
| + |
| +// guardedWork is the pass-through hostwrite.Work for writes already under |
| +// the install's Guard.With (ruling 34). |
| +type guardedWork struct{} |
| + |
| +func (guardedWork) With(_ context.Context, _ hostwrite.Identity, fn func() error) error { return fn() } |
| + |
| +// relocationInstall is §5.8's local install (B1-2), under the host guard |
| +// with the stamp the installing CAS issued: files, wake state and env from |
| +// P's manifest; the row minted held by the operation (ruling 31); the held |
| +// config rendered and the pinned release staged through root's relay (R9, |
| +// M3); every step journaled; then the receipt. Idempotent: a pass that |
| +// ends in ErrNotYet (the stage not live yet) is repeated whole. |
| +func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + return func(ctx context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + if cfg.Data == nil || cfg.Registry == nil || a.Reconcile == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return hostclaim.LocalInstall{}, ErrClaimStopUnavailable |
| + } |
| + c := o.Claim |
| + local, err := a.localStopIdentity(ctx) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + stamp, err := strconv.ParseUint(c.Op.Journal["install_stamp"], 10, 64) |
| + if err != nil || stamp == 0 { |
| + return hostclaim.LocalInstall{}, fmt.Errorf("%w: no install stamp", hostrelocate.ErrRefused) |
| + } |
| + if hostclaim.PreparedOpened(c) { |
| + return hostclaim.LocalInstall{}, fmt.Errorf("%w: P was admitted; the local install is never remade", hostrelocate.ErrRefused) // B2-5 |
| + } |
| + var ref hostclaim.RecoveryPin |
| + if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + pin, err := hostrelocate.ReadPin(ctx, a.Claims, ref) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + p := c.Op.Journal["prepared"] |
| + prefix := c.Holder.Location + "/files/" + p + "/" |
| + origins := append(append([]string(nil), pin.Files.Origins...), prefix) |
| + rec, err := relocationRecord(ctx, cfg, a.Claims, o) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if err := a.Guard.Activate(ctx, o.Host, local.Actor.Box, nil); err != nil { // idempotent on the same stamp |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if captured.Stamp != stamp || captured.Incarnation != c.Holder.Incarnation { |
| + return hostclaim.LocalInstall{}, hostguard.ErrStampMismatch |
| + } |
| + check := func(fresh *hostclaim.Claim) error { |
| + if fresh.Op.ID != c.Op.ID || fresh.Op.Phase != hostclaim.PhaseInstalled || fresh.Op.Exec != local.Actor.Box || fresh.Op.Journal["prepared"] != p || |
| + fresh.Op.Journal["install_stamp"] != c.Op.Journal["install_stamp"] || fresh.Op.Journal["stuck"] != "" || hostclaim.PreparedOpened(fresh) { |
| + return fmt.Errorf("%w: the install's authority changed", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| + } |
| + journal := relocationInstallJournal{V: 1, Host: o.Host, Op: c.Op.ID, Box: local.Actor.Box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, Prepared: p, Steps: map[string]string{}} |
| + var receipt hostclaim.LocalInstall |
| + err = a.Guard.With(ctx, o.Host, captured, check, func() error { |
| + if err := os.MkdirAll(cfg.FilesDir(o.Host), 0o700); err != nil { |
| + return err |
| + } |
| + m, _, err := hostfiles.Restore(ctx, cfg.Data, prefix, cfg.FilesDir(o.Host), origins) |
| + if err != nil { |
| + return err |
| + } |
| + journal.Steps["files"] = strconv.FormatInt(m.Seq, 10) |
| + if ws, ok, err := hostfiles.LoadWake(ctx, cfg.Data, prefix, m, origins); err != nil { |
| + return err |
| + } else if ok { |
| + if err := hostfiles.ImportWake(cfg.Registry, o.Host, ws); err != nil { |
| + return err |
| + } |
| + journal.Steps["wake"] = "imported" |
| + } else { |
| + journal.Steps["wake"] = "none" |
| + } |
| + if _, ok := m.Platform[hostfiles.EnvItem]; ok { |
| + env, err := hostfiles.ReadPlatform(ctx, cfg.Data, prefix, m, hostfiles.EnvItem, origins) |
| + if err != nil { |
| + return err |
| + } |
| + if err := writeFileSynced(cfg.EnvPath(o.Host), env, 0o600); err != nil { |
| + return err |
| + } |
| + journal.Steps["env"] = "written" |
| + } else if err := os.Remove(cfg.EnvPath(o.Host)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err // ENOENT in P's manifest means the host has none: any env file here is not the host's |
| + } |
| + // The row, held by the operation, owned by this installation |
| + // (ruling 31, 38); the typed inventory sees the restored tree. |
| + if err := a.Reconcile.MintHeld(ctx, rec, captured, "relocating", c.Op.ID); err != nil { |
| + return err |
| + } |
| + journal.Steps["row"] = c.Op.ID |
| + rt := rec.Route |
| + rt.Status, rt.HoldOp = "relocating", c.Op.ID |
| + // CC13: the held config with current inputs, recorded as the |
| + // operation's (moves Plan 1 Task 7b), under this With (ruling 34). |
| + rendered, err := appconfig.RenderHeldUnder(ctx, a.Claims, cfg.SecretsKey, cfg.DataDir, rt, c.Op.ID, local.Actor.Box, |
| + func(appconfig.HeldRender) (hostwrite.Work, error) { return guardedWork{}, nil }) |
| + if err != nil { |
| + return err |
| + } |
| + journal.Steps["render"] = rendered.SHA256 |
| + // The pinned release (R9): install_release, never the route's |
| + // version. The edge unit cannot write the version store, so |
| + // MaterializeHeld returns ErrStageBlocked and root's relay stages |
| + // it (M3); the receipt waits until live/<host> names it. |
| + version := c.Op.Journal["install_release"] |
| + if version == "" { |
| + return fmt.Errorf("%w: no install_release journaled", hostrelocate.ErrRefused) |
| + } |
| + proof := release.NewProofGate(release.ProofPolicyPath, cfg.DataDir) |
| + if _, err := release.MaterializeHeld(ctx, a.Claims, release.OptDir(), rt, version, proof, func(publish func() error) error { return publish() }); err != nil && !errors.Is(err, release.ErrStageBlocked) { |
| + return err |
| + } else if errors.Is(err, release.ErrStageBlocked) { |
| + req := release.HeldStageRequest{Account: rec.Account, App: rec.App, Host: o.Host, Version: version, Op: c.Op.ID, Box: local.Actor.Box} |
| + if err := writeHeldStageRequest(cfg.DataDir, req); err != nil { |
| + return err |
| + } |
| + } |
| + if live, err := release.LiveVersion(release.OptDir(), o.Host); err != nil || live != version { |
| + return fmt.Errorf("%w: the pinned release %s is not staged yet (live %q)", hostrelocate.ErrNotYet, version, live) |
| + } |
| + journal.Steps["materialize"] = version |
| + raw, err := json.Marshal(journal) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.MkdirAll(filepath.Dir(relocationInstallPath(cfg, o.Host, c.Op.ID)), 0o700); err != nil { |
| + return err |
| + } |
| + if err := writeFileSynced(relocationInstallPath(cfg, o.Host, c.Op.ID), raw, 0o600); err != nil { |
| + return err |
| + } |
| + sum := sha256.Sum256(raw) |
| + receipt = hostclaim.LocalInstall{Box: local.Actor.Box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, FilesManifestVersion: m.Seq, JournalSHA256: hex.EncodeToString(sum[:])} |
| + return nil |
| + }) |
| + return receipt, err |
| + } |
| +} |
| + |
| +// relocationVerifyLocal reports whether the receipt's install is whole on |
| +// this box now: the journal at the receipt's digest, the guard record at |
| +// its stamp, every manifest entry present at its size and digest, the env |
| +// file as P's manifest says, and the row held by the operation. |
| +func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) { |
| + return func(ctx context.Context, o hostclaim.Observed, l hostclaim.LocalInstall) (bool, error) { |
| + got, ok, err := a.relocationReceipt(cfg)(ctx, o) |
| + if err != nil || !ok || got != l { |
| + return false, err |
| + } |
| + captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if errors.Is(err, hostguard.ErrNoRecord) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if captured.Stamp != l.InstallStamp { |
| + return false, nil |
| + } |
| + row, err := cfg.Registry.Get(o.Host) |
| + if err != nil { |
| + return false, err |
| + } |
| + if row == nil || row.HoldOp != o.Claim.Op.ID || !registry.IsHold(row.Status) { |
| + return false, nil |
| + } |
| + p := o.Claim.Op.Journal["prepared"] |
| + raw, _, err := cfg.Data.Get(ctx, hostfiles.ManifestKey(o.Claim.Holder.Location+"/files/"+p+"/")) |
| + if err != nil { |
| + return false, err |
| + } |
| + var m hostfiles.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return false, err |
| + } |
| + return hostfiles.Present(cfg.FilesDir(o.Host), cfg.EnvPath(o.Host), m) // Task 8c adds Present: every live entry at its size and digest, the env present iff the manifest has it |
| + } |
| +} |
| + |
| +// relocationReceipt reads this box's durable install journal for the |
| +// claim's operation; false when there is none or it is not this operation's. |
| +func (a ClaimAssembly) relocationReceipt(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + return func(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + raw, err := os.ReadFile(relocationInstallPath(cfg, o.Host, o.Claim.Op.ID)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return hostclaim.LocalInstall{}, false, nil |
| + } |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, false, err |
| + } |
| + var j relocationInstallJournal |
| + if err := json.Unmarshal(raw, &j); err != nil || j.V != 1 || j.Host != o.Host || j.Op != o.Claim.Op.ID || j.Steps["materialize"] == "" { |
| + return hostclaim.LocalInstall{}, false, nil |
| + } |
| + seq, _ := strconv.ParseInt(j.Steps["files"], 10, 64) |
| + sum := sha256.Sum256(raw) |
| + return hostclaim.LocalInstall{Box: j.Box, Enrollment: j.Enrollment, InstallStamp: j.InstallStamp, FilesManifestVersion: seq, JournalSHA256: hex.EncodeToString(sum[:])}, true, nil |
| + } |
| +} |
| + |
| +var _ = hostbox.ErrChanged |
| +``` |
| + |
| +`writeFileSynced` is relocationeffects.go:399. `hostfiles.Present(dir, envPath string, m Manifest) (bool, error)` is new in `internal/hostfiles/restore.go` (a read-only walk: each non-deleted entry's size and sha256, the env file's presence equal to `m.Platform[EnvItem]`'s). Add `HeldStageRequest.Stamp: stamp` the day M3 lands. |
| + |
| +In `RelocationEffects` (Task 8a) set `Install: a.relocationInstall(cfg)`, `VerifyLocal: a.relocationVerifyLocal(cfg)`, `Receipt: a.relocationReceipt(cfg)`. |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ ./internal/hostbox/ ./internal/registry/ ./internal/hostfiles/ ./internal/provision/ -run 'RelocationInstall|RelocationVerifyLocal|RelocationReceipt|MintHeld|RelocatingIsAHold|Present|Reconcile' -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/host/relocationinstall.go internal/host/relocationinstall_test.go internal/host/relocationassembly.go internal/hostbox/reconcile_adapter.go internal/hostbox/reconcile_adapter_mint_test.go internal/provision/reconcile.go internal/registry/registry.go internal/registry/registry_test.go internal/hostfiles/restore.go |
| +git commit -m "host: a relocation target installs from P under its install stamp, mints its row held by the operation and stages the pinned release through root's relay |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8d: The relocation owner set and its source |
| + |
| +**Files:** |
| +- Create: `internal/host/relocationwake.go`, `internal/host/relocationattempts.go`, `internal/host/relocationowners.go`, tests `relocationwake_test.go`, `relocationattempts_test.go`, `relocationowners_test.go` |
| +- Modify: `internal/hostrelocate/participant.go` (export `CertifiedFor`), `internal/host/relocationassembly.go` (set `Admit`) |
| + |
| +**Interfaces:** |
| +- Consumes: `ClaimRelocationOwners` (claimowners.go:61-69), `ClaimKindOwners` (26-30), `claimRelocationC1` (87-95); `ClaimProductionRelocationSource` (claimrelocation.go:19-22), `RegisterProductionClaimRelocationSource` (32); `ClaimWakeInputs` (claimwakeassembly.go:45-53), `ClaimLaunchInputs` (28-43), `ClaimFencedPublication` (81-83), `ClaimPublication` (57-66), `ClaimPublicationReceipt` (70-73), `ErrClaimRootPending`; `OperationAttempts`, `OperationStart` (claimoperations.go), `ClaimLaunchDigest`, `ClaimProbeReadiness`; `hostclaim.Admission` (generation.go:10-26), `relocatingAdmission` (284), `relocationProbeOpening` (307), `WakeRetryAttempt` (wakeretry.go:21-30), `BeginProbeAttempt` (relocation.go:941); `genselect.Select`, `SelectPrepared` (select.go:278), `PreparedPin` (64), `Marker`; `activator.ProbeWake` (probewake.go:185), `ProbeConfig` (55-68), `ProbeResult` (72-76), `ErrProbeRefused`, `ErrProbeFailed`, `ErrAttemptOver`; `hostbox.Policy`, `PolicyRefusal` (wakepolicy.go:53-64), `Selection` (work.go:17-22); `hostrelocate.ProbeDeadline`; the stage A verifier `StopReceiptReleases.VerifyRelease` (relocationreleases.go:42). |
| +- Produces: `RelocationWakeInputs`; `relocationProbeAttempts`; `RelocationSource` and `NewRelocationSource(cfg RelocationAssemblyConfig, assembly func() *ClaimAssembly) *RelocationSource`; `(ClaimAssembly) relocationAdmit(cfg)`; `hostrelocate.CertifiedFor`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package host |
| + |
| +// Select: the first attempt opens P from its prepared pin with KeepStamp |
| +// and PredecessorCertified only after this operation's verified release; |
| +// every later attempt is cold into a fresh generation from the |
| +// predecessor, no KeepStamp, never certified, never P (Review Focus 1, 4). |
| +func TestRelocationSelectCertifiesOnlyPsOpeningAfterItsRelease(t *testing.T) { /* table: drain with a verified release -> certified; failover -> not; release present but the receipt unverifiable -> Select refuses; later attempt -> not certified */ } |
| +func TestRelocationSelectNeverReopensP(t *testing.T) { /* prepared_opened journaled -> NewGeneration != P, Prepared false, KeepStamp false */ } |
| +func TestRelocationSelectRefusesEverythingButThisBoxsProbe(t *testing.T) { /* kind serving; phase installed; exec another box; p.Probe false; stuck: hostrelocate.ErrRefused each */ } |
| + |
| +// AdmissionEvidence binds the admission to a fresh baseline and, on P's |
| +// opening, to the prepared manifest by digest. |
| +func TestRelocationAdmissionEvidenceBindsTheBaselineAndThePreparedManifest(t *testing.T) {} |
| + |
| +// PublishFenced writes P's marker on the opening and a fresh generation's |
| +// marker on a later attempt, after Sync, only with the root present. |
| +func TestRelocationPublishFencedWritesTheAdmittedGenerationsMarker(t *testing.T) {} |
| + |
| +// The attempt journal: OpenProbe writes the window once per record |
| +// attempt k; Reserve(0) binds a session to k; Healthy settles; a session |
| +// bound to an earlier k refuses once the claim's count moved. |
| +func TestRelocationProbeAttemptsBindOneSessionPerRecordAttempt(t *testing.T) {} |
| + |
| +// The owner set is complete, or Open refuses (ruling 37); the policy and |
| +// the refusals only refuse. |
| +func TestRelocationSourceOpensACompleteOwnerSet(t *testing.T) { /* Open returns owners with every field set; claimRelocationC1(env on, true, owners) == (true, nil); Policy refuses a captured stamp != holder.stamp and a lost box, admits op.exec's probe */ } |
| + |
| +// BeforeAdmission verifies the source's release or its fence; AfterAdmission |
| +// verifies the target's installation (the receipt, the stamp on P's opening). |
| +func TestRelocationAdmissionHooksVerifyReleaseAndInstall(t *testing.T) {} |
| + |
| +// relocationAdmit drives one attempt through ProbeWake with P's install |
| +// manifest and the probe deadline, and reports the session and health. |
| +func TestRelocationAdmitRunsOneProbeAttempt(t *testing.T) { /* a fake Probe records ProbeConfig{Host, Op, HoldOp: op, Deadline: probing_since+ProbeDeadline, Prepared: &im}; success -> (session, true, nil); ErrProbeFailed -> (0, false, nil); ErrProbeRefused -> (0, false, err) */ } |
| +``` |
| + |
| +Plus, in `internal/hostrelocate`, `TestCertifiedForIsExported` is unnecessary: the existing `certifiedFor` tests are renamed. |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ -run 'RelocationSelect|RelocationAdmissionEvidence|RelocationPublishFenced|RelocationProbeAttempts|RelocationSource|RelocationAdmissionHooks|RelocationAdmit' -count=1` |
| +Expected: FAIL to compile. |
| + |
| +- [ ] **Step 3: The wake inputs** |
| + |
| +`internal/host/relocationwake.go`: |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RelocationWakeInputs is the relocating kind's ClaimWakeInputs (claim |
| +// Plan 11 R1): the probe's launch inputs are the serving launch source's |
| +// (executable, args, env, identity, limit) and the admission is §5.8's. |
| +type RelocationWakeInputs struct { |
| + Launch func(context.Context, registry.Route) (ClaimServingLaunch, error) // the provider's serving launch source |
| + Files ClaimFilesManifest // C8, main's provider |
| + Markers ClaimMarkerStore |
| + Claims release.Store |
| + Data genselect.Reader |
| + Bucket string |
| + Box string |
| + NewID func() string |
| + Now func() time.Time |
| + // Releases verifies a journaled release at the pin (stage A's verifier |
| + // over main's Complete, relocationreleases.go:42). |
| + Releases hostclaim.ReleaseAuthority |
| + mu sync.Mutex |
| + published map[string]ClaimPublicationReceipt |
| +} |
| + |
| +var ( |
| + _ ClaimWakeInputs = (*RelocationWakeInputs)(nil) |
| + _ ClaimFencedPublication = (*RelocationWakeInputs)(nil) |
| +) |
| + |
| +func (r *RelocationWakeInputs) probe(o hostclaim.Observed, p activator.StartPolicy) error { |
| + if r == nil || r.Launch == nil || r.Files == nil || r.Markers == nil || r.Claims == nil || r.Data == nil || r.Bucket == "" || r.Box == "" || r.NewID == nil || r.Now == nil || r.Releases == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + c := o.Claim |
| + if o.Absent || c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseRelocatingProbing || c.Op.Exec != r.Box || !p.Probe || c.Op.Journal["stuck"] != "" { |
| + return fmt.Errorf("%w: not this box's relocation probe", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| +} |
| + |
| +func (r *RelocationWakeInputs) request(o hostclaim.Observed) genselect.Request { |
| + return genselect.Request{Bucket: r.Bucket, Region: o.Claim.Op.Intent["region"], Host: o.Host, Holder: *o.Claim.Holder} |
| +} |
| + |
| +// Select (§5.8 "The probe's admission"): the first attempt opens P from |
| +// its prepared pin with KeepStamp, certified only by this operation's |
| +// verified release (C4: PredecessorCertified); every later attempt is |
| +// cold into a fresh generation from the predecessor. |
| +func (r *RelocationWakeInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) { |
| + if err := r.probe(o, p); err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + c, h := o.Claim, o.Claim.Holder |
| + if rt.Host != o.Host || !strings.HasPrefix(rt.DBPath, "/") { |
| + return ClaimLaunchInputs{}, hostbox.ErrUnsupported |
| + } |
| + l, err := r.Launch(ctx, rt) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + if l.Identity.Config == "" || l.Identity.Release == "" || l.Executable == "" || len(l.ExecutableSHA256) != 64 || l.Version == "" { |
| + return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| + } |
| + if l.Version != c.Op.Journal["install_release"] { |
| + return ClaimLaunchInputs{}, fmt.Errorf("%w: the staged release %s is not the pinned %s", hostrelocate.ErrRefused, l.Version, c.Op.Journal["install_release"]) |
| + } |
| + var ref hostclaim.RecoveryPin |
| + if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + pin, err := hostrelocate.ReadPin(ctx, r.Claims, ref) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + prepared := c.Op.Journal["prepared"] |
| + region := c.Op.Intent["region"] |
| + in := ClaimLaunchInputs{Roles: []string{"main"}, Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: append([]string(nil), l.Env...), |
| + DBPath: rt.DBPath, Version: l.Version, Limit: l.Limit, ReadyPath: "/"} |
| + adm := hostclaim.Admission{ObservedOpID: c.Op.ID, ObservedPhase: hostclaim.PhaseRelocatingProbing, ObservedSession: h.Session, ObservedGeneration: h.Generation, |
| + Box: r.Box, Mode: "cold", Region: region, PredecessorRegion: region} |
| + if n := len(h.Admissions); n > 0 { |
| + adm.PredecessorRegion = h.Admissions[n-1].Region |
| + } |
| + switch { |
| + case !hostclaim.PreparedOpened(c): |
| + certified := hostrelocate.CertifiedFor(c) |
| + if certified { |
| + var rel hostclaim.Release |
| + if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) != nil { |
| + return ClaimLaunchInputs{}, hostbox.ErrChanged |
| + } |
| + if err := r.Releases.VerifyRelease(ctx, *c, rel); err != nil { // the receipt, root's signature, the obligation discharged |
| + return ClaimLaunchInputs{}, err |
| + } |
| + } |
| + adm.NewGeneration, adm.KeepStamp, adm.PredecessorCertified = prepared, true, certified |
| + in.Prepared = true |
| + if pin.DB == nil { |
| + in.Empty = true // an evidenced empty bootstrap opened as P |
| + break |
| + } |
| + var im moves.InstallManifest |
| + if err := readInstallManifest(ctx, r.Data, r.Bucket, h.Location+"/"+prepared+"/", &im); err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + plan, err := genselect.SelectPrepared(ctx, r.Data, r.request(o), genselect.PreparedPin{Generation: prepared, SnapshotKey: im.SnapshotKey, SnapshotVersion: im.SnapshotVersion, SnapshotTXID: im.N, SnapshotCRC64: im.SnapshotCRC64}) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + adm.RemoteMaxTXID, in.Baseline = pin.DB.MaxTXID, plan |
| + default: |
| + adm.NewGeneration = r.NewID() |
| + if !claimFreshGeneration(h, adm.NewGeneration) { |
| + return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| + } |
| + plan, err := genselect.Select(ctx, r.Data, r.request(o)) |
| + switch { |
| + case errors.Is(err, genselect.ErrEmpty): |
| + in.Empty = true |
| + case err != nil: |
| + return ClaimLaunchInputs{}, err |
| + default: |
| + adm.RemoteMaxTXID, in.Baseline = plan.MaxTXID, plan |
| + } |
| + } |
| + in.Admission = adm |
| + return in, nil |
| +} |
| + |
| +// Materialize proves the held identities and has C8 establish the files |
| +// manifest, as the serving owners do; RenderHeld and MaterializeHeld ran |
| +// at the local install. |
| +func (r *RelocationWakeInputs) Materialize(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + if work == nil || in.Identity.Config == "" || in.Identity.Release == "" { |
| + return hostwrite.ErrUnavailable |
| + } |
| + if err := work.With(ctx, in.Identity, func() error { return nil }); err != nil { |
| + return err |
| + } |
| + return r.Files.Establish(ctx, o, in, work) |
| +} |
| + |
| +// EmptyAuthority: P's opening is empty only with an evidenced empty pin; |
| +// a later attempt over an empty predecessor is an ordinary empty cold |
| +// admission (a complete listing of the location holds nothing). |
| +func (r *RelocationWakeInputs) EmptyAuthority(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + if !in.Empty || in.Baseline != nil { |
| + return hostbox.ErrChanged |
| + } |
| + if in.Prepared { |
| + var ref hostclaim.RecoveryPin |
| + if json.Unmarshal([]byte(o.Claim.Op.Journal["pin"]), &ref) != nil || !ref.Empty { |
| + return fmt.Errorf("%w: no evidenced empty pin", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| + } |
| + versions, err := r.Data.ListVersions(ctx, r.Bucket, strings.TrimSuffix(o.Claim.Holder.Location, "/")+"/") |
| + if err != nil { |
| + return err |
| + } |
| + if len(versions) != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// Publish is unfenced and refused; PublishFenced is the marker writer. |
| +func (r *RelocationWakeInputs) Publish(context.Context, hostclaim.Observed, ClaimLaunchInputs) error { |
| + return hostwrite.ErrUnavailable |
| +} |
| + |
| +// PublishFenced writes generation.json for the admitted generation (moves |
| +// §7.14; spec §5.8 "generation.json is not written here: the probe's |
| +// admission publishes it"): P's marker on the opening, a fresh generation's |
| +// on a later attempt. It is claimServingOwners.PublishFenced's rule for a |
| +// relocating holder: Sync, the generation's own root (none yet is |
| +// ErrClaimRootPending), supersede the current marker by its exact ETag. |
| +func (r *RelocationWakeInputs) PublishFenced(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if pub.Sync == nil || pub.Check == nil { |
| + return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable |
| + } |
| + h := o.Claim.Holder |
| + if h.Generation == "" || h.Generation != in.Admission.NewGeneration || h.WriterBox != r.Box { |
| + return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| + } |
| + return publishMarker(ctx, r.Data, r.Markers, r.Bucket, r.Box, r.Now, o, in.Empty, pub, &r.mu, &r.published) |
| +} |
| + |
| +// AdmissionEvidence binds a relocation probe admission to its observation |
| +// and to the certified baseline, read again: on P's opening the exact |
| +// prepared pin and P's files manifest by digest (prepared_authority); on a |
| +// later attempt the predecessor's fresh baseline. |
| +func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + c, h := o.Claim, o.Claim.Holder |
| + if in.ObservedOpID != c.Op.ID || in.ObservedPhase != hostclaim.PhaseRelocatingProbing || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != r.Box || in.Mode != "cold" || in.Coverage != "" || len(in.AdditionalWritesLost) != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + prepared := c.Op.Journal["prepared"] |
| + if in.NewGeneration == prepared { |
| + if !in.KeepStamp || hostclaim.PreparedOpened(c) { |
| + return hostbox.ErrChanged |
| + } |
| + var auth hostclaim.PreparedAuthority |
| + if json.Unmarshal([]byte(c.Op.Journal["prepared_authority"]), &auth) != nil || auth.Generation != prepared { |
| + return hostbox.ErrChanged |
| + } |
| + raw, _, err := r.Claims.Get(ctx, hostclaim.RecoveryPinKey(o.Host, c.Op.ID, 0)) // placeholder: see below |
| + _ = raw |
| + _ = err |
| + if baseline == nil { |
| + var ref hostclaim.RecoveryPin |
| + if json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref) != nil || !ref.Empty || in.RemoteMaxTXID != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if baseline.Generation != prepared || baseline.MaxTXID != in.RemoteMaxTXID || baseline.MaxTXID != auth.SnapshotTXID { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if in.KeepStamp || in.PredecessorCertified || !claimFreshGeneration(h, in.NewGeneration) { |
| + return hostbox.ErrChanged |
| + } |
| + if baseline == nil { |
| + return nil // an empty predecessor, proved by EmptyAuthority |
| + } |
| + fresh, err := genselect.Select(ctx, r.Data, r.request(o)) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Generation != baseline.Generation || fresh.MaxTXID != baseline.MaxTXID || in.RemoteMaxTXID != fresh.MaxTXID || !reflect.DeepEqual(fresh.Files, baseline.Files) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// PublicationEvidence is claimServingOwners.publicationEvidence's rule for |
| +// a relocating holder: this process's receipt for the admitted session, |
| +// the marker at exactly that version naming the holder, and the root still |
| +// there. |
| +func (r *RelocationWakeInputs) PublicationEvidence(ctx context.Context, o hostclaim.Observed) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + return publicationEvidence(ctx, r.Data, r.Bucket, r.Box, o, &r.mu, r.published) |
| +} |
| +``` |
| + |
| +Replace the `placeholder` lines: the prepared manifest check on P's opening reads P's files manifest (`r.Data` is the data bucket; use a `release.Store` field `Files` for `hostfiles.ManifestKey(location+"/files/"+prepared+"/")`) and compares its sha256 with `auth.FilesManifestSHA256`; the field is added to the struct as `Files release.Store` beside `Data`. `publishMarker` and `publicationEvidence` are the serving owners' bodies (claimowners.go `PublishFenced` and `publicationEvidence`) extracted into package functions taking the reader, markers, bucket, box, clock, observation and the receipt map, so one implementation serves both kinds; `claimServingOwners` calls them with its own fields. `readInstallManifest` is Task 8b's read of the current install manifest version, factored out of `PreparedFromInstallManifest.Verify`. In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor` (exported; the participant and the owners use the same rule). |
| + |
| +- [ ] **Step 4: The attempt journal** |
| + |
| +`internal/host/relocationattempts.go`, modelled on `movesProbeAttempts` (movesprobeattempts.go:57-100) without the record: |
| + |
| +```go |
| +// relocationProbeAttempts is region HA's OperationAttempts for a probe |
| +// admitted under relocating/probing (claim Plan 11 R1, D2). The record |
| +// attempt k is the claim's probe_attempts.<box>, counted by the |
| +// participant before each admission (BeginProbeAttempt); the journal at |
| +// <DataDir>/relocate/<host>/<op>/attempts.json binds each admitted session |
| +// to one k and holds the attempt's opened time and each start's |
| +// reservation and state. Every method refuses (hostbox.ErrChanged) once |
| +// the claim no longer names the admitted writer (op, kind, session, |
| +// stamp, generation; phase probing, not stuck). |
| +type relocationProbeAttempts struct { |
| + Claims release.Store |
| + DataDir string |
| + Box string |
| + NewID func() string |
| + Now func() time.Time |
| + Settle, Ready time.Duration |
| + mu sync.Mutex |
| + current map[string]boundAttempt // host\x00session -> {k, attempt} |
| +} |
| + |
| +type relocationAttemptJournal struct { |
| + V int `json:"v"` |
| + Host, Op string `json:"host","op"` |
| + Opened map[string]time.Time `json:"opened"` // "k" -> first app start |
| + Sessions map[string]int `json:"sessions"` // "session" -> k |
| + Starts map[string]probeStart `json:"starts"` // "k/n" -> start |
| +} |
| +``` |
| + |
| +`bound(ctx, s)` reads the claim fresh, checks `claimSameWriter(s.Admitted, fresh, hostclaim.SessionContinues)`, phase `probing`, no `stuck`, `s.Owner` equal to the holder's tuple, and takes `k` from `probe_attempts.<box>` (`journalUint`, 1 to `MaxProbeAttempts`); a session already bound to another `k` refuses. `OpenProbe` writes `Opened[k]` once. `Reserve(0)` validates the journaled start; `Reserve(n)` writes start `n` with a fresh `WakeRetryAttempt{ID, InvocationID, Session, Box, Enrollment, BootID, ControllerID, LaunchInputs: ClaimLaunchDigest(s.Inputs)}`. `CurrentAttempt` returns the last reserved. `Failed` and `Healthy` settle the start; `Healthy` runs `again` after `Settle` (default `moves.SettleSeconds`) bounded by `Ready` (default `moves.ProbeSeconds`), as `movesProbeAttempts.Healthy` does. Writes go through `writeFileSynced`. |
| + |
| +- [ ] **Step 5: The source, the hooks, the policy and the probe driver** |
| + |
| +`internal/host/relocationowners.go`: |
| + |
| +```go |
| +// RelocationSource is region HA's ClaimProductionRelocationSource (claim |
| +// Plan 11 R1, R8): registered once at agent startup (Task 10), opened by |
| +// the production provider only with CARLOS_REGION_HA on. Open returns the |
| +// complete owner set (ruling 37) and two refusals (ruling 39). |
| +type RelocationSource struct { |
| + cfg RelocationAssemblyConfig |
| + launch func(context.Context, registry.Route) (ClaimServingLaunch, error) |
| + files ClaimFilesManifest |
| + markers ClaimMarkerStore |
| + pub ed25519.PublicKey |
| + mu sync.Mutex |
| + actor hostbox.Actor |
| + owners *ClaimRelocationOwners |
| +} |
| + |
| +func (s *RelocationSource) Open(ctx context.Context, store release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimRelocationOwners, []hostbox.PolicyRefusal, error) { |
| + if s == nil || store == nil || data == nil || actor.Box == "" || s.launch == nil || s.files == nil || s.markers == nil { |
| + return ClaimRelocationOwners{}, nil, hostwrite.ErrUnavailable |
| + } |
| + directory := s.cfg.Directory |
| + releases := StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: store, Directory: directory}} |
| + inputs := &RelocationWakeInputs{Launch: s.launch, Files: s.files, Markers: s.markers, Claims: store, Files: s.cfg.Data, Data: hostbox.RequestStore{Store: data, Check: func(context.Context) error { return nil }}, |
| + Bucket: actor.Bucket, Box: actor.Box, NewID: newClaimID, Now: time.Now, Releases: releases} |
| + attempts := &relocationProbeAttempts{Claims: store, DataDir: s.cfg.DataDir, Box: actor.Box, NewID: newClaimID, Now: time.Now} |
| + owners := ClaimRelocationOwners{ |
| + BeforeAdmission: relocationBeforeAdmission(store, actor.Box, releases, directory, time.Now), |
| + AfterAdmission: relocationAfterAdmission(s.cfg, actor.Box), |
| + ClaimKindOwners: ClaimKindOwners{Inputs: inputs, AdmissionEvidence: inputs.AdmissionEvidence, PublicationEvidence: inputs.PublicationEvidence}, |
| + Operations: attempts, |
| + ProbeReady: nil, // claimProbeSocketReady (claimconstruction.go:385) |
| + Policy: relocationPolicy(actor.Box), |
| + } |
| + s.mu.Lock() |
| + s.actor, s.owners = actor, &owners |
| + s.mu.Unlock() |
| + return owners, []hostbox.PolicyRefusal{refuseStaleStamp, refuseLostBox}, nil |
| +} |
| + |
| +func (s *RelocationSource) Close(context.Context) error { return nil } |
| + |
| +// relocationPolicy is the typed Policy.Relocation owner: it runs after |
| +// relocationProbe (wakepolicy.go:187) passed and only refuses: the captured |
| +// installation must be the holder's, this box a participant, not lost. |
| +func relocationPolicy(box string) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { |
| + return func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if c == nil || c.Holder == nil || s.Box != box || !slices.Contains(c.Op.Participants, box) || slices.Contains(c.Lost, box) { |
| + return hostbox.ErrChanged |
| + } |
| + if s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +func refuseStaleStamp(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if c.Op.Kind == hostclaim.KindRelocating && (s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +func refuseLostBox(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if slices.Contains(c.Lost, s.Box) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// relocationBeforeAdmission verifies the source's release before the |
| +// target's first admission: a drain's journaled release re-verified at |
| +// its receipt (root's signature, the obligation discharged), or a |
| +// failover's fence consumed (the source on lost, its fence journaled). |
| +// A later attempt (P opened) needs neither: its predecessor is this box's |
| +// own previous session, which main's local stop ended. |
| +func relocationBeforeAdmission(store release.Store, box string, releases StopReceiptReleases, d hostactors.Directory, now func() time.Time) func(context.Context, hostclaim.Observed) error { |
| + return func(ctx context.Context, prior hostclaim.Observed) error { |
| + c := prior.Claim |
| + if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.Exec != box { |
| + return hostbox.ErrChanged |
| + } |
| + if hostclaim.PreparedOpened(c) { |
| + return nil |
| + } |
| + var rel hostclaim.Release |
| + if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) == nil && rel.ReleasedBy != "" { |
| + return releases.VerifyRelease(ctx, *c, rel) |
| + } |
| + src := c.Op.Intent["source_box"] |
| + if slices.Contains(c.Lost, src) && c.Op.Journal["fence."+src] != "" { |
| + return nil |
| + } |
| + return fmt.Errorf("%w: no release and no fence for %s", hostrelocate.ErrRefused, src) |
| + } |
| +} |
| + |
| +// relocationAfterAdmission verifies the target's installation after the |
| +// admission: on P's opening the admitted stamp is install_stamp |
| +// (KeepStamp) and the local install receipt is this box's, as journaled. |
| +func relocationAfterAdmission(cfg RelocationAssemblyConfig, box string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + return func(_ context.Context, prior, admitted hostclaim.Observed) error { |
| + c, n := prior.Claim, admitted.Claim |
| + if c == nil || n == nil || n.Holder == nil || n.Op.ID != c.Op.ID || n.Op.Exec != box || n.Holder.WriterBox != box { |
| + return hostbox.ErrChanged |
| + } |
| + var l hostclaim.LocalInstall |
| + if json.Unmarshal([]byte(n.Op.Journal["local_install"]), &l) != nil || l.Box != box || l.InstallStamp == 0 { |
| + return hostbox.ErrChanged |
| + } |
| + raw, err := os.ReadFile(relocationInstallPath(cfg, admitted.Host, n.Op.ID)) |
| + if err != nil { |
| + return err |
| + } |
| + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != l.JournalSHA256 { |
| + return hostbox.ErrChanged |
| + } |
| + if n.Holder.Generation == n.Op.Journal["prepared"] && n.Holder.Stamp != l.InstallStamp { |
| + return hostbox.ErrChanged // P's opening keeps the install stamp |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// relocationAdmit is one probe attempt (hostrelocate.Effects.Probe): the |
| +// activator's ProbeWake with the probe deadline and P's install manifest, |
| +// through main's claim runtime, the dispatcher and this box's owners. It |
| +// returns the admitted session and the attempt's exact health; a refused |
| +// probe is an error, a failed one is (0, false, nil). |
| +func (a ClaimAssembly) relocationAdmit(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, bool) (uint64, bool, error) { |
| + return func(ctx context.Context, o hostclaim.Observed, certified bool) (uint64, bool, error) { |
| + if cfg.Probe == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return 0, false, ErrClaimStopUnavailable |
| + } |
| + c := o.Claim |
| + if certified != hostrelocate.CertifiedFor(c) { |
| + return 0, false, fmt.Errorf("%w: the participant and the owners disagree on certification", hostrelocate.ErrRefused) |
| + } |
| + since, err := time.Parse(time.RFC3339Nano, c.Op.Journal["probing_since"]) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + pc := activator.ProbeConfig{Host: o.Host, Op: c.Op.ID, HoldOp: c.Op.ID, Deadline: since.Add(hostrelocate.ProbeDeadline)} |
| + if !hostclaim.PreparedOpened(c) { |
| + var im moves.InstallManifest |
| + if err := readInstallManifest(ctx, cfg.Versions, cfg.Bucket, c.Holder.Location+"/"+c.Op.Journal["prepared"]+"/", &im); err == nil { |
| + pc.Prepared = &im |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return 0, false, err |
| + } |
| + } |
| + res, err := cfg.Probe(ctx, pc) |
| + switch { |
| + case err == nil: |
| + return res.Session, true, nil |
| + case errors.Is(err, activator.ErrProbeFailed), errors.Is(err, activator.ErrAttemptOver): |
| + return 0, false, nil |
| + default: |
| + return 0, false, err |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`newClaimID` is the 16-byte hex generator claimconstruction.go:242 uses, factored into a package function. `hostbox.RequestStore` is the `genselect.Reader` wrapper the serving owners use (claimconstruction.go:257). `NewRelocationSource(cfg, launch, files, markers)` sets the fields; Task 10 supplies `launch` from `claimServingLaunchSource` (claimproduction.go:139) and `files`/`markers` from the production provider: the provider's `OpenClaimOwners` opens the relocation source after the moves source (claimproduction.go:151), so Task 10 extends `openClaimRelocationOwners`'s caller to hand the source `out.Serving.Launch`, `out.Serving.Files` and `out.Serving.Markers` before `Open` (a setter `SetServingSources` on `RelocationSource`, called at claimproduction.go:150, inside the same `regionHAEnabled` gate). In `RelocationEffects` set `Admit: a.relocationAdmit(cfg)`. |
| + |
| +- [ ] **Step 6: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ ./internal/hostrelocate/ -run 'RelocationSelect|RelocationAdmissionEvidence|RelocationPublishFenced|RelocationProbeAttempts|RelocationSource|RelocationAdmissionHooks|RelocationAdmit|Plan11|CertifiedFor|Participant' -count=1` |
| +Expected: PASS, including main's `TestPlan11*` dispatch tests with the real owner set substituted for `plan9D5Owner` in a new case. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/host/relocationwake.go internal/host/relocationattempts.go internal/host/relocationowners.go internal/host/relocation*_test.go internal/host/relocationassembly.go internal/host/claimowners.go internal/host/claimconstruction.go internal/host/claimproduction.go internal/hostrelocate/participant.go internal/hostrelocate/participant_test.go |
| +git commit -m "host: region HA's relocation owners (inputs, evidence, attempts, policy, hooks) register as the claim's production relocation source; the probe runs through the activator |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: Reconcile under a relocating claim, and the completion |
| + |
| +**Files:** |
| +- Modify: `internal/hostbox/reconcile.go:192-205` (every `RowHeld` hold-only), `internal/host/relocationeffects.go:842` (`Finish` releases the hold), `internal/hostclaim/relocation.go:1003` (`released_move`, M1) |
| +- Test: `internal/hostbox/reconcile_relocation_test.go`, `internal/host/relocationeffects_test.go`, `internal/hostclaim/relocation_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Reconciler.Install` (reconcile.go:166), `holdOnly` (253), `RelocationRow` (relocationrows.go:47); `registry.ReleaseHold(host, op) (bool, error)` (registry.go:1263); `ReconcileAdapter` fixture from Task 8c's tests. |
| +- Produces: `RelocationEffects.Registry *registry.Registry` (set by Task 8a's config); `CompleteRelocation` journals `released_move`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// Every relocating row decision is hold-only (ruling 31): the target's |
| +// existing row is kept with its hold; a source after its fence closes is |
| +// held; the source in starting is left alone; a box with no role and a row |
| +// keeps it held; nothing is activated and no inventory is written. |
| +func TestReconcileLeavesARelocatingTargetsRowToItsInstall(t *testing.T) { /* adapter fixture (Task 8c); row minted by MintHeld; Install over phases installing..probing: err nil or ErrChanged, row unchanged, guard dir unchanged, claim ETag unchanged */ } |
| + |
| +// After the serving CAS the target releases its own hold once (Finish), the |
| +// row is ready, and the next reconcile pass on the serving executor finds a |
| +// coherent inventory (Review Focus 5). |
| +func TestReconcileAdapterClearsTheHoldAfterCompletion(t *testing.T) { /* MintHeld at install_stamp; CompleteRelocation; effects.Finish(host, op) -> ReleaseHold; adapter.Install succeeds through the real census */ } |
| + |
| +// A converted landing's completion journals released_move for moves (M1). |
| +func TestCompleteRelocationJournalsReleasedMoveForAConvertedLanding(t *testing.T) { /* hostclaim: conversion = committed-landing -> Op.Journal["released_move"] == op id on the serving op; a plain relocation journals none */ } |
| +``` |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/hostbox/ ./internal/hostclaim/ ./internal/host/ -run 'ReconcileLeavesARelocating|ReconcileAdapterClearsTheHold|CompleteRelocationJournalsReleasedMove' -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Hold-only for every held row** |
| + |
| +In `internal/hostbox/reconcile.go` `Install`, line 204, replace the decision with: |
| + |
| +```go |
| + // Under a relocating claim reconcile never activates (ruling 31): |
| + // Plan 11's policy admits only the probe, and the target's row is |
| + // its install's. Every held decision is a kept row's hold write; |
| + // a ready decision (the source before its fence closes) leaves |
| + // the row as it is. |
| + dec = &decided{row: d, holdOnly: d.Case == RowHeld} |
| +``` |
| + |
| +and in `checkOn`, the `RowReady` case under `relocates` returns `ErrChanged` as today (the row is left alone). `holdOnly` is unchanged: `HoldRow` is a no-op when the hold is already the operation's. |
| + |
| +- [ ] **Step 4: The completion's hold release** |
| + |
| +In `internal/host/relocationeffects.go`, `Finish`: |
| + |
| +```go |
| +func (e *RelocationEffects) Finish(ctx context.Context, host, op string) error { |
| + if e.Controllers == nil || e.Identity == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + if e.Registry != nil && e.Claims != nil { |
| + // The serving executor releases its own row's hold once the |
| + // operation is over (ruling 32): the activator then retires the |
| + // probe entry and the next request wakes the host as serving. |
| + obs, err := hostclaim.Read(ctx, e.Claims, host) |
| + if err != nil { |
| + return err |
| + } |
| + id, err := e.Identity(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if !obs.Absent && obs.Claim.Op.Kind == hostclaim.KindServing && obs.Claim.Op.Exec == id.Actor.Box { |
| + if _, err := e.Registry.ReleaseHold(host, op); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + return e.Controllers.Finish(ctx, host, op) |
| +} |
| +``` |
| + |
| +`RelocationEffects` gains `Registry *registry.Registry` and `Claims release.Store`, set in Task 8a's `RelocationEffects` from `cfg.Registry` and `a.Claims`. `ReleaseHold` reports `false` with no error when the row is not held by `op` (registry.go:1263); that is the replay. |
| + |
| +- [ ] **Step 5: `released_move` (M1)** |
| + |
| +In `internal/hostclaim/relocation.go` `CompleteRelocation`, before `c.Op = Op{Kind: KindServing, ...}`: |
| + |
| +```go |
| + next := Op{Kind: KindServing, Exec: target, Participants: []string{target}} |
| + if c.Op.Journal["conversion"] == "committed-landing" { |
| + // M1 on amadan #236: the move that this relocation finished is |
| + // named on the serving op, so moves' close can find it. |
| + next.Journal = map[string]string{"released_move": opID} |
| + } |
| + c.Op = next |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/hostbox/ ./internal/hostclaim/ ./internal/host/ -run 'Reconcile|CompleteRelocation|Finish|Sweep' -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/hostbox/reconcile.go internal/hostbox/reconcile_relocation_test.go internal/host/relocationeffects.go internal/host/relocationeffects_test.go internal/host/relocationassembly.go internal/hostclaim/relocation.go internal/hostclaim/relocation_test.go |
| +git commit -m "hostbox: under a relocating claim reconcile only holds; the serving target releases its hold at completion; a converted landing's completion names its move |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 10: The passes behind `CARLOS_REGION_HA`, and the rollout checklist |
| + |
| +**Files:** |
| +- Create: `internal/host/relocatepass.go`, `internal/host/relocatepass_test.go`, `internal/host/relocationlanding.go`, `internal/edgesfile/snapshot.go` |
| +- Modify: `internal/host/agent.go:214-278, 450` (register the source; start the pass), `internal/host/claimproduction.go:150` (hand the serving sources to the source), `internal/hostrelocate/worker.go` (the no-target bound), `internal/console/boxchoice.go`, `instancesplacement.go:114`, `regioncandidates.go:203` (read through `edgesfile.LiveSnapshot`), `internal/console/claim_runtime.go:71` (`Executors`) |
| + |
| +**Interfaces:** |
| +- Consumes: `hostrelocate.Worker` (worker.go:102-141), `Participant` (participant.go:92-98), `RegionChooser` (chooser.go:22-30), `StorePinner` (pin.go:75-91); `hostfence.ClaimFenceAuthority` (claim_fence.go:14); `hostactors.Directory.CensusBoxRelocation` (census.go:425), `CheckBoxStart` (296), `ExactEvidence` (326), `Box.EnrolledAt` (roster.go:28); `hostfloor.Read` (floor.go:72), `ReadEnrollment` (report.go:89); `boxvolumes.List`, `Discharge(records, Entry, enrolledAt, terminatedAt)` (volumes.go:250, 327); `termfence.Record` (fence.go:73); `hostops.ExecutorChooser` (executor.go:21-40), `ClaimLoads` (loads.go:18), `ProductionAuthority.Executors` (backfill.go:98); `release.Channel` (release.go:1220); `lease.Acquire`, `NewVersionedS3`; `genselect.Select`; `activator.Service.ProbeWake`; `status.LoadBoxesComplete` (status/fleet.go:234); `console.readLiveEdgesSnapshot` (instancesplacement.go:114), `boxSnapshot` (boxchoice.go:26); `hostclaim.ConvertCommittedLanding` (relocating.go:221), `CommittedLandingGuard` (187), `CommittedLanding` (175); `provision.GetRecord` (provision.go:2530), `MoveState` (937), `MoveCommitted` (1093); `moves.CommitScopes` (parkscope.go:87); `hostops.AppAdmission` registration (`hostops/ingest.go` `registerStart`) for the worker's `Admission`. |
| +- Produces: `relocatePassEnabled`, `runRelocatePass`, `NewRelocationRuntime` (the worker, participant and source for an agent); `edgesfile.LiveSnapshot(ctx, s, now) (boxchoice.Snapshot, error)`; `CommittedLandingRecords`; the rollout checklist. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +func TestRelocatePassIsOffUnlessBothFlagsAreOn(t *testing.T) { /* the 1b-next table (L13395) */ } |
| +func TestRelocatePassStaysOffWhileThePreflightRefuses(t *testing.T) { /* a runtime whose Roles.Preflight returns ErrClaimIsolationUnavailable: the pass logs once and steps nothing */ } |
| +func TestRelocatePassStepsEveryRelocationInItsRegionAndSweeps(t *testing.T) { /* two relocating claims in ie, one in uk, one serving: Worker.Step and Participant.Step called for the two; Sweep once */ } |
| +func TestRelocatePassAlertsOnceWhileAConversionWaitsForATarget(t *testing.T) { /* ruling 35: the chooser refuses; one log line per host; after ReleaseDeadline from the fence the op is stuck */ } |
| +func TestRelocatePassConvertsAFencedCommittedLanding(t *testing.T) { /* landing claim at committed, record MoveCommitted, target fenced: ConvertCommittedLanding called with CommittedLandingRecords; an uncommitted record: nothing written */ } |
| +func TestRelocationPinnerSelectsOverTheVersionedBucket(t *testing.T) { /* genselect's versioned fixture: the pin names the plan's versions and the channel's Version */ } |
| +func TestRelocationReleaseIsTheChannelPointer(t *testing.T) { /* ruling 30: a signed pointer at v1.5.0 and a route Version v1.4.0 -> "v1.5.0"; an unverifiable pointer refuses */ } |
| +func TestDischargeIsUnqualifiedWithoutEnrolledAt(t *testing.T) { /* ruling 3 as main now allows: EnrolledAt zero -> Qualified false, reason; set -> boxvolumes.Discharge's verdict */ } |
| +func TestEdgesLiveSnapshotIsTheConsolesRead(t *testing.T) { /* console's existing eligibility tests pass through edgesfile.LiveSnapshot */ } |
| +func TestCreateDoorUsesTheChooserOnlyWithRegionHAOn(t *testing.T) { /* console: ConfigureClaimRuntime with RegionHA off -> Executors nil; on -> set */ } |
| +``` |
| + |
| +- [ ] **Step 2: Run them to see them fail** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ ./internal/edgesfile/ ./internal/console/ -run 'RelocatePass|RelocationPinner|RelocationRelease|DischargeIsUnqualified|EdgesLiveSnapshot|CreateDoorUsesTheChooser' -count=1` |
| +Expected: FAIL to compile. |
| + |
| +- [ ] **Step 3: The edges snapshot, shared** |
| + |
| +Move `readEdgesSnapshot`, `readLiveEdgesSnapshot` and `boxSnapshot` (console/instancesplacement.go, boxchoice.go) into `internal/edgesfile/snapshot.go` as `LiveSnapshot(ctx, s release.Store, now time.Time) (boxchoice.Snapshot, error)`, keeping the console's `edgesSnapshot` type for its displays by having the console build it from the same read (`edgesfile` already imports `status`, fold.go:10; `boxchoice` imports only `boxid`, so no cycle). The console's three callers read through it. |
| + |
| +- [ ] **Step 4: The runtime** |
| + |
| +`internal/host/relocatepass.go`: |
| + |
| +```go |
| +// relocatePassEnabled: the claim and region HA both on (spec §8 step 2). |
| +func relocatePassEnabled(getenv func(string) string) bool { |
| + return hostwrite.Enabled(getenv) && regionHAEnabled(getenv) |
| +} |
| + |
| +// RelocationRuntime is one agent's relocation: its worker, participant, |
| +// effects and owner source, built once the claim assembly exists. |
| +type RelocationRuntime struct { |
| + Worker hostrelocate.Worker |
| + Participant hostrelocate.Participant |
| + Effects *RelocationEffects |
| + alerted sync.Map // host\x00cause -> struct{}: one alert per host and cause (rulings 35, 36) |
| +} |
| + |
| +// NewRelocationRuntime binds the production providers. It returns |
| +// (nil, nil) with either flag off. |
| +func NewRelocationRuntime(ctx context.Context, lifetime context.Context, getenv func(string) string, store release.Store, a *ClaimAssembly, reg *registry.Registry, svc *activator.Service, dd string) (*RelocationRuntime, error) { |
| + if !relocatePassEnabled(getenv) || a == nil || a.RetirementAuthority == nil || svc == nil { |
| + return nil, nil |
| + } |
| + pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return nil, err |
| + } |
| + enrollment, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json") |
| + if err != nil { |
| + return nil, err |
| + } |
| + d := a.RetirementAuthority.Directory |
| + roster, err := d.Read(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + self, ok := roster.Roster.Boxes[enrollment.Box] |
| + if !ok { |
| + return nil, hostbox.ErrChanged |
| + } |
| + region, dataBucket := self.Region, getenv("CARLOS_HIBERNATE_BUCKET") |
| + versioned, err := lease.NewVersionedS3(ctx, map[string]string{dataBucket: self.Cloud.Region}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var secrets *ecdh.PrivateKey |
| + if raw := getenv("CARLOS_SECRETS_KEY"); raw != "" { |
| + if secrets, err = appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY"); err != nil { |
| + return nil, err |
| + } |
| + } |
| + cfg := RelocationAssemblyConfig{Lifetime: lifetime, |
| + Exec: execctl.Config{Store: store, Guard: a.Guard, Directory: d, Clock: execctl.SystemClock{}, CloseAdmission: func() {}, CancelWork: func() {}}, |
| + Directory: d, Data: a.Data.(release.Store), Versions: versioned, Bucket: dataBucket, Region: region, Registry: reg, |
| + FilesDir: func(host string) string { return filepath.Join(dd, host) }, EnvPath: func(host string) string { return filepath.Join(dd, host+".env") }, |
| + DataDir: dd, WorkDir: filepath.Join(dd, "relocate", "build"), ReleaseDir: filepath.Join(dd, "relocate", "release"), SecretsKey: secrets, |
| + Probe: svc.ProbeWake, Now: time.Now} |
| + effects := a.RelocationEffects(cfg) |
| + chooser := hostrelocate.RegionChooser{Records: store, Namespace: self.Namespace, Region: region, |
| + Executors: hostops.ExecutorChooser{Directory: d, Edges: func(ctx context.Context) (boxchoice.Snapshot, error) { return edgesfile.LiveSnapshot(ctx, store, time.Now()) }, |
| + Loads: func(ctx context.Context) (map[string]boxchoice.Load, error) { return hostops.ClaimLoads(ctx, store) }, SecretsKeyFP: expectedSecretsFP(secrets), HomeName: region, Now: time.Now}} |
| + worker := hostrelocate.Worker{Claims: store, Directory: d, |
| + Fences: hostfence.ClaimFenceAuthority{Directory: d, Now: time.Now}, |
| + Releases: StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: store, Directory: d}}, |
| + Admission: relocationAppAdmission{Store: store, Owner: hostops.RegistrationOwner{...}}, // claim Plan 5's app gate: hostops' registration under a box owner tuple (ruling 4) |
| + Census: func(ctx context.Context, starter string, parts []string) error { |
| + floor, err := hostfloor.Read(ctx, store, pub, d.Bucket) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = d.CensusBoxRelocation(ctx, hostactors.ExactEvidence{PublicKey: pub, Store: store, Boxes: d.PrivateBoxReport, Self: localBoxReport(store)}, floor, |
| + hostactors.Scope{StarterBox: starter, Participants: parts, Namespace: self.Namespace, Regions: []string{region}, Pools: []string{self.Pool}}, time.Now()) |
| + return err |
| + }, |
| + Choose: chooser.Choose, Eligible: chooser.Eligible, |
| + Relocatable: func(ctx context.Context, obs hostclaim.Observed) error { _, err := relocationRecord(ctx, cfg, store, obs); return err }, |
| + Leases: func(ctx context.Context, location string) (hostrelocate.Lease, error) { return lease.Acquire(ctx, dataBucket, location, region) }, |
| + Pins: hostrelocate.StorePinner{Claims: store, Data: cfg.Data, Bucket: dataBucket, Region: region, |
| + SelectDB: func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) { return genselect.Select(ctx, versioned, r) }, |
| + Release: channelRelease(store, pub)}, // ruling 30 |
| + Prepared: PreparedFromInstallManifest{Claims: store, Files: cfg.Data, Data: versioned, Bucket: dataBucket}, |
| + Discharge: volumeDischarge(store), |
| + Notify: func(ctx context.Context) { _ = release.BumpSerial(ctx, store, time.Now()) }, |
| + Self: termfence.WrittenBy{Box: enrollment.Box, Enrollment: enrollment.Enrollment}, |
| + Region: region, Controller: newClaimID(), NewID: newClaimID, Now: time.Now} |
| + participant := hostrelocate.Participant{Claims: store, Box: enrollment.Box, Enrollment: enrollment.Enrollment, Effects: effects, Now: time.Now} |
| + return &RelocationRuntime{Worker: worker, Participant: participant, Effects: effects}, nil |
| +} |
| + |
| +// channelRelease is ruling 30: the host's channel pointer in the control |
| +// bucket, by the holder's account and app and the record's channel. |
| +func channelRelease(store release.Store, pub ed25519.PublicKey) func(context.Context, string) (string, error) { |
| + return func(ctx context.Context, host string) (string, error) { |
| + obs, err := hostclaim.Read(ctx, store, host) |
| + if err != nil || obs.Absent || obs.Claim.Holder == nil { |
| + return "", errors.Join(err, hostrelocate.ErrRefused) |
| + } |
| + rec, err := provision.GetRecord(ctx, store, obs.Claim.Holder.Account, obs.Claim.Holder.App, host) |
| + if err != nil { |
| + return "", err |
| + } |
| + p, err := release.Channel(ctx, store, pub, obs.Claim.Holder.Account, obs.Claim.Holder.App, rec.Channel) |
| + if err != nil { |
| + return "", err |
| + } |
| + return p.Version, nil |
| + } |
| +} |
| + |
| +// volumeDischarge is ruling 3 with enrolled_at now in the roster |
| +// (claim Plan 11 Task A): unqualified without it. |
| +func volumeDischarge(store release.Store) func(context.Context, hostactors.Box, termfence.Record) *hostclaim.LocalDischarge { |
| + return func(ctx context.Context, b hostactors.Box, f termfence.Record) *hostclaim.LocalDischarge { |
| + if b.EnrolledAt.IsZero() { |
| + return &hostclaim.LocalDischarge{Reason: "the roster records no enrollment time"} |
| + } |
| + at, err := time.Parse(time.RFC3339Nano, f.ObservedAt) |
| + if err != nil { |
| + return &hostclaim.LocalDischarge{Reason: err.Error()} |
| + } |
| + records, err := boxvolumes.List(ctx, store, b.Enrollment) |
| + if err != nil { |
| + return &hostclaim.LocalDischarge{Reason: err.Error()} |
| + } |
| + v := boxvolumes.Discharge(records, boxvolumes.Entry{Box: b.BoxID, Enrollment: b.Enrollment, Cloud: b.Cloud}, b.EnrolledAt, at) |
| + return &hostclaim.LocalDischarge{Qualified: v.Discharge, Reason: v.Reason} |
| + } |
| +} |
| + |
| +// runRelocatePass replays every relocation in this box's region and this |
| +// box's participations, converts a fenced committed landing (J3), and |
| +// sweeps. It starts no relocation: carlos ops host relocate and spec 3's |
| +// placer call Worker.Start. One pass every 30 seconds and on a control |
| +// serial change. |
| +func (r *RelocationRuntime) Run(ctx context.Context, store release.Store, serial <-chan struct{}) { |
| + tick := time.NewTicker(30 * time.Second) |
| + defer tick.Stop() |
| + for { |
| + pass, cancel := context.WithTimeout(ctx, 2*time.Minute) |
| + r.once(pass, store) |
| + cancel() |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-tick.C: |
| + case <-serial: |
| + } |
| + } |
| +} |
| + |
| +func (r *RelocationRuntime) once(ctx context.Context, store release.Store) { |
| + keys, err := store.List(ctx, "control/hosts/") |
| + if err != nil { |
| + log.Printf("relocate: list claims: %v", err) |
| + return |
| + } |
| + for _, key := range keys { |
| + host, ok := strings.CutSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json") |
| + if !ok || strings.Contains(host, "/") { |
| + continue |
| + } |
| + obs, err := hostclaim.Read(ctx, store, host) |
| + if err != nil || obs.Absent { |
| + continue |
| + } |
| + switch obs.Claim.Op.Kind { |
| + case hostclaim.KindLanding: |
| + r.convertFencedLanding(ctx, store, obs) // step 6 |
| + continue |
| + case hostclaim.KindRelocating: |
| + default: |
| + continue |
| + } |
| + if _, err := r.Worker.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| + r.alert(host, "worker", err) |
| + } |
| + if _, err := r.Participant.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| + r.alert(host, r.Participant.Box, err) |
| + } |
| + } |
| + if err := r.Participant.Sweep(ctx); err != nil { |
| + log.Printf("relocate: sweep controllers: %v", err) |
| + } |
| +} |
| + |
| +// alert logs one line per host and cause (rulings 35, 36): a refusal that |
| +// never clears is visible, never silent, and never a storm. |
| +func (r *RelocationRuntime) alert(host, who string, err error) { |
| + if _, said := r.alerted.LoadOrStore(host+"\x00"+err.Error(), struct{}{}); !said { |
| + log.Printf("relocate %s: %s: %v", host, who, err) |
| + } |
| +} |
| +``` |
| + |
| +`relocationAppAdmission` is claim Plan 5's app-admission registration over `hostops`' `AppAdmission` (`apps/<acct>/<app>/admission.json`, production_create.go:24; `registerStart`, ingest.go:32): `Register` writes a registration under the box owner tuple and returns its id, `Recheck` re-reads it open; the exact helper is `hostops`' and this task calls it, adding an exported `hostops.RegisterBoxOperation`/`RecheckBoxOperation` pair if none is exported (the implementer checks `internal/hostops/enroll.go` and `ingest.go` first and lists the deviation). `localBoxReport` is the box's own census report as `hostfencepass.go`'s `hostFenceOnce` builds it. `expectedSecretsFP` is the fingerprint the edges file publishes (edgesfile.go). `relocationRecord` is Task 8c's. |
| + |
| +- [ ] **Step 5: Registration, the agent and the console** |
| + |
| +In `internal/host/agent.go`, before `claimEntryContext` (214): `if regionHAEnabled(os.Getenv) { if err := RegisterProductionClaimRelocationSource(NewRelocationSource(...)); err != nil { log.Fatal(err) } }` with the source's config filled from the same values `NewRelocationRuntime` uses (`dd`, `release.OptDir()`); and after `armMovesPass` (450): build the runtime from `entry.Assembly` (via `productionClaimFactory`'s entry) and `go runtime.Run(agentCtx, store, serialChanges)`. In `claimproduction.go:150`, inside the `regionHAEnabled` gate, hand the source `out.Serving.Launch`, `out.Serving.Files` and `out.Serving.Markers` (`source.SetServingSources(...)`) before `openClaimRelocationOwners`. In `internal/console/claim_runtime.go:71` set `a.Executors = s.executorChooser(d)` (nil with `Config.RegionHA` off: hostclaimexecutor.go). |
| + |
| +- [ ] **Step 6: The fenced committed landing (J3) and the no-target bound (J3-3)** |
| + |
| +`internal/host/relocationlanding.go`: |
| + |
| +```go |
| +// CommittedLandingRecords is hostclaim.CommittedLandingGuard over the |
| +// authoritative instance record: the move named by the claim's op has |
| +// committed for this incarnation (provision.MoveCommitted, no rollback), |
| +// and the regions and park scopes are the commit's (moves.CommitScopes). |
| +type CommittedLandingRecords struct{ Records MovesRecordReader } |
| + |
| +func (g CommittedLandingRecords) CheckLandingCommitted(ctx context.Context, o hostclaim.Observed, opID string) (hostclaim.CommittedLanding, error) { |
| + c := o.Claim |
| + if g.Records == nil || c == nil || c.Holder == nil || c.Op.ID != opID { |
| + return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState |
| + } |
| + rec, err := g.Records(ctx, c.Holder.Account, c.Holder.App, o.Host) |
| + if err != nil { |
| + return hostclaim.CommittedLanding{}, err |
| + } |
| + m := rec.Move |
| + if rec == nil || m == nil || m.ID != opID || m.Incarnation != c.Holder.Incarnation || m.State != provision.MoveCommitted || m.Rollback != nil || m.CommittedAt == nil { |
| + return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState |
| + } |
| + source, staging, err := moves.CommitScopes(c, m.FlippedAt) |
| + if err != nil { |
| + return hostclaim.CommittedLanding{}, err |
| + } |
| + return hostclaim.CommittedLanding{FromRegion: m.From, ToRegion: m.To, SourceScope: source, StagingScope: staging}, nil |
| +} |
| +``` |
| + |
| +`convertFencedLanding` (in relocatepass.go): for a landing claim whose `op.exec` box has a termination fence (`Worker.fence`, exported as `Fence(ctx, box) (*hostclaim.FenceRef, termfence.Record, error)`), call `hostclaim.ConvertCommittedLanding(ctx, store, obs, c.Op.ID, c.Op.Exec, *ref, r.Worker.Fences, CommittedLandingRecords{MovesRecordsFrom(store)}, r.Worker.Discharge(ctx, box, record), newClaimID)`; `ErrWrongState` (not committed) is moves' case and is not logged; any other error is alerted once. |
| + |
| +In `internal/hostrelocate/worker.go`, the `PhaseStarting` branch with no target: on a chooser error, parse the newest `fence.*.observed_at` and, `ReleaseDeadline` after it, `MarkStuck(id, "no eligible box for the converted landing after ...")`; before that return the chooser's error (the pass alerts once). Test `TestWorkerBoundsAConversionWaitingForATarget` beside `TestWorkerNamesAConvertedLandingsTargetBeforeItsPin`. |
| + |
| +- [ ] **Step 7: Run the tests** |
| + |
| +Run: `nice -n 19 go test ./internal/host/ ./internal/hostrelocate/ ./internal/edgesfile/ ./internal/console/ -run 'RelocatePass|RelocationPinner|RelocationRelease|DischargeIsUnqualified|EdgesLiveSnapshot|CreateDoorUsesTheChooser|WorkerBoundsAConversion|Eligib|Placement' -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/host/relocatepass.go internal/host/relocatepass_test.go internal/host/relocationlanding.go internal/host/agent.go internal/host/claimproduction.go internal/hostrelocate/worker.go internal/hostrelocate/worker_test.go internal/edgesfile/snapshot.go internal/edgesfile/snapshot_test.go internal/console/ |
| +git commit -m "host: with CARLOS_HOST_CLAIM and CARLOS_REGION_HA on, every box registers its relocation owners, replays its region's relocations and its own participations, and converts a fenced committed landing |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 9: The rollout checklist** (not code; recorded in the plan and in the handoff) |
| + |
| +Before `CARLOS_REGION_HA` is set on any box with a second box in its region, on dev with two fixed boxes: |
| + |
| +1. **Floor and flags.** The release floor includes claim Plan 11's additions (its rollout section); `CARLOS_HOST_CLAIM` and `CARLOS_MOVES_PHASE1` are on; claim Plan 9's activation census and floor guard pass (`#250`); every box's root runs `isolated-unit-v3`. |
| +2. **Preflight.** On each box, a serving wake passes `ClaimRoleAssembly.Preflight` (the isolated launch): `TestRelocatePassStaysOffWhileThePreflightRefuses`'s production counterpart is the agent's log line at startup. |
| +3. **IAM (W3).** The box role may create-only `PutObject` under `control/hosts/*/evidence/*`, `control/relocations/*` and the data bucket's `instances/*/files/*/` prefixes; `DeleteObject` denies do not cover them (the lease-release outage rule). |
| +4. **Root's scan (W2).** Terminate a drain on dev: root's writers-stopped observation passes under the deployed `carlos-instance-control.service` (host PID namespace, ptrace), and the full stop's receipt lands with its `.sig`. |
| +5. **The live witness (W1).** With the real Litestream replicator, a drain's `WritersStopped` passes; if root refuses on a descriptor outside the hint, file the claim ask of ruling 29 and keep drains off. |
| +6. **M3.** Root's held-stage relay serves a relocating request (the target's `live/<host>` names `install_release`). Until then relocation waits in `installed`. |
| +7. **C1.** Drains stay off until the claim answers C1 (ruling 33). Test failover first: terminate the executor of one host; watch `starting -> pinning -> installing -> installed -> probing -> serving`, one cold restart on the target, the source's `park-local` left open (no `enrolled_at` discharge yet unless the roster has it). |
| +8. **J3.** A committed landing whose target is terminated converts; moves' M1/M2 close it. |
| +9. **L21.** The instance-key plan has landed, or every app in the region writes `instance.key` before its first manifest. |
| +10. **Capabilities.** Every box and edge publishes `region-ha-1` (spec §8 step 3). |
| + |
| +--- |
| + |
| +### Task 11: The merge gate (L18) |
| + |
| +Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `origin/region-ha-two-box-build` (e814ece3 keeps the landing's op id) and these additions to the joint round's list: the committed-landing conversion on both sides of the commit; the one-cold-restart completion (ruling 32); a drain under ruling 28's order; a probe-failed target with C1 open going `stuck` at the deadline and never queuing an obligation. |
| + |
| +- [ ] **Step 1: Confirm the joint round has passed** (as L13562-13566; stop otherwise and record the gap on amadan #236). |
| +- [ ] **Step 2: Run the branch gate** once, outside the sandbox: `mkdir -p /tmp/k && go build ./... && TMPDIR=/tmp/k nice -n 19 make test`, then the three cross-builds. |
| +- [ ] **Step 3: Push and read CI**: `git push origin region-ha-1b-next-build && amadan ci status carlos/platform -branch region-ha-1b-next-build`. Merging is Paul's, through `amadan branch merge`, after re-merging main and waiting for CI on the merged tip. |
| + |
| +--- |
| + |
| +## After this plan |
| + |
| +| What | Waits for | |
| +|---|---| |
| +| Drains (`reason: drain`, `rebalance`) | C1 | |
| +| The zero-downtime completion through `relocating/serving` | Task 11's round; a follow-up plan | |
| +| The L21 instance key | rastrillo #66, its own plan | |
| +| Automatic discharge of a fenced box's chores | roster entries with `enrolled_at` (now written by Plan 11 with the claim flag on) | |
| +| `carlos ops host relocate`, the console's view, spec 3's placer | Paul's copy review; spec 3 | |
| +| `control/relocations/` create-only policy | a tofu change (ruling 5) | |
| + |
| +## What cannot be planned yet |
| + |
| +- **The serving reconcile after a later stamp** (Review Focus 5): whether main's census accepts the target's tree at a new stamp is answered by Task 9's test over the real adapter; a refusal there is a claim ask, not a region HA change. |
| +- **The app-admission registration's exact helper** (ruling 4): `hostops` has the registry; whether an exported box-owner registration exists is checked at Task 10 and listed as a deviation if one is added. |
| +- **W1 and W2** are observable only on a box. |
| + |
| +## Self-review |
| + |
| +- **Spec coverage.** §5.8 release sequence (8a), pinned set and build (8b), local install and receipt (8c), probe admission with `KeepStamp`, coverage via `PredecessorCertified`, P opened once (8d), who acts and the passes (10), serving (9); §5.3 row cases under ruling 31 (9); §6 and §8 as the checklist (10); L18 (11). |
| +- **Placeholders.** Task 8b and Task 10 step 4 name functions by their 1b-next or main bodies rather than repeating them in full; each is cited by file and line and exists on this branch. Task 8d's `AdmissionEvidence` marks two lines to replace with the manifest-digest check it describes; the implementer removes the marker. |
| +- **Type consistency.** `RelocationEffects`' provider signatures are relocationeffects.go:478-536's; `RelocationAssemblyConfig` is shared by 8a to 10; `relocationInstallPath` is used by 8c and 8d; `CertifiedFor` by 8d and the participant; `MintHeld`'s `(rec, captured, status, op)` by 8c and 9's tests; `CommittedLandingRecords` by 10. |
| +- **Review Focus.** Each line names its test and task. |
| +- **Dependencies.** Every open ask on #236 is in the dependency table with a fallback; the plan builds nothing that assumes an answer. |
diff --git a/docs/superpowers/plans/CODEX-PLAN-REGION-HA-STAGE-B-BRIEF-1.md b/docs/superpowers/plans/CODEX-PLAN-REGION-HA-STAGE-B-BRIEF-1.md| new file mode 100644 |
| index 000000000..cb7610ff4 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-PLAN-REGION-HA-STAGE-B-BRIEF-1.md |
| @@ -0,0 +1,25 @@ |
| +# Astra round 1 brief: region HA 1b-next stage B plan (rev 1) |
| + |
| +**Review:** `docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md`, rev 1, on branch `region-ha-1b-next-build`. Every signature in it is cited file:line against this branch at b250a1fc. Main is merged in at dc333fb5, including claim Plans 5, 6b, 8/8b, 9 and 11 and moves Plans 1–5. |
| + |
| +**Context:** |
| +- Stage A is built: Tasks 1–7 and 9a of `2026-10-07-region-ha-1b-next-relocation.md`, plus R9 and J3. |
| +- This plan rewrites the gated stage B (8a–c, 9, 10) against main's real claim assembly, and absorbs Task 11, the L18 merge gate. |
| +- The contracts are on amadan carlos/platform #236 (R1–R10, C1–C5, M1–M3, J2, J3, M10) and in claim Plan 11 (`docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md`). |
| +- The spec is `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`. |
| +- Rulings 1–27 are the 1b-next plan's; this plan continues at 28. |
| + |
| +**Judge:** |
| +1. **Code against the branch.** Does every task's code compile against the real code on this branch? Are the cited signatures right? Does anything duplicate a concept main already implements? The rule is one implementation per concept, and main's wins. |
| +2. **Obligations.** Is every stage B obligation from the 1b-next plan's re-plan list, the ledger and the reviews met, re-homed or retired with a sound reason? |
| +3. **Open dependencies.** Is each fallback safe while its ask is unanswered (C1, M1–M3, W1–W3)? Above all: ruling 33 (drains stay off until C1) and ruling 32 (one cold restart at the end). |
| +4. **Authority.** Does any path let a box or a request supply authority that root or the region controller must own? Check: |
| + - the pinned release (ruling 30, the channel pointer); |
| + - `install_release`; |
| + - the held-stage relay; |
| + - the `WithRefusals` refusals; |
| + - the pass-through `hostwrite.Work` in ruling 34. |
| +5. **Tests.** Are they real? Would each fail if its guard were removed? |
| +6. **Task 10.** Does the activation checklist cover everything needed before `CARLOS_REGION_HA` is turned on? |
| + |
| +Give findings P1/P2/P3 with file:line, then a verdict: ready to build, or not. |