carlos / platform Public

host: a repair across a configuration change replaces the claim's install receipt in one CAS before admission, so EnsureLocal, VerifyReceipt and the post-admission check compare one digest (final review A3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev cd740ca8b0dbdbb958569fe535dc1437278054d4 parent ecef64d
7 files changed, +249 −12
  • internal/host/relocationassembly.go +1 −0
  • internal/host/relocationeffects.go +28 −1
  • internal/host/relocationeffects_test.go +1 −0
  • internal/host/relocationinstall_linux_test.go +110 −1
  • internal/hostclaim/relocation.go +51 −9
  • internal/hostclaim/relocation_test.go +52 −0
  • internal/hostrelocate/participant.go +6 −1
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go
index 700acd375..b1d48b400 100644
--- a/internal/host/relocationassembly.go
+++ b/internal/host/relocationassembly.go
@@ -101,6 +101,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func(
receipts := RelocationReleaseReceipts{Store: a.Claims, Directory: cfg.Directory}
paths := relocationHostPaths(cfg.DataDir, cfg.Registry)
return &RelocationEffects{
+ Claims: a.Claims,
Participation: ack,
Identity: func(ctx context.Context) (RelocationIdentity, error) { return a.localStopIdentity(ctx) },
Producers: func(ctx context.Context, o hostclaim.Observed) (StopDrainer, error) {
diff --git a/internal/host/relocationeffects.go b/internal/host/relocationeffects.go
index b8f32a627..7e105c56e 100644
--- a/internal/host/relocationeffects.go
+++ b/internal/host/relocationeffects.go
@@ -8,6 +8,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
execctl "github.com/carlosframework/platform/internal/hostexec"
"github.com/carlosframework/platform/internal/hostrelocate"
+ "github.com/carlosframework/platform/internal/release"
)
// RelocationIdentity is this box's verified actor and stop identity, as
@@ -62,6 +63,9 @@ type ReleasePositions interface {
// operation that lists this box, whichever of the claim's participation
// loop, WorkSource.Prepare or this pass asks first.
type RelocationEffects struct {
+ // Claims is the claim store: EnsureLocal replaces the journaled
+ // receipt there after a repair (final review A3).
+ Claims release.Store
// Participation is main's participation owner for the observed
// operation (productionClaimLocal.Acknowledged): it opens or reuses
// the box's one hostexec controller for {host, op} and returns its
@@ -291,8 +295,18 @@ func (e *RelocationEffects) InstallLocal(ctx context.Context, obs hostclaim.Obse
// the same Install provider under that stamp. A retired stamp is never
// reactivated: Install refuses it, and the probe deadline replaces the
// target. An opened P is never reinstalled.
+//
+// A repair re-renders the held configuration, so its journal, and the
+// digest the receipt carries, differ from the journaled receipt's when
+// the configuration changed since (final review A3). The receipt is then
+// replaced in the claim, in one CAS on a fresh read of the same
+// installation (hostclaim.ReplaceLocalInstall), before any admission, so
+// this verification, VerifyReceipt and relocationAfterAdmission all
+// compare the one digest the box holds with the one the claim journals. A
+// CAS that fails leaves the old receipt, which the next pass finds not
+// verifiable and repairs again to the same journal.
func (e *RelocationEffects) EnsureLocal(ctx context.Context, obs hostclaim.Observed) error {
- if e.VerifyLocal == nil || e.Install == nil || obs.Claim == nil {
+ if e.VerifyLocal == nil || e.Install == nil || e.Claims == nil || obs.Claim == nil {
return ErrClaimStopUnavailable
}
c := obs.Claim
@@ -313,6 +327,19 @@ func (e *RelocationEffects) EnsureLocal(ctx context.Context, obs hostclaim.Obser
if got.Box != l.Box || got.Enrollment != l.Enrollment || got.InstallStamp != l.InstallStamp {
return fmt.Errorf("%w: the restored install is not the receipt's", ErrClaimStopReceipt)
}
+ if got != l {
+ fresh, err := hostclaim.Read(ctx, e.Claims, obs.Host)
+ if err != nil {
+ return err
+ }
+ if fresh.Absent || fresh.Claim.Op.ID != c.Op.ID || fresh.Claim.Op.Journal["prepared"] != c.Op.Journal["prepared"] {
+ return fmt.Errorf("%w: the installation changed under the repair", hostrelocate.ErrRefused)
+ }
+ if obs, err = hostclaim.Update(ctx, e.Claims, fresh, hostclaim.ReplaceLocalInstall(c.Op.ID, l, got)); err != nil {
+ return err
+ }
+ l = got
+ }
ok, err := e.VerifyLocal(ctx, obs, l)
if err == nil && !ok {
err = fmt.Errorf("%w: the restored install did not verify", ErrClaimStopReceipt)
diff --git a/internal/host/relocationeffects_test.go b/internal/host/relocationeffects_test.go
index 0562af2e4..c82e829dc 100644
--- a/internal/host/relocationeffects_test.go
+++ b/internal/host/relocationeffects_test.go
@@ -89,6 +89,7 @@ func effectsFixture(t *testing.T) (*RelocationEffects, hostclaim.Observed, *[]st
seen := &StopRequest{}
exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC)
e := &RelocationEffects{
+ Claims: release.NewDirStore(t.TempDir()),
Participation: func(context.Context, hostclaim.Observed) (execctl.State, error) {
return execctl.State{Binding: execctl.Binding{Host: "shop.example", Incarnation: "inc", Op: "rel-1", Enrollment: "enr", BootID: "boot", ControllerID: "c9"}, LeaseETag: "lease-1", Expires: exp}, nil
},
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go
index d3901076c..480a545a0 100644
--- a/internal/host/relocationinstall_linux_test.go
+++ b/internal/host/relocationinstall_linux_test.go
@@ -238,7 +238,7 @@ func (f *installFixture) paths() relocationPaths {
}
func (f *installFixture) effects() *RelocationEffects {
- return &RelocationEffects{Install: f.a.relocationInstall(f.cfg), VerifyLocal: f.a.relocationVerifyLocal(f.cfg), Receipt: f.a.relocationReceipt(f.cfg)}
+ return &RelocationEffects{Claims: f.claims, Install: f.a.relocationInstall(f.cfg), VerifyLocal: f.a.relocationVerifyLocal(f.cfg), Receipt: f.a.relocationReceipt(f.cfg)}
}
// Findings 3 and 5, Review Focus 2: from an empty target, the install mints
@@ -686,6 +686,115 @@ func TestRelocationEnsureLocalRefusesARetiredStamp(t *testing.T) {
}
}
+// Stage B final review A3 (Astra 3): a repair after the held configuration
+// changed re-renders it, so the repaired journal's digest is not the
+// journaled receipt's. EnsureLocal replaces the receipt in one CAS on a
+// fresh read before any admission, so the pre-admission verification, the
+// receipt check after P opens (VerifyReceipt) and the post-admission check
+// (relocationAfterAdmission) all compare the digest the box holds with the
+// one the claim journals. A CAS that fails once leaves the old receipt, and
+// the next pass repairs to the same journal and records it.
+func TestRelocationEnsureLocalRepairsAcrossAConfigurationChange(t *testing.T) {
+ f := newInstallFixture(t, nil)
+ ctx := t.Context()
+ l, err := f.install()
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipt, _ := json.Marshal(l)
+ if f.obs, err = hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error {
+ c.Op.Phase = hostclaim.PhaseRelocatingProbing
+ c.Op.Journal["local_install"] = string(receipt)
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e := f.effects()
+ file := filepath.Join(f.paths().FilesDir, "public", "index.html")
+ journaled := func() hostclaim.LocalInstall {
+ t.Helper()
+ var got hostclaim.LocalInstall
+ if err := json.Unmarshal([]byte(f.read().Claim.Op.Journal["local_install"]), &got); err != nil {
+ t.Fatal(err)
+ }
+ return got
+ }
+ onDisk := func() hostclaim.LocalInstall {
+ t.Helper()
+ got, ok, err := f.a.relocationReceipt(f.cfg)(ctx, f.read())
+ if err != nil || !ok {
+ t.Fatalf("the durable receipt: %v %v", ok, err)
+ }
+ return got
+ }
+ agree := func(want string) {
+ t.Helper()
+ if j, d := journaled(), onDisk(); j != d {
+ t.Fatalf("%s: the claim journals %+v, the box holds %+v", want, j, d)
+ }
+ if ok, err := e.VerifyReceipt(ctx, f.read()); err != nil || !ok {
+ t.Fatalf("%s: VerifyReceipt %v %v", want, ok, err)
+ }
+ prior := f.read()
+ admitted := f.read()
+ admitted.Claim.Holder.WriterBox, admitted.Claim.Holder.Generation = "dst", admitted.Claim.Op.Journal["prepared"]
+ if err := relocationAfterAdmission(f.r.DataDir, "dst")(ctx, prior, admitted); err != nil {
+ t.Fatalf("%s: after admission: %v", want, err)
+ }
+ }
+ agree("after the install")
+
+ // The configuration changes, then a file is lost: the repair renders
+ // the new configuration and records the new receipt.
+ f.configs = map[string]string{"CARLOS_FEATURE": "2"}
+ if err := os.Remove(file); err != nil {
+ t.Fatal(err)
+ }
+ before := f.read().Claim.Gen
+ if err := e.EnsureLocal(ctx, f.read()); err != nil {
+ t.Fatalf("the repair across a configuration change: %v", err)
+ }
+ if got, err := os.ReadFile(file); err != nil || string(got) != "<h1>shop</h1>" {
+ t.Fatalf("not restored: %q %v", got, err)
+ }
+ repaired := journaled()
+ if repaired == l || repaired.Box != l.Box || repaired.Enrollment != l.Enrollment || repaired.InstallStamp != l.InstallStamp || f.read().Claim.Gen == before {
+ t.Fatalf("the receipt was not replaced for the same installation: %+v (was %+v)", repaired, l)
+ }
+ agree("after the repair")
+ // Whole now: nothing is reinstalled or rewritten.
+ gen := f.read().Claim.Gen
+ if err := e.EnsureLocal(ctx, f.read()); err != nil || f.read().Claim.Gen != gen {
+ t.Fatalf("a whole install was repaired again: %v gen %d -> %d", err, gen, f.read().Claim.Gen)
+ }
+
+ // The configuration changes again and the CAS fails once: the old
+ // receipt stays in the claim, the box holds the new journal, and the
+ // next pass repairs to the same journal and records it.
+ f.configs = map[string]string{"CARLOS_FEATURE": "3"}
+ if err := os.Remove(file); err != nil {
+ t.Fatal(err)
+ }
+ e.Claims = errPutStore{Store: f.claims}
+ if err := e.EnsureLocal(ctx, f.read()); err == nil {
+ t.Fatal("a failed receipt CAS was reported as a repair")
+ }
+ if j, d := journaled(), onDisk(); j != repaired || d == repaired {
+ t.Fatalf("after the failed CAS: the claim journals %+v, the box holds %+v", j, d)
+ }
+ if ok, err := e.VerifyReceipt(ctx, f.read()); err != nil || ok {
+ t.Fatalf("the old receipt verified against the new journal: %v %v", ok, err)
+ }
+ e.Claims = f.claims
+ if err := e.EnsureLocal(ctx, f.read()); err != nil {
+ t.Fatalf("the pass after the failed CAS: %v", err)
+ }
+ if journaled() == repaired {
+ t.Fatal("the receipt was not replaced on the next pass")
+ }
+ agree("after the retried repair")
+}
+
func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) {
f := newInstallFixture(t, nil)
ctx := t.Context()
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go
index 9d9d71348..ffe02e47f 100644
--- a/internal/hostclaim/relocation.go
+++ b/internal/hostclaim/relocation.go
@@ -895,17 +895,9 @@ func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error {
if err := requireRelocation(c, opID, PhaseInstalled); err != nil {
return err
}
- stamp, err := journalUint(c.Op, "install_stamp")
- if err != nil {
+ if err := requireReceiptOfInstallation(c, l); err != nil {
return err
}
- if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] ||
- l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) {
- return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation)
- }
- if preparedOpened(c) {
- return fmt.Errorf("%w: P was admitted; the local install is never remade", ErrRelocation)
- }
var old LocalInstall
if readJournal(c.Op, "local_install", &old) {
if old == l {
@@ -917,6 +909,56 @@ func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error {
}
}
+// ReplaceLocalInstall replaces the journaled receipt old with l after a
+// repair of the local install under the same installation (stage B final
+// review A3): the repair re-renders the held configuration, which may
+// have changed since old was journaled, so the box's durable journal and
+// its digest are new while the box, enrollment and install stamp are the
+// same. Only before P is admitted (an opened P is never reinstalled), only
+// over exactly old, and only for the same installation: the three readers
+// of the receipt (the pre-admission verification, the post-admission
+// check and the receipt check after P opens) then compare one digest.
+func ReplaceLocalInstall(opID string, old, l LocalInstall) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := requireRelocation(c, opID, PhaseInstalled, PhaseRelocatingProbing); err != nil {
+ return err
+ }
+ if err := requireReceiptOfInstallation(c, l); err != nil {
+ return err
+ }
+ if l.Box != old.Box || l.Enrollment != old.Enrollment || l.InstallStamp != old.InstallStamp {
+ return fmt.Errorf("%w: the repaired receipt is not the journaled installation's", ErrRelocation)
+ }
+ var cur LocalInstall
+ if !readJournal(c.Op, "local_install", &cur) || cur != old {
+ return fmt.Errorf("%w: the journaled receipt is not the one being replaced", ErrRelocation)
+ }
+ if cur == l {
+ return ErrAlreadyRecorded
+ }
+ return journalJSON(c, "local_install", l)
+ }
+}
+
+// requireReceiptOfInstallation is the receipt's binding to the operation's
+// installation (RecordLocalInstall, ReplaceLocalInstall): the executor,
+// its enrollment, the journaled install stamp, a well-formed digest, and
+// P not yet admitted.
+func requireReceiptOfInstallation(c *Claim, l LocalInstall) error {
+ stamp, err := journalUint(c.Op, "install_stamp")
+ if err != nil {
+ return err
+ }
+ if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] ||
+ l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) {
+ return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation)
+ }
+ if preparedOpened(c) {
+ return fmt.Errorf("%w: P was admitted; the local install is never remade", ErrRelocation)
+ }
+ return nil
+}
+
// ToProbing enters probing, which requires a local install receipt for
// exactly op.exec, its enrollment and the journaled install_stamp. now
// starts the probe deadline (§5.8: 10 minutes).
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go
index 2359995d4..0aa35ddc8 100644
--- a/internal/hostclaim/relocation_test.go
+++ b/internal/hostclaim/relocation_test.go
@@ -1333,3 +1333,55 @@ func TestRelocationNameTargetInStartingIsAFailoversAlone(t *testing.T) {
t.Fatalf("a drain named a target in starting: %v", err)
}
}
+
+// Stage B final review A3: a repair of the local install replaces the
+// journaled receipt with the repaired one, in installed and in probing
+// before P is admitted, only over exactly the journaled receipt and only
+// for the same box, enrollment and install stamp; never after P opens.
+func TestReplaceLocalInstallOnlyOverTheJournaledReceiptBeforePOpens(t *testing.T) {
+ ctx := context.Background()
+ for _, phase := range []string{PhaseInstalled, PhaseRelocatingProbing} {
+ s, o := relAtPhase(t, phase)
+ old := localInstall(o.Claim)
+ if phase == PhaseInstalled {
+ o = step(t, s, o, RecordLocalInstall("rel-1", old))
+ }
+ repaired := old
+ repaired.JournalSHA256 = strings.Repeat("c", 64)
+ other := repaired
+ other.JournalSHA256 = strings.Repeat("d", 64)
+ if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", other, repaired)); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("%s: replaced over a receipt that is not journaled: %v", phase, err)
+ }
+ for name, bad := range map[string]func(*LocalInstall){
+ "another box": func(l *LocalInstall) { l.Box = relSource },
+ "another enrollment": func(l *LocalInstall) { l.Enrollment = relEnrollment(relSource) },
+ "another stamp": func(l *LocalInstall) { l.InstallStamp++ },
+ "a malformed digest": func(l *LocalInstall) { l.JournalSHA256 = "x" },
+ } {
+ l := repaired
+ bad(&l)
+ if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, l)); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("%s: %s: %v", phase, name, err)
+ }
+ }
+ if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, old)); !errors.Is(err, ErrAlreadyRecorded) {
+ t.Fatalf("%s: the same receipt: %v", phase, err)
+ }
+ o = step(t, s, o, ReplaceLocalInstall("rel-1", old, repaired))
+ var got LocalInstall
+ if !readJournal(o.Claim.Op, "local_install", &got) || got != repaired || o.Claim.Op.Phase != phase {
+ t.Fatalf("%s: journaled %+v in %s", phase, got, o.Claim.Op.Phase)
+ }
+ if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, other)); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("%s: replaced over the superseded receipt: %v", phase, err)
+ }
+ }
+ s, o := relAtPhase(t, "probing+P")
+ old := localInstall(o.Claim)
+ repaired := old
+ repaired.JournalSHA256 = strings.Repeat("c", 64)
+ if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, repaired)); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("replaced after P opened: %v", err)
+ }
+}
diff --git a/internal/hostrelocate/participant.go b/internal/hostrelocate/participant.go
index 6e1504a36..09fd47149 100644
--- a/internal/hostrelocate/participant.go
+++ b/internal/hostrelocate/participant.go
@@ -52,7 +52,12 @@ type Effects interface {
// complete on this box and, when it is not (a lost lease's scoped
// cleanup retires an unopened P's local install), restores it under
// install_stamp from P's files manifest. It is never called once P is
- // opened: an opened P is never reinstalled (rev 15, round 14).
+ // opened: an opened P is never reinstalled (rev 15, round 14). A
+ // repair may write the claim: the typed inventory, and the receipt
+ // itself when the repaired journal's digest differs (a configuration
+ // changed since it was journaled; final review A3), replaced in one
+ // CAS before any admission. The caller re-reads the same installation
+ // after it.
EnsureLocal(context.Context, hostclaim.Observed) error
// VerifyReceipt is called before every admission after P is opened:
// it reports whether this box's durable local install receipt is