carlos / platform Public

docs: region HA stage B plan rev 3 answers Astra round 2: O2 withdrawn for O2', C7 withdrawn (ColdHandover.Run takes the probe receipt via N6), C10 and C11 raised, PrepareState for the instance directory, fresh-read transitions, census-opaque journals, build execution authority, bounded installed phase, ported lifecycle property

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b897dbb7fe67c81d19381ee63af08b77eb16e392 parent 7a41192
1 file changed, +1177 −93
  • docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md +1177 −93
diff --git a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
index 2833157fd..f38d1e910 100644
--- a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
+++ b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
@@ -1,14 +1,716 @@
-# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 2)
+# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 3)
> **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`. Rev 2 adds main's participation owner (`productionClaimLocal`), main's WorkSource as the install's authority, main's prepared-publication contract, root's attempt reader for kind relocating, and an explicit stop of the passed probe before the serving CAS. Seven tasks: 8a the participation and release providers, 8b the remote build of P (with the guard activated from an empty target and the O2 pin fix), 8c the local install, 8d the relocation owner set, root's attempt reader and the preflight gate, 9 reconcile and the completion with its cold restart, 10 the passes behind the flags with the rollout checklist, 11 the merge gate. Every open ask on amadan #236, and the three new claim asks rev 2 raises (C6, C7, C9), is a dependency with a named fallback, never an assumption.
+**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`. Rev 2 adds main's participation owner (`productionClaimLocal`), main's WorkSource as the install's authority, main's prepared-publication contract, root's attempt reader for kind relocating, and an explicit stop of the passed probe before the serving CAS. Seven tasks: 8a the participation and release providers, 8b the remote build of P (with the guard activated from an empty target and the O2 pin fix), 8c the local install, 8d the relocation owner set, root's attempt reader and the preflight gate, 9 reconcile and the completion with its cold restart, 10 the passes behind the flags with the rollout checklist, 11 the merge gate. Every open ask on amadan #236 and every claim ask this plan raises (C6, C9, C10, C11; C7 withdrawn in rev 3) is a dependency with a named fallback or an explicit activation gate, never an assumption. Rev 3 adds the completion's handover from the probe's own receipt (N6), main's `PrepareState` for the instance directory, census-opaque journals (N9) and O2' in place of O2.
**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 808c36a7 (main merged at dc333fb5); file:line cites are to this branch.
+**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 808c36a7, and rev 3's again at 7a41192c (main merged at dc333fb5); file:line cites are to this branch.
+
+---
+
+## Rev 3: round 2 dispositions
+
+Astra's round 2 (`CODEX-PLAN-REGION-HA-STAGE-B-R2.md`) raised 7 P1s and 4 P2s, and its verdict was not ready. Every cited file:line was read at 7a41192c, and all eleven findings reproduce. Nine are accepted as framed and two are accepted with a different fix; none is declined. Counts: **accepted 9, accepted-changed 2, declined 0**.
+
+The coordinator's binding direction was followed: O2 is withdrawn for O2', C7 is examined against `ColdHandover.Run`, and the deleted lifecycle property is ported. No O2 pinner commit was ever made: rev 2 changed only the plan document. Task 8b step 0 now specifies O2' instead.
+
+All rev 3 code was assembled in a throwaway `git archive 7a41192c` under a unique `$TMPDIR` directory, now deleted. The checks run there:
+
+- `go build ./...` is clean.
+- `go vet` is clean on host, hostbox, hostclaim, hostrelocate, hostfiles, provision and registry.
+- darwin, windows and linux/arm64 builds of `./internal/...` are clean, and `gofmt -l` is empty.
+- The whole `hostrelocate`, `hostclaim`, `hostbox`, `hostfiles` and `registry` packages pass.
+- The `internal/host` claim, Plan 9, Plan 11, relocation, census and handover tests pass. The thirteen that listen on a unix socket were run with the sandbox off and `TMPDIR` in a private `/tmp` directory, and pass with the N6 handover extraction and wiring.
+
+| # | Task | Finding | Disposition | Change | Test |
+|---|---|---|---|---|---|
+| 1 (P1) | 8b | Rev 2's O2 accepted any non-empty `CARLOS_INSTANCE_KEY` in the pinned env. An app can ignore it, seal with its own `instance.key`, and die before publishing that file. | accepted | Ruling 55: rev 2's presence-only O2 is withdrawn and ruling 25's refusal stands. The pinner implements O2' from the instance-key plan rev 2 (branch `instance-key-build`). Three sources count: the `instance.key` file; the platform key in the pinned env plus the agent's durable adoption record for the incarnation; or the bucket-held config's key. They are read through `StorePinner.KeyEvidence`, which is nil until that plan lands, so only the file counts until then. | `TestStorePinnerNeedsPositiveEvidenceForAnEnvKey` (written, passes). Its first case is Astra's: an unused platform key plus an unpublished app key refuses. Also: adoption without the platform key refuses, an empty key refuses, a reader error refuses, and config evidence passes. |
+| 2 (P1) | 8c | `park.Local.RecordInventory` updates the claim on every call (park/local.go:330), so `Participant.Step`'s `RecordLocalInstall` on its original observation (participant.go:165) conflicts every time. | accepted | Ruling 62. `Participant.sameInstallation` re-reads the claim after `InstallLocal` and after every `EnsureLocal`, requires the same op, executor, phase, P, install stamp and holder, and makes the transition on that fresh read. The install calls `RecordInventory` only when the box's claim inventory does not already say nested. | `TestRelocationInstallRecordsItsReceiptThroughParticipantStep`: `Participant.Step` over the real effects records `local_install` on the first pass, then enters probing. Also a repairing `EnsureLocal` followed by `ToProbing`, with no conflict loop. |
+| 3 (P1) | 8c | Rev 2 made `InstanceDir` with `MkdirAll`. `PrepareState` refuses an existing directory without its typed, materialized path entry (claimroleassembly_linux.go:339-347). | accepted | Ruling 57. The install calls main's own `ClaimRoleAssembly.PrepareState` under the install work, after `MintHeld` and before any file: intended entry, mkdir, then materialized with device and inode. The probe's `PrepareState` then accepts the directory as its own. `MkdirAll` is gone. | `TestRelocationFromAnEmptyTargetToTheServingWake` keeps the real `PrepareState` on both sides; `TestRelocationInstallMaterializesTheInstanceDirectoryTyped` checks the inventory entry and inode. |
+| 4 (P1) | 8d | Root's reader required `holder.stamp == install_stamp`, which only P's opening keeps; later cold admissions take a new stamp (generation.go:141-143). Main's own `claimLaunchAuthority` pins every relocating launch to `install_stamp` too (claimlaunch_root.go:129-132). | accepted-changed | Ruling 58. The reader applies the install-stamp check only to P's opening and validates a later attempt's start at the current writer stamp. Main's root pin is a claim rule, so it is claim ask **C11**. Until C11, a later attempt is never made: `Preflight` answers `ErrLaterAttemptUnlaunchable` once P has opened, and the participant moves the op to `probe-failed` at once rather than spending attempts root refuses. | `TestRootReadsTheRelocationAttemptJournal` (extended, passes): a second attempt at its own stamp and generation reads its start. `TestParticipantFailsAProbeRootCannotRelaunch`: after P's opening fails, the next pass makes `probe-failed` and counts no attempt. Successful second and third launches through root wait on C11 (`TestRootLaunchesLaterRelocationAttempts`, written against C11's patch). |
+| 5 (P1) | 9 | Main's participation cleanup stops the observation captured when its controller opened (claimparticipation.go:127, :184). After the serving CAS that no longer matches the writer, `stopCapturedSession` refuses, and the slot and `.hostexec` record deny serving for good. | accepted-changed | Ruling 56. The participation is discharged by the exact completed probe evidence: the serving op's `CompletedRelocation` (`completed_relocation`) names the op and the probe stop, and root's receipt for it is in this box's guard journal. That is a new CC9 discharge rule, so it is claim ask **C10**, with its patch written and compiled. Without it completion cannot serve, so C10 is an activation gate. | `TestRelocationCompletionDischargesParticipationWithoutRestart`: completion with the same agent process, no restart; the slot closes, `ClearStale` removes the record, the serving `Check` passes, and the serving wake admits. Written against C10's patch. |
+| 6 (P1) | 9 | Rev 2's C7 proposed a serving-shaped re-stop of S. `ClaimProducers.DrainClaim` rejects a second fence for the same producers (claimstop_producers.go:77), and `ColdHandover.Run` (hostbox/coldhandover.go:59) already accepts an exact prior receipt beside a separately validated successor, without matching op ids. | accepted | Ruling 56. **C7 is withdrawn.** `CompleteRelocation` journals the probe stop on the serving op. On the first serving cold wake on that box, `BeforeAdmission` finds and verifies that receipt and stops nothing, and `AfterAdmission` hands it to root's `/claim/handover`, so `Run` runs with prior = the probe stop's target. That needs no new claim semantic, only agent-side routing (named change N6: `coldHandoverWith` extracted from `coldAdmissionHook`, and a completed-relocation branch in `wireClaimAdmission`). | Main's handover tests pass unchanged with N6, run outside the sandbox. `TestRelocationFromAnEmptyTargetToTheServingWake` exercises `Run` with the probe receipt as prior and a cold serving successor. |
+| 7 (P1) | 8b | The remote build checked claim fields and the guard stamp, but `Local` ran only at activation, so a lost execution lease did not stop later writes. | accepted | Ruling 59. `relocationBuildAuthority` re-reads the claim and guard and runs main's `WorkSource.Local` (the participation slot's binding, deadline and loss latch, plus identity and quarantine) before every durable build write. `relocationBuildP` takes it and calls it in `authorityStore` and before the manifest copy. | `TestRelocationBuildStopsWhenOnlyTheExecutionLeaseIsLost`: the replica lease, claim and guard stay valid, the hostexec deadline passes between the snapshot and the anchor, and the next write is refused. |
+| 8 (P2) | 8a, 10 | The census refuses every unclassified regular file (empty_census.go:144), not only host-named ones, and `claimDataCensus` does not classify `relocate/`. | accepted | Ruling 61. Named change N9 classifies `relocate` as opaque in `claimDataCensus`, as the claim's own `.hostclaims` and `.hostexec` are. Lifecycle: `sweepRelocationJournals` removes `ops/<op>`, `release/<op>.json` and `build/<op>` once the host's claim no longer runs that op, once per pass. | `TestCensusInitializesWithOldRelocationJournals` (written, passes): an empty installation initialises with a previous relocation's install, attempt, position and scratch files present, and refuses without N9. `TestRelocationJournalsAreSweptWhenTheirOpEnds`. |
+| 9 (P2) | 8c | `Present` accepted any existing env path. | accepted | Ruling 64. The env file must be a regular file at the platform entry's size and sha256; a deleted or absent entry means no file. | `TestPresentReadsTheEnvBack` (written, passes): truncated, another key at the same size, a directory and a symlink all fail; a deleted entry needs absence. |
+| 10 (P2) | 9 | The probe deadline starts only at `ToProbing`, so a refusing `EnsureLocal` in `installed` waits forever. In `probing`, C1 also blocks automatic replacement of a live target. | accepted | Ruling 60. `ToInstalledAt` journals `installed_at`, and the worker marks the op `stuck` `ProbeDeadline` after it if the target never entered probing. In `probing`, the probe deadline gives `probe-failed`, then `stuck` at `ReleaseDeadline` (ruling 26's bound). Neither phase replaces a live target automatically while C1 is open. Ruling 52's promised replacement is corrected to these outcomes. | `TestWorkerBoundsAnInstallThatNeverProbes` (installed) and `TestWorkerBoundsARefusedProbe` (probing then probe-failed then stuck), each with a retired stamp. |
+| 11 (P2) | 8a | C6's fallback said main's census always keeps denial; `CheckLocal` returns at once for a complete matching installation (empty_census.go:58-63). The deleted 200-seed property was not replaced. | accepted | Ruling 63. C6's fallback is corrected: both outcomes are safe. The 200-seed lifecycle property is ported to `productionClaimLocal` (`TestProductionParticipationLifecycleProperties`, specified below). | The ported property, plus the two C6 outcomes as named cases. |
+
+### Claim asks after rev 3
+
+- **C7 is withdrawn** (ruling 56). The handover needs no new claim semantic: root's `ColdHandover.Run` takes the probe stop's exact receipt as the prior, and the agent routes it there (N6).
+- **C10 (new)** replaces what remains of C7: a CC9 discharge of a relocation participation by its exact completed probe evidence (ruling 56). The proposed patch is `claimparticipation_completed.go` plus one call in `productionClaimLocal.cleanup`, written below and compiled. There is no serving fallback: without it a completed relocation's target cannot serve. It is an activation gate.
+- **C11 (new)**: `claimLaunchAuthority` pins a relocating launch to `install_stamp` only for P's opening and validates a later attempt against the admitted writer's stamp, through root's attempt reader (ruling 58). Fallback: one launchable attempt per target. After P's opening fails, the op goes to `probe-failed` at once.
+- **C6 stays**, with its fallback corrected (ruling 63). **C9 stays** unchanged.
+
+### Named changes to claim-owned code, rev 3
+
+| # | File | Change | Why it is not an ask |
+|---|---|---|---|
+| N6 | `claimhandover_transport.go`, `claimhandover_crossbox.go`, `claimconstruction.go` | `coldHandoverWith` extracted from `coldAdmissionHook` unchanged; `relocationBefore`/`relocationAfter` named from the inline closures unchanged; `wireClaimAdmission` gains a `completed *relocationCompletedHandover` (nil leaves it as it was) that routes only a serving prior whose op journals this box's completed relocation and whose writer is that probe session | Root's `Run` stays the authority; the agent only chooses which completed receipt to hand it |
+| N9 | `claimcensus.go` | `"relocate"` added to `claimDataCensus`, opaque like `.hostexec` | Region HA's self-verifying journals, swept by op; no installation artifact lives there |
+
+## Rulings, rev 3 (55 to 64)
+
+55. **O2 is withdrawn; the pinner implements O2'** (finding 1; withdraws 54). Ruling 25's refusal stands unless the pinned manifest has the `instance.key` file, or `StorePinner.KeyEvidence` (O2' from the instance-key plan rev 2) reports the bucket-held config's key, or reports an adoption record *and* the pinned env carries a non-empty `CARLOS_INSTANCE_KEY`. The adoption record is the agent's durable record, written when the app's helper reports it used `$CARLOS_INSTANCE_KEY` for this incarnation. `KeyEvidence` is nil until that plan lands. The question names host, account, app, incarnation and the pinned release, so the evidence can be version-bound (migration evidence is O2''s to define). A reader error refuses. *Cost if wrong:* an app that really uses the env key but has no adoption record yet goes `stuck`, as ruling 25 already does.
+56. **Completion hands over from the probe's existing receipt, and discharges its participation with it** (findings 5 and 6; C7 withdrawn, C10 raised). `CompleteRelocation` journals `completed_relocation` on the serving op: the op, the probe stop (box, session, fence id, receipt digest), the stamp, phase and generation of the stopped session. On the first serving cold wake on that box, N6 does three things. Before admission, `relocationCompletedHandover.Before` finds the exact receipt in this box's guard journal (`CompletedWriterStops` with matching fence id, obligation `writer-session-S`, phase and digest) and verifies it, in place of `Wake.Stop`, which would ask a second, serving-shaped fence that S's producers reject. After admission, `coldHandoverWith` posts that receipt's target as `Prior` to root's `/claim/handover`. Root's `ColdHandover.Run` takes `prior` as it is: it checks the successor's stamp and session above the prior's, same box and enrollment, the receipt and the prior's exact inventory, and never matches op ids. The participation slot opened at acknowledgement is discharged by the same evidence (C10). *Cost if wrong:* if C10 is refused, the completed target's slot denies serving; C10 is an activation gate, and the flags stay off.
+57. **The instance directory is made by main's `PrepareState`** (finding 3). The install reads the claim fresh after `MintHeld`, checks its authority, and calls `ClaimRoleAssembly.PrepareState(ctx, installed, work, {DBPath})` under the install work, then restores into the materialized directory. On a non-Linux build `PrepareState` refuses, so the install refuses there; relocation is Linux-only. *Cost if wrong:* none beyond main's protocol.
+58. **A later probe attempt is made only when root can launch it** (finding 4). The reader validates a later attempt at the current writer stamp. Main's `claimLaunchAuthority` still pins `install_stamp`, so until C11 `Preflight` answers `ErrLaterAttemptUnlaunchable` once P has opened, and the participant makes `probe-failed` at once. *Cost if wrong:* one probe attempt per target until C11; a failed P opening goes `probe-failed` sooner.
+59. **Every durable build write rechecks the execution lease** (finding 7). `relocationBuildAuthority` is the claim at `installing` and the install stamp, the guard's capture, and main's `WorkSource.Local`. *Cost if wrong:* one claim read and one guard read per build write.
+60. **`installed` and `probing` are bounded by real outcomes** (finding 10; corrects 52's promise). `installed_at` plus `ProbeDeadline` gives `stuck` from `installed`. The probe deadline gives `probe-failed`, then `stuck` at `ReleaseDeadline` while C1 is open. No automatic live-target replacement is claimed. *Cost if wrong:* an operator instead of a replacement, as ruling 26.
+61. **Region HA's journals are census-opaque and swept by op** (finding 8). N9 adds `relocate` to `claimDataCensus`, and `sweepRelocationJournals` runs once per pass. *Cost if wrong:* a stale journal for a removed host sits under an opaque directory until the next pass reads its claim as gone.
+62. **Each participant transition is made on a fresh read of the same installation** (finding 2). `Participant.sameInstallation` checks op, executor, phase, P, install stamp, incarnation, session and stamp. `RecordInventory` runs only when the claim's inventory is not already nested. *Cost if wrong:* one claim read per transition.
+63. **C6's fallback is two safe outcomes** (finding 11). If the excluded target's slot was captured at its own install stamp, main's `CheckLocal` finds the complete installation and cleanup succeeds; `ClearStale` then removes the denial while the inert, held, never-reactivated installation waits for its `park-local`. If it was captured at an earlier stamp, the census refuses the row and the denial stands until `park-local`. Either way no writer can start there (`WakePhase` needs `op.exec`; the row is held; the stamp is never reactivated). *Cost if wrong:* an executable update may run on a box holding an inert installation, which the update path already tolerates for parked residue.
+64. **Recovery verifies the env file's bytes** (finding 9). `hostfiles.Present` requires a regular file at the platform entry's size and digest, and absence for a deleted or missing entry.
+
+### The ported lifecycle property (finding 11)
+
+`TestProductionParticipationLifecycleProperties` (`internal/host`, Linux, sandbox off, `TMPDIR` in a private short directory) ports the deleted 200-seed property to `productionClaimLocal`. Seeds: 200; `-short` 20; one sweep per revision, niced.
+
+**Harness:** a real `productionClaimLocal` in agent mode over real `hostexec` controllers, a `DirStore` claim, the real census and guard, and an injectable `collectStop`.
+
+**Events** (the deleted test's twelve, retargeted):
+
+- execution-lease renewal failure;
+- close timeout;
+- cleanup failure (root refuses, then later accepts);
+- `ClearStale` failures: claim unreadable, claim absent, invalid local record;
+- agent crash (the owner dropped, reopened through `clearUntracked`);
+- a successor relocation before the sweep, listing this box or excluding it;
+- completion, with C10's discharge;
+- storage recovery.
+
+**Added for rev 3:**
+
+- an excluded target with its unopened install captured at its own stamp, and captured at the source's stamp (both C6 outcomes);
+- a retired stamp in `installed` and in `probing` (ruling 60);
+- a subsequent operation of the same host after completion.
+
+**Invariants:**
+
+- (a) no `ClearStale` without a cleanup that succeeded in the same close;
+- (b) a `.hostexec` record exists while any slot or retained denial does;
+- (c) at most one controller per host;
+- (d) no pass past its deadline;
+- (e) once faults stop, within eight passes:
+ - the current participation is ready;
+ - after completion the record is cleared, the serving `Check` passes and the serving wake's N6 handover runs;
+ - a subsequent operation opens;
+- (f) no install stamp is ever reactivated;
+- (g) an excluded target's installation is retired only by its `park-local`.
+
+**Mutation checks**, each failing the property:
+
+- remove C10's discharge;
+- remove `sameInstallation`;
+- clear without a successful cleanup;
+- reactivate a retired stamp.
+
+### Where rev 3 changes the tasks
+
+- **Task 8a.** `relocationeffects.go`: `PreflightKind` takes the observation. `relocationassembly.go`: `LaterProbeAttempts` and `relocationPreflight(cfg)`. `relocatepass.go` (Task 10): `DataDir` and the sweep. New `relocationsweep.go`, and N9's `claimcensus.go` change, land here with `TestCensusInitializesWithOldRelocationJournals` and `TestRelocationJournalsAreSweptWhenTheirOpEnds`. The lifecycle property lands here too.
+- **Task 8b.** Step 0 is O2', with `pin_envkey_test.go` replaced by `TestStorePinnerNeedsPositiveEvidenceForAnEnvKey`. `relocationBuildP` takes the execution authority (ruling 59); `relocationBuildAuthority` is in `relocationinstall.go`.
+- **Task 8c.** `relocationinstall.go` is the file below (PrepareState, inventory once, build authority). `present.go` and the new `present_test.go`.
+- **Task 8d.** `relocationattempts.go`'s reader (ruling 58). `participant.go`'s patch now carries `sameInstallation` and `ErrLaterAttemptUnlaunchable`.
+- **Task 9.**
+ - `hostclaim/relocation_probestop.go` gains `CompletedRelocation`, `ReadCompletedRelocation` and `ToInstalledAt`.
+ - `CompleteRelocation` journals `completed_relocation`, and the worker bounds `installed`.
+ - New `relocationhandover.go`, with N6's three patches.
+ - C10's proposed patch (`claimparticipation_completed.go` and the `claimparticipation.go` hunk) is **not** committed by stage B. It is the ask's content, carried for the claim session. Stage B's tests that need it skip with `C10` until it lands.
+ - `hostclaim/relocation_test.go`: `TestRelocationFailoverCitesTheFenceAndDeclaresTheLoss` expects exactly the `completed_relocation` journal on the serving op.
+
+### Rev 3 new files
+
+`internal/host/relocationhandover.go` (Task 9, N6):
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+// relocationCompletedHandover is the first serving cold wake after a
+// relocation completed on this box (rulings 49 and 56; Astra round 2
+// finding 6). The predecessor is the probe session S that the target
+// stopped at relocating/probing with root's writer-session receipt, and
+// the serving op journals that stop (CompletedRelocation). Nothing is
+// stopped again and nothing is relabelled: before admission the receipt
+// is found and verified in this box's guard journal (in place of main's
+// Wake.Stop, which would ask a serving-shaped re-stop of S); after
+// admission it is handed to root's ColdHandover.Run as the exact prior,
+// which Run accepts beside the separately validated successor without
+// matching their operation ids.
+type relocationCompletedHandover struct {
+ Guard hostguard.Guard
+ Identity hostbox.IdentityProvider
+ Socket string
+ Box string
+}
+
+// completedPrior reports whether prior is a serving claim whose writer is
+// the stopped probe session this box completed a relocation with.
+func (h relocationCompletedHandover) completedPrior(prior hostclaim.Observed) (hostclaim.CompletedRelocation, bool) {
+ c := prior.Claim
+ if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || c.Op.Exec != h.Box || c.Holder.WriterBox != h.Box {
+ return hostclaim.CompletedRelocation{}, false
+ }
+ done, ok := hostclaim.ReadCompletedRelocation(c)
+ if !ok || done.Stop.Box != h.Box || done.Stop.Session != c.Holder.Session || done.Stamp != c.Holder.Stamp || done.Generation != c.Holder.Generation {
+ return hostclaim.CompletedRelocation{}, false
+ }
+ return done, true
+}
+
+// receipt is root's completed writer-session stop of S, exactly the one
+// the serving op journals: fence id and the receipt bytes' digest.
+func (h relocationCompletedHandover) receipt(ctx context.Context, prior hostclaim.Observed) (hostguard.FenceReceipt, error) {
+ done, ok := h.completedPrior(prior)
+ if !ok {
+ return hostguard.FenceReceipt{}, hostbox.ErrChanged
+ }
+ inc := prior.Claim.Holder.Incarnation
+ receipts, err := h.Guard.CompletedWriterStops(ctx, prior.Host, hostguard.Captured{Incarnation: inc, Stamp: done.Stamp}, done.Op, h.Box)
+ if err != nil {
+ return hostguard.FenceReceipt{}, err
+ }
+ want := hostguard.WriterStopObligation(done.Stop.Session)
+ for _, r := range receipts {
+ sum := sha256.Sum256(r.Bytes)
+ if r.Target.Session == done.Stop.Session && r.Target.ObligationID == want && r.Target.FenceID == done.Stop.Receipt && r.Target.Phase == done.Phase && hex.EncodeToString(sum[:]) == done.Stop.ReceiptSHA256 {
+ return r, nil
+ }
+ }
+ return hostguard.FenceReceipt{}, fmt.Errorf("%w: no completed stop of session %d under %s", ErrClaimStopReceipt, done.Stop.Session, done.Op)
+}
+
+// Before replaces the predecessor stop for exactly this prior: the stop
+// already happened, so its receipt is found and verified, nothing more.
+func (h relocationCompletedHandover) Before(ctx context.Context, prior hostclaim.Observed) error {
+ r, err := h.receipt(ctx, prior)
+ if err != nil {
+ return err
+ }
+ return verifyRetirementReceipt(ctx, r)
+}
+
+// After is main's cold handover with that receipt as the prior.
+func (h relocationCompletedHandover) After() func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
+ return coldHandoverWith(h.Identity, h.Socket, h.receipt)
+}
+```
+
+N6's patches:
+
+```diff
+--- a/internal/host/claimhandover_transport.go
++++ b/internal/host/claimhandover_transport.go
+@@ -51,12 +51,22 @@
+ w.WriteHeader(http.StatusNoContent)
+ }
+ func coldAdmissionHook(guard hostguard.Guard, identity hostbox.IdentityProvider, socket string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
++ return coldHandoverWith(identity, socket, func(ctx context.Context, prior hostclaim.Observed) (hostguard.FenceReceipt, error) {
++ old := prior.Claim.Holder
++ return guard.CompletedColdPredecessor(ctx, prior.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, old.Session, prior.Claim.Op.ID, prior.Claim.Op.Phase)
++ })
++}
++
++// coldHandoverWith is coldAdmissionHook with the predecessor's completed
++// receipt found by find (extracted unchanged; region HA N6): root's
++// ColdHandover.Run validates the exact prior receipt and the successor.
++func coldHandoverWith(identity hostbox.IdentityProvider, socket string, find func(context.Context, hostclaim.Observed) (hostguard.FenceReceipt, error)) func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
+ return func(ctx context.Context, prior, admitted hostclaim.Observed) error {
+ if prior.Claim == nil || prior.Claim.Holder == nil || admitted.Claim == nil || admitted.Claim.Holder == nil || prior.Host != admitted.Host {
+ return hostbox.ErrChanged
+ }
+- old, next := prior.Claim.Holder, admitted.Claim.Holder
+- receipt, err := guard.CompletedColdPredecessor(ctx, prior.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, old.Session, prior.Claim.Op.ID, prior.Claim.Op.Phase)
++ next := admitted.Claim.Holder
++ receipt, err := find(ctx, prior)
+ if err != nil {
+ return err
+ }
+```
+
+```diff
+--- a/internal/host/claimhandover_crossbox.go
++++ b/internal/host/claimhandover_crossbox.go
+@@ -140,28 +140,30 @@
+
+ // wireClaimAdmission routes the source proof to its operation owner before
+ // lease acquisition and checks the target installation after admission.
+-func wireClaimAdmission(w *ClaimWakeAssembly, box string, local func(context.Context, hostclaim.Observed, hostclaim.Observed) error, landing claimCrossBoxHandover, relocation *ClaimRelocationOwners) {
++func wireClaimAdmission(w *ClaimWakeAssembly, box string, local func(context.Context, hostclaim.Observed, hostclaim.Observed) error, landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, completed *relocationCompletedHandover) {
+ before := landing.Before
+ after := landing.After
+- if relocation != nil {
+- before = func(ctx context.Context, prior hostclaim.Observed) error {
+- if prior.Claim.Op.Kind != hostclaim.KindRelocating {
+- return landing.Before(ctx, prior)
+- }
+- if relocation.BeforeAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" {
+- return hostbox.ErrChanged
++ if relocation != nil && completed != nil {
++ // Region HA N6: the first serving wake after a relocation completed
++ // on this box hands over from the probe's existing receipt; every
++ // other serving prior keeps main's stop and local handover.
++ completedAfter := completed.After()
++ w.BeforeAdmission = func(ctx context.Context, prior hostclaim.Observed) error {
++ if _, ok := completed.completedPrior(prior); ok {
++ return completed.Before(ctx, prior)
+ }
+- return relocation.BeforeAdmission(ctx, prior)
++ return claimPredecessorStop(w, box, relocationBefore(landing, relocation, box))(ctx, prior)
+ }
+- after = func(ctx context.Context, prior, admitted hostclaim.Observed) error {
+- if prior.Claim.Op.Kind != hostclaim.KindRelocating {
+- return landing.After(ctx, prior, admitted)
++ w.AfterAdmission = func(ctx context.Context, prior, admitted hostclaim.Observed) error {
++ if _, ok := completed.completedPrior(prior); ok {
++ return completedAfter(ctx, prior, admitted)
+ }
+- if relocation.AfterAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" {
+- return hostbox.ErrChanged
+- }
+- return relocation.AfterAdmission(ctx, prior, admitted)
++ return claimAdmissionHandover(box, local, relocationAfter(landing, relocation, box))(ctx, prior, admitted)
+ }
++ return
++ }
++ if relocation != nil {
++ before, after = relocationBefore(landing, relocation, box), relocationAfter(landing, relocation, box)
+ }
+ w.BeforeAdmission = claimPredecessorStop(w, box, before)
+ w.AfterAdmission = claimAdmissionHandover(box, local, after)
+@@ -173,3 +175,30 @@
+ }
+ return prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "")
+ }
++
++// relocationBefore and relocationAfter are wireClaimAdmission's relocating
++// routing (Plan 11), unchanged, named so the completed-relocation branch
++// (N6) can fall through to them.
++func relocationBefore(landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, box string) func(context.Context, hostclaim.Observed) error {
++ return func(ctx context.Context, prior hostclaim.Observed) error {
++ if prior.Claim.Op.Kind != hostclaim.KindRelocating {
++ return landing.Before(ctx, prior)
++ }
++ if relocation.BeforeAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" {
++ return hostbox.ErrChanged
++ }
++ return relocation.BeforeAdmission(ctx, prior)
++ }
++}
++
++func relocationAfter(landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, box string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
++ return func(ctx context.Context, prior, admitted hostclaim.Observed) error {
++ if prior.Claim.Op.Kind != hostclaim.KindRelocating {
++ return landing.After(ctx, prior, admitted)
++ }
++ if relocation.AfterAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" {
++ return hostbox.ErrChanged
++ }
++ return relocation.AfterAdmission(ctx, prior, admitted)
++ }
++}
+```
+
+```diff
+--- a/internal/host/claimconstruction.go
++++ b/internal/host/claimconstruction.go
+@@ -284,7 +284,11 @@
+ if groupD.Dispatch.Relocation != nil {
+ relocation = &owners.Relocation
+ }
+- wireClaimAdmission(wake, actor.Box, wake.AfterAdmission, handover, relocation)
++ var completed *relocationCompletedHandover
++ if relocation != nil {
++ completed = &relocationCompletedHandover{Guard: guard, Identity: owners.Identity, Socket: controlsock.Path, Box: actor.Box}
++ }
++ wireClaimAdmission(wake, actor.Box, wake.AfterAdmission, handover, relocation, completed)
+ retirement := park.Local{Authorize: func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
+ return owners.Authorize(ctx, hostclaim.Observed{Host: c.Host, Claim: c}, o)
+ }, Guard: guard, Claims: store, DataDir: r.DataDir, Box: actor.Box}
+```
+
+C10's proposed patch (for the claim session; not committed by stage B):
+
+```go
+package host
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// completedRelocationDischarge is claim ask C10's proposed CC9 discharge
+// (region HA ruling 56; Astra round 2 finding 5). The participation's
+// controller opened while the box acknowledged the relocation, so its
+// captured observation names an earlier writer; after the serving CAS,
+// stopCapturedSession(captured) can never match. When the claim now serves
+// on this box, the captured relocation is the one the serving op records
+// as completed here, and root's completed writer-session receipt for the
+// recorded probe session is in this box's guard journal, verified, the
+// session is stopped with root's evidence and the participation is
+// discharged: nothing is stopped again. done false falls through to main's
+// cleanup unchanged.
+func (a ClaimAssembly) completedRelocationDischarge(ctx context.Context, captured hostclaim.Observed) (done bool, err error) {
+ if captured.Claim == nil || captured.Claim.Op.Kind != hostclaim.KindRelocating || captured.Claim.Holder == nil {
+ return false, nil
+ }
+ local, err := a.localStopIdentity(ctx)
+ if err != nil {
+ return false, nil
+ }
+ now, err := hostclaim.Read(ctx, a.Claims, captured.Host)
+ if err != nil || now.Absent {
+ return false, nil
+ }
+ h := relocationCompletedHandover{Guard: a.Guard, Box: local.Actor.Box}
+ record, ok := h.completedPrior(now)
+ if !ok || record.Op != captured.Claim.Op.ID || now.Claim.Holder.Incarnation != captured.Claim.Holder.Incarnation {
+ return false, nil
+ }
+ r, err := h.receipt(ctx, now)
+ if err != nil {
+ return true, err
+ }
+ return true, verifyRetirementReceipt(ctx, r)
+}
+```
+
+```diff
+--- a/internal/host/claimparticipation.go
++++ b/internal/host/claimparticipation.go
+@@ -219,6 +219,12 @@
+ return hostwrite.ErrUnavailable
+ }
+ p.cancelWork(o.Host)
++ // Claim ask C10 (region HA ruling 56): a relocation that completed on
++ // this box is discharged by its exact completed probe evidence, not by
++ // stopping the session captured when the controller opened.
++ if done, err := a.completedRelocationDischarge(ctx, o); done {
++ return err
++ }
+ // Cleanup uses the journal and producer set directly. It must never call
+ // WorkSource.Select: Open is still waiting for this cleanup to finish.
+ has, err := a.Launches.HasHost(ctx, o.Host)
+```
+
+`internal/host/relocationsweep.go` (Task 8a, ruling 61) and N9:
+
+```go
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// sweepRelocationJournals is the lifecycle half of N9 (ruling 61): every
+// region HA journal under <DataDir>/relocate names its host inside and its
+// op in its path, and is removed once the host's claim no longer runs that
+// op (completed, replaced by a successor, or the claim gone). Positions,
+// attempts and install journals are needed only while their op runs;
+// the build scratch only while its op is installing. A journal that
+// cannot be read is left in place and reported: it is census-opaque, so
+// it blocks nothing, and absence is never evidence.
+func sweepRelocationJournals(ctx context.Context, dataDir string, claims release.Store) error {
+ root := filepath.Join(dataDir, "relocate")
+ var errs []error
+ gone := func(host, op string) (bool, error) {
+ obs, err := hostclaim.Read(ctx, claims, host)
+ if err != nil {
+ return false, err
+ }
+ return obs.Absent || obs.Claim == nil || obs.Claim.Op.ID != op, nil
+ }
+ hostOf := func(path string) (string, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ var probe struct {
+ Host string `json:"host"`
+ Request struct{ Host string }
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return "", err
+ }
+ if probe.Host != "" {
+ return probe.Host, nil
+ }
+ return probe.Request.Host, nil
+ }
+ // ops/<op>/{install,attempts}.json
+ ops, err := os.ReadDir(filepath.Join(root, "ops"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ for _, e := range ops {
+ dir := filepath.Join(root, "ops", e.Name())
+ host := ""
+ for _, name := range []string{"install.json", "attempts.json"} {
+ if h, err := hostOf(filepath.Join(dir, name)); err == nil && h != "" {
+ host = h
+ break
+ }
+ }
+ if host == "" {
+ continue
+ }
+ if done, err := gone(host, e.Name()); err != nil {
+ errs = append(errs, err)
+ } else if done {
+ errs = append(errs, os.RemoveAll(dir))
+ }
+ }
+ // release/<op>.json
+ positions, err := os.ReadDir(filepath.Join(root, "release"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ for _, e := range positions {
+ op, ok := strings.CutSuffix(e.Name(), ".json")
+ if !ok {
+ continue
+ }
+ path := filepath.Join(root, "release", e.Name())
+ host, err := hostOf(path)
+ if err != nil || host == "" {
+ errs = append(errs, err)
+ continue
+ }
+ if done, err := gone(host, op); err != nil {
+ errs = append(errs, err)
+ } else if done {
+ errs = append(errs, os.Remove(path))
+ }
+ }
+ // build/<op>/<host>.db: the scratch of a build whose op is over.
+ builds, err := os.ReadDir(filepath.Join(root, "build"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return errors.Join(append(errs, err)...)
+ }
+ for _, e := range builds {
+ dir := filepath.Join(root, "build", e.Name())
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ for _, f := range files {
+ host, ok := strings.CutSuffix(f.Name(), ".db")
+ if !ok {
+ continue
+ }
+ if done, err := gone(host, e.Name()); err != nil {
+ errs = append(errs, err)
+ } else if done {
+ errs = append(errs, os.RemoveAll(dir))
+ }
+ break
+ }
+ }
+ return errors.Join(errs...)
+}
+```
+
+```diff
+--- a/internal/host/claimcensus.go
++++ b/internal/host/claimcensus.go
+@@ -45,7 +45,11 @@
+ // census reads neither. The registry is bootstrap's file, shared by
+ // every host, plus SQLite's sidecars once something has opened it.
+ reg := claimCensusRel(dataDir, filepath.Join(dataDir, "registry.db"))
+- return []string{".hostclaims", ".hostexec", "hostguard", hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")}
++ // Region HA's relocation journals (N9) verify their own contents, like
++ // the claim's: install and attempt journals, release positions and the
++ // build's scratch, every one keyed by op id under relocate/ and swept
++ // once its operation is over. No installation artifact lives there.
++ return []string{".hostclaims", ".hostexec", "hostguard", "relocate", hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")}
+ }
+
+ func claimOptCensus(optDir string) []string {
+```
+
+`internal/host/relocationcensus_test.go` (written, passes):
+
+```go
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Astra round 2 finding 8, ruling 61: an empty installation initialises with
+// a previous relocation's journals present under relocate/ (N9 classifies
+// the directory as the claim classifies its own journals); without the
+// classification the same tree refuses, which is the defect.
+func TestCensusInitializesWithOldRelocationJournals(t *testing.T) {
+ ctx := t.Context()
+ r := claimCensusBox(t)
+ reg, err := registry.Open(filepath.Join(r.DataDir, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(r.DataDir, ".hostclaims", "inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer inventory.Close()
+ launches, err := hostbox.OpenLaunchJournal(filepath.Join(r.DataDir, ".hostclaims", "launches.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer launches.Close()
+ for path, body := range map[string]string{
+ "relocate/ops/op-old/install.json": `{"v":1,"host":"old.example","op":"op-old"}`,
+ "relocate/ops/op-old/attempts.json": `{"v":1,"host":"old.example","op":"op-old"}`,
+ "relocate/release/op-old.json": `{"v":1,"request":{"Host":"old.example"}}`,
+ "relocate/build/op-old/old.example.db": "scratch",
+ } {
+ full := filepath.Join(r.DataDir, filepath.FromSlash(path))
+ if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ store := release.NewDirStore(t.TempDir())
+ created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
+ record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created}
+ if err := provision.CreateRecord(ctx, store, record); err != nil {
+ t.Fatal(err)
+ }
+ key := provision.RecordKey(record.Account, record.App, record.Host)
+ obs, err := hostclaim.Read(ctx, store, record.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: key, Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "box", nil, nil, "adopt", func() string { return "id" }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ selected := hostbox.RecordSelection{Host: record.Host, Incarnation: obs.Claim.Holder.Incarnation}
+ census := hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1",
+ Roots: map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir}, SharedPaths: claimGlobalSharedPaths(r)}
+ unclassified := census
+ unclassified.SharedPaths = map[string][]string{}
+ for root, entries := range claimGlobalSharedPaths(r) {
+ for _, e := range entries {
+ if e != "relocate" {
+ unclassified.SharedPaths[root] = append(unclassified.SharedPaths[root], e)
+ }
+ }
+ }
+ if err := unclassified.Initialize(ctx, obs, selected); !errors.Is(err, hostbox.ErrTypedInventory) {
+ t.Fatalf("without N9 the journals were accepted: %v", err)
+ }
+ if err := census.Initialize(ctx, obs, selected); err != nil {
+ t.Fatalf("initialisation with old relocation journals: %v", err)
+ }
+}
+```
+
+`internal/hostfiles/present_test.go` (written, passes):
+
+```go
+package hostfiles
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Astra round 2 finding 9: after a good install, a truncated env, another
+// key, a directory or a symlink in its place is not present; a deleted
+// entry wants no env file.
+func TestPresentReadsTheEnvBack(t *testing.T) {
+ env := []byte("CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n")
+ sum := sha256.Sum256(env)
+ m := Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{EnvItem: {Object: "o", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(env)), Seq: 1}}}
+ for name, tc := range map[string]struct {
+ make func(path string) error
+ want bool
+ }{
+ "the published bytes": {func(p string) error { return os.WriteFile(p, env, 0o600) }, true},
+ "truncated": {func(p string) error { return os.WriteFile(p, env[:10], 0o600) }, false},
+ "another key, same size": {func(p string) error {
+ other := append([]byte(nil), env...)
+ other[len(other)-2] = '2'
+ return os.WriteFile(p, other, 0o600)
+ }, false},
+ "a directory": {func(p string) error { return os.Mkdir(p, 0o700) }, false},
+ "a symlink to the bytes": {func(p string) error {
+ real := p + ".real"
+ if err := os.WriteFile(real, env, 0o600); err != nil {
+ return err
+ }
+ return os.Symlink(real, p)
+ }, false},
+ "absent": {func(string) error { return nil }, false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "shop.example.env")
+ if err := tc.make(path); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := Present(t.TempDir(), path, m); err != nil || ok != tc.want {
+ t.Fatalf("Present = %v, %v; want %v", ok, err, tc.want)
+ }
+ })
+ }
+ deleted := m
+ deleted.Platform = map[string]Entry{EnvItem: {Deleted: true, Seq: 2}}
+ dir := t.TempDir()
+ path := filepath.Join(dir, "shop.example.env")
+ if ok, err := Present(dir, path, deleted); err != nil || !ok {
+ t.Fatalf("a deleted entry with no env file: %v %v", ok, err)
+ }
+ if err := os.WriteFile(path, env, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if ok, _ := Present(dir, path, deleted); ok {
+ t.Fatal("a deleted entry accepted an env file")
+ }
+}
+```
---
@@ -128,7 +830,7 @@ Stage A (Tasks 1 to 7, 9a), the merge of main (dc333fb5 and its fix round), R9 (
## Open dependencies
-Each is modelled as a dependency with a fallback. None is assumed. Rev 2 adds C6, C7, C9, C8 with O1, and N2's reader.
+Each is modelled as a dependency with a fallback. None is assumed. Rev 2 added C6, C7, C9, C8 with O1, and N2's reader; rev 3 withdraws C7 and adds C10 and C11.
| # | To | Ask | Fallback until answered | Where |
|---|---|---|---|---|
@@ -137,8 +839,10 @@ Each is modelled as a dependency with a fallback. None is assumed. Rev 2 adds C6
| 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 |
-| **C6** | claim | A kind-scoped CC9 discharge for a relocation target the op excluded (`replaceTargetSlot` removes it from participants, relocation.go:1091) while its unopened install and held row remain. Main's cleanup proves absence with the census (claimparticipation.go:237), which refuses that row. | **The denial stands, fail-closed.** The excluded box keeps main's retained slot and its `.hostexec` record until the completion's `park-local` (queued for every `installed_boxes` entry, relocation.go:1014-1031) retires the install; then the next participation pass's cleanup proves absence and clears it. Until then that box takes no executable update (§5.10) and no new operation of that host. Bounded by the operation's deadlines, or `stuck` for an operator. Checklist line 11. | ruling 42, Task 10 |
-| **C7** | claim | The serving cold wake's handover from the operation's own stopped writer session after a kind-changing CAS. After `CompleteRelocation` the predecessor is session S on this box, at kind serving. Main's `stopPredecessor` asks a serving-shaped stop of S, and `CompletedColdPredecessor` matches the receipt's op and phase (hostguard/coldhandover.go:93), while S's receipt names the relocating op at probing. Moves' `ReleaseMove` (move.go:145) leaves a landing session the same way. Asked: confirm, with a test on main, that root issues a serving-shaped receipt for the already-stopped S and the handover finds it, or accept the operation's writer-session receipt journaled on the serving op. | **No serving fallback, so this is an activation gate.** `TestRelocationFromAnEmptyTargetToTheServingWake` runs through the serving wake. If the handover refuses, relocation stays dormant (the flags stay off) and the failure is filed on #236 with the test. | ruling 49, Task 9, Task 11 |
+| **C6** | claim | A kind-scoped CC9 discharge for a relocation target the op excluded (`replaceTargetSlot` removes it from participants, relocation.go:1091) while its unopened install and held row remain. | **Rev 3, ruling 63: two safe outcomes.** If the slot was captured at the target's own install stamp, main's `CheckLocal` finds the complete installation and cleanup succeeds, so `ClearStale` removes the denial while the inert, held, never-reactivated installation waits for its `park-local`. If it was captured earlier, the census refuses the row and the denial stands until `park-local`. No writer can start there either way. The ported lifecycle property covers both. | rulings 42, 63 |
+| ~~C7~~ | claim | **Withdrawn in rev 3 (ruling 56).** `ColdHandover.Run` accepts the probe stop's exact receipt as prior beside a validated cold serving successor without matching op ids; the agent supplies it through N6. Nothing is re-stopped or relabelled. | none | ruling 56 |
+| **C10** | claim | A CC9 discharge of a relocation participation by its exact completed probe evidence: the serving op's `completed_relocation` names the captured op, and root's completed `writer-session-S` receipt for it is in this box's guard journal and verifies (patch in the rev 3 section). | **None that serves: an activation gate.** Without it main's cleanup stops the stale captured observation and refuses, the slot and `.hostexec` record deny serving, and a completed target never serves. The flags stay off. | ruling 56, Task 9 |
+| **C11** | claim | `claimLaunchAuthority` (claimlaunch_root.go:129-132) pins a relocating launch to `install_stamp` only for P's opening, and validates a later attempt at the admitted writer's stamp through root's attempt reader. | **One launchable attempt per target.** `Preflight` answers `ErrLaterAttemptUnlaunchable` once P has opened, and the participant makes `probe-failed` at once (no attempts spent on refusals). `RelocationAssemblyConfig.LaterProbeAttempts` is set when C11 lands. | ruling 58, Task 8d |
| C8 + **O1** | moves (platform-d8, #236) | The production `ClaimFilesManifest.Establish` provider and its `RegisterProductionClaimMoveSources` caller (none on this branch). O1: `Establish` publishes `<host>.env`'s exact bytes before it returns, and an error fails the wake. | **Relocation stays unbound** (ruling 53). `RelocationSource.Open` returns the zero owner set while `ClaimServingSources.Files` is nil, `claimRelocationC1` answers (false, nil), relocating is refused as before Plan 11, and the runtime is not built. | ruling 53, Tasks 8d and 10 |
| **C9** | claim | The claim's `Policy` admits relocating `install`, `held-config` and `held-release` for `op.exec` in `installed`, and in `probing` before P opens, with the hold absent or the op's own. Its `WithRefusals` then also run for them. | `relocationInstallPolicy` on main's WorkSource (ruling 46): the same `Guard`, `Prepare` and `Local` main uses, so the claim's execution, deadline and quarantine checks run on every `With`; the policy itself is region HA's, followed by region HA's `Policy.Relocation` owner. | ruling 46, Task 8c |
| M1 | moves | Close the move record after a converted landing (`released_move`) | Region HA's half is built here (Task 9): `CompleteRelocation` journals `released_move` when `conversion == committed-landing`. Until moves relaxes `closeMove`, a converted host cannot move again: operator path. | Task 9 |
@@ -175,12 +879,12 @@ Rulings 28 to 41 are rev 1's. Three are amended in rev 2: 32 by 49, 34 by 46, an
46. **The install's authority is main's WorkSource, with only its policy replaced** (findings 5 and 10; supersedes 34). `relocationInstallSource` copies the bound assembly's `hostbox.WorkSource` (`ClaimProviders.Selector`, claimassembly.go:156 and :177-183: `Guard`, `Box`, `Region`, `Now`, `Prepare` = `productionClaimLocal.Prepare`, `Local` = `Identity.Local(productionClaimLocal)`), sets `Continue` nil, and sets `Policy` to `relocationInstallPolicy`. That policy admits only `install`, `held-config` and `held-release` under `relocationInstallAuthority` (installed, or probing before P), at the holder's capture, with the hold absent or the op's, then runs region HA's `Policy.Relocation` owner. `WorkSource.With` runs `Local` before `Policy` on every call (work.go:80-101, `Local` at :93), so a lost lease, a passed deadline or a quarantine stops the next write. The held config goes through `RenderHeldUnder` with a `held-config` selection, and the release through `MaterializeHeldUnder` with a `held-release` selection (finding 5; never `MaterializeHeld`, which refuses under the claim). *Cost if wrong:* the three actions' policy is region HA's rather than the claim's until C9. Every other check is main's.
47. **P's opening publishes through main's prepared-publication contract** (finding 6), as moves' landing probe does. `Materialize` records the admitted spawn selection as the session's fence and, for a prepared admission, publishes P's marker under it (`preparedMarker`: install.json must be the prepared authority's bytes; the marker names P with the build's pin, or is already P's). `Publish`, which the assembly calls before replication only for a prepared admission, finds or writes it under that recorded fence and records the receipt. `PublishFenced` is main's `publishFresh` (N3) for a later attempt's fresh generation and for an empty P, which `Select` marks `Prepared` false so the assembly's root-pending wait applies (claimwakeassembly.go:612-617). `PublicationEvidence` checks the prepared receipt, or delegates to main's `freshEvidence`. *Cost if wrong:* none beyond moves'; no shared assembly change.
48. **Root reads relocation's attempts, and nothing is counted until it can** (finding 7). `relocationAttemptReader.ReadAttempt` returns the reserved start of attempt k = `probe_attempts.<op.exec>` only when the claim is the relocating op at `probing`, unstuck, holder stamp equal to `install_stamp`, and the journal binds the holder's session to k with the same generation and stamp. Root's `authorize` then binds the reservation id, invocation, launch-input digest and peer (claimlaunch_authority.go:67-112). Root construction installs it with `CARLOS_REGION_HA` on (N2). The participant calls the new `Effects.Preflight` (root's protocol, then `PreflightKind(relocating)`) before `BeginProbeAttempt`; a refusal returns `ErrNotYet` with nothing counted. *Cost if wrong:* while root refuses, the probe deadline passes with no attempt counted and the op goes `probe-failed`, then a replacement or `stuck`. It is visible and spends no attempt.
-49. **The cold restart is an explicit stop, receipt and completion** (finding 8; amends 32). After `RecordProbe`, the target, with the op still `relocating/probing` so the captured session still matches `sameStopWriter`: (1) `activator.Service.FenceAndStop(host, S)` closes local admission for the host and stops the probe entry through the activator's own stop path (maintenance.go:53-155); `ErrStopUnproven` still means the fence is installed and is accepted, any other error stops here. (2) `ClaimWakeAssembly.Stop` (main's `stopCapturedSession`) asks root for a writer-session stop of S, which `RelocationWriterStop` authorizes (hostguard/writerstop.go:72-88); its obligation id is deterministic, so a repeat meets the same fence and receipt, and main then releases the attempt's lease. (3) Root's receipt for S is read from this box's guard journal (`CompletedWriterStops`), and `RecordProbeStop` journals `probe_stop` {box, session, fence id, sha256}. (4) The worker's `CompleteRelocation` requires `probe_stop` naming the holder's session. (5) The serving box releases its row's hold (`ReleaseHold`) and lifts the fence at S. (6) The next request wakes the host cold as serving; its handover from S is C7. The pass runs participant, worker and completed hold in that order per host, so steps 1 to 5 take one pass. *Cost if wrong:* if C7 answers that the handover cannot find S's stop, the host is unserved after completion. That is why C7 is an activation gate, proved by the end-to-end test.
+49. **The cold restart is an explicit stop, receipt and completion** (finding 8; amends 32). After `RecordProbe`, the target, with the op still `relocating/probing` so the captured session still matches `sameStopWriter`: (1) `activator.Service.FenceAndStop(host, S)` closes local admission for the host and stops the probe entry through the activator's own stop path (maintenance.go:53-155); `ErrStopUnproven` still means the fence is installed and is accepted, any other error stops here. (2) `ClaimWakeAssembly.Stop` (main's `stopCapturedSession`) asks root for a writer-session stop of S, which `RelocationWriterStop` authorizes (hostguard/writerstop.go:72-88); its obligation id is deterministic, so a repeat meets the same fence and receipt, and main then releases the attempt's lease. (3) Root's receipt for S is read from this box's guard journal (`CompletedWriterStops`), and `RecordProbeStop` journals `probe_stop` {box, session, fence id, sha256}. (4) The worker's `CompleteRelocation` requires `probe_stop` naming the holder's session. (5) The serving box releases its row's hold (`ReleaseHold`) and lifts the fence at S. (6) The next request wakes the host cold as serving; rev 3 (ruling 56): its handover runs root's `ColdHandover.Run` with S's receipt as prior (N6), and the participation is discharged by the same evidence (C10). The pass runs participant, worker and completed hold in that order per host, so steps 1 to 5 take one pass. *Cost if wrong:* without C10 the completed target's participation denies serving; that is the activation gate (rev 3), proved by the end-to-end test.
50. **The data bucket is opened as a `release.Store` of its own** (finding 9): `release.NewS3Store(ctx, dataBucket)` (store.go:680) beside the versioned client, in `openRelocationStores` (the runtime) and through `RelocationSource.FilesStore` (the owners). No type assertion. *Cost if wrong:* `NewS3Store` signs with the default chain's region, the box's own, which is the hibernate bucket's region for a box in its region; a cross-region data bucket would need a region-pinned client, which no relocation uses (a relocation never leaves its region).
51. **The lease signs for the AWS region; everything else uses the logical one** (finding 12). `LeaseRegion` = roster `Cloud.Region`, as `Wake.LeaseRegion` (claimassembly.go:186), for `lease.Acquire` and `NewVersionedS3`. `Region` = roster `Region` for genselect, pins, the worker and the chooser. *Cost if wrong:* none; two fields.
52. **An install stamp is never reactivated** (finding 13). `Guard.Activate` refuses retired stamps (guard.go:207-210), so stage A's "restore a retired install" was unreachable and is retired. `EnsureLocal` repairs damage only while the install stamp is the active one (the install checks `Capture` equals `install_stamp` and accepts `installed` and `probing` before P). A retired stamp, or a lost lease, which is main's retained denial (ruling 42), refuses. The probe deadline then makes the op `probe-failed` and the worker replaces the target, whose `install()` issues a new stamp. *Cost if wrong:* a damaged install after a lease loss costs a target replacement rather than a repair.
53. **Relocation stays unbound without moves' C8 provider** (finding 11). `RelocationSource.Open` returns the zero owner set and no refusals while `ClaimServingSources.Files` is nil and logs it once. `claimRelocationC1` then answers (false, nil) (claimowners.go:87-89), relocating stays refused as before Plan 11, and `NewRelocationRuntime` is not built. *Cost if wrong:* nothing relocates until moves registers its source, which is the dependency's point.
-54. **The instance key is evidenced by the file or by the env** (O2, a stage A fix in Task 8b). The pinner's `needKey` refusal (ruling 25, pin.go:250) also accepts a pinned manifest whose platform `EnvItem` holds a non-empty `CARLOS_INSTANCE_KEY` (a `KEY=value` line, optionally `export`ed or quoted), read through `hostfiles.ReadPlatform` over the pin's origins. An env item that is named but unreadable is an error, never "no key". The install writes `EnvItem` byte for byte and never mints a key (O3). *Cost if wrong:* a host whose env names the variable with a value the app does not use is pinned as keyed. Its data is still sealed under whatever key the app reads from the same env, which is the env pinned.
+54. *Withdrawn in rev 3 by ruling 55 (Astra round 2 finding 1).* **The instance key is evidenced by the file or by the env** (O2, a stage A fix in Task 8b). The pinner's `needKey` refusal (ruling 25, pin.go:250) also accepts a pinned manifest whose platform `EnvItem` holds a non-empty `CARLOS_INSTANCE_KEY` (a `KEY=value` line, optionally `export`ed or quoted), read through `hostfiles.ReadPlatform` over the pin's origins. An env item that is named but unreadable is an error, never "no key". The install writes `EnvItem` byte for byte and never mints a key (O3). *Cost if wrong:* a host whose env names the variable with a value the app does not use is pinned as keyed. Its data is still sealed under whatever key the app reads from the same env, which is the env pinned.
## L21: where the platform-provisioned `CARLOS_INSTANCE_KEY` belongs
@@ -195,7 +899,7 @@ Stage B's cost without the L21 plan is unchanged from rev 1: a host whose app mi
## 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) and with moves' C8 provider bound (ruling 53); root installs its relocating attempt reader only with it on (N2); 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, and is not switched on until C7's end-to-end test passes on a dev box.
+- **Activation gate (claim §1.1, L18).** Relocation does not merge until Task 11's joint round passes on the two-box model, and is not switched on until C10 and C11 are answered and the end-to-end test passes on a dev box.
- **One implementation per concept; main's wins.** No second scanner, receipt verifier, roster reader, fence writer, inventory, retirement, marker publisher, participation controller or WorkSource. The probe is main's attempt path; the attempt journal is region HA's only because `OperationAttempts` is per kind. The install policy is region HA's only until C9.
- **Absence is never evidence.** A fence, receipt, pin, manifest, env item or inventory that cannot be read is an error.
- **No region HA file under a managed root names a host** (ruling 44): journals under `<DataDir>/relocate/ops/<op>/`, positions under `<DataDir>/relocate/release/<op>.json`, the build's scratch removed before the install.
@@ -489,7 +1193,7 @@ type RelocationEffects struct {
Admit func(context.Context, hostclaim.Observed, bool) (uint64, bool, error)
// PreflightKind is root's isolated-launch capability for kind
// relocating (ClaimRoleAssembly.Preflight then PreflightKind; ruling 48).
- PreflightKind func(context.Context) error
+ PreflightKind func(context.Context, hostclaim.Observed) error
// StopProbe is ruling 49's stop of the passed probe session (Task 9).
StopProbe func(context.Context, hostclaim.Observed) (hostclaim.ProbeStop, error)
// ReleaseHold releases this box's row hold for a completed operation it
@@ -746,7 +1450,7 @@ func (e *RelocationEffects) Preflight(ctx context.Context, obs hostclaim.Observe
if e.PreflightKind == nil || obs.Claim == nil || obs.Claim.Op.Kind != hostclaim.KindRelocating {
return ErrClaimStopUnavailable
}
- return e.PreflightKind(ctx)
+ return e.PreflightKind(ctx, obs)
}
// EndProbe is ruling 49's stop of the passed probe session.
@@ -838,6 +1542,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"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/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -878,7 +1583,11 @@ type RelocationAssemblyConfig struct {
// Source is the bound assembly's WorkSource (ClaimProviders.Selector):
// the install authority's guard, identity, Local and Prepare (ruling 46).
Source hostbox.WorkSource
- Now func() time.Time
+ // LaterProbeAttempts is set only once claim ask C11 is answered on
+ // main: root then authorizes a relocating launch at the admitted
+ // writer's stamp, not only at install_stamp (ruling 58).
+ LaterProbeAttempts bool
+ Now func() time.Time
}
// RelocationEffects binds every provider to this bound assembly. ack is
@@ -924,7 +1633,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func(
VerifyLocal: a.relocationVerifyLocal(cfg),
Receipt: a.relocationReceipt(cfg),
Admit: a.relocationAdmit(cfg),
- PreflightKind: a.relocationPreflight(),
+ PreflightKind: a.relocationPreflight(cfg),
StopProbe: a.relocationStopProbe(cfg),
ReleaseHold: a.relocationReleaseHold(cfg),
}
@@ -933,11 +1642,18 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func(
// relocationPreflight is ruling 48's gate before an attempt is counted:
// root speaks the isolated launch protocol, and its capability for kind
// relocating answers only with the attempt reader installed (Task 8d).
-func (a ClaimAssembly) relocationPreflight() func(context.Context) error {
- return func(ctx context.Context) error {
- if a.Wake == nil || a.Wake.Roles == nil {
+func (a ClaimAssembly) relocationPreflight(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) error {
+ return func(ctx context.Context, o hostclaim.Observed) error {
+ if a.Wake == nil || a.Wake.Roles == nil || o.Claim == nil {
return activator.ErrClaimIsolationUnavailable
}
+ // Ruling 58: main's root pins every relocating launch to
+ // install_stamp (claimLaunchAuthority), and a cold attempt after
+ // P's opening runs at a new stamp. Until claim ask C11 relaxes the
+ // pin to P's opening, such an attempt is not made.
+ if hostclaim.PreparedOpened(o.Claim) && !cfg.LaterProbeAttempts {
+ return hostrelocate.ErrLaterAttemptUnlaunchable
+ }
if err := a.Wake.Roles.Preflight(ctx, ClaimLaunchInputs{}); err != nil {
return err
}
@@ -1034,14 +1750,43 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
- Consumes: `StorePinner.filesPin` (pin.go:224), `liveEntry`, `InstanceKeyPath`, `referencedOrigins` (288); `hostfiles.ReadPlatform` (restore.go:203), `EnvItem` (platform.go:13); `hostguard.Guard.Activate` (guard.go:183), `ClaimReader` (28), `Capture` (record.go:103), `ErrStampMismatch`; `hostbox.WorkSource.Local` (work.go:30); `hostrelocate.ReadPin` (pin.go:326), `CopyPinnedManifest` (355); `moves.BuildGeneration` (build.go:162), `BuildInputs` (105-116), `InstallManifestKey` (93), `GenerationPrefix` (74); `lease.Acquire` (lease.go:153).
- Produces: `InstanceKeyEnv`, `StorePinner.envCarriesInstanceKey`; `(ClaimAssembly) relocationActivate(cfg)`, `relocationBuild(cfg)`, `relocationBuildP(cfg)`; `removeRelocationScratch`; `PreparedFromInstallManifest`.
-- [ ] **Step 0: O2, the env-carried instance key (ruling 54, a stage A fix)**
+- [ ] **Step 0: O2', positive key evidence (ruling 55, a stage A fix; rev 2's O2 withdrawn)**
-The check moves after `referencedOrigins` so the env item is read over the complete origin set:
+The pinner implements O2' from the instance-key plan rev 2 (`instance-key-build`). The check moves after `referencedOrigins` so the env item is read over the complete origin set:
```diff
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
-@@ -247,16 +247,25 @@
+@@ -88,6 +88,10 @@
+ // Stage B wires it to the controller's registry. Nil is ErrUnsupported;
+ // an empty version refuses the pin, never a guess.
+ Release func(ctx context.Context, host string) (string, error)
++ // KeyEvidence is O2's reader from the instance-key plan (region HA
++ // ruling 55): nil until that plan lands, and then only the instance.key
++ // file evidences a nonempty database's key.
++ KeyEvidence func(ctx context.Context, q KeyQuestion) (KeySource, error)
+ }
+
+ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.Time) (hostclaim.RecoveryPin, error) {
+@@ -142,7 +146,7 @@
+ // that writer's instance.key: the pinned manifest must carry it (rev
+ // 18, round 17 finding 1).
+ needKey := c.Holder.Session != 0 && obj.DB != nil && obj.DB.MaxTXID > 0
+- files, retained, err := p.filesPin(ctx, c.Holder, released && rel.ManifestSeq == 0, needKey)
++ files, retained, err := p.filesPin(ctx, c.Host, version, c.Holder, released && rel.ManifestSeq == 0, needKey)
+ if err != nil {
+ return hostclaim.RecoveryPin{}, err
+ }
+@@ -221,7 +225,7 @@
+ // stays restorable at g10. Every referenced object must lie under this
+ // holder's location (its incarnation) in this region's data bucket, at
+ // files/<generation>/objects/; any other reference refuses the pin.
+-func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) {
++func (p StorePinner) filesPin(ctx context.Context, host, release string, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) {
+ chain := []string{h.Generation}
+ for i := len(h.Admissions) - 1; i >= 0; i-- {
+ chain = append(chain, h.Admissions[i].Generation, h.Admissions[i].Predecessor)
+@@ -247,16 +251,26 @@
if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion {
return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err)
}
@@ -1056,25 +1801,26 @@ The check moves after `referencedOrigins` so the env item is read over the compl
return FilesPin{}, nil, err
}
+ if needKey && !liveEntry(m, InstanceKeyPath) {
-+ // The key is evidenced either as the instance.key file or, for
-+ // an app on rastrillo's carlos.InstanceKey (L21, O2), as a
-+ // non-empty CARLOS_INSTANCE_KEY in the pinned <host>.env.
-+ // Neither: the manifest was copied before the app started and
-+ // the key it minted afterwards never published (§5.6's
-+ // continuous-sync window): installing this set would mint a
-+ // new key over data sealed with the old one.
-+ ok, err := p.envCarriesInstanceKey(ctx, prefix, m, origins)
++ // Ruling 25 stands: a nonempty database is pinned only with
++ // positive evidence of the key that sealed it. Without the
++ // instance.key file, that is O2' from the instance-key plan:
++ // the platform key in the pinned env plus the agent's durable
++ // adoption record for this incarnation, or the bucket-held
++ // config's key (region HA ruling 55). A platform key alone is
++ // not evidence: the app may ignore it and seal with its own
++ // instance.key, which this manifest lacks.
++ ok, err := p.keyEvidenced(ctx, host, release, h, prefix, m, origins)
+ if err != nil {
+ return FilesPin{}, nil, err
+ }
+ if !ok {
-+ return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s and no %s in its env for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, InstanceKeyEnv)
++ return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s and no evidence the app uses another key for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath)
+ }
+ }
sum := sha256.Sum256(raw)
return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil
}
-@@ -395,3 +404,28 @@
+@@ -395,3 +409,72 @@
}
return pinned, nil
}
@@ -1084,10 +1830,33 @@ The check moves after `referencedOrigins` so the env item is read over the compl
+// published as the manifest's platform EnvItem.
+const InstanceKeyEnv = "CARLOS_INSTANCE_KEY"
+
-+// envCarriesInstanceKey reports whether m's platform env item has a
-+// non-empty CARLOS_INSTANCE_KEY (O2). No env item is false; an env item
-+// named but unreadable is an error, never "no key".
-+func (p StorePinner) envCarriesInstanceKey(ctx context.Context, prefix string, m hostfiles.Manifest, origins []string) (bool, error) {
++// KeySource is O2' (instance-key plan rev 2): where a key other than the
++// instance.key file is positively evidenced for one incarnation.
++type KeySource int
++
++const (
++ // KeyUnevidenced: no evidence. The pin refuses (ruling 25).
++ KeyUnevidenced KeySource = iota
++ // KeyAdopted: the agent's durable adoption record says the app's
++ // helper reported using $CARLOS_INSTANCE_KEY for this incarnation. It
++ // counts only with the platform key present in the pinned env.
++ KeyAdopted
++ // KeyConfig: the key is held in the bucket-held config for this
++ // incarnation, outside the files set.
++ KeyConfig
++)
++
++// KeyQuestion is what the pinner asks O2's evidence reader.
++type KeyQuestion struct {
++ Host, Account, App, Incarnation string
++ // Release is the pinned release the target will run.
++ Release string
++}
++
++// envHasInstanceKey reports whether m's platform env item has a non-empty
++// CARLOS_INSTANCE_KEY. No env item is false; an env item named but
++// unreadable is an error, never "no key".
++func (p StorePinner) envHasInstanceKey(ctx context.Context, prefix string, m hostfiles.Manifest, origins []string) (bool, error) {
+ if e, ok := m.Platform[hostfiles.EnvItem]; !ok || e.Deleted {
+ return false, nil
+ }
@@ -1103,6 +1872,27 @@ The check moves after `referencedOrigins` so the env item is read over the compl
+ }
+ return false, nil
+}
++
++// keyEvidenced is O2' without the instance.key file: an adoption record
++// with the platform key in the pinned env, or the bucket-held config's
++// key. With no KeyEvidence reader (the instance-key plan not landed),
++// nothing but the file is evidence.
++func (p StorePinner) keyEvidenced(ctx context.Context, host, release string, h *hostclaim.Holder, prefix string, m hostfiles.Manifest, origins []string) (bool, error) {
++ if p.KeyEvidence == nil {
++ return false, nil
++ }
++ src, err := p.KeyEvidence(ctx, KeyQuestion{Host: host, Account: h.Account, App: h.App, Incarnation: h.Incarnation, Release: release})
++ if err != nil {
++ return false, err
++ }
++ switch src {
++ case KeyConfig:
++ return true, nil
++ case KeyAdopted:
++ return p.envHasInstanceKey(ctx, prefix, m, origins)
++ }
++ return false, nil
++}
```
`internal/hostrelocate/pin_envkey_test.go`:
@@ -1123,23 +1913,27 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// O2 (L21): an app on rastrillo's carlos.InstanceKey keeps its key as
-// CARLOS_INSTANCE_KEY in <host>.env and never writes instance.key. A
-// pinned manifest whose env item carries a non-empty key evidences it; an
-// env without one, or with an empty value, still refuses.
-func TestStorePinnerAcceptsAnInstanceKeyInTheEnv(t *testing.T) {
+// O2' (instance-key plan rev 2, region HA ruling 55): with no instance.key
+// file, a nonempty database is pinned only with positive evidence that the
+// app uses another key. The platform key alone is not evidence: Astra
+// round 2's case, an unused platform key and an unpublished app key, refuses.
+func TestStorePinnerNeedsPositiveEvidenceForAnEnvKey(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
+ const withKey = "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n"
for _, tc := range []struct {
- name string
- env string // "" is no env item
- want error
+ name string
+ env string // "" is no env item
+ evidence func(context.Context, KeyQuestion) (KeySource, error)
+ want error
}{
- {"the env carries the key", "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n", nil},
- {"exported and quoted", "export CARLOS_INSTANCE_KEY=\"k1\"\n", nil},
- {"an empty key", "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", ErrFilesUnevidenced},
- {"no key in the env", "CARLOS_ADMIN_TOKEN=t\n", ErrFilesUnevidenced},
- {"no env item", "", ErrFilesUnevidenced},
+ {"an unused platform key and an unpublished app key", withKey, nil, ErrFilesUnevidenced},
+ {"a platform key, the reader says unevidenced", withKey, func(context.Context, KeyQuestion) (KeySource, error) { return KeyUnevidenced, nil }, ErrFilesUnevidenced},
+ {"an adoption record and the platform key", withKey, func(context.Context, KeyQuestion) (KeySource, error) { return KeyAdopted, nil }, nil},
+ {"an adoption record without the platform key", "CARLOS_ADMIN_TOKEN=t\n", func(context.Context, KeyQuestion) (KeySource, error) { return KeyAdopted, nil }, ErrFilesUnevidenced},
+ {"an adoption record, an empty key", "CARLOS_INSTANCE_KEY=\n", func(context.Context, KeyQuestion) (KeySource, error) { return KeyAdopted, nil }, ErrFilesUnevidenced},
+ {"the bucket-held config's key", "", func(context.Context, KeyQuestion) (KeySource, error) { return KeyConfig, nil }, nil},
+ {"the reader fails", withKey, func(context.Context, KeyQuestion) (KeySource, error) { return KeyUnevidenced, errReaderDown }, errReaderDown},
} {
t.Run(tc.name, func(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
@@ -1154,16 +1948,25 @@ func TestStorePinnerAcceptsAnInstanceKeyInTheEnv(t *testing.T) {
m.Platform[hostfiles.EnvItem] = hostfiles.Entry{Object: key, SHA256: sha, Size: int64(len(tc.env)), Mode: 0o600, Seq: 4}
}
putManifest(t, data, "g3", m)
+ var asked KeyQuestion
p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
+ if tc.evidence != nil {
+ p.KeyEvidence = func(ctx context.Context, q KeyQuestion) (KeySource, error) { asked = q; return tc.evidence(ctx, q) }
+ }
if _, err := p.Pin(ctx, pinClaim(), at); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
+ if tc.evidence != nil && (asked.Host != host || asked.Release != "v1.4.2") {
+ t.Fatalf("asked %+v", asked)
+ }
})
}
}
+
+var errReaderDown = errors.New("adoption records unreadable")
```
-Run: `nice -n 19 go test ./internal/hostrelocate/ -run TestStorePinner -count=1`. Expected: PASS (it passed in the scratch build, with every existing pinner test).
+Run: `nice -n 19 go test ./internal/hostrelocate/ -run TestStorePinner -count=1`. Expected: PASS (rev 3's scratch build ran it with every existing pinner test).
- [ ] **Step 1: Write the failing tests**
@@ -1481,7 +2284,8 @@ import (
// Present reports, read-only, whether dir and envPath hold exactly what m
// names (region HA ruling 52, VerifyLocal): every live, non-skipped,
// non-torn entry present as a regular file at its size and sha256, and
-// the env file present exactly when m carries one. Extra files are not
+// the env file a regular file at its platform entry's size and sha256, or
+// absent when m carries none or a deleted one. Extra files are not
// checked: the app may write after P's opening, and Present is called
// only before it.
func Present(dir, envPath string, m Manifest) (bool, error) {
@@ -1494,16 +2298,24 @@ func Present(dir, envPath string, m Manifest) (bool, error) {
return false, err
}
}
- _, wantEnv := m.Platform[EnvItem]
- _, err := os.Lstat(envPath)
- switch {
- case err == nil:
- return wantEnv, nil
- case errors.Is(err, os.ErrNotExist):
- return !wantEnv, nil
- default:
+ // The env file is the platform entry's exact bytes (Astra round 2
+ // finding 9): a regular file at its size and digest; a deleted or
+ // absent entry means no env file at all.
+ e, wantEnv := m.Platform[EnvItem]
+ if wantEnv && e.Deleted {
+ wantEnv = false
+ }
+ if !wantEnv {
+ _, err := os.Lstat(envPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return true, nil
+ }
return false, err
}
+ if e.Torn || e.Skipped != "" || e.SHA256 == "" {
+ return false, nil
+ }
+ return presentFile(envPath, e.Size, e.SHA256)
}
func presentFile(path string, size int64, sum string) (bool, error) {
@@ -1830,8 +2642,14 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
check := func(fresh *hostclaim.Claim) error {
return relocationInstallAuthority(fresh, c.Op.ID, box, hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing)
}
- if err := a.Reconcile.Reconciler.Local.RecordInventory(ctx, o.Host, captured, "nested", nil, check); err != nil {
- return hostclaim.LocalInstall{}, err
+ // The claim's typed inventory for this box, once: RecordInventory
+ // writes the claim on every call, so a repeated install (a retry, a
+ // repairing EnsureLocal) does not rewrite it once it says nested
+ // (Astra round 2 finding 2; the participant re-reads after).
+ if entry, ok := c.Inventory[box]; !ok || entry.Layout != "nested" {
+ if err := a.Reconcile.Reconciler.Local.RecordInventory(ctx, o.Host, captured, "nested", nil, check); err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
}
// The build's scratch names the host: gone before the census runs
// (ruling 44), even after a crash between the build and here.
@@ -1845,11 +2663,32 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
return err
}
journal.Steps["row"] = c.Op.ID
- if err := os.MkdirAll(provision.InstanceDir(cfg.DataDir, o.Host), 0o700); err != nil {
- return err
- }
+ return nil
+ })
+ if err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ // The instance directory through main's typed protocol (ruling 57;
+ // Astra round 2 finding 3): ClaimRoleAssembly.PrepareState records
+ // the path entry intended, makes the directory, then records its
+ // device and inode materialized, under the install work; the
+ // probe's own PrepareState then finds it as its own. Never MkdirAll.
+ if a.Wake == nil || a.Wake.Roles == nil {
+ return hostclaim.LocalInstall{}, ErrClaimStopUnavailable
+ }
+ installed, err := hostclaim.Read(ctx, a.Claims, o.Host)
+ if err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ if err := check(installed.Claim); err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ if err := a.Wake.Roles.PrepareState(ctx, installed, work, ClaimLaunchInputs{DBPath: rt.DBPath}); err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ err = work.With(ctx, hostwrite.Identity{}, func() error {
if err := os.MkdirAll(paths.FilesDir, 0o700); err != nil {
- return err
+ return err // inside the materialized instance directory
}
var err error
if m, _, err = hostfiles.Restore(ctx, cfg.Data, prefix, paths.FilesDir, origins); err != nil {
@@ -2012,6 +2851,42 @@ func (a ClaimAssembly) relocationReceipt(cfg RelocationAssemblyConfig) func(cont
return hostclaim.LocalInstall{Box: j.Box, Enrollment: j.Enrollment, InstallStamp: j.InstallStamp, FilesManifestVersion: seq, JournalSHA256: hex.EncodeToString(sum[:])}, true, nil
}
}
+
+// relocationBuildAuthority is the remote build's check before every
+// durable write (ruling 59; Astra round 2 finding 7): the claim still runs
+// this box's installing op at the install stamp, the guard still captures
+// it, and main's Local still admits it. Local is the execution lease's
+// own check (productionClaimLocal.Check: the slot's binding, deadline and
+// loss latch; identity and quarantine), so an execution lease lost while
+// the replica lease, claim and guard are unchanged stops the next write.
+// relocationBuildP's authorityStore and its manifest copy call it before
+// each PutIfAbsent, CopyVersion and create-only put.
+func (a ClaimAssembly) relocationBuildAuthority(cfg RelocationAssemblyConfig, host, op string, captured hostguard.Captured) func(context.Context) error {
+ return func(ctx context.Context) error {
+ src := cfg.Source
+ if src.Local == nil || src.Box == "" {
+ return ErrClaimStopUnavailable
+ }
+ obs, err := hostclaim.Read(ctx, a.Claims, host)
+ if err != nil {
+ return err
+ }
+ c := obs.Claim
+ if err := relocationInstallAuthority(c, op, src.Box, hostclaim.PhaseInstalling); err != nil {
+ return err
+ }
+ now, err := hostguard.Capture(a.Guard.DataDir, host)
+ if err != nil {
+ return err
+ }
+ if now != captured || captured.Stamp != c.Holder.Stamp {
+ return hostguard.ErrStampMismatch
+ }
+ sel := hostbox.Selection{Host: host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured,
+ Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}
+ return src.Local(ctx, c, sel)
+ }
+}
```
`relocationPolicy` is Task 8d's (`relocationowners.go`); land Task 8d's step 5 file's `relocationPolicy` here if 8c is built first. In `RelocationEffects` set `Install`, `VerifyLocal`, `Receipt`. Add `HeldStageRequest.Stamp: stamp` the day M3 lands.
@@ -2126,20 +3001,38 @@ func TestRootReadsTheRelocationAttemptJournal(t *testing.T) {
}
claim := func() *hostclaim.Claim {
return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"},
- Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "probe_attempts.dst": "1"}}}
+ Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}}
}
r := relocationAttemptReader{DataDir: dd}
got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: claim()})
if err != nil || got != attempt {
t.Fatalf("read %+v %v", got, err)
}
+ // Astra round 2 finding 4: a later cold attempt runs at its own
+ // admission's stamp; the reader takes the journaled start at that
+ // stamp (root's claimLaunchAuthority pin is claim ask C11).
+ j2, _ := readRelocationAttempts(dd, "shop.example", "op1")
+ j2.Sessions["14"] = 2
+ later := hostclaim.WakeRetryAttempt{ID: "c", InvocationID: "d", Session: 14, Box: "dst"}
+ j2.Starts["2"] = relocationStart{Session: 14, Generation: "g2", Stamp: 14, State: probeReserved, Attempt: later}
+ if err := j2.write(dd); err != nil {
+ t.Fatal(err)
+ }
+ second := claim()
+ second.Holder.Stamp, second.Holder.Session, second.Holder.Generation = 14, 14, "g2"
+ second.Op.Journal["probe_attempts.dst"] = "2"
+ second.Op.Journal["prepared_opened"] = "P1"
+ if got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: second}); err != nil || got != later {
+ t.Fatalf("second attempt: %+v %v", got, err)
+ }
for name, change := range map[string]func(*hostclaim.Claim){
- "another session": func(c *hostclaim.Claim) { c.Holder.Session = 8 },
- "another generation": func(c *hostclaim.Claim) { c.Holder.Generation = "g2" },
- "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp = 10; c.Op.Journal["install_stamp"] = "10" },
- "a later attempt": func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" },
- "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled },
- "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "x" },
+ "another session": func(c *hostclaim.Claim) { c.Holder.Session = 8 },
+ "another generation": func(c *hostclaim.Claim) { c.Holder.Generation = "g2" },
+ "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp = 10; c.Op.Journal["install_stamp"] = "10" },
+ "P at a stamp not the install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] = "8" },
+ "a later attempt": func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" },
+ "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled },
+ "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "x" },
} {
c := claim()
change(c)
@@ -2245,7 +3138,15 @@ In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor
```diff
--- a/internal/hostrelocate/participant.go
+++ b/internal/hostrelocate/participant.go
-@@ -70,6 +70,15 @@
+@@ -4,6 +4,7 @@
+ "context"
+ "encoding/json"
+ "errors"
++ "fmt"
+ "slices"
+ "time"
+
+@@ -70,6 +71,15 @@
// caller counts an attempt in the claim before each call, so a Probe
// that returned early would spend the attempts without probing.
Probe(ctx context.Context, obs hostclaim.Observed, certified bool) (session uint64, passed bool, err error)
@@ -2261,19 +3162,65 @@ In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor
// Retained lists the participations whose execution controllers this
// box still holds; Finish closes one and releases its lifetime lock.
// A controller is finished once the claim no longer runs that
-@@ -180,6 +189,11 @@
+@@ -152,8 +162,15 @@
+ if err := p.Effects.EnsureLocal(ctx, obs); err != nil {
+ return obs, err
+ }
++ // A repairing EnsureLocal may have written the claim (the
++ // typed inventory): the transition is made on a fresh read of
++ // the same installation (rev 3, Astra round 2 finding 2).
++ fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled)
++ if err != nil {
++ return obs, err
++ }
+ // The target enters probing itself (§5.8 phase table).
+- return p.update(ctx, obs, hostclaim.ToProbing(id, p.Now()))
++ return p.update(ctx, fresh, hostclaim.ToProbing(id, p.Now()))
+ }
+ l, err := p.Effects.InstallLocal(ctx, obs)
+ if err != nil {
+@@ -162,7 +179,14 @@
+ if l.Box != p.Box || l.Enrollment != p.Enrollment {
+ return obs, ErrRefused
+ }
+- return p.update(ctx, obs, hostclaim.RecordLocalInstall(id, l))
++ // The install recorded its typed inventory in the claim (park's
++ // RecordInventory): record the receipt on a fresh read of the
++ // same installation, never on the stale observation.
++ fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled)
++ if err != nil {
++ return obs, err
++ }
++ return p.update(ctx, fresh, hostclaim.RecordLocalInstall(id, l))
+ case executor && c.Op.Phase == hostclaim.PhaseRelocatingProbing && c.Op.Journal["probe"] == "":
+ if !hostclaim.PreparedOpened(c) {
+ // Before P's first admission the local install must be whole.
+@@ -180,6 +204,24 @@
return p.update(ctx, obs, hostclaim.MarkStuck(id, "the local install receipt is missing after P was admitted (§5.8, B2-5)"))
}
}
++ // EnsureLocal may have written the claim: count on a fresh read
++ // of the same installation.
++ if !hostclaim.PreparedOpened(c) {
++ fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseRelocatingProbing)
++ if err != nil {
++ return obs, err
++ }
++ obs, c = fresh, fresh.Claim
++ }
+ // Root must be able to authorize a relocating launch before an
-+ // attempt is spent (ruling 48): a refusal counts nothing.
-+ if err := p.Effects.Preflight(ctx, obs); err != nil {
++ // attempt is spent (ruling 48): a refusal counts nothing. A later
++ // attempt root cannot launch (ruling 58, until claim ask C11) is
++ // probe-failed now, not three uncountable refusals.
++ if err := p.Effects.Preflight(ctx, obs); errors.Is(err, ErrLaterAttemptUnlaunchable) {
++ return p.update(ctx, obs, hostclaim.ToProbeFailed(id, p.Now()))
++ } else if err != nil {
+ return obs, errors.Join(ErrNotYet, err)
+ }
// The attempt is counted in the claim before it is made (§5.8:
// at most three); the fourth is probe-failed, by this CAS.
counted, err := hostclaim.Update(ctx, p.Claims, obs, hostclaim.BeginProbeAttempt(id, p.Box))
-@@ -190,7 +204,7 @@
+@@ -190,7 +232,7 @@
return obs, err
}
obs, c = counted, counted.Claim
@@ -2282,7 +3229,7 @@ In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor
if err != nil || !passed {
return obs, errors.Join(ErrNotYet, err)
}
-@@ -201,6 +215,18 @@
+@@ -201,10 +243,44 @@
return obs, errors.Join(ErrNotYet, err)
}
return p.update(ctx, fresh, hostclaim.RecordProbe(id, hostclaim.Probe{Box: p.Box, Session: session}))
@@ -2301,7 +3248,33 @@ In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor
}
return obs, ErrNotYet
}
-@@ -231,7 +257,7 @@
+
++// ErrLaterAttemptUnlaunchable is Preflight's answer for a cold attempt
++// after P's opening while root pins every relocating launch to
++// install_stamp (claimLaunchAuthority, claim ask C11; ruling 58).
++var ErrLaterAttemptUnlaunchable = errors.New("hostrelocate: root cannot launch a relocation attempt after P's opening")
++
++// sameInstallation re-reads the claim after an effect that may have written
++// it and requires the same operation, executor, phase, prepared P and
++// install stamp, unstuck: only the effect's own bookkeeping may differ.
++func (p Participant) sameInstallation(ctx context.Context, obs hostclaim.Observed, phase string) (hostclaim.Observed, error) {
++ fresh, err := hostclaim.Read(ctx, p.Claims, obs.Host)
++ if err != nil {
++ return obs, err
++ }
++ a, b := obs.Claim, fresh.Claim
++ if fresh.Absent || b == nil || b.Holder == nil || b.Op.Kind != hostclaim.KindRelocating || b.Op.ID != a.Op.ID || b.Op.Exec != p.Box || b.Op.Phase != phase ||
++ b.Op.Journal["prepared"] != a.Op.Journal["prepared"] || b.Op.Journal["install_stamp"] != a.Op.Journal["install_stamp"] || b.Op.Journal["stuck"] != "" ||
++ b.Holder.Incarnation != a.Holder.Incarnation || b.Holder.Session != a.Holder.Session || b.Holder.Stamp != a.Holder.Stamp {
++ return obs, errors.Join(ErrNotYet, fmt.Errorf("%w: the installation changed under the effect", ErrRefused))
++ }
++ return fresh, nil
++}
++
+ // Sweep finishes every retained controller whose participation is over:
+ // the claim no longer runs that operation, or this box is no longer a
+ // participant of it. It runs once per pass, for hosts the pass itself
+@@ -231,7 +307,7 @@
return errors.Join(errs...)
}
@@ -2310,7 +3283,7 @@ In `internal/hostrelocate/participant.go` rename `certifiedFor` to `CertifiedFor
// (§5.8, L9): only the admission that opens P, when this operation
// journaled a release whose session is the predecessor writer's. Claim
// Plan 11 gives a relocation no coverage value of its own, so the release
-@@ -239,7 +265,7 @@
+@@ -239,7 +315,7 @@
// opening only with that release and a pin reaching it
// (relocationProbeOpening). Otherwise false: a failover, a later attempt,
// or a host that never had a writer is an uncertified cold admission.
@@ -3169,7 +4142,12 @@ func (r relocationAttemptReader) ReadAttempt(_ context.Context, o hostclaim.Obse
return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged
}
h := c.Holder
- if c.Op.Journal["install_stamp"] != strconv.FormatUint(h.Stamp, 10) {
+ // P's opening keeps the install stamp; a later cold attempt runs at
+ // its own admission's stamp (generation.go: !KeepStamp sets stamp to
+ // the session), which the journaled start must name (Astra round 2
+ // finding 4). Main's claimLaunchAuthority still pins every relocating
+ // launch to install_stamp until claim ask C11.
+ if h.Generation == c.Op.Journal["prepared"] && c.Op.Journal["install_stamp"] != strconv.FormatUint(h.Stamp, 10) {
return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged
}
k, err := strconv.Atoi(c.Op.Journal["probe_attempts."+c.Op.Exec])
@@ -3553,7 +4531,9 @@ func (a ClaimAssembly) relocationBuild(cfg RelocationAssemblyConfig) func(contex
if err != nil {
return err
}
- if err := build(ctx, o, captured); err != nil {
+ // Every durable build write runs the execution authority first
+ // (ruling 59).
+ if err := build(ctx, o, captured, a.relocationBuildAuthority(cfg, o.Host, o.Claim.Op.ID, captured)); err != nil {
return err
}
return removeRelocationScratch(cfg, o.Claim.Op.ID)
@@ -3640,8 +4620,10 @@ func TestRelocationFromAnEmptyTargetToTheServingWake(t *testing.T) {
(finding 6); after RecordProbe, the activator entry stopped and fenced, root's receipt journaled as
probe_stop, CompleteRelocation, the hold released and the fence lifted in the same pass (finding 8).
Then one HTTP request to the host: the activator wakes it cold as serving, stopPredecessor and the
- cold handover pass (C7), a new session is admitted on dst, the request is answered. If the handover
- refuses, the test fails with C7's text: that is the activation gate. */
+ cold handover pass (N6 hands root's Run the probe receipt; C10 discharged the participation), a new
+ session is admitted on dst, the request is answered. Rev 3 adds: the real PrepareState on both sides;
+ a second and third attempt through root (with C11); completion without an agent restart; and an
+ empty installation of another host initialising while this relocation's journals exist. */
}
// Ruling 49 step 4.
@@ -3673,6 +4655,7 @@ package hostclaim
import (
"fmt"
+ "time"
)
// ProbeStop is the target's journaled stop of its passed probe session
@@ -3719,6 +4702,48 @@ func probeStopped(c *Claim, p Probe) bool {
var s ProbeStop
return readJournal(c.Op, "probe_stop", &s) && s.Box == p.Box && s.Session == p.Session && s.Receipt != ""
}
+
+// CompletedRelocation is the serving op's record of the relocation it
+// ended (region HA ruling 56): the op, and the probe session's stop that
+// root receipted at relocating/probing. The next serving cold wake on Box
+// hands over from exactly that receipt (ColdHandover.Run takes the prior
+// receipt's target as it is), and the participation discharge (claim ask
+// C10) reads it. Journaled by CompleteRelocation under JournalCompleted.
+type CompletedRelocation struct {
+ Op string `json:"op"`
+ Stop ProbeStop `json:"stop"`
+ Stamp uint64 `json:"stamp"` // the stopped session's installation stamp
+ Phase string `json:"phase"` // the stop's phase: probing
+ Generation string `json:"generation"` // the stopped session's generation
+}
+
+// JournalCompleted is the serving op's journal key for CompletedRelocation.
+const JournalCompleted = "completed_relocation"
+
+// ReadCompletedRelocation reads the serving op's record; false when none.
+func ReadCompletedRelocation(c *Claim) (CompletedRelocation, bool) {
+ var r CompletedRelocation
+ if c == nil || c.Op.Kind != KindServing || !readJournal(c.Op, JournalCompleted, &r) || r.Op == "" || r.Stop.Box == "" || r.Stop.Session == 0 {
+ return CompletedRelocation{}, false
+ }
+ return r, true
+}
+
+// ToInstalledAt is ToInstalled that also journals installed_at, the start
+// of the installed bound (ruling 60).
+func ToInstalledAt(opID string, a PreparedAuthority, now time.Time) func(*Claim) error {
+ inner := ToInstalled(opID, a)
+ return func(c *Claim) error {
+ if now.IsZero() {
+ return fmt.Errorf("%w: installed needs its start time", ErrRelocation)
+ }
+ if err := inner(c); err != nil {
+ return err
+ }
+ c.Op.Journal["installed_at"] = now.UTC().Format(time.RFC3339Nano)
+ return nil
+ }
+}
```
`CompleteRelocation` requires it and names a converted landing's move (M1):
@@ -3738,16 +4763,24 @@ func probeStopped(c *Claim, p Probe) bool {
target := c.Op.Exec
var owed []string
readJournal(c.Op, "installed_boxes", &owed)
-@@ -1052,7 +1057,13 @@
+@@ -1052,7 +1057,21 @@
c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-generation", Region: region, Generation: g, Retirement: newID(), Owner: "region:" + region})
}
}
- c.Op = Op{Kind: KindServing, Exec: target, Participants: []string{target}}
-+ next := Op{Kind: KindServing, Exec: target, Participants: []string{target}}
++ var stop ProbeStop
++ readJournal(c.Op, "probe_stop", &stop)
++ done, err := json.Marshal(CompletedRelocation{Op: opID, Stop: stop, Stamp: c.Holder.Stamp, Phase: c.Op.Phase, Generation: c.Holder.Generation})
++ if err != nil {
++ return err
++ }
++ // Ruling 56: the serving op keeps the stopped probe session's
++ // receipt reference for the next cold wake's handover.
++ next := Op{Kind: KindServing, Exec: target, Participants: []string{target}, Journal: map[string]string{JournalCompleted: string(done)}}
+ if c.Op.Journal["conversion"] == "committed-landing" {
+ // M1 on amadan #236: the move this relocation finished is named
+ // on the serving op, so moves' close can find it.
-+ next.Journal = map[string]string{"released_move": opID}
++ next.Journal["released_move"] = opID
+ }
+ c.Op = next
return nil
@@ -3778,6 +4811,28 @@ The worker completes only after the stop:
if w.Now().Sub(since) > ProbeDeadline {
return w.update(ctx, obs, hostclaim.ToProbeFailed(id, w.Now()))
}
+@@ -410,9 +413,19 @@
+ if err != nil || !done || !acked(c) {
+ return obs, errors.Join(ErrNotYet, err)
+ }
+- return w.update(ctx, obs, hostclaim.ToInstalled(id, a))
++ return w.update(ctx, obs, hostclaim.ToInstalledAt(id, a, w.Now()))
++ case hostclaim.PhaseInstalled:
++ // The target's local install and its entry into probing (Plan 6b).
++ // Ruling 60: bounded. A target that cannot enter probing within
++ // ProbeDeadline (a retired stamp, a participation main denies) is
++ // stuck for an operator: no automatic replacement of a live target
++ // is made here.
++ at, err := time.Parse(time.RFC3339Nano, c.Op.Journal["installed_at"])
++ if err == nil && w.Now().Sub(at) > ProbeDeadline {
++ return w.update(ctx, obs, hostclaim.MarkStuck(id, fmt.Sprintf("%s did not enter probing within %s of installed", c.Op.Exec, ProbeDeadline)))
++ }
++ return obs, ErrNotYet
+ }
+- // installed is the target's: its local install and its probe (Plan 6b).
+ return obs, ErrNotYet
+ }
+
```
Every `CompleteRelocation` in `hostclaim/relocation_test.go` (seven sites) is preceded by `o = step(t, s, o, RecordProbeStop("rel-1", ProbeStop{Box: <the probe's box>, Session: o.Claim.Holder.Session, Receipt: "control/hosts/h/evidence/stop.json", ReceiptSHA256: strings.Repeat("a", 64)}))`. `TestWorkerDrivesADrain` expects `ErrNotYet` from the worker after `RecordProbe`, then `RecordProbeStop`, then serving. With these edits the whole `hostclaim` and `hostrelocate` packages passed in the scratch build, the lifecycle property included.
@@ -3789,7 +4844,7 @@ Every `CompleteRelocation` in `hostclaim/relocation_test.go` (seven sites) is pr
- [ ] **Step 6: Run the tests**
Run: `nice -n 19 go test ./internal/hostbox/ ./internal/hostclaim/ ./internal/hostrelocate/ -count=1`, then outside the sandbox `TMPDIR=/tmp/k nice -n 19 go test ./internal/host/ -run 'RelocationFromAnEmptyTarget|RelocationStopProbe|Reconcile|Finish' -count=1`.
-Expected: PASS. A failure of `TestRelocationFromAnEmptyTargetToTheServingWake` at the serving wake's handover is C7: record it on #236 and keep the flags off.
+Expected: PASS. Until C10 lands on main the completion half of `TestRelocationFromAnEmptyTargetToTheServingWake` skips with `C10`; record its result on #236 and keep the flags off.
- [ ] **Step 7: Commit**
@@ -3885,6 +4940,7 @@ type RelocationRuntime struct {
Participant hostrelocate.Participant
Effects *RelocationEffects
Registry *registry.Registry
+ DataDir string // the managed data root: relocate/ journals (N9)
alerted sync.Map // host\x00cause: one alert per host and cause (rulings 35, 36)
}
@@ -4002,6 +5058,12 @@ func (r *RelocationRuntime) once(ctx context.Context, store release.Store) {
}
r.releaseCompletedHold(ctx, host)
}
+ // Ruling 61: journals of operations that are over go, once per pass.
+ if r.DataDir != "" {
+ if err := sweepRelocationJournals(ctx, r.DataDir, store); err != nil {
+ log.Printf("relocate: sweep journals: %v", err)
+ }
+ }
}
// releaseCompletedHold finds this box's row still held relocating by an
@@ -4127,20 +5189,22 @@ Before `CARLOS_REGION_HA` is set on any box with a second box in its region, on
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` or a `CARLOS_INSTANCE_KEY` in its env before its first manifest (O2).
+9. **L21.** The instance-key plan has landed with O2' wired, or every app in the region writes `instance.key` before its first manifest (rev 3, ruling 55: a platform key alone is not evidence).
10. **Capabilities.** Every box and edge publishes `region-ha-1` (spec §8 step 3).
-11. **C6.** An excluded target keeps main's denial until the completion's `park-local`. Check that no executable update is scheduled on a box during a relocation that excluded it, or accept the wait.
-12. **C7 (gate).** `TestRelocationFromAnEmptyTargetToTheServingWake` passes on the branch and on dev: after a failover, the first request after completion is served on the target from a cold serving wake.
+11. **C6.** An excluded target's participation ends in one of ruling 63's two safe outcomes. Check that no writer starts on it and that its `park-local` retires the inert installation.
+12. **C10 (gate).** `TestRelocationFromAnEmptyTargetToTheServingWake` passes on the branch and on dev with C10 on main: after a failover the first request after completion is served on the target from a cold serving wake, handed over from the probe's receipt (N6), with no agent restart.
13. **C8 and O1 (gate).** Moves' `ClaimProductionMoveSources` is registered on every box, with `Establish` publishing `<host>.env`'s exact bytes. Without it, the agent logs "relocation owners not opened" and nothing relocates.
14. **W4.** Root's relocating attempt reader is live: `carlos-instance-control` answers the relocating capability, and a probe's first launch is authorized from `<DataDir>/relocate/ops/<op>/attempts.json`.
-15. **Census.** After a relocation completes on dev, `find /data -path '*<host>*'` on the target lists only the host's instance directory, and on the source nothing outside it after its `park-local`.
+15. **Census.** After a relocation completes on dev, `find /data -path '*<host>*'` on the target lists only the host's instance directory and the census-opaque `relocate/` (N9), and on the source nothing outside them after its `park-local`; within a pass of completion `relocate/` holds nothing of that op.
+16. **C11.** Until it lands each target gets one launchable probe attempt; set `LaterProbeAttempts` only after it does.
+17. **O2'.** The instance-key plan's `KeyEvidence` reader is wired into `StorePinner`; until then only `instance.key` evidences a nonempty database's key.
---
### 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). Add to the joint round's list: the committed-landing conversion on both sides of the commit; the one-cold-restart completion by ruling 49's sequence, through the serving wake (C7); a drain under ruling 28's order; a probe-failed target with C1 open going `stuck` at the deadline and never queuing an obligation; a target replaced mid-install keeping main's denial until its `park-local` (C6); and no attempt counted while root refuses the kind (ruling 48).
+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). Add to the joint round's list: the committed-landing conversion on both sides of the commit; the one-cold-restart completion by ruling 49's sequence, through the serving wake's N6 handover with C10; a drain under ruling 28's order; a probe-failed target with C1 open going `stuck` at the deadline and never queuing an obligation; a target replaced mid-install keeping main's denial until its `park-local` (C6); and no attempt counted while root refuses the kind (ruling 48).
- [ ] **Step 1: Confirm the joint round has passed** (as L13562-13566; stop otherwise and record the gap on amadan #236), and that `TestRelocationFromAnEmptyTargetToTheServingWake` passes.
- [ ] **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.
@@ -4153,7 +5217,8 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori
| What | Waits for |
|---|---|
| Drains (`reason: drain`, `rebalance`) | C1 |
-| Switching relocation on anywhere | C7's end-to-end test on dev, C8 with O1 registered, Task 11 |
+| Switching relocation on anywhere | C10 on main and the end-to-end test on dev, C8 with O1 registered, O2' wired, Task 11 |
+| More than one probe attempt per target | C11 |
| A shorter denial for an excluded target | C6 |
| The install's three actions under the claim's own Policy | C9 |
| The zero-downtime completion through `relocating/serving` | Task 11's round; a follow-up plan |
@@ -4164,7 +5229,7 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori
## What cannot be planned yet
-- **C7's answer.** Whether main's serving cold wake hands over from a stopped operation session after a kind-changing CAS is decided by the end-to-end test against main's code. If it does not, the fix is the claim's (C7), not a region HA workaround.
+- **C10's answer.** C7 is withdrawn (rev 3): `ColdHandover.Run` takes the probe receipt as it is. The participation discharge is C10's; its patch is written and compiled here, and the claim decides it.
- **The app-admission registration's exact helper** (ruling 4) and the other Task 10 helpers rev 1 named; each is checked at Task 10 and listed as a deviation if one is added.
- **W1, W2 and W4** are observable only on a box.
@@ -4176,10 +5241,29 @@ The rev 2 code was assembled in a throwaway copy of 808c36a7 (`git archive` into
- **Tests run and passing:** `relocation_rev2_test.go` (six tests), `pin_envkey_test.go`, the edited `relocationeffects_test.go` cases, and the whole `hostrelocate`, `hostclaim`, `hostbox`, `hostfiles` and `registry` packages with this plan's stage A test edits.
- **Stubbed, not compiled as plan code:** `relocationBuildP` (the 1b-next body this plan cites by line, with Task 8b's bindings), and `NewRelocationRuntime`'s worker literal with the rev 1 helpers it names (Task 10 step 4). The `/* … */` test bodies are specifications. The Linux socket tests in `internal/host` (`TestPlan11Rev2KindPreflightRefusesUnverifiedOperation`, `TestPlan11FailedIsolatedStartCanRetryAndDrain`) cannot listen on a unix socket inside the sandbox; they need the sandbox off, as the Global Constraints say.
+### Rev 3 compile record
+
+In `git archive 7a41192c`, with every rev 2 file restored from this plan and rev 3's changes applied:
+
+- `go build ./...` is clean.
+- `go vet` is clean on the seven packages.
+- darwin, windows and linux/arm64 builds of `./internal/...` are clean, and `gofmt -l` is empty.
+- `hostrelocate`, `hostclaim`, `hostbox`, `hostfiles` and `registry` pass in full.
+- `internal/host`'s claim, Plan 9, Plan 11, relocation, census, handover, participation and moves tests pass. The thirteen socket tests ran with the sandbox off and pass with N6.
+
+New tests written and passing:
+
+- `TestStorePinnerNeedsPositiveEvidenceForAnEnvKey`;
+- `TestPresentReadsTheEnvBack`;
+- `TestCensusInitializesWithOldRelocationJournals`;
+- the extended `TestRootReadsTheRelocationAttemptJournal`.
+
+C10's patch compiles and runs under those tests, but is carried for the claim, not committed by stage B. Still stubbed: `relocationBuildP`'s body (it now takes the execution authority) and the Task 10 worker literal.
+
## Self-review
- **Spec coverage.** §5.8 release (8a), pin and build (8b), local install and receipt (8c), probe admission with `KeepStamp`, `PredecessorCertified`, P opened once, P's marker (8d), the end of the probe and the serving restart (9), who acts and the passes (10), serving reconcile (9); §5.3 under ruling 31 (9); §6 and §8 as the checklist (10); L18 (11).
- **Round 1.** Each finding has a disposition, a change and a named test in the table at the top. Findings 2 and 8 are proved from the production state by `TestRelocationFromAnEmptyTargetToTheServingWake`, not by a pre-activated fixture.
- **Placeholders.** `relocationBuildP` and the Task 10 worker literal are cited by their 1b-next or rev 1 bodies; the `/* … */` tests are specifications with their assertions named.
- **Type consistency.** `RelocationEffects`' fields are the file in Task 8a; `RelocationAssemblyConfig` is shared by 8a to 10; `relocationOpDir` names every journal; `CertifiedFor` is shared by 8d and the participant; `MintHeld(rec, captured, status, op)` by 8c and 9; `ProbeStop` by 9 and the participant.
-- **Dependencies.** Every open ask is in the dependency table with a fallback or, for C7, C8 and M3, an explicit activation gate.
+- **Dependencies.** Every open ask is in the dependency table with a fallback or, for C10, C8 and M3, an explicit activation gate. C7 is withdrawn.