region ha: a replacement target activates its unopened installation at its new install stamp on the participant's path, so a target replaced at installed, or in probing before P's first admission, makes its local install instead of stalling on ErrNoRecord or ErrStampMismatch (Astra round 8, P2).
How it works: - The local install (InstallLocal and EnsureLocal's repair, both e.Install) now activates before it captures the installation (relocationActivateInstalled). - It uses the build's one mechanism, relocationActivateAt, the build's relocationActivate with its phases as a parameter: - main's guard.Activate, under the reader that holds every fresh read to relocationInstallAuthority; - the same checks: this box the executor, not stuck, not lost, P not opened, the holder's stamp the install stamp; - now also this box the named target_box; - main's Local check at that installation. - The phases are widened only for the install's activation: exactly installed, or probing with P unopened. The build still activates at installing alone. - The guard's own refusals stand: a retired or older stamp is never reactivated. The install maps those, and a stamp mismatch or a box the claim does not name, to hostrelocate.ErrRefused. - For the original target, already activated by its build at the same stamp, the activation is the guard's no-op. Tests: - The replacement fixture no longer calls Guard.Activate. It drives dst2, a fresh box, through hostrelocate.Participant.Step, so production's activation runs. - Both replacement cases establish P's files through the real provider. With the activation removed, they fail with "hostguard: no local record". - New negatives: - a retired stamp: refused, wrapping ErrRetiredStamp, and not reactivated; - a box the claim does not name, and the replaced box: refused, no guard record made; - probing after P opened: activation refused. The census covers neither function; no census or copy rows change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+169
−10
internal/host/relocationinstall.go+49 −3internal/host/relocationprobefiles_linux_test.go+120 −7
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go| index 4b25b3503..d84abf804 100644 |
| --- a/internal/host/relocationinstall.go |
| +++ b/internal/host/relocationinstall.go |
| @@ -108,15 +108,50 @@ func (r relocationActivationReader) Read(ctx context.Context, host string) (host |
| // Idempotent on the same stamp; a retired stamp is ErrRetiredStamp, never |
| // reactivated (ruling 52). |
| func (a ClaimAssembly) relocationActivate(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostguard.Captured, error) { |
| + return a.relocationActivateAt(cfg, hostclaim.PhaseInstalling) |
| +} |
| + |
| +// relocationActivateInstalled is the same activation for a target that |
| +// resumes at installed without having built P (Astra round 8): a |
| +// replacement named at installed, or in probing before P's first |
| +// admission, which hostclaim.ReplaceTarget leaves at installed under a new |
| +// install stamp, with P and its prepared authority kept and no build to |
| +// run. The local install activates its unopened installation at that |
| +// stamp before it captures it, under the build's reader and checks with |
| +// the phase widened to exactly installed and probing, P not opened |
| +// (relocationInstallAuthority refuses an opened P), this box the executor |
| +// and the named current target, and the holder's stamp the install stamp. |
| +// For the original target, activated by its build at the same stamp, it |
| +// is the guard's idempotent no-op. A retired or older stamp is never |
| +// reactivated (ErrRetiredStamp, ErrOldStamp). |
| +func (a ClaimAssembly) relocationActivateInstalled(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostguard.Captured, error) { |
| + return a.relocationActivateAt(cfg, hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing) |
| +} |
| + |
| +// relocationActivateAt is the one activation: this box's guard record at |
| +// install_stamp, every fresh read the guard makes held to this box's op at |
| +// one of phases (relocationInstallAuthority: the executor, not stuck, not |
| +// lost, P not opened, the holder's stamp the install stamp), this box the |
| +// named current target, and main's Local check at that installation. |
| +func (a ClaimAssembly) relocationActivateAt(cfg RelocationAssemblyConfig, phases ...string) func(context.Context, hostclaim.Observed) (hostguard.Captured, error) { |
| return func(ctx context.Context, o hostclaim.Observed) (hostguard.Captured, error) { |
| src := cfg.Source |
| if src.Local == nil || src.Box == "" || o.Claim == nil || o.Claim.Holder == nil { |
| return hostguard.Captured{}, ErrClaimStopUnavailable |
| } |
| op := o.Claim.Op.ID |
| + authority := func(c *hostclaim.Claim) error { |
| + if err := relocationInstallAuthority(c, op, src.Box, phases...); err != nil { |
| + return err |
| + } |
| + if c.Op.Intent["target_box"] != src.Box { |
| + return fmt.Errorf("%w: %s is not the named target", hostrelocate.ErrRefused, src.Box) |
| + } |
| + return nil |
| + } |
| guard := a.Guard |
| guard.Claims = relocationActivationReader{claims: a.Claims, check: func(ctx context.Context, c *hostclaim.Claim) error { |
| - if err := relocationInstallAuthority(c, op, src.Box, hostclaim.PhaseInstalling); err != nil { |
| + if err := authority(c); err != nil { |
| return err |
| } |
| return relocationInstallLocal(ctx, src, c, hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}) |
| @@ -410,7 +445,9 @@ func relocationInstallSource(src hostbox.WorkSource, reg *registry.Registry) hos |
| // every write through the typed install authority (ruling 46): |
| // |
| // 1. the captured installation must be the active install stamp |
| -// (activated at installing; never reactivated, ruling 52); |
| +// (activated at installing by the build, or here for a replacement |
| +// that resumes at installed, relocationActivateInstalled; a retired |
| +// stamp is never reactivated, ruling 52); |
| // 2. the claim's typed inventory says nested (park.Local, once); |
| // 3. the build's scratch is removed (ruling 44); |
| // 4. in the install work: MintHeld first (the census initialises the |
| @@ -426,6 +463,7 @@ func relocationInstallSource(src hostbox.WorkSource, reg *registry.Registry) hos |
| // |
| // Idempotent: a pass that ends in ErrNotYet is repeated whole. |
| func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + activate := a.relocationActivateInstalled(cfg) |
| render, materialize, verifyLive := cfg.render, cfg.materialize, cfg.verifyLive |
| if verifyLive == nil { |
| verifyLive = release.VerifyHeldLiveUnder |
| @@ -501,7 +539,15 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont |
| } |
| rt := rec.Route |
| paths := relocationPathsFor(cfg.DataDir, rt) |
| - captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + // The installation at the install stamp, activated here when this |
| + // box resumes at installed without a build of its own (a |
| + // replacement; the original target's build activated it already): |
| + // a stamp the guard retired, or one older than its history, is |
| + // never reactivated, and refuses. |
| + captured, err := activate(ctx, o) |
| + if errors.Is(err, hostguard.ErrRetiredStamp) || errors.Is(err, hostguard.ErrOldStamp) || errors.Is(err, hostguard.ErrStampMismatch) || errors.Is(err, hostguard.ErrNotParticipant) { |
| + err = errors.Join(hostrelocate.ErrRefused, err) |
| + } |
| if err != nil { |
| return hostclaim.LocalInstall{}, err |
| } |
diff --git a/internal/host/relocationprobefiles_linux_test.go b/internal/host/relocationprobefiles_linux_test.go| index 8bd741de1..e853ead93 100644 |
| --- a/internal/host/relocationprobefiles_linux_test.go |
| +++ b/internal/host/relocationprobefiles_linux_test.go |
| @@ -14,7 +14,10 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -78,9 +81,10 @@ func (f *probeFilesFixture) installAndProbe() { |
| // P, installed) names the replacement box as the executor, keeps P and its |
| // prepared authority, issues a new install stamp (the CAS's generation) |
| // and drops the receipt; ToInstalled does not run again. The replacement, |
| -// dst2, is a fresh box: its guard is activated at the new stamp, and it |
| -// makes its own local install, records it and enters probing. The replaced |
| -// target's receipt and journal bytes are kept for the negatives. |
| +// dst2, is a fresh box with no guard record: its participant's local |
| +// install activates the installation at the new stamp, makes the install, |
| +// records it and enters probing (participate). The replaced target's |
| +// receipt and journal bytes are kept for the negatives. |
| func newReplacedProbeFilesFixture(t *testing.T, opts buildOptions, phase string) *probeFilesFixture { |
| t.Helper() |
| f := newProbeFilesInstalled(t, opts) |
| @@ -121,14 +125,36 @@ func newReplacedProbeFilesFixture(t *testing.T, opts buildOptions, phase string) |
| t.Fatalf("the replacement keeps P's authority at the build stamp %d under a new stamp %d: %+v %v", build, c.Holder.Stamp, auth, err) |
| } |
| f.onBox(claimCensusBox(t), next) |
| - if err := f.a.Guard.Activate(t.Context(), buildHost, next.Box, nil); err != nil { |
| - t.Fatal(err) |
| - } |
| - f.installAndProbe() |
| + f.participate() |
| f.bindProvider() |
| return f |
| } |
| +// participate drives this box's relocation participant from installed: |
| +// its first pass makes the local install (InstallLocal, which activates a |
| +// replacement's installation at its new stamp; nothing else does) and |
| +// journals the receipt, its second enters probing. |
| +func (f *probeFilesFixture) participate() { |
| + f.t.Helper() |
| + e := f.effects() |
| + incarnation := f.read().Claim.Holder.Incarnation |
| + e.Participation = func(context.Context, hostclaim.Observed) (execlease.State, error) { |
| + return execlease.State{Binding: execlease.Binding{Host: buildHost, Incarnation: incarnation, Op: "rel-1"}}, nil |
| + } |
| + p := hostrelocate.Participant{Claims: f.claims, Box: f.actor.Box, Enrollment: f.actor.Enrollment, Effects: e, Now: time.Now} |
| + o, err := p.Step(f.t.Context(), buildHost) |
| + if err != nil || o.Claim.Op.Journal["local_install"] == "" { |
| + f.t.Fatalf("the participant's install: %v", err) |
| + } |
| + if o, err = p.Step(f.t.Context(), buildHost); err != nil || o.Claim.Op.Phase != hostclaim.PhaseRelocatingProbing { |
| + f.t.Fatalf("the participant's entry into probing: %v", err) |
| + } |
| + if err := json.Unmarshal([]byte(o.Claim.Op.Journal["local_install"]), &f.receipt); err != nil { |
| + f.t.Fatal(err) |
| + } |
| + f.obs = o |
| +} |
| + |
| // bindProvider opens the real C8 provider the production source opens |
| // and binds the relocation's wake inputs to it. |
| func (f *probeFilesFixture) bindProvider() { |
| @@ -447,3 +473,90 @@ func TestRelocationReplacementRefusesTheWrongStamp(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Astra round 8: a replacement's activation is the install's, and no |
| +// broader. The box that runs it must be the claim's executor and named |
| +// target, P must be unopened, and a stamp the guard retired is never |
| +// reactivated; each refusal leaves the guard as it was. |
| +func TestRelocationReplacementActivationRefuses(t *testing.T) { |
| + opts := buildOptions{DB: true, Files: map[string]string{"public/index.html": "<h1>shop</h1>"}, Platform: map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: installWake()}} |
| + t.Run("a retired stamp", func(t *testing.T) { |
| + f := newReplacedProbeFilesFixture(t, opts, hostclaim.PhaseInstalled) |
| + if err := f.a.Guard.RetireInstallation(t.Context(), buildHost, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.update(func(c *hostclaim.Claim) error { |
| + c.Op.Phase = hostclaim.PhaseInstalled |
| + delete(c.Op.Journal, "local_install") |
| + return nil |
| + }) |
| + if _, err := f.effects().InstallLocal(t.Context(), f.read()); !errors.Is(err, hostrelocate.ErrRefused) || !errors.Is(err, hostguard.ErrRetiredStamp) { |
| + t.Fatalf("a retired stamp was reactivated: %v", err) |
| + } |
| + if captured, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); err == nil && captured.Stamp != 0 { |
| + t.Fatalf("the retired installation is active again: %+v", captured) |
| + } |
| + }) |
| + // The claim keeps its executor and named target one box (Validate |
| + // refuses them apart), so a box that is not named is one of two: the |
| + // claim names another box, or the replaced box asks after the claim |
| + // has named its replacement. |
| + for name, tc := range map[string]struct { |
| + change func(*hostclaim.Claim) |
| + box string |
| + }{ |
| + "a box the claim does not name": {change: func(c *hostclaim.Claim) { c.Op.Exec, c.Op.Intent["target_box"] = "dst3", "dst3" }}, |
| + "the replaced box": {change: func(*hostclaim.Claim) {}, box: "dst"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := newReplacedNamedFixture(t, opts, tc.change) |
| + if tc.box != "" { |
| + f.cfg.Source.Box = tc.box |
| + } |
| + if _, err := f.a.relocationActivateInstalled(f.cfg)(t.Context(), f.read()); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("activated: %v", err) |
| + } |
| + if _, err := f.effects().InstallLocal(t.Context(), f.read()); err == nil { |
| + t.Fatal("installed") |
| + } |
| + if _, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); !errors.Is(err, hostguard.ErrNoRecord) { |
| + t.Fatalf("a guard record was made: %v", err) |
| + } |
| + }) |
| + } |
| + t.Run("probing after P opened", func(t *testing.T) { |
| + f := newReplacedProbeFilesFixture(t, opts, hostclaim.PhaseRelocatingProbing) |
| + if _, err := f.probe(firstProbeEnv); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !hostclaim.PreparedOpened(f.read().Claim) { |
| + t.Fatal("P is not opened") |
| + } |
| + if _, err := f.a.relocationActivateInstalled(f.cfg)(t.Context(), f.read()); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("activated after P opened: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// newReplacedNamedFixture is the replacement at installed of |
| +// newReplacedProbeFilesFixture on a fresh box dst2, stopped before dst2's |
| +// participant runs, with change applied to the claim. |
| +func newReplacedNamedFixture(t *testing.T, opts buildOptions, change func(*hostclaim.Claim)) *probeFilesFixture { |
| + t.Helper() |
| + f := newProbeFilesInstalled(t, opts) |
| + next := hostbox.Actor{Box: "dst2", Enrollment: strings.Repeat("b", 32), Region: "ie", Revision: 4} |
| + f.update(func(c *hostclaim.Claim) error { |
| + stamp := c.Gen + 1 |
| + c.Op.Exec, c.Op.Phase = next.Box, hostclaim.PhaseInstalled |
| + c.Op.Intent["target_box"], c.Op.Intent["target_enrollment"] = next.Box, next.Enrollment |
| + c.Op.Participants = []string{"src", next.Box} |
| + c.Op.Journal["exec_ack."+next.Box] = `{"op":"rel-1"}` |
| + c.Holder.Stamp = stamp |
| + c.Op.Journal["install_stamp"] = strconv.FormatUint(stamp, 10) |
| + delete(c.Op.Journal, "local_install") |
| + change(c) |
| + return nil |
| + }) |
| + f.onBox(claimCensusBox(t), next) |
| + return f |
| +} |