host: an unreadable install journal refuses rather than reads as no receipt; VerifyLocal reads P's manifest as the install does; one environment for the pass; the serial, floor-seam, op-id and attempt-count notes (final review minors 1, 3, 4, 5, 6, 7, 10, 11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+83
−22
internal/host/claimparticipation.go+4 −1internal/host/relocatepass.go+17 −9internal/host/relocationflush.go+3 −1internal/host/relocationinstall.go+17 −8internal/host/relocationinstall_linux_test.go+35 −2internal/host/relocationowners.go+7 −1
diff --git a/internal/host/claimparticipation.go b/internal/host/claimparticipation.go| index dc7272992..b1eec455c 100644 |
| --- a/internal/host/claimparticipation.go |
| +++ b/internal/host/claimparticipation.go |
| @@ -60,7 +60,10 @@ func (p *productionClaimLocal) lockDir(name string) string { |
| } |
| // updateFloor is the controller's update-floor guard directory and |
| -// journal: production's under OptDir, none under the N12 test seam. |
| +// journal: production's under OptDir, none under the N12 test seam. No |
| +// production constructor sets lockRoot (OpenClaimOwners builds the owner |
| +// without it; only tests assign it), so the floor is never bypassed in |
| +// production. |
| func (p *productionClaimLocal) updateFloor() (string, string) { |
| if p.lockRoot != "" { |
| return "", "" |
diff --git a/internal/host/relocatepass.go b/internal/host/relocatepass.go| index 708b7da15..3623fc26b 100644 |
| --- a/internal/host/relocatepass.go |
| +++ b/internal/host/relocatepass.go |
| @@ -5,7 +5,6 @@ import ( |
| "crypto/ed25519" |
| "errors" |
| "log" |
| - "os" |
| "path/filepath" |
| "strings" |
| "sync" |
| @@ -70,7 +69,10 @@ type RelocationRuntime struct { |
| // read that failed once is never taken as "no relocation on this box". |
| upgrade func(context.Context) (*RelocationRuntime, error) |
| // config is the assembly config the full pass was built with |
| - // (NewRelocationRuntime); zero for the completion-only runtime. |
| + // (NewRelocationRuntime); zero for the completion-only runtime. No |
| + // production code reads it: it is the seam Task 10's review M7 test |
| + // asserts the held-stage evidence (ReleasePub, Ratchet) through |
| + // (relocatepass_carries_test.go), kept for that reading alone. |
| config RelocationAssemblyConfig |
| } |
| @@ -387,8 +389,11 @@ func wakeDispatch(a *ClaimAssembly) (ClaimOwnerDispatch, bool) { |
| return d, ok |
| } |
| -// Run is one pass every 30 seconds and on a control serial change, each |
| -// bounded at two minutes. |
| +// Run is one pass every 30 seconds, each bounded at two minutes, and one |
| +// more whenever serial is signalled. The agent gives no serial channel |
| +// (launchRelocationPass): the control serial's one wake channel is the |
| +// regional loop's (agent.go, serialClaims), so the pass runs on its ticker |
| +// alone and a BumpSerial wakes it no sooner than the next tick. |
| func (r *RelocationRuntime) Run(ctx context.Context, store release.Store, serial <-chan struct{}) { |
| tick := time.NewTicker(30 * time.Second) |
| defer tick.Stop() |
| @@ -659,15 +664,18 @@ func (a *relocationAlerts) size() int { |
| } |
| // registerRelocationSource registers region HA's owner source with the |
| -// roster directory the claim's own owners read (claimproduction.go). |
| +// roster directory the claim's own owners read (claimproduction.go). The |
| +// release key and the deployment bucket are read from the claim entry's |
| +// environment, the one the flags and the claim store came from (final |
| +// review minor 5: one reading of the environment). |
| func registerRelocationSource(claimCtx context.Context, dataDir string) error { |
| - store := claimStore(claimCtx) |
| - pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + store, getenv := claimStore(claimCtx), claimEntryEnv(claimCtx) |
| + pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| if err != nil || store == nil { |
| return errors.Join(hostwrite.ErrUnavailable, err) |
| } |
| return RegisterProductionClaimRelocationSource(&RelocationSource{DataDir: dataDir, |
| - Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET")}, |
| + Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: getenv("CARLOS_DEPLOYMENT_BUCKET")}, |
| FilesStore: func(ctx context.Context, bucket string) (release.Store, error) { |
| s, err := release.NewS3Store(ctx, bucket) |
| if err != nil { |
| @@ -704,7 +712,7 @@ func startRelocationPass(agentCtx, claimCtx context.Context, claims *ClaimWiring |
| ha := regionHAEnabled(getenv) |
| if ha { |
| full = func(ctx context.Context) (*RelocationRuntime, error) { |
| - in, err := productionRelocationRuntimeInputs(os.Getenv, release.OptDir()) |
| + in, err := productionRelocationRuntimeInputs(getenv, release.OptDir()) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go| index e97e75740..dc27ee063 100644 |
| --- a/internal/host/relocationflush.go |
| +++ b/internal/host/relocationflush.go |
| @@ -200,7 +200,9 @@ func (f ReleasePositionsFile) Recorded(_ context.Context, r StopRequest) (Releas |
| // validOpID is the one check that an op id can name a file or directory |
| // under the data root (region HA ruling 44): non-empty, not "." or "..", |
| // no separator or NUL. Release positions and every relocation journal use |
| -// it. |
| +// it. It is not moves.ValidOpID, which checks moves' own id shape |
| +// (mv_/rb_ prefixes) that relocation's hex ids (newClaimID) never have: |
| +// this is path safety, not identity. |
| func validOpID(op string) bool { |
| return op != "" && op != "." && op != ".." && !strings.ContainsAny(op, "/\\\x00") |
| } |
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go| index dfbca99ac..ed2ecc185 100644 |
| --- a/internal/host/relocationinstall.go |
| +++ b/internal/host/relocationinstall.go |
| @@ -637,13 +637,16 @@ func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func( |
| return false, nil |
| } |
| p := o.Claim.Op.Journal["prepared"] |
| - raw, _, err := cfg.Data.Get(ctx, hostfiles.ManifestKey(o.Claim.Holder.Location+"/files/"+p+"/")) |
| + prefix := o.Claim.Holder.Location + "/files/" + p + "/" |
| + m, found, err := hostfiles.Load(ctx, cfg.Data, prefix) |
| if err != nil { |
| return false, err |
| } |
| - var m hostfiles.Manifest |
| - if err := json.Unmarshal(raw, &m); err != nil { |
| - return false, err |
| + if !found { |
| + // Not "the install is incomplete": the install itself refuses |
| + // without it, so a repair could not mend this (absence is |
| + // never evidence). |
| + return false, fmt.Errorf("%w: P's files manifest %s is missing", hostrelocate.ErrRefused, prefix) |
| } |
| // No env file only by the pin's evidence, as the install judged it. |
| pin, err := relocationPin(ctx, a.Claims, o.Claim) |
| @@ -660,8 +663,11 @@ func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func( |
| } |
| // relocationReceipt reads this box's durable install journal for the |
| -// claim's operation; false when there is none, it is damaged, or it is not |
| -// this operation's. |
| +// claim's operation; false, with no error, only when there is none. A |
| +// journal that does not decode, names another host or operation, or |
| +// records no materialized release is an error, never "no receipt" |
| +// (final review minor 1, as readRelocationProbeFence): it is never |
| +// reinstalled over, so an operator keeps the evidence. |
| func (a ClaimAssembly) relocationReceipt(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| return func(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| if o.Claim == nil { |
| @@ -679,8 +685,11 @@ func (a ClaimAssembly) relocationReceipt(cfg RelocationAssemblyConfig) func(cont |
| return hostclaim.LocalInstall{}, false, err |
| } |
| var j relocationInstallJournal |
| - if err := json.Unmarshal(raw, &j); err != nil || j.V != 1 || j.Host != o.Host || j.Op != o.Claim.Op.ID || j.Steps["materialize"] == "" { |
| - return hostclaim.LocalInstall{}, false, nil |
| + if err := json.Unmarshal(raw, &j); err != nil { |
| + return hostclaim.LocalInstall{}, false, fmt.Errorf("%w: %s cannot be read: %v", hostrelocate.ErrRefused, path, err) |
| + } |
| + if j.V != 1 || j.Host != o.Host || j.Op != o.Claim.Op.ID || j.Steps["materialize"] == "" { |
| + return hostclaim.LocalInstall{}, false, fmt.Errorf("%w: %s is not this operation's completed install journal", hostrelocate.ErrRefused, path) |
| } |
| seq, _ := strconv.ParseInt(j.Steps["files"], 10, 64) |
| sum := sha256.Sum256(raw) |
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go| index 480a545a0..0bd92f496 100644 |
| --- a/internal/host/relocationinstall_linux_test.go |
| +++ b/internal/host/relocationinstall_linux_test.go |
| @@ -817,6 +817,16 @@ func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) { |
| if ok, err := verify(ctx, f.read(), l); err != nil || ok { |
| t.Fatalf("a missing file verified: %v %v", ok, err) |
| } |
| + // Final review minor 10: P's manifest is read as the install reads it |
| + // (hostfiles.Load); one that is gone is a refusal, not an install to |
| + // repair (the install refuses without it too). |
| + o := f.read() |
| + if err := f.cfg.Data.Delete(ctx, hostfiles.ManifestKey(o.Claim.Holder.Location+"/files/"+o.Claim.Op.Journal["prepared"]+"/")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := verify(ctx, o, l); ok || !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("a missing manifest: %v %v", ok, err) |
| + } |
| } |
| func TestRelocationReceiptIsTheDurableJournal(t *testing.T) { |
| @@ -833,12 +843,35 @@ func TestRelocationReceiptIsTheDurableJournal(t *testing.T) { |
| if got, ok, err := receipt(ctx, f.read()); err != nil || !ok || got != l { |
| t.Fatalf("receipt %+v %v %v", got, ok, err) |
| } |
| + // Final review minor 1: a journal that is present but cannot be read, |
| + // or is not this operation's completed install, is an error, never |
| + // "no receipt" that EnsureLocal would reinstall over. |
| + journaled, _ := json.Marshal(l) |
| + if _, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op.Journal["local_install"] = string(journaled); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| path, _ := relocationInstallPath(f.r.DataDir, "rel-1") |
| - if err := os.WriteFile(path, []byte(`{"v":1,"host":"`), 0o600); err != nil { |
| + for name, body := range map[string]string{ |
| + "a damaged journal": `{"v":1,"host":"`, |
| + "another operation's": `{"v":1,"host":"` + buildHost + `","op":"rel-2","steps":{"materialize":"v1"}}`, |
| + "another host's": `{"v":1,"host":"other.example","op":"rel-1","steps":{"materialize":"v1"}}`, |
| + "no materialized release": `{"v":1,"host":"` + buildHost + `","op":"rel-1","steps":{}}`, |
| + } { |
| + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok, err := receipt(ctx, f.read()); ok || !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("%s: %v %v", name, ok, err) |
| + } |
| + if err := f.effects().EnsureLocal(ctx, f.read()); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("%s: EnsureLocal reinstalled over it: %v", name, err) |
| + } |
| + } |
| + if err := os.Remove(path); err != nil { |
| t.Fatal(err) |
| } |
| if _, ok, err := receipt(ctx, f.read()); ok || err != nil { |
| - t.Fatalf("a damaged journal: %v %v", ok, err) |
| + t.Fatalf("no journal: %v %v", ok, err) |
| } |
| } |
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go| index 28a0229c9..5dad4dd25 100644 |
| --- a/internal/host/relocationowners.go |
| +++ b/internal/host/relocationowners.go |
| @@ -220,7 +220,13 @@ func relocationProbeClaim(c *hostclaim.Claim) (relocationProbe, error) { |
| // probe deadline and, on P's prepared opening, P's install manifest. The |
| // claim it reads was checked by Preflight before the attempt was counted |
| // (relocationProbeClaim); it is read again here, on the counted |
| -// observation. |
| +// observation. The install manifest read and a ProbeWake error that is |
| +// not a probe result (an activator or launch failure) run after the count, |
| +// so an attempt spent on transient I/O is an attempt all the same (final |
| +// review minor 11; the same shape as moves' probe): until claim ask C11 |
| +// one attempt is one launch, and its follow-up (plan "After this plan", |
| +// C11) is where three attempts become three probes by moving these reads |
| +// before the count. |
| func (a ClaimAssembly) relocationAdmit(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, bool) (uint64, bool, error) { |
| return func(ctx context.Context, o hostclaim.Observed, certified bool) (uint64, bool, error) { |
| if cfg.Activator == nil || cfg.Versions == nil || o.Claim == nil || o.Claim.Holder == nil { |