carlos / platform Public

host: a relocation mints its route with the app's update policy, as main's mint does

Main's settings work (merged in c429c6d5) gave RouteFor the app's update
policy and made the ordinary mint wait when the policy cannot be read,
so an instance never lands as auto while its app's default is manual.
relocationRecord minted the relocated row without it. It now reads the
policy the way Reconcile's policyFor does: an absent manifest is no
policy, any other read failure refuses. The census entry for
relocationRecord is re-reviewed: one more Get, still no write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 2f0439fdbe490b9a99b13f159c9ee4f309b5b691 parent c429c6d
3 files changed, +100 −3
  • internal/host/relocationinstall.go +24 −2
  • internal/host/relocationrecord_policy_test.go +74 −0
  • internal/provision/plan9_b8_census_test.go +2 −1
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index 15c2b52e5..b4d950617 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -205,7 +205,11 @@ func relocationInstallPath(dataDir, op string) (string, error) {
// relocationRecord reads the host's authoritative record and the route
// this box mints for it (ruling 45): always the nested layout, which the
// isolated launch requires, so only a hibernating, path-deriving instance
-// record relocates.
+// record relocates. The route carries the app's update policy as the
+// ordinary mint's does (settings spec §3.2, provision.Reconcile): an
+// absent manifest is no policy, and a manifest that cannot be read
+// refuses, so a relocated instance never lands as auto when its app's
+// default is manual.
func relocationRecord(ctx context.Context, dataDir string, claims release.Store, o hostclaim.Observed) (provision.ClaimRecord, error) {
if o.Claim == nil || o.Claim.Holder == nil {
return provision.ClaimRecord{}, hostbox.ErrChanged
@@ -221,13 +225,31 @@ func relocationRecord(ctx context.Context, dataDir string, claims release.Store,
if record.Host != o.Host || !record.DerivesPaths() || !record.HibernateOrDefault() || record.Unit != "" {
return provision.ClaimRecord{}, fmt.Errorf("%w: %s is not a relocatable hibernating instance record", hostrelocate.ErrRefused, o.Host)
}
- rt := provision.RouteFor(record, dataDir, "", true, nil)
+ policy, err := relocationUpdatePolicy(ctx, claims, record)
+ if err != nil {
+ return provision.ClaimRecord{}, err
+ }
+ rt := provision.RouteFor(record, dataDir, "", true, policy)
if rt.DBPath != provision.NestedDBPath(dataDir, o.Host) || rt.Socket != provision.NestedSocket(o.Host) {
return provision.ClaimRecord{}, fmt.Errorf("%w: %s does not mint nested", hostrelocate.ErrRefused, o.Host)
}
return provision.ClaimRecord{Host: o.Host, Key: provision.RecordKey(record.Account, record.App, record.Host), Family: "instance", Account: record.Account, App: record.App, CreatedAt: record.CreatedAt, Value: record, Route: rt}, nil
}
+// relocationUpdatePolicy is the app's update policy for record, read the
+// way provision.Reconcile's policyFor reads it: an absent manifest is
+// known to have none; any other read failure is an error, never "none".
+func relocationUpdatePolicy(ctx context.Context, store release.Store, record provision.Record) (*provision.UpdatePolicy, error) {
+ m, err := provision.GetManifest(ctx, store, record.Account, record.App)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ return nil, nil
+ case err != nil:
+ return nil, fmt.Errorf("the update policy of %s/%s could not be read: %w", record.Account, record.App, err)
+ }
+ return m.Updates, nil
+}
+
// relocationPin reads the pin the claim's op journals, through ReadPin.
func relocationPin(ctx context.Context, claims release.Store, c *hostclaim.Claim) (hostrelocate.PinObject, error) {
var ref hostclaim.RecoveryPin
diff --git a/internal/host/relocationrecord_policy_test.go b/internal/host/relocationrecord_policy_test.go
new file mode 100644
index 000000000..9dce22163
--- /dev/null
+++ b/internal/host/relocationrecord_policy_test.go
@@ -0,0 +1,74 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestRelocationRecordCarriesTheAppUpdatePolicy: the route a relocation
+// mints carries the app's update policy as the ordinary mint's does
+// (settings spec §3.2). With no manifest the instance keeps today's
+// default; with a manual default it lands manual; and a manifest that
+// cannot be read refuses rather than minting the instance as auto.
+func TestRelocationRecordCarriesTheAppUpdatePolicy(t *testing.T) {
+ ctx := context.Background()
+ acct, app, host := domains.Sqid(12), "shop", "shop.example.com"
+ rec := provision.Record{Host: host, App: app, Account: acct, Channel: "stable"}
+ key := provision.RecordKey(acct, app, host)
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Key: key}}}
+ store := func(t *testing.T, manifest *provision.Manifest) release.Store {
+ s := release.NewDirStore(t.TempDir())
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if manifest != nil {
+ if err := provision.PutManifest(ctx, s, acct, app, *manifest); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return s
+ }
+ dataDir := t.TempDir()
+
+ t.Run("no manifest", func(t *testing.T) {
+ got, err := relocationRecord(ctx, dataDir, store(t, nil), obs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Route.UpdateSettings != "" {
+ t.Fatalf("update settings %q, want today's default", got.Route.UpdateSettings)
+ }
+ })
+ t.Run("manual default", func(t *testing.T) {
+ got, err := relocationRecord(ctx, dataDir, store(t, &provision.Manifest{Updates: &provision.UpdatePolicy{DefaultUpdates: release.UpdatesManual}}), obs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := provision.EffectiveUpdates(rec, &provision.UpdatePolicy{DefaultUpdates: release.UpdatesManual}).Column()
+ if want == "" || got.Route.UpdateSettings != want {
+ t.Fatalf("update settings %q, want %q", got.Route.UpdateSettings, want)
+ }
+ if !strings.Contains(got.Route.UpdateSettings, release.UpdatesManual) {
+ t.Fatalf("update settings %q do not say manual", got.Route.UpdateSettings)
+ }
+ })
+ t.Run("manifest unreadable", func(t *testing.T) {
+ boom := errors.New("bucket down")
+ s := failingGetStore{Store: store(t, &provision.Manifest{}), bad: provision.ManifestKey(acct, app), err: boom}
+ if _, err := relocationRecord(ctx, dataDir, s, obs); !errors.Is(err, boom) {
+ t.Fatalf("err = %v, want the manifest read's failure", err)
+ }
+ })
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 4d6f93e7a..86d338d20 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -718,7 +718,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/host/relocationbuild.go", "authorityStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "43094463f918b134d6260d5f8db154e83edd07a738e89f020a2df563334b10cd"},
{"internal/host/relocationbuild.go", "authorityStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "b72a077c23b92c84d67a1f4563299014ccfc9046c3137f0d8ca707ac3991099f"},
// Reads the relocating host's record at its key and mints the nested route from it (RouteFor, ruling 45); no write.
- {"internal/host/relocationinstall.go", "relocationRecord", "key-site", "key:RecordKey", "dcc49823752c8e520311f7fd899239850980f222fd3ee255b847de273a1ad072"},
+ // Re-reviewed 2026-10-12: it also reads the app manifest's update policy for RouteFor (relocationUpdatePolicy, a Get); still no write.
+ {"internal/host/relocationinstall.go", "relocationRecord", "key-site", "key:RecordKey", "4271b1a1b0500da0cf1d4c52de1e6f26e7ac9c9bda85a64ff735ab584e9c5ee3"},
{"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"},
{"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"},
{"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},