carlos / platform Public

host: a relocation target activates its guard at the install stamp from an empty box and builds P under the replica lease, rechecking its authority before every write; the pin needs positive evidence of the instance key, and a pin's empty database, its files digest and a configured key's env are never inferred from absence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 43288063b9be7fc7e71eece6faadd39179d6f58a parent 734e842
7 files changed, +1432 −16
  • internal/host/relocationassembly.go +1 −0
  • internal/host/relocationbuild.go +308 −0
  • internal/host/relocationbuild_test.go +630 −0
  • internal/host/relocationinstall.go +154 −0
  • internal/hostrelocate/pin.go +120 −16
  • internal/hostrelocate/pin_envkey_test.go +173 −0
  • internal/hostrelocate/pin_test.go +46 −0
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go
index 9d72194ed..e2089018b 100644
--- a/internal/host/relocationassembly.go
+++ b/internal/host/relocationassembly.go
@@ -108,6 +108,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func(
FilesDir: func(host string) string { return paths(host).FilesDir }, EnvPath: func(host string) string { return paths(host).EnvPath }}.Flush,
Positions: ReleasePositionsFile{Dir: cfg.ReleaseDir},
PreflightKind: a.relocationPreflight(cfg),
+ BuildP: a.relocationBuild(cfg),
}
}
diff --git a/internal/host/relocationbuild.go b/internal/host/relocationbuild.go
new file mode 100644
index 000000000..7f4afdf6e
--- /dev/null
+++ b/internal/host/relocationbuild.go
@@ -0,0 +1,308 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/hostrelocate"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// relocationBuild is the target's build at installing: its guard
+// activated at install_stamp from an empty box (ruling 43), then P built
+// under the replica lease with every durable write behind the build
+// authority (ruling 59), then the build's host-named scratch removed
+// (ruling 44).
+func (a ClaimAssembly) relocationBuild(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) error {
+ activate := a.relocationActivate(cfg)
+ build := a.relocationBuildP(cfg)
+ return func(ctx context.Context, o hostclaim.Observed) error {
+ captured, err := activate(ctx, o)
+ if err != nil {
+ return err
+ }
+ if err := build(ctx, o, a.relocationBuildAuthority(cfg, o, captured)); err != nil {
+ return err
+ }
+ return removeRelocationScratch(cfg, o.Claim.Op.ID)
+ }
+}
+
+// relocationBuildP builds P from the journaled pin (§5.8 "Building the
+// prepared generation", L16, L17) under the replica lease on
+// holder.location, taken for the roster's cloud region (ruling 51); the
+// lease's loss cancels what follows. auth is the build authority, run
+// before the manifest copy, before the database build, and before every
+// write the builder makes (authorityStore). P's files manifest is written
+// first, so a crash between the two steps leaves an install manifest never
+// ahead of its files manifest. A replay from installing finds its own
+// manifest copy (CopyPinnedManifest is create-exact) and its own intent,
+// snapshot and anchor (moves §7.6).
+func (a ClaimAssembly) relocationBuildP(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, func(context.Context) error) error {
+ return func(ctx context.Context, o hostclaim.Observed, auth func(context.Context) error) error {
+ if cfg.Versions == nil || cfg.Data == nil || cfg.Bucket == "" || cfg.LeaseRegion == "" || auth == nil || o.Claim == nil || o.Claim.Holder == nil {
+ return ErrClaimStopUnavailable
+ }
+ c := o.Claim
+ var ref hostclaim.RecoveryPin
+ if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil {
+ return err
+ }
+ pin, err := hostrelocate.ReadPin(ctx, a.Claims, ref)
+ if err != nil {
+ return err
+ }
+ p := c.Op.Journal["prepared"]
+ work, err := relocationBuildDir(cfg, c.Op.ID)
+ if err != nil || p == "" {
+ return errors.Join(fmt.Errorf("%w: no prepared generation to build", hostrelocate.ErrRefused), err)
+ }
+ held, err := lease.Acquire(ctx, cfg.Bucket, c.Holder.Location, cfg.LeaseRegion)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = held.Release(context.WithoutCancel(ctx)) }()
+ alive, stop := context.WithCancel(ctx)
+ defer stop()
+ go func() {
+ select {
+ case <-held.Lost():
+ stop()
+ case <-alive.Done():
+ }
+ }()
+ check := func() error {
+ if err := auth(alive); err != nil {
+ return err
+ }
+ return alive.Err()
+ }
+ if err := check(); err != nil {
+ return err
+ }
+ if _, err := hostrelocate.CopyPinnedManifest(alive, a.Claims, cfg.Data, pin.Files, c.Holder.Location+"/files/"+p+"/"); err != nil {
+ return err
+ }
+ if pin.DB == nil {
+ return nil // an evidenced empty bootstrap: no snapshot, no anchor (claim P6-D3 forbids [1, 0])
+ }
+ if err := check(); err != nil {
+ return err
+ }
+ src := make([]moves.SealedObject, 0, len(pin.DB.Files))
+ for _, f := range pin.DB.Files {
+ src = append(src, moves.SealedObject{Key: f.Key, VersionID: f.VersionID, Size: f.Size})
+ }
+ if err := os.MkdirAll(work, 0o700); err != nil {
+ return err
+ }
+ _, err = moves.BuildGeneration(alive, moves.BuildInputs{Store: authorityStore{VersionedStore: cfg.Versions, check: check}, Bucket: cfg.Bucket, Source: src, N: pin.DB.MaxTXID,
+ GenerationPrefix: moves.GenerationPrefix(c.Holder.Location, p), Op: c.Op.ID, Host: o.Host, WorkDir: work, Now: cfg.Now, Verify: lease.CheckDB})
+ return err
+ }
+}
+
+// authorityStore is the versioned store the builder writes through: every
+// write re-checks the build's authority first, so a delayed build whose
+// phase, executor, stamp, execution lease or replica lease moved writes
+// nothing more. Reads are the store's own; the build never deletes.
+type authorityStore struct {
+ lease.VersionedStore
+ check func() error
+}
+
+func (s authorityStore) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+ if err := s.check(); err != nil {
+ return "", err
+ }
+ return s.VersionedStore.PutIfAbsent(ctx, bucket, key, body)
+}
+
+func (s authorityStore) CopyVersion(ctx context.Context, sb, sk, sv, db, dk string, createOnly bool) (string, string, error) {
+ if err := s.check(); err != nil {
+ return "", "", err
+ }
+ return s.VersionedStore.CopyVersion(ctx, sb, sk, sv, db, dk, createOnly)
+}
+
+func (s authorityStore) DeleteVersion(context.Context, string, string, string) error {
+ return fmt.Errorf("%w: the build never deletes", hostrelocate.ErrRefused)
+}
+
+// relocationBuildDir is the build's scratch for op, moves.BuildInputs'
+// WorkDir: <WorkDir>/<op>, where BuildGeneration leaves <host>.db.
+func relocationBuildDir(cfg RelocationAssemblyConfig, op string) (string, error) {
+ if cfg.WorkDir == "" || !validOpID(op) {
+ return "", ErrClaimStopUnavailable
+ }
+ return filepath.Join(cfg.WorkDir, op), nil
+}
+
+// removeRelocationScratch removes the build's scratch for op, which names
+// the host (<WorkDir>/<op>/<host>.db). The build removes it once P is
+// built; the local install removes it again first, so a crash in between
+// never leaves a host-named file for the census.
+func removeRelocationScratch(cfg RelocationAssemblyConfig, op string) error {
+ dir, err := relocationBuildDir(cfg, op)
+ if err != nil {
+ return err
+ }
+ return os.RemoveAll(dir)
+}
+
+// readInstallManifest is the current install.json of a generation prefix,
+// its bytes, and its version: the one reader for the build verifier, the
+// prepared marker and the probe driver. No current version is an error
+// that is both hostbox.ErrChanged and os.ErrNotExist.
+func readInstallManifest(ctx context.Context, data genselect.Reader, bucket, prefix string) (moves.InstallManifest, []byte, error) {
+ key := moves.InstallManifestKey(prefix)
+ versions, err := data.ListVersions(ctx, bucket, key)
+ if err != nil {
+ return moves.InstallManifest{}, nil, err
+ }
+ id := ""
+ for _, v := range versions {
+ if v.Key == key && v.IsLatest && !v.DeleteMarker {
+ id = v.VersionID
+ }
+ }
+ if id == "" {
+ return moves.InstallManifest{}, nil, fmt.Errorf("%w: %s has no install.json (%w)", hostbox.ErrChanged, prefix, os.ErrNotExist)
+ }
+ raw, err := data.GetVersion(ctx, bucket, key, id)
+ if err != nil {
+ return moves.InstallManifest{}, nil, err
+ }
+ var m moves.InstallManifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return moves.InstallManifest{}, nil, err
+ }
+ return m, raw, nil
+}
+
+// PreparedFromInstallManifest is hostrelocate.Prepared: P is built when a
+// fresh read verifies its files manifest (sequence 0, exactly the pinned
+// entries) and, unless the pin is empty, its completed install manifest at
+// the pin's N whose snapshot and anchor versions exist.
+type PreparedFromInstallManifest struct {
+ Claims release.Store // pin objects and the retained manifest
+ Files release.Store // files manifests
+ Data lease.VersionedStore // LTX files and install manifests
+ Bucket string
+}
+
+var _ hostrelocate.Prepared = PreparedFromInstallManifest{}
+
+func (v PreparedFromInstallManifest) Verify(ctx context.Context, o hostclaim.Observed) (hostclaim.PreparedAuthority, bool, error) {
+ if v.Claims == nil || v.Files == nil || v.Data == nil || v.Bucket == "" || o.Claim == nil || o.Claim.Holder == nil {
+ return hostclaim.PreparedAuthority{}, false, ErrClaimStopUnavailable
+ }
+ c := o.Claim
+ var ref hostclaim.RecoveryPin
+ if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ pin, err := hostrelocate.ReadPin(ctx, v.Claims, ref)
+ if err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ // A files digest without its retained copy, or a copy without its
+ // digest, is never read as a host that published no files (absence is
+ // never evidence); CopyPinnedManifest refuses the same pin.
+ if (pin.Files.SHA256 == "") != (pin.Files.Retained == "") {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: the pinned files manifest names a digest or a retained copy without the other", hostrelocate.ErrRefused)
+ }
+ p := c.Op.Journal["prepared"]
+ raw, _, err := v.Files.Get(ctx, hostfiles.ManifestKey(c.Holder.Location+"/files/"+p+"/"))
+ if errors.Is(err, os.ErrNotExist) {
+ return hostclaim.PreparedAuthority{}, false, nil // not built yet
+ }
+ if err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ var m hostfiles.Manifest
+ if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion || m.Seq != 0 {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: P's files manifest is not a prepared manifest", hostrelocate.ErrRefused)
+ }
+ pinned := hostfiles.Manifest{Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ if pin.Files.Retained != "" {
+ // The pinner's retained copy of the pinned bytes, never the source
+ // prefix (ruling 21): P's entries must be exactly theirs.
+ pinnedRaw, _, err := v.Claims.Get(ctx, pin.Files.Retained)
+ if err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ if sum := sha256.Sum256(pinnedRaw); hex.EncodeToString(sum[:]) != pin.Files.SHA256 {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: the pinned files manifest changed", hostrelocate.ErrRefused)
+ }
+ if err := json.Unmarshal(pinnedRaw, &pinned); err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ }
+ if !sameEntries(m, pinned) {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: P's files manifest is not the pinned manifest", hostrelocate.ErrRefused)
+ }
+ filesSum := sha256.Sum256(raw)
+ auth := hostclaim.PreparedAuthority{Generation: p, FilesSeq: 0, SourceFilesSeq: pin.Files.Seq, FilesManifestSHA256: hex.EncodeToString(filesSum[:])}
+ if pin.DB == nil {
+ return auth, true, nil
+ }
+ prefix := moves.GenerationPrefix(c.Holder.Location, p)
+ im, body, err := readInstallManifest(ctx, v.Data, v.Bucket, prefix)
+ if errors.Is(err, os.ErrNotExist) {
+ return hostclaim.PreparedAuthority{}, false, nil // the build has not completed
+ }
+ if err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ key := moves.InstallManifestKey(prefix)
+ if im.Op != c.Op.ID || im.Host != o.Host || im.N != pin.DB.MaxTXID || im.SnapshotKey != moves.SnapshotKey(prefix, im.N) || im.AnchorKey != moves.AnchorKey(prefix, im.N) ||
+ im.SnapshotVersion == "" || im.AnchorVersion == "" || im.IntegrityCheck != "ok" {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: install manifest %s is not this pin's build of P", hostrelocate.ErrRefused, key)
+ }
+ for _, want := range []struct{ key, version string }{{im.SnapshotKey, im.SnapshotVersion}, {im.AnchorKey, im.AnchorVersion}} {
+ vs, err := v.Data.ListVersions(ctx, v.Bucket, want.key)
+ if err != nil {
+ return hostclaim.PreparedAuthority{}, false, err
+ }
+ found := false
+ for _, ov := range vs {
+ if ov.Key == want.key && ov.VersionID == want.version && !ov.DeleteMarker {
+ found = true
+ }
+ }
+ if !found {
+ return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: %s@%s named by the install manifest is not there", hostrelocate.ErrRefused, want.key, want.version)
+ }
+ }
+ sum := sha256.Sum256(body)
+ auth.InstallManifest, auth.SHA256, auth.SnapshotTXID = key, hex.EncodeToString(sum[:]), im.N
+ return auth, true, nil
+}
+
+// sameEntries compares two manifests' entries and platform items, nothing
+// else: P's prepared copy differs from the pinned bytes only in Seq.
+func sameEntries(a, b hostfiles.Manifest) bool {
+ type entries struct{ E, P map[string]hostfiles.Entry }
+ norm := func(m map[string]hostfiles.Entry) map[string]hostfiles.Entry {
+ if m == nil {
+ return map[string]hostfiles.Entry{}
+ }
+ return m
+ }
+ ra, _ := json.Marshal(entries{norm(a.Entries), norm(a.Platform)})
+ rb, _ := json.Marshal(entries{norm(b.Entries), norm(b.Platform)})
+ return string(ra) == string(rb)
+}
diff --git a/internal/host/relocationbuild_test.go b/internal/host/relocationbuild_test.go
new file mode 100644
index 000000000..efb0cd2cf
--- /dev/null
+++ b/internal/host/relocationbuild_test.go
@@ -0,0 +1,630 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/benbjohnson/litestream"
+ lsfile "github.com/benbjohnson/litestream/file"
+ "github.com/carlosframework/platform/internal/genselect"
+ "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/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/superfly/ltx"
+)
+
+const buildHost = "shop.example"
+
+// buildFixture is a relocation target at installing on an empty box: the
+// claim journals a pin of a real litestream replica sealed into a
+// versioned bucket (served over a loopback S3 endpoint, so the replica
+// lease is lease.Acquire's own), a retained files manifest with one entry,
+// an empty guard directory, and main's Local recorded.
+type buildFixture struct {
+ t *testing.T
+ claims *release.DirStore
+ files *hookedStore
+ bucket *leasetest.VersionedBucket
+ versions *hookedVersions
+ a ClaimAssembly
+ cfg RelocationAssemblyConfig
+ obs hostclaim.Observed
+ locals []hostbox.Selection
+ localErr error
+ verifier PreparedFromInstallManifest
+}
+
+// hookedStore and hookedVersions run after, once a write has landed, so a
+// test can move the build's authority between two durable writes.
+type hookedStore struct {
+ release.Store
+ afterPut func(key string)
+}
+
+func (s *hookedStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
+ out, err := s.Store.Put(ctx, key, body, etag)
+ if err == nil && s.afterPut != nil {
+ s.afterPut(key)
+ }
+ return out, err
+}
+
+type hookedVersions struct {
+ lease.VersionedStore
+ afterPut func(key string)
+}
+
+func (s *hookedVersions) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+ out, err := s.VersionedStore.PutIfAbsent(ctx, bucket, key, body)
+ if err == nil && s.afterPut != nil {
+ s.afterPut(key)
+ }
+ return out, err
+}
+
+// buildOptions shape a buildFixture: a pin with or without a database, the
+// data root (a fresh one with a hostguard directory when empty), and the
+// pinned manifest's entries and platform items, each stored as an object
+// under the source generation's prefix.
+type buildOptions struct {
+ DB bool
+ Data string
+ Files map[string]string
+ Platform map[string]string
+}
+
+func newBuildFixture(t *testing.T, withDB bool) *buildFixture {
+ return newBuildFixtureWith(t, buildOptions{DB: withDB, Files: map[string]string{"public/hello.txt": "hello"}})
+}
+
+func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
+ t.Helper()
+ ctx := t.Context()
+ withDB := o.DB
+ f := &buildFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()}
+ f.bucket.AddBucket("data")
+ f.files = &hookedStore{Store: release.NewDirStore(t.TempDir())}
+ f.versions = &hookedVersions{VersionedStore: f.bucket}
+ handler, _ := leasetest.NewS3Server(f.bucket, "data")
+ srv := httptest.NewServer(handler) // 127.0.0.1 only
+ t.Cleanup(srv.Close)
+ t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
+ t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
+ t.Setenv("AWS_SESSION_TOKEN", "")
+ t.Setenv("AWS_PROFILE", "")
+ t.Setenv("AWS_REGION", "")
+
+ created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC)
+ enrollment := strings.Repeat("a", 32)
+ nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + buildHost + ".json", Account: "acct", App: "app", Host: buildHost, Region: "ie", RecordRegion: "ie", CreatedAt: created}
+ body := []byte(`{"host":"` + buildHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`)
+ obs, err := hostclaim.Read(ctx, f.claims, buildHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tr := range []func(*hostclaim.Claim) error{
+ hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment}),
+ hostclaim.CompleteCreate("c1", "src"),
+ hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}),
+ } {
+ if obs, err = hostclaim.Update(ctx, f.claims, obs, tr); err != nil {
+ t.Fatal(err)
+ }
+ }
+ location := obs.Claim.Holder.Location
+ const op = "rel-1"
+
+ pin := hostrelocate.PinObject{V: 1, Host: buildHost, Op: op, ClaimGen: obs.Claim.Gen, PinnedAt: created, Release: "v1"}
+ if withDB {
+ files, n := sealedReplica(t, f.bucket, moves.GenerationPrefix(location, "g1"))
+ pin.DB = &genselect.Plan{Generation: "g1", Files: files, MaxTXID: n, MaxLevel0TXID: n}
+ } else {
+ pin.Empty = "session-0"
+ }
+ srcPrefix := location + "/files/g1/"
+ pinned := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Platform: map[string]hostfiles.Entry{}, Entries: map[string]hostfiles.Entry{}}
+ object := func(body string, mode uint32) hostfiles.Entry {
+ sum := sha256.Sum256([]byte(body))
+ sha := hex.EncodeToString(sum[:])
+ key := srcPrefix + "objects/" + fmt.Sprintf("%016d-%s", 5, sha)
+ if _, err := f.files.Store.Put(ctx, key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ return hostfiles.Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode, Seq: 5}
+ }
+ for rel, body := range o.Files {
+ pinned.Entries[rel] = object(body, 0o644)
+ }
+ for name, body := range o.Platform {
+ pinned.Platform[name] = object(body, 0o600)
+ }
+ pinnedRaw, err := json.Marshal(pinned)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pinnedSum := sha256.Sum256(pinnedRaw)
+ retained := hostrelocate.RetainedManifestKey(buildHost, op, hex.EncodeToString(pinnedSum[:]))
+ if _, err := f.claims.Put(ctx, retained, pinnedRaw, ""); err != nil {
+ t.Fatal(err)
+ }
+ pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Seq: 5, SHA256: hex.EncodeToString(pinnedSum[:]), Origins: []string{srcPrefix}, Retained: retained}
+ pinRaw, err := json.Marshal(pin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pinKey := hostrelocate.PinKey(buildHost, op, obs.Claim.Gen)
+ if _, err := f.claims.Put(ctx, pinKey, pinRaw, ""); err != nil {
+ t.Fatal(err)
+ }
+ pinSum := sha256.Sum256(pinRaw)
+ ref := hostclaim.RecoveryPin{Key: pinKey, SHA256: hex.EncodeToString(pinSum[:]), Generation: "g1", FilesManifest: retained, FilesSeq: 5, PinnedAt: created, Release: "v1", Empty: pin.DB == nil}
+ if pin.DB != nil {
+ ref.MaxTXID = pin.DB.MaxTXID
+ }
+ refRaw, err := json.Marshal(ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The installing CAS: this box executes the op, the holder's stamp is
+ // the install stamp, P is named (hostclaim's install()).
+ if obs, err = hostclaim.Update(ctx, f.claims, obs, func(c *hostclaim.Claim) error {
+ stamp := c.Gen + 1
+ c.Holder.Stamp = stamp
+ c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: op, Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"},
+ Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"},
+ Journal: map[string]string{"pin": string(refRaw), "prepared": "p1", "install_stamp": strconv.FormatUint(stamp, 10), "install_release": "v1"}}
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ f.obs = obs
+
+ data := o.Data
+ if data == "" {
+ data = t.TempDir()
+ }
+ if err := os.MkdirAll(filepath.Join(data, "hostguard"), 0o770); err != nil {
+ t.Fatal(err)
+ }
+ f.a = ClaimAssembly{Claims: f.claims, Guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.claims}}}
+ f.cfg = RelocationAssemblyConfig{Data: f.files, Versions: f.versions, Bucket: "data", Region: "ie", LeaseRegion: "eu-west-1", DataDir: data,
+ WorkDir: filepath.Join(data, "relocate", "build"), Now: func() time.Time { return created.Add(time.Hour) },
+ Source: hostbox.WorkSource{Box: "dst", Region: "ie", Local: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection) error {
+ f.locals = append(f.locals, s)
+ return f.localErr
+ }}}
+ f.verifier = PreparedFromInstallManifest{Claims: f.claims, Files: f.files, Data: f.bucket, Bucket: "data"}
+ return f
+}
+
+// sealedReplica writes a real SQLite database through litestream's file
+// replica and seals every LTX file into the bucket under prefix, keyed as
+// genselect lists a generation (<prefix><level>/<min>-<max>.ltx, prefix
+// moves.GenerationPrefix).
+func sealedReplica(t *testing.T, b *leasetest.VersionedBucket, prefix string) ([]genselect.File, uint64) {
+ t.Helper()
+ ctx := t.Context()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "src.db")
+ write := func(n int) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
+ t.Fatal(err)
+ }
+ for range n {
+ if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, strings.Repeat("x", 100)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ write(1)
+ client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
+ db := litestream.NewDB(path)
+ db.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+ db.SetMetaPath(filepath.Join(dir, "meta"))
+ db.MonitorInterval = time.Hour
+ db.Replica = litestream.NewReplicaWithClient(db, client)
+ db.Replica.SyncInterval = time.Hour
+ if err := db.Open(); err != nil {
+ t.Fatal(err)
+ }
+ for range 2 {
+ if err := db.SyncAndWait(ctx); err != nil {
+ t.Fatal(err)
+ }
+ write(2)
+ }
+ if err := db.SyncAndWait(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Close(ctx); err != nil {
+ t.Fatal(err)
+ }
+ var out []genselect.File
+ var n uint64
+ for level := 0; level <= litestream.SnapshotLevel; level++ {
+ itr, err := client.LTXFiles(ctx, level, 0, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ infos, err := ltx.SliceFileIterator(itr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, info := range infos {
+ rc, err := client.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := io.ReadAll(rc)
+ rc.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := prefix + fmt.Sprintf("%04x/", level) + ltx.FormatFilename(info.MinTXID, info.MaxTXID)
+ v := b.Put("data", key, body)
+ out = append(out, genselect.File{Key: key, VersionID: v, Level: level, MinTXID: uint64(info.MinTXID), MaxTXID: uint64(info.MaxTXID), Size: int64(len(body))})
+ if level == 0 && uint64(info.MaxTXID) > n {
+ n = uint64(info.MaxTXID)
+ }
+ }
+ }
+ if n == 0 {
+ t.Fatal("the source replica has no level-0 files")
+ }
+ return out, n
+}
+
+func (f *buildFixture) build() error {
+ return f.a.relocationBuild(f.cfg)(f.t.Context(), f.obs)
+}
+
+func (f *buildFixture) prefix() string {
+ return moves.GenerationPrefix(f.obs.Claim.Holder.Location, "p1")
+}
+
+func (f *buildFixture) installManifestExists() bool {
+ _, ok := f.bucket.Current("data", moves.InstallManifestKey(f.prefix()))
+ return ok
+}
+
+// moveExec moves the op to another executor, as a replaced target sees it.
+func (f *buildFixture) moveExec() {
+ f.t.Helper()
+ o, err := hostclaim.Read(context.Background(), f.claims, buildHost)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error {
+ c.Op.Exec, c.Op.Intent["target_box"] = "other", "other"
+ c.Op.Participants = []string{"src", "other"}
+ return nil
+ }); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *buildFixture) retireStamp() {
+ f.t.Helper()
+ if err := f.a.Guard.RetireInstallation(context.Background(), buildHost, func(*hostclaim.Claim) error { return nil }); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+// Prepared is done only when P's files manifest and, for a non-empty pin,
+// its install manifest both verify; an unconfigured verifier reports
+// nothing built. Done carries what ToInstalled requires: P's manifest at
+// sequence 0 built from the pinned sequence, and the install manifest at
+// the pin's N.
+func TestPreparedFromInstallManifestNeedsBothManifests(t *testing.T) {
+ if _, done, err := (PreparedFromInstallManifest{}).Verify(t.Context(), hostclaim.Observed{Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}}}); done || err == nil {
+ t.Fatalf("an unconfigured verifier reported P built: %v", err)
+ }
+ f := newBuildFixture(t, true)
+ if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil {
+ t.Fatalf("before the build: done=%v %v", done, err)
+ }
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ a, done, err := f.verifier.Verify(t.Context(), f.obs)
+ if err != nil || !done {
+ t.Fatalf("after the build: done=%v %v", done, err)
+ }
+ var ref hostclaim.RecoveryPin
+ _ = json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref)
+ if a.Generation != "p1" || a.FilesSeq != 0 || a.SourceFilesSeq != 5 || len(a.FilesManifestSHA256) != 64 ||
+ a.InstallManifest != moves.InstallManifestKey(f.prefix()) || len(a.SHA256) != 64 || a.SnapshotTXID != ref.MaxTXID {
+ t.Fatalf("authority: %+v", a)
+ }
+ // An empty bootstrap needs only P's files manifest.
+ empty := newBuildFixture(t, false)
+ if err := empty.build(); err != nil {
+ t.Fatal(err)
+ }
+ if a, done, err := empty.verifier.Verify(t.Context(), empty.obs); err != nil || !done || a.InstallManifest != "" || a.SnapshotTXID != 0 {
+ t.Fatalf("empty bootstrap: %+v done=%v %v", a, done, err)
+ }
+ if empty.installManifestExists() {
+ t.Fatal("an empty pin built a snapshot")
+ }
+}
+
+// A crash after the files manifest and before the install manifest, and a
+// store holding the install manifest without P's files manifest, each
+// leave P unverified, never an error that would stop the worker.
+func TestPreparedRefusesAPartialBuild(t *testing.T) {
+ t.Run("files manifest only", func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ // The process dies after P's files manifest: the next authority
+ // check refuses, as a crash would stop it.
+ f.files.afterPut = func(string) { f.localErr = errors.New("crashed") }
+ if err := f.build(); err == nil {
+ t.Fatal("the stopped build succeeded")
+ }
+ if _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/")); err != nil {
+ t.Fatalf("P's files manifest was not written first: %v", err)
+ }
+ if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil {
+ t.Fatalf("done=%v err=%v", done, err)
+ }
+ })
+ t.Run("install manifest only", func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ v := f.verifier
+ v.Files = release.NewDirStore(t.TempDir()) // P's files manifest is not there
+ if _, done, err := v.Verify(t.Context(), f.obs); done || err != nil {
+ t.Fatalf("done=%v err=%v", done, err)
+ }
+ })
+ t.Run("a files manifest that is not the pinned entries", func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ bad := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ raw, _ := json.Marshal(bad)
+ if _, err := f.files.Store.Put(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
+ t.Fatalf("done=%v err=%v", done, err)
+ }
+ })
+}
+
+// A build whose authority moved under it stops before the next durable
+// write: after P's files manifest the executor moved; after the snapshot
+// PUT the executor moved, or the guard retired the stamp. No install
+// manifest is written for a box that no longer builds.
+func TestRelocationBuildStopsWhenAuthorityChanges(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ hook func(f *buildFixture)
+ after string
+ }{
+ {"after the files manifest", func(f *buildFixture) { f.moveExec() }, "files"},
+ {"after the snapshot PUT", func(f *buildFixture) { f.moveExec() }, "snapshot"},
+ {"after the stamp is retired", func(f *buildFixture) { f.retireStamp() }, "snapshot"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ fired := false
+ fire := func(string) {
+ if !fired {
+ fired = true
+ tc.hook(f)
+ }
+ }
+ if tc.after == "files" {
+ f.files.afterPut = fire
+ } else {
+ f.versions.afterPut = func(key string) {
+ if strings.HasPrefix(key, f.prefix()) && strings.HasSuffix(key, ".ltx") {
+ fire(key)
+ }
+ }
+ }
+ if err := f.build(); !errors.Is(err, hostrelocate.ErrRefused) || !fired {
+ t.Fatalf("the build went on after its authority changed: %v (fired %v)", err, fired)
+ }
+ if f.installManifestExists() {
+ t.Fatal("the install manifest was written for a box that no longer builds")
+ }
+ })
+ }
+}
+
+// Ruling 59: the replica lease, the claim and the guard all stay valid;
+// only main's Local refuses (the execution lease lost), and the next write
+// is refused with its error.
+func TestRelocationBuildStopsWhenOnlyTheExecutionLeaseIsLost(t *testing.T) {
+ f := newBuildFixture(t, true)
+ f.versions.afterPut = func(key string) {
+ if strings.HasSuffix(key, ".ltx") {
+ f.localErr = execlease.ErrLost
+ }
+ }
+ if err := f.build(); !errors.Is(err, execlease.ErrLost) {
+ t.Fatalf("the build went on without its execution lease: %v", err)
+ }
+ if f.installManifestExists() {
+ t.Fatal("the install manifest was written after the execution lease was lost")
+ }
+ if c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); err != nil || c.Stamp != f.obs.Claim.Holder.Stamp {
+ t.Fatalf("the guard moved: %+v %v", c, err)
+ }
+}
+
+// A replay from installing (a crash after the build, before ToInstalled)
+// is a success that changes nothing: the same files manifest bytes, the
+// same install manifest version.
+func TestRelocationBuildReplaysToTheSameBytes(t *testing.T) {
+ f := newBuildFixture(t, true)
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ key := moves.InstallManifestKey(f.prefix())
+ first := f.bucket.LatestID("data", key)
+ filesKey := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/p1/")
+ before, _, err := f.files.Get(t.Context(), filesKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.build(); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ after, _, err := f.files.Get(t.Context(), filesKey)
+ if err != nil || string(after) != string(before) || f.bucket.LatestID("data", key) != first {
+ t.Fatalf("the replay rewrote P: %v", err)
+ }
+ if _, done, err := f.verifier.Verify(t.Context(), f.obs); !done || err != nil {
+ t.Fatalf("after the replay: done=%v %v", done, err)
+ }
+}
+
+// Finding 2, ruling 43: the build activates the target's guard at
+// install_stamp from an empty box, under main's Local, before Capture;
+// never for another executor, never past a refusing Local, never at
+// another phase, never a retired stamp. The build's host-named scratch is
+// gone once P is built.
+func TestRelocationBuildActivatesTheGuardFromAnEmptyTarget(t *testing.T) {
+ t.Run("from an empty box", func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost)
+ if err != nil || c.Incarnation != f.obs.Claim.Holder.Incarnation || c.Stamp != f.obs.Claim.Holder.Stamp {
+ t.Fatalf("capture %+v %v", c, err)
+ }
+ if len(f.locals) == 0 || f.locals[0].Action != relocationActionInstall || f.locals[0].Captured.Stamp != f.obs.Claim.Holder.Stamp || f.locals[0].Box != "dst" {
+ t.Fatalf("main's Local was not asked for the install: %+v", f.locals)
+ }
+ if _, err := os.Stat(filepath.Join(f.cfg.WorkDir, "rel-1")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the build's scratch survived: %v", err)
+ }
+ })
+ for _, tc := range []struct {
+ name string
+ change func(f *buildFixture)
+ want error
+ }{
+ {"another executor", func(f *buildFixture) {
+ f.moveExec()
+ f.obs, _ = hostclaim.Read(context.Background(), f.claims, buildHost)
+ }, hostrelocate.ErrRefused},
+ {"main's Local refuses", func(f *buildFixture) { f.localErr = execlease.ErrLost }, execlease.ErrLost},
+ {"installed, not installing", func(f *buildFixture) {
+ o, _ := hostclaim.Read(context.Background(), f.claims, buildHost)
+ f.obs, _ = hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Phase = hostclaim.PhaseInstalled; return nil })
+ }, hostrelocate.ErrRefused},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ tc.change(f)
+ before := f.bucket.Snapshot()
+ if err := f.build(); !errors.Is(err, tc.want) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ if entries, _ := os.ReadDir(filepath.Join(f.a.Guard.DataDir, "hostguard")); len(entries) != 0 {
+ t.Fatalf("the guard was written: %v", entries)
+ }
+ if f.bucket.Snapshot() != before {
+ t.Fatal("the bucket was written")
+ }
+ })
+ }
+ t.Run("a retired stamp", func(t *testing.T) {
+ f := newBuildFixture(t, true)
+ if _, err := f.a.relocationActivate(f.cfg)(t.Context(), f.obs); err != nil {
+ t.Fatal(err)
+ }
+ f.retireStamp()
+ before := f.bucket.Snapshot()
+ if err := f.build(); !errors.Is(err, hostguard.ErrRetiredStamp) {
+ t.Fatalf("a retired stamp was reactivated: %v", err)
+ }
+ if f.bucket.Snapshot() != before {
+ t.Fatal("the bucket was written")
+ }
+ })
+}
+
+// A pin whose files digest names no retained copy is refused, never read
+// as a host that published no files (absence is never evidence). The
+// pinned manifest here is itself empty, so only that check tells the two
+// apart.
+func TestPreparedRefusesAFilesDigestWithoutItsRetainedCopy(t *testing.T) {
+ ctx := t.Context()
+ f := newBuildFixtureWith(t, buildOptions{})
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ if _, done, err := f.verifier.Verify(ctx, f.obs); !done || err != nil {
+ t.Fatalf("the built empty pin: done=%v %v", done, err)
+ }
+ var ref hostclaim.RecoveryPin
+ if err := json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref); err != nil {
+ t.Fatal(err)
+ }
+ pin, err := hostrelocate.ReadPin(ctx, f.claims, ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pin.Files.SHA256 == "" || pin.Files.Retained == "" {
+ t.Fatalf("the fixture's pin has no retained manifest: %+v", pin.Files)
+ }
+ pin.Files.Retained = ""
+ raw, err := json.Marshal(pin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref.Key += ".forged"
+ if _, err := f.claims.Put(ctx, ref.Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(raw)
+ ref.SHA256 = hex.EncodeToString(sum[:])
+ refRaw, err := json.Marshal(ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := *f.obs.Claim
+ c.Op.Journal = map[string]string{}
+ for k, v := range f.obs.Claim.Op.Journal {
+ c.Op.Journal[k] = v
+ }
+ c.Op.Journal["pin"] = string(refRaw)
+ obs := f.obs
+ obs.Claim = &c
+ if _, done, err := f.verifier.Verify(ctx, obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
+ t.Fatalf("done=%v err=%v", done, err)
+ }
+}
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
new file mode 100644
index 000000000..74206d16d
--- /dev/null
+++ b/internal/host/relocationinstall.go
@@ -0,0 +1,154 @@
+package host
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostrelocate"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// relocationActionInstall is the install authority's action for the
+// target's guard activation, its build writes and its local install
+// (ruling 46). The claim's Policy admits no action for relocating (R1);
+// main's Local is asked for this one until claim ask C9 moves the install
+// into the claim.
+const relocationActionInstall = "install"
+
+func relocationInstallStamp(c *hostclaim.Claim) (uint64, error) {
+ stamp, err := strconv.ParseUint(c.Op.Journal["install_stamp"], 10, 64)
+ if err != nil || stamp == 0 {
+ return 0, fmt.Errorf("%w: no install stamp", hostrelocate.ErrRefused)
+ }
+ return stamp, nil
+}
+
+// relocationInstallAuthority is the install's claim check (rulings 43, 46,
+// 52): this box executes this relocating op at one of phases, P unopened,
+// not stuck, not lost, and the holder's stamp is the journaled install
+// stamp.
+func relocationInstallAuthority(c *hostclaim.Claim, op, box string, phases ...string) error {
+ if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.ID != op || c.Op.Exec != box || c.Op.Journal["stuck"] != "" ||
+ slices.Contains(c.Lost, box) || !slices.Contains(phases, c.Op.Phase) || hostclaim.PreparedOpened(c) {
+ return fmt.Errorf("%w: the install's authority changed", hostrelocate.ErrRefused)
+ }
+ stamp, err := relocationInstallStamp(c)
+ if err != nil {
+ return err
+ }
+ if c.Holder.Stamp != stamp {
+ return fmt.Errorf("%w: the holder's stamp is not the install stamp", hostrelocate.ErrRefused)
+ }
+ return nil
+}
+
+// relocationInstallLocal is main's Local check (identity, quarantine, the
+// participation slot's binding, deadline and loss latch) for an install
+// action on c at captured: the one Selection both the guard activation
+// and every build write ask with.
+func relocationInstallLocal(ctx context.Context, src hostbox.WorkSource, c *hostclaim.Claim, captured hostguard.Captured) error {
+ sel := hostbox.Selection{Host: c.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)
+}
+
+// relocationActivationReader validates every fresh read the guard makes
+// while it activates the target's installation (ruling 43), as reconcile's
+// activationReader does for its own.
+type relocationActivationReader struct {
+ claims release.Store
+ check func(context.Context, *hostclaim.Claim) error
+}
+
+func (r relocationActivationReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) {
+ obs, err := hostclaim.Read(ctx, r.claims, host)
+ if err != nil {
+ return obs, err
+ }
+ if obs.Absent || obs.Claim == nil {
+ return hostclaim.Observed{}, hostbox.ErrChanged
+ }
+ if err := r.check(ctx, obs.Claim); err != nil {
+ return hostclaim.Observed{}, err
+ }
+ return obs, nil
+}
+
+// relocationActivate is ruling 43: the first act of installing, before the
+// remote build reads hostguard.Capture. It activates this box's guard
+// record at install_stamp (Activate takes holder.stamp, which the
+// installing CAS set to install_stamp), creating the record on an empty
+// target, under a reader that refuses any read that is not this box's
+// installing op at that stamp or that main's Local check refuses.
+// 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 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
+ 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 {
+ return err
+ }
+ return relocationInstallLocal(ctx, src, c, hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp})
+ }}
+ if err := guard.Activate(ctx, o.Host, src.Box, nil); err != nil {
+ return hostguard.Captured{}, err
+ }
+ captured, err := hostguard.Capture(a.Guard.DataDir, o.Host)
+ if err != nil {
+ return hostguard.Captured{}, err
+ }
+ stamp, err := relocationInstallStamp(o.Claim)
+ if err != nil {
+ return hostguard.Captured{}, err
+ }
+ if captured.Stamp != stamp || captured.Incarnation != o.Claim.Holder.Incarnation {
+ return hostguard.Captured{}, hostguard.ErrStampMismatch
+ }
+ return captured, nil
+ }
+}
+
+// relocationBuildAuthority is the remote build's check before every
+// durable write (ruling 59): the claim still runs this box's installing op
+// at the install stamp with the same P and pin, the guard still captures
+// that installation, and main's Local still admits it. Local is the
+// execution lease's own check, so an execution lease lost while the
+// replica lease, claim and guard are unchanged stops the next write.
+func (a ClaimAssembly) relocationBuildAuthority(cfg RelocationAssemblyConfig, o hostclaim.Observed, captured hostguard.Captured) func(context.Context) error {
+ return func(ctx context.Context) error {
+ src := cfg.Source
+ if src.Local == nil || src.Box == "" || o.Claim == nil {
+ return ErrClaimStopUnavailable
+ }
+ fresh, err := hostclaim.Read(ctx, a.Claims, o.Host)
+ if err != nil {
+ return err
+ }
+ c := fresh.Claim
+ if err := relocationInstallAuthority(c, o.Claim.Op.ID, src.Box, hostclaim.PhaseInstalling); err != nil {
+ return err
+ }
+ if c.Op.Journal["prepared"] != o.Claim.Op.Journal["prepared"] || c.Op.Journal["pin"] != o.Claim.Op.Journal["pin"] {
+ return fmt.Errorf("%w: the build's P or pin changed", hostrelocate.ErrRefused)
+ }
+ now, err := hostguard.Capture(a.Guard.DataDir, o.Host)
+ if err != nil {
+ return err
+ }
+ if now != captured || captured.Stamp != c.Holder.Stamp {
+ return fmt.Errorf("%w: this box's installation is not the one the installing CAS issued (%w)", hostrelocate.ErrRefused, hostguard.ErrStampMismatch)
+ }
+ return relocationInstallLocal(ctx, src, c, captured)
+ }
+}
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go
index dc8e635fb..4ca47d4a0 100644
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/release"
)
@@ -37,6 +38,18 @@ type PinObject struct {
// Release is the app release version the relocation installs, read
// at pinning from StorePinner.Release (R9); a reuse carries it.
Release string `json:"release"`
+ // InstanceKey is the key a nonempty database is sealed with when the
+ // pinned manifest evidences it by an adoption record rather than by
+ // instance.key (O2', instance-key plan rev 3): its digest and source.
+ // The install delivers exactly this key or refuses (O3').
+ InstanceKey *KeyPin `json:"instance_key,omitempty"`
+}
+
+// KeyPin is a pinned instance key: the SHA-256 of the key and the file
+// that supplies it at launch (instancekey.SourcePlatform or SourceConfig).
+type KeyPin struct {
+ Source string `json:"source"`
+ SHA256 string `json:"sha256"`
}
// FilesPin names one generation's files manifest by prefix, sequence and
@@ -142,11 +155,15 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T
// 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)
+ // The writer whose data is pinned: the session admitted as the pinned
+ // database's generation (O2', instance-key plan ruling 20). Asked only
+ // when instance.key is missing.
+ writer := func() (instancekey.Binding, error) { return pinnedWriter(c, obj.DB.Generation) }
+ files, keyPin, retained, err := p.filesPin(ctx, writer, c.Holder, released && rel.ManifestSeq == 0, needKey)
if err != nil {
return hostclaim.RecoveryPin{}, err
}
- obj.Files = files
+ obj.Files, obj.InstanceKey = files, keyPin
if released && obj.Files.Seq < rel.ManifestSeq {
return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the files manifest is at %d, the release at %d", ErrNotYet, obj.Files.Seq, rel.ManifestSeq)
}
@@ -221,7 +238,7 @@ var ErrFilesUnevidenced = errors.New("hostrelocate: the files evidence does not
// 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, writer func() (instancekey.Binding, error), h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, *KeyPin, []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)
@@ -241,24 +258,33 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, released
continue
}
if err != nil {
- return FilesPin{}, nil, err
+ return FilesPin{}, nil, nil, err
}
var m hostfiles.Manifest // decoded from the bytes the digest names
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)
- }
- if needKey && !liveEntry(m, InstanceKeyPath) {
- // The manifest was copied before the app started and the
- // key it minted afterwards never published (§5.6's
- // continuous-sync window, L21): installing this set would mint
- // a new key over data sealed with the old one.
- return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath)
+ return FilesPin{}, nil, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err)
}
if origins, err = referencedOrigins(h.Location, origins, m); err != nil {
- return FilesPin{}, nil, err
+ return FilesPin{}, nil, nil, err
+ }
+ var keyPin *KeyPin
+ if needKey && !liveEntry(m, InstanceKeyPath) {
+ // (a) failed: no instance.key. (b'): an adoption record bound
+ // to the pinned writer, for the key the target will deliver
+ // (O2', instance-key plan rev 3). Without it the manifest may
+ // have been copied before the app minted a key of its own
+ // (§5.6's continuous-sync window), and installing this set
+ // would mint a new key over data sealed with the old one.
+ w, err := writer()
+ if err != nil {
+ return FilesPin{}, nil, nil, err
+ }
+ if keyPin, err = p.adoptedKey(ctx, w, prefix, m, origins); err != nil {
+ return FilesPin{}, nil, nil, err
+ }
}
sum := sha256.Sum256(raw)
- return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil
+ return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, keyPin, raw, nil
}
// No manifest anywhere in the chain. An empty files pin needs the
// same kind of evidence an empty database pin does (ruling 15): no
@@ -272,14 +298,14 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, released
// 17, finding F2), never an install that mints a fresh key over data
// sealed with the old one.
if h.Session != 0 && !releasedEmpty {
- return FilesPin{}, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location)
+ return FilesPin{}, nil, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location)
}
gen := h.Generation
if gen == "" {
gen = hostclaim.RootGeneration
}
prefix := h.Location + "/files/" + gen + "/"
- return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil
+ return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil, nil
}
// referencedOrigins adds to origins the files prefix of every generation
@@ -340,6 +366,12 @@ func ReadPin(ctx context.Context, claims release.Store, ref hostclaim.RecoveryPi
if obj.Release == "" || obj.Release != ref.Release {
return PinObject{}, fmt.Errorf("%w: pin object %s names release %q, the journal %q", ErrRefused, ref.Key, obj.Release, ref.Release)
}
+ // A pin without a database is an empty bootstrap only by its named
+ // evidence, and the journal's copy must agree: a missing plan is never
+ // read as an empty database (absence is never evidence).
+ if (obj.DB == nil) == (obj.Empty == "") || ref.Empty != (obj.DB == nil) {
+ return PinObject{}, fmt.Errorf("%w: pin object %s does not say whether its database is empty", ErrRefused, ref.Key)
+ }
return obj, nil
}
@@ -395,3 +427,75 @@ func CopyPinnedManifest(ctx context.Context, claims, data release.Store, pin Fil
}
return pinned, nil
}
+
+// pinnedWriter is the writer whose data a pin of generation gen holds: the
+// holder's own session when gen is the holder's generation, else the
+// admitted session recorded for gen. A generation no admission names has
+// no writer to bind evidence to, and refuses.
+func pinnedWriter(c *hostclaim.Claim, gen string) (instancekey.Binding, error) {
+ h := c.Holder
+ w := instancekey.Binding{Host: c.Host, Incarnation: h.Incarnation, Generation: gen}
+ if gen == h.Generation {
+ w.Session = h.Session
+ }
+ for i := len(h.Admissions) - 1; w.Session == 0 && i >= 0; i-- {
+ if h.Admissions[i].Generation == gen {
+ w.Session = h.Admissions[i].Session
+ }
+ }
+ if w.Session == 0 || w.Incarnation == "" || gen == "" {
+ return instancekey.Binding{}, fmt.Errorf("%w: no admitted writer for generation %q", ErrFilesUnevidenced, gen)
+ }
+ return w, nil
+}
+
+// adoptedKey is O2' source (b') (instance-key plan rev 3, ruling 20): the
+// pinned manifest's adoption record must bind exactly the pinned writer,
+// and name the key the target will deliver. A platform-sourced record must
+// match the env item's own key, which the install restores byte for byte.
+// A config-sourced record needs an env item with no own key (one would
+// override the configuration at launch); its digest is pinned, and the
+// install verifies the configuration it renders still delivers it (O3').
+// The env item is parsed with instancekey.ParseEnv, the activator's own
+// rule. A named item that cannot be read is an error, never "no evidence".
+func (p StorePinner) adoptedKey(ctx context.Context, writer instancekey.Binding, prefix string, m hostfiles.Manifest, origins []string) (*KeyPin, error) {
+ read := func(name string) ([]byte, bool, error) {
+ if e, ok := m.Platform[name]; !ok || e.Deleted {
+ return nil, false, nil
+ }
+ raw, err := hostfiles.ReadPlatform(ctx, p.Data, prefix, m, name, origins)
+ return raw, err == nil, err
+ }
+ rec, ok, err := read(instancekey.AdoptionItem)
+ if err != nil {
+ return nil, err
+ }
+ refuse := func(why string) (*KeyPin, error) {
+ return nil, fmt.Errorf("%w: %s's pinned manifest has no %s and %s, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, why)
+ }
+ if !ok {
+ return refuse("no adoption record")
+ }
+ a, err := instancekey.ParseAdoption(rec)
+ if err != nil {
+ return refuse("its adoption record is malformed")
+ }
+ if !a.Binds(writer) {
+ return refuse("its adoption record is not the pinned writer's")
+ }
+ env, haveEnv, err := read(hostfiles.EnvItem)
+ if err != nil {
+ return nil, err
+ }
+ own, _ := instancekey.Effective(nil, instancekey.ParseEnv(env))
+ switch {
+ case a.Source == instancekey.SourcePlatform && own != "" && instancekey.Digest(own) == a.SHA256:
+ // A configured key needs the env item that shows the own file supplies
+ // none: a manifest without host.env is not evidence that nothing
+ // overrides the configuration (absence is never evidence).
+ case a.Source == instancekey.SourceConfig && haveEnv && own == "":
+ default:
+ return refuse("its adoption record does not name the key the env delivers")
+ }
+ return &KeyPin{Source: a.Source, SHA256: a.SHA256}, nil
+}
diff --git a/internal/hostrelocate/pin_envkey_test.go b/internal/hostrelocate/pin_envkey_test.go
new file mode 100644
index 000000000..bca47d25f
--- /dev/null
+++ b/internal/hostrelocate/pin_envkey_test.go
@@ -0,0 +1,173 @@
+package hostrelocate
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// O2' (instance-key plan rev 3, replacing stage B's O2 and ruling 55's
+// KeyEvidence reader): a nonempty database's pinned manifest evidences its
+// key by (a) instance.key, or (b') an adoption record bound to the pinned
+// writer for the key the target will deliver. Presence of a key, in the env
+// or in the configuration, is never evidence (Astra rounds 1 and 2).
+func TestStorePinnerKeyEvidence(t *testing.T) {
+ ctx := context.Background()
+ at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
+ const key = "q+/Zk3vJ0aW1Yx9sR7tUe2oPl4mN8bC6dF5gH0iJ1kA="
+ writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"}
+ earlier := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 2, Generation: "g2"}
+ envWithKey := "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=" + key + "\n"
+ rec := func(b instancekey.Binding, source, k string) string {
+ return string(instancekey.AdoptionRecord(b, source, instancekey.Digest(k)))
+ }
+ for _, tc := range []struct {
+ name string
+ appKey bool // (a) instance.key in the manifest
+ env, adoption string // "" is no item
+ unreadable string // a named item whose object is missing
+ holder func(*hostclaim.Holder)
+ want error
+ pinned *KeyPin
+ }{
+ {name: "(a) the app's own instance.key", appKey: true, env: envWithKey, want: nil},
+ {name: "(b') the platform key, adopted by the pinned writer", env: envWithKey, adoption: rec(writer, "platform", key), pinned: &KeyPin{Source: "platform", SHA256: instancekey.Digest(key)}},
+ {name: "(b') a configured key, adopted by the pinned writer", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}},
+ {name: "(b') a configured key under an empty override", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}},
+ {name: "a backed-up platform key, the app's own key missing", env: envWithKey, want: ErrFilesUnevidenced},
+ {name: "configured A, the app's own unpublished B: no report, no record", env: "CARLOS_ADMIN_TOKEN=t\n", want: ErrFilesUnevidenced},
+ {name: "a stale record from a previous release", env: envWithKey, adoption: rec(earlier, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a rollback: a new session, the old session's record", env: envWithKey, adoption: rec(writer, "platform", key),
+ holder: func(h *hostclaim.Holder) {
+ h.Session, h.Generation = 4, "g4"
+ h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: "g4", Predecessor: "g3", Session: 4})
+ }, want: ErrFilesUnevidenced},
+ {name: "a record from another installation", env: envWithKey, adoption: rec(instancekey.Binding{Host: host, Incarnation: "inc-0", Session: 3, Generation: "g3"}, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a platform record for another key", env: envWithKey, adoption: rec(writer, "platform", "another-key"), want: ErrFilesUnevidenced},
+ {name: "a config record while the env's own key overrides it", env: envWithKey, adoption: rec(writer, "config", key), want: ErrFilesUnevidenced},
+ {name: "a platform record without the platform key", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced},
+ {name: "an exported key line is not what the app receives", env: "export CARLOS_INSTANCE_KEY=" + key + "\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a malformed record", env: envWithKey, adoption: `{"v":1}`, want: ErrFilesUnevidenced},
+ {name: "no admitted writer for the pinned generation", env: envWithKey, adoption: rec(writer, "platform", key), holder: func(h *hostclaim.Holder) { h.Incarnation = "" }, want: ErrFilesUnevidenced},
+ {name: "a named adoption item that cannot be read", env: envWithKey, unreadable: instancekey.AdoptionItem, want: errUnreadable},
+ {name: "a named env item that cannot be read", adoption: rec(writer, "config", "configured-A"), unreadable: hostfiles.EnvItem, want: errUnreadable},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ obs := pinClaim()
+ obs.Claim.Holder.Incarnation = "inc-1"
+ gen := "g3"
+ if tc.holder != nil {
+ tc.holder(obs.Claim.Holder)
+ gen = obs.Claim.Holder.Generation
+ }
+ m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ if tc.appKey {
+ m = keyed(m)
+ }
+ item := func(name, body string, put bool) {
+ sum := sha256.Sum256([]byte(body))
+ sha := hex.EncodeToString(sum[:])
+ obj := pinLocation + "/files/" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
+ if put {
+ if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ m.Platform[name] = hostfiles.Entry{Object: obj, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4}
+ }
+ if tc.env != "" {
+ item(hostfiles.EnvItem, tc.env, true)
+ }
+ if tc.adoption != "" {
+ item(instancekey.AdoptionItem, tc.adoption, true)
+ }
+ if tc.unreadable != "" {
+ item(tc.unreadable, "never stored\n", false)
+ }
+ putManifest(t, data, gen, m)
+ p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
+ ref, err := p.Pin(ctx, obs, at)
+ switch {
+ case tc.want == errUnreadable:
+ if err == nil || errors.Is(err, ErrFilesUnevidenced) {
+ t.Fatalf("got %v, want a read error, never a refusal for no evidence", err)
+ }
+ return
+ case !errors.Is(err, tc.want):
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ if err != nil {
+ return
+ }
+ raw, _, err := claims.Get(ctx, ref.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var obj PinObject
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ t.Fatal(err)
+ }
+ if (obj.InstanceKey == nil) != (tc.pinned == nil) || (tc.pinned != nil && *obj.InstanceKey != *tc.pinned) {
+ t.Fatalf("pinned key %+v, want %+v", obj.InstanceKey, tc.pinned)
+ }
+ })
+ }
+}
+
+var errUnreadable = errors.New("sentinel: any read error")
+
+// A configured key's record vouches only beside the env item that shows
+// the instance's own file supplies no key. A manifest without host.env is
+// not evidence that nothing overrides the configuration at launch (absence
+// is never evidence): the pin refuses, and the same record beside an env
+// item with no key of its own pins.
+func TestStorePinnerConfigRecordNeedsTheEnvItem(t *testing.T) {
+ ctx := context.Background()
+ at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
+ writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"}
+ record := string(instancekey.AdoptionRecord(writer, instancekey.SourceConfig, instancekey.Digest("configured-A")))
+ for _, tc := range []struct {
+ name string
+ env bool
+ want error
+ }{
+ {"no env item", false, ErrFilesUnevidenced},
+ {"an env item with no key of its own", true, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ obs := pinClaim()
+ obs.Claim.Holder.Incarnation = "inc-1"
+ m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ item := func(name, body string) {
+ sum := sha256.Sum256([]byte(body))
+ sha := hex.EncodeToString(sum[:])
+ obj := pinLocation + "/files/g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
+ if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ m.Platform[name] = hostfiles.Entry{Object: obj, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4}
+ }
+ item(instancekey.AdoptionItem, record)
+ if tc.env {
+ item(hostfiles.EnvItem, "CARLOS_ADMIN_TOKEN=t\n")
+ }
+ putManifest(t, data, "g3", m)
+ p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
+ if _, err := p.Pin(ctx, obs, at); !errors.Is(err, tc.want) && (tc.want != nil || err != nil) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go
index eabb20cdf..c75168102 100644
--- a/internal/hostrelocate/pin_test.go
+++ b/internal/hostrelocate/pin_test.go
@@ -624,3 +624,49 @@ func TestStorePinnerPinsTheRouteRelease(t *testing.T) {
t.Fatalf("a pin object with no release was reused: %v", err)
}
}
+
+// Absence is never evidence: a pin with no database is an empty bootstrap
+// only when the object names its evidence and the journal agrees. An
+// object with neither a database nor evidence, or one the journal
+// disagrees with about emptiness, is refused, never read as an empty
+// database.
+func TestReadPinNeedsTheEmptyEvidence(t *testing.T) {
+ ctx := context.Background()
+ plan := &genselect.Plan{Generation: "g3", MaxTXID: 41}
+ for _, tc := range []struct {
+ name string
+ obj PinObject
+ empty bool // the journal's copy
+ ok bool
+ }{
+ {"an evidenced empty bootstrap", PinObject{Empty: "session-0"}, true, true},
+ {"a database", PinObject{DB: plan}, false, true},
+ {"no database and no evidence", PinObject{}, true, false},
+ {"a database and empty evidence", PinObject{DB: plan, Empty: "release"}, false, false},
+ {"an empty pin the journal calls a database", PinObject{Empty: "session-0"}, false, false},
+ {"a database the journal calls empty", PinObject{DB: plan}, true, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ claims := release.NewDirStore(t.TempDir())
+ obj := tc.obj
+ obj.V, obj.Host, obj.Op, obj.ClaimGen, obj.Release = 1, host, "rel-1", 12, "v1"
+ raw, err := json.Marshal(obj)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := PinKey(host, "rel-1", 12)
+ if _, err := claims.Put(ctx, key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ ref := pinRef(key, raw, obj)
+ ref.Empty = tc.empty
+ _, err = ReadPin(ctx, claims, ref)
+ if tc.ok && err != nil {
+ t.Fatalf("an evidenced pin was refused: %v", err)
+ }
+ if !tc.ok && !errors.Is(err, ErrRefused) {
+ t.Fatalf("got %v, want ErrRefused", err)
+ }
+ })
+ }
+}