carlos / platform Public

review fix S2: claim activation refuses over unfinished seed state

Activating claims while an import was unfinished stranded it: the claim
console refuses commit, abort and finish, and a claim-armed box arms no
rollback, so a swapped import stayed gated with no way out.

(a) Activation refuses. Backfill.Publish, under the collecting manifest
(the record-writer freeze), now refuses the census while:
- any record carries an unfinished seed (reserving, awaiting-upload,
  uploaded, an import at ready, swapped or placed, aborted, rolled-back:
  provision.SeedSource.Unfinished);
- any box journals an import or gates a route. Each box says so in its
  signed census report (CensusReport.Seeds, read by CollectCensusSeeds
  from its registry; ImportJournalHosts answers through root's registry
  child too);
- any box's report carries no seed state. That fails closed.
Unfinished state is ErrPending and missing evidence is ErrIdentity, each
wrapped with a DRAFT sentence (SeedActivationError). Copy review is owed
for the four sentences in internal/hostops/backfill_seeds.go.

(b) An armed box only protects. With claim control on, the journal pass
keeps every journaled host handled and out of the removal phase and does
nothing else: it no longer redoes the swap's record CAS or clears a
committed import's gate there, and starts no rollback.

Also corrects the record-write census's note on the placed recorder, which
is assigned whatever the claim flag says, and re-reviews the two units
this touches.

Tests: activation refused for each unfinished record state, for swapped,
placed, rolling-back and cleanup-pending imports read from a real box
registry, and when a box's evidence is missing; published when nothing is
unfinished; the armed-box journal pass leaves the row, the journal, the
record and the box untouched in ten states, and fails with the guard
removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6a77665bef1662a510fadfe3e476762a502a6081 parent be5bd11
12 files changed, +751 −8
  • internal/hostops/backfill.go +4 −0
  • internal/hostops/backfill_publish.go +6 −0
  • internal/hostops/backfill_seeds.go +154 −0
  • internal/hostops/backfill_seeds_test.go +341 −0
  • internal/provision/plan9_b8_census_test.go +11 −5
  • internal/provision/seed.go +24 −0
  • internal/provision/seed_test.go +31 −0
  • internal/provision/seedjournal_test.go +93 −0
  • internal/provision/seedreconcile.go +20 −3
  • internal/registry/importjournal.go +30 −0
  • internal/registry/importjournal_test.go +34 −0
  • internal/registry/remote_dispatch.go +3 −0
diff --git a/internal/hostops/backfill.go b/internal/hostops/backfill.go
index 0c30ff0d3..9f13f21c2 100644
--- a/internal/hostops/backfill.go
+++ b/internal/hostops/backfill.go
@@ -43,6 +43,10 @@ type CensusReport struct {
CapturedAt time.Time `json:"captured_at"`
Complete bool `json:"complete"`
Installations []CensusInstallation `json:"installations"`
+ // Seeds is the box's own seed state (backfill_seeds.go). Signed with
+ // the rest of the report. Nil is a box that did not say, which
+ // activation reads as unknown and refuses.
+ Seeds *CensusSeeds `json:"seeds,omitempty"`
}
type Census struct {
Records []Record
diff --git a/internal/hostops/backfill_publish.go b/internal/hostops/backfill_publish.go
index 9f770662c..c14b30295 100644
--- a/internal/hostops/backfill_publish.go
+++ b/internal/hostops/backfill_publish.go
@@ -50,6 +50,12 @@ func (b *Backfill) Publish(ctx context.Context, signer release.Signer, collectin
sum := sha256.Sum256(body)
p.Reports[box] = hex.EncodeToString(sum[:])
}
+ // No activation over unfinished seed state (seeded-instances; merge
+ // review S2): with claims on nothing can commit, abort or finish it.
+ // Checked here, under the collecting freeze, on the census just read.
+ if err := activationSeedRefusal(census); err != nil {
+ return err
+ }
for _, r := range census.Records {
if err := CheckHostQuarantine(ctx, b.Store, r.Holder.Host); err != nil {
return err
diff --git a/internal/hostops/backfill_seeds.go b/internal/hostops/backfill_seeds.go
new file mode 100644
index 000000000..3a26b8492
--- /dev/null
+++ b/internal/hostops/backfill_seeds.go
@@ -0,0 +1,154 @@
+package hostops
+
+import (
+ "sort"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Claim activation and seeded instances (seeded-instances spec §3.4, §5.6;
+// merge review S2).
+//
+// A claim console refuses every import door, runs no seed sweep and
+// finishes no seed, and a claim-armed box arms neither the swap nor the
+// rollback. So a seed that is unfinished when claims come on is stranded:
+// an import stays gated with nothing able to commit it, put its old route
+// back or clean it up. Activation therefore refuses while any unfinished
+// seed state exists, and the claim's own seed algorithm (#250) can lift
+// this once it is built.
+//
+// Publish makes the check, under the collecting manifest, which is the
+// record-writer freeze. Three things count, and each is read from evidence
+// activation already authenticates:
+//
+// - an unfinished seed on a record, from the census's own record read
+// (provision.SeedSource.Unfinished);
+// - an import journal row on a box;
+// - a gated route on a box.
+//
+// The last two are box-local: a committed import's journal row and gate
+// stay until the box's next pass clears them, and a rollback's journal row
+// outlives its record. So each box says them in its signed census report
+// (CensusReport.Seeds). A report that does not carry them is not read as
+// "none": activation refuses, as it does for a report that is incomplete.
+
+// CensusSeeds is a box's own seed state at its census: the hosts its import
+// journal holds a row for, in any state, and the hosts whose route carries
+// an import gate, open or closed. Both sorted. Empty lists are a box that
+// has none; a nil *CensusSeeds on the report is a box that did not say.
+type CensusSeeds struct {
+ Journaled []string `json:"journaled"`
+ Gated []string `json:"gated"`
+}
+
+// SeedStateReader is what a box's census collector reads its seed state
+// from: the box's registry. Both reads are served by a registry opened
+// through root's registry child as well as one opened directly.
+type SeedStateReader interface {
+ List() ([]registry.Route, error)
+ ImportJournalHosts() ([]string, error)
+}
+
+// CollectCensusSeeds reads a box's seed state for its census report. An
+// error is a box that cannot say: the collector then leaves
+// CensusReport.Seeds nil, and activation refuses.
+func CollectCensusSeeds(r SeedStateReader) (*CensusSeeds, error) {
+ journaled, err := r.ImportJournalHosts()
+ if err != nil {
+ return nil, err
+ }
+ routes, err := r.List()
+ if err != nil {
+ return nil, err
+ }
+ out := &CensusSeeds{Journaled: append([]string{}, journaled...), Gated: []string{}}
+ for _, rt := range routes {
+ if rt.ImportGate != "" {
+ out.Gated = append(out.Gated, rt.Host)
+ }
+ }
+ sort.Strings(out.Journaled)
+ sort.Strings(out.Gated)
+ return out, nil
+}
+
+// SeedActivationError is an activation refused over seed state. It is
+// ErrPending when a seed is unfinished (finish or abandon it, then activate
+// again) and ErrIdentity when a box's report carries no seed state (the
+// evidence is incomplete, as for any other incomplete report).
+//
+// DRAFT COPY: the four sentences below are operator-facing and have not
+// been through copy review. They are listed, with this site, in the merge
+// report's review-fixes section.
+type SeedActivationError struct {
+ base error
+ text string
+}
+
+func (e *SeedActivationError) Error() string { return e.base.Error() + ": " + e.text }
+func (e *SeedActivationError) Unwrap() error { return e.base }
+
+func seedEvidenceMissing(box string) error {
+ return &SeedActivationError{ErrIdentity, "box " + box + " reported no seed state, so an import in flight there cannot be ruled out"}
+}
+
+func seedJournaled(box, host string) error {
+ return &SeedActivationError{ErrPending, "box " + box + " still holds an import journal row for " + host}
+}
+
+func seedGated(box, host string) error {
+ return &SeedActivationError{ErrPending, "box " + box + " still gates " + host + " for an import"}
+}
+
+func seedUnfinished(host string, seed *provision.SeedSource) error {
+ return &SeedActivationError{ErrPending, host + " has an unfinished seed (" + seed.Kind + ", " + seed.State + ")"}
+}
+
+// activationSeedRefusal is Publish's seed check over one census: nil when
+// every box reported its seed state and no box and no record holds any
+// that is unfinished. Boxes and hosts are judged in name order, so the
+// answer for one census is always the same one.
+func activationSeedRefusal(c Census) error {
+ boxes := make([]string, 0, len(c.Reports))
+ for box := range c.Reports {
+ boxes = append(boxes, box)
+ }
+ sort.Strings(boxes)
+ // Evidence first: with any box unknown, nothing else can clear it.
+ for _, box := range boxes {
+ if c.Reports[box].Seeds == nil {
+ return seedEvidenceMissing(box)
+ }
+ }
+ for _, box := range boxes {
+ seeds := c.Reports[box].Seeds
+ for _, host := range sortedHosts(seeds.Journaled) {
+ return seedJournaled(box, host)
+ }
+ for _, host := range sortedHosts(seeds.Gated) {
+ return seedGated(box, host)
+ }
+ }
+ records := append([]Record(nil), c.Records...)
+ sort.Slice(records, func(i, j int) bool { return records[i].Holder.Host < records[j].Holder.Host })
+ for _, r := range records {
+ if r.Holder.Family != "instance" {
+ continue
+ }
+ var rec provision.Record
+ if err := decode(r.Body, &rec); err != nil {
+ return ErrIdentity
+ }
+ if rec.Seed.Unfinished() {
+ return seedUnfinished(r.Holder.Host, rec.Seed)
+ }
+ }
+ return nil
+}
+
+func sortedHosts(hosts []string) []string {
+ out := append([]string(nil), hosts...)
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/hostops/backfill_seeds_test.go b/internal/hostops/backfill_seeds_test.go
new file mode 100644
index 000000000..dff0d637d
--- /dev/null
+++ b/internal/hostops/backfill_seeds_test.go
@@ -0,0 +1,341 @@
+package hostops
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// activation is a deployment at the moment claim activation publishes its
+// backfill: a signed roster of two boxes, the collecting manifest (the
+// record-writer freeze), and one signed census report per box.
+type activation struct {
+ t *testing.T
+ ctx context.Context
+ f *fixture
+ sg release.Signer
+ d hostactors.Directory
+ roster hostactors.Roster
+ boxKeys map[string]ed25519.PrivateKey
+ records []Record
+}
+
+func newActivation(t *testing.T) *activation {
+ t.Helper()
+ a := &activation{t: t, ctx: context.Background(), f: newFixture(t), boxKeys: map[string]ed25519.PrivateKey{}}
+ pub, key, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.sg = release.RootSigner(key)
+ a.roster = hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Regions: map[string]hostactors.Region{"ie": {}},
+ Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ for i, ref := range []string{"box-a", "box-b"} {
+ boxPub, boxKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.boxKeys[ref] = boxKey
+ a.roster.Boxes[ref] = hostactors.Box{CensusPublicKey: boxPub, Enrollment: strings.Repeat(string(rune('a'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref,
+ Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
+ }
+ raw, err := release.SignRootEnvelope(a.sg, a.roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.f.store.Put(a.ctx, hostactors.Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ a.d = hostactors.Directory{Store: a.f.store, PublicKey: pub, Bucket: "bucket"}
+ return a
+}
+
+// record writes one instance record, with seed as its seed (nil for none).
+func (a *activation) record(host string, seed *provision.SeedSource) {
+ a.t.Helper()
+ r, err := PrepareInstance(provision.Record{Schema: 1, Account: "bab", App: "shop", Host: host, Slug: strings.SplitN(host, ".", 2)[0],
+ Channel: "edge", Region: "ie", Acquisition: "acquisition", Seed: seed}, a.f.now, "ie")
+ if err != nil {
+ a.t.Fatal(err)
+ }
+ if _, err := a.f.store.Put(a.ctx, r.Holder.Key, r.Body, ""); err != nil {
+ a.t.Fatal(err)
+ }
+ if r, err = ReadRecord(a.ctx, a.f.store, r.Holder.Key); err != nil {
+ a.t.Fatalf("the fixture's record does not read: %v", err)
+ }
+ a.records = append(a.records, r)
+}
+
+// publish writes the collecting manifest over the records, each box's
+// signed report carrying its seed evidence (a missing entry is a report
+// with none), and runs activation's Publish under the manifest's etag.
+func (a *activation) publish(evidence map[string]*CensusSeeds) error {
+ a.t.Helper()
+ p := BackfillPlan{V: 1, ID: "backfill", Phase: "collecting", DirectoryRevision: 1, CreatedAt: a.f.now, Hosts: map[string]BackfillHost{}}
+ for _, r := range a.records {
+ p.Hosts[r.Holder.Host] = BackfillHost{Key: r.Holder.Key, ETag: r.ETag, Acquisition: "acquisition", Region: "ie", Box: "box-a"}
+ }
+ raw, err := release.SignRootEnvelope(a.sg, p)
+ if err != nil {
+ a.t.Fatal(err)
+ }
+ tag, err := a.f.store.Put(a.ctx, BackfillKey, raw, "")
+ if err != nil {
+ a.t.Fatal(err)
+ }
+ for ref, box := range a.roster.Boxes {
+ report := CensusReport{Box: ref, Enrollment: box.Enrollment, BootID: "boot", Reservation: p.ID, DirectoryRevision: 1,
+ CapturedAt: a.f.now, Complete: true, Installations: []CensusInstallation{}, Seeds: evidence[ref]}
+ if err := PublishCensusReport(a.ctx, a.f.store, a.d, report, a.boxKeys[ref]); err != nil {
+ a.t.Fatalf("publish %s's census report: %v", ref, err)
+ }
+ }
+ b := &Backfill{Store: a.f.store, Directory: a.d}
+ return b.Publish(a.ctx, a.sg, tag)
+}
+
+// phase is the backfill manifest's phase now.
+func (a *activation) phase() string {
+ a.t.Helper()
+ b := &Backfill{Store: a.f.store, Directory: a.d}
+ p, _, err := b.readPlan(a.ctx)
+ if err != nil {
+ a.t.Fatal(err)
+ }
+ return p.Phase
+}
+
+func noSeeds() map[string]*CensusSeeds {
+ return map[string]*CensusSeeds{"box-a": {Journaled: []string{}, Gated: []string{}}, "box-b": {Journaled: []string{}, Gated: []string{}}}
+}
+
+func testSeed(kind, state string) *provision.SeedSource {
+ id, _ := seeds.NewID()
+ s := &provision.SeedSource{ID: id, Kind: kind, State: state, SHA256: strings.Repeat("a", 64), Bytes: 10, By: "t", At: time.Date(2026, 10, 5, 11, 0, 0, 0, time.UTC)}
+ if kind == seeds.KindImport {
+ s.Replaces, s.VerifyHash = "127.0.0.1:8401", strings.Repeat("b", 64)
+ } else {
+ s.From = "src.shop.example.com"
+ }
+ return s
+}
+
+// refused asserts err is activation's seed refusal over base, naming what.
+func refused(t *testing.T, err, base error, what string) {
+ t.Helper()
+ var se *SeedActivationError
+ if !errors.As(err, &se) || !errors.Is(err, base) || !strings.Contains(err.Error(), what) {
+ t.Fatalf("Publish = %v; want a seed refusal over %v naming %q", err, base, what)
+ }
+}
+
+// Merge review S2 (a): with every box reporting no seed state and no
+// record carrying an unfinished seed, activation publishes as before. A
+// finished seed does not hold it.
+func TestActivationPublishesWithNoUnfinishedSeed(t *testing.T) {
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ a.record("copy.shop.example.com", testSeed(seeds.KindReplica, seeds.StatePlaced))
+ a.record("moved.shop.example.com", testSeed(seeds.KindImport, seeds.StateCommitted))
+ if err := a.publish(noSeeds()); err != nil {
+ t.Fatalf("Publish: %v", err)
+ }
+ if got := a.phase(); got != "adopting" {
+ t.Fatalf("phase = %q, want adopting", got)
+ }
+}
+
+// Activation refuses over each unfinished seed a record can carry, under
+// the collecting freeze, and publishes nothing. The states outside the
+// ruling's list do not refuse.
+func TestActivationRefusesAnUnfinishedSeedRecord(t *testing.T) {
+ for _, c := range []struct {
+ kind, state string
+ refuse bool
+ }{
+ {seeds.KindReplica, seeds.StateReserving, true},
+ {seeds.KindImport, seeds.StateAwaitingUpload, true},
+ {seeds.KindImport, seeds.StateUploaded, true},
+ {seeds.KindImport, seeds.StateReady, true},
+ {seeds.KindImport, seeds.StateSwapped, true},
+ {seeds.KindImport, seeds.StatePlaced, true},
+ {seeds.KindImport, seeds.StateAborted, true},
+ {seeds.KindReplica, seeds.StateAborted, true},
+ {seeds.KindImport, seeds.StateRolledBack, true},
+ {seeds.KindReplica, seeds.StateRolledBack, true},
+ {seeds.KindReplica, seeds.StateReady, false},
+ {seeds.KindImport, seeds.StateRefused, false},
+ } {
+ t.Run(c.kind+" "+c.state, func(t *testing.T) {
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ a.record("seeded.shop.example.com", testSeed(c.kind, c.state))
+ err := a.publish(noSeeds())
+ if !c.refuse {
+ if err != nil || a.phase() != "adopting" {
+ t.Fatalf("Publish = %v, phase %q; want published", err, a.phase())
+ }
+ return
+ }
+ refused(t, err, ErrPending, "seeded.shop.example.com has an unfinished seed ("+c.kind+", "+c.state+")")
+ if got := a.phase(); got != "collecting" {
+ t.Fatalf("phase = %q after a refusal, want collecting", got)
+ }
+ })
+ }
+}
+
+// Activation refuses when a box's signed report carries no seed state: it
+// fails closed, whatever the other boxes and the records say.
+func TestActivationRefusesWithoutABoxesSeedEvidence(t *testing.T) {
+ for name, evidence := range map[string]map[string]*CensusSeeds{
+ "no box says": {},
+ "one box says": {"box-a": {Journaled: []string{}, Gated: []string{}}},
+ "the other one": {"box-b": {Journaled: []string{}, Gated: []string{}}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ err := a.publish(evidence)
+ refused(t, err, ErrIdentity, "reported no seed state")
+ if got := a.phase(); got != "collecting" {
+ t.Fatalf("phase = %q after a refusal, want collecting", got)
+ }
+ })
+ }
+ // A collector that cannot read its registry leaves the evidence out.
+ if seeds, err := CollectCensusSeeds(failingSeedState{}); err == nil || seeds != nil {
+ t.Fatalf("CollectCensusSeeds over a registry that cannot be read = %+v, %v", seeds, err)
+ }
+}
+
+type failingSeedState struct{}
+
+func (failingSeedState) List() ([]registry.Route, error) { return nil, errors.New("registry busy") }
+func (failingSeedState) ImportJournalHosts() ([]string, error) {
+ return nil, errors.New("registry busy")
+}
+
+// importOnBox is a box's registry holding one import, taken as far as
+// state: swapped (the gated row and a journal row), rolling-back (the gate
+// closed), or cleanup-pending (the original route back, the journal row
+// still there).
+func importOnBox(t *testing.T, host, state string) *registry.Registry {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "127.0.0.1:8401", Status: "ready", App: "shop", Account: "bab", CreatedAt: 1700000000}); err != nil {
+ t.Fatal(err)
+ }
+ original, err := reg.Get(host)
+ if err != nil || original == nil {
+ t.Fatal(original, err)
+ }
+ id, _ := seeds.NewID()
+ next := registry.Route{Host: host, Kind: "instance", Socket: "/run/carlos/instances/x/app.sock", DBPath: "/data/instances/x/" + host + ".db",
+ Status: "ready", App: "shop", Account: "bab", Hibernate: true, Provisioned: true, ImportGate: strings.Repeat("b", 64),
+ SeedSource: seeds.RowSeed{ID: id, Kind: seeds.KindImport, State: seeds.StateSwapped}.Encode(), CreatedAt: 1700000001}
+ if err := reg.SwapForImport(registry.ImportJournal{Host: host, SeedID: id, Original: *original, RecordKey: provision.RecordKey("bab", "shop", host), At: 1,
+ EnvBefore: registry.EnvBeforeAbsent}, next); err != nil {
+ t.Fatal(err)
+ }
+ if state == registry.JournalSwapped {
+ return reg
+ }
+ if err := reg.CloseImportGate(host, id, 7); err != nil {
+ t.Fatal(err)
+ }
+ if state == registry.JournalRollingBack {
+ return reg
+ }
+ if err := reg.RestoreImportRoute(host, id); err != nil {
+ t.Fatal(err)
+ }
+ return reg
+}
+
+// The ruling's four states, with the box's evidence read from a real
+// registry by the collector's own function. swapped and placed are an
+// import the record still shows; rolling-back and cleanup-pending are
+// shown here with the record already gone, where only the box's signed
+// evidence can say an import is still in flight.
+func TestActivationRefusesAnImportInFlightOnABox(t *testing.T) {
+ const host = "old.shop.example.com"
+ for _, c := range []struct {
+ name string
+ record string // the record's seed state, "" for no record
+ journal string
+ wantJournaled bool
+ wantGated bool
+ }{
+ {"swapped", seeds.StateSwapped, registry.JournalSwapped, true, true},
+ {"placed", seeds.StatePlaced, registry.JournalSwapped, true, true},
+ {"rolling-back", "", registry.JournalRollingBack, true, true},
+ {"cleanup-pending", "", registry.JournalCleanupPending, true, false},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ if c.record != "" {
+ a.record(host, testSeed(seeds.KindImport, c.record))
+ }
+ evidence := noSeeds()
+ got, err := CollectCensusSeeds(importOnBox(t, host, c.journal))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (len(got.Journaled) == 1 && got.Journaled[0] == host) != c.wantJournaled || (len(got.Gated) == 1 && got.Gated[0] == host) != c.wantGated {
+ t.Fatalf("the box's evidence = %+v; want journaled %v, gated %v", got, c.wantJournaled, c.wantGated)
+ }
+ evidence["box-b"] = got
+ refused(t, a.publish(evidence), ErrPending, "box box-b still holds an import journal row for "+host)
+ if got := a.phase(); got != "collecting" {
+ t.Fatalf("phase = %q after a refusal, want collecting", got)
+ }
+ })
+ }
+ // A gate with no journal row (a committed import whose box has not yet
+ // cleared its gate cannot be told from one that never will) refuses too.
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ evidence := noSeeds()
+ evidence["box-a"] = &CensusSeeds{Journaled: []string{}, Gated: []string{host}}
+ refused(t, a.publish(evidence), ErrPending, "box box-a still gates "+host+" for an import")
+}
+
+// A box with no import reports two empty lists, which is not "did not
+// say": the collector's answer for a clean registry lets activation go on.
+func TestCollectCensusSeedsOnACleanBox(t *testing.T) {
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.Upsert(registry.Route{Host: "plain.shop.example.com", Kind: "instance", Socket: "/run/x.sock", App: "shop", Account: "bab", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := CollectCensusSeeds(reg)
+ if err != nil || got == nil || got.Journaled == nil || got.Gated == nil || len(got.Journaled)+len(got.Gated) != 0 {
+ t.Fatalf("CollectCensusSeeds = %+v, %v; want two empty lists", got, err)
+ }
+ a := newActivation(t)
+ a.record("plain.shop.example.com", nil)
+ if err := a.publish(map[string]*CensusSeeds{"box-a": got, "box-b": got}); err != nil {
+ t.Fatalf("Publish with the collector's clean evidence: %v", err)
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index e57845be4..e0294e091 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -794,7 +794,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/hostfiles/prepared.go", "Prepared.Commit", "nonrecord", "sel:Put", "375def2559531419376e77104d3b86c318a0b6181055947aa6d57e68b53061de"},
{"internal/hostfiles/prepared.go", "Prepared.put", "nonrecord", "sel:Put", "902a2f060e7475a68d5d3eb2cfbff14f8de2cbcef35d690931f81e25ae3d9182"},
{"internal/hostguard/record.go", "writeRecord", "key-site", "key:writeRecord", "0ba1097cba3d4a5f5fc0b06765de6f8ee535befb939e83f1cc7167c7996ac60b"},
- {"internal/hostops/backfill_publish.go", "Backfill.Publish", "nonrecord", "sel:Put", "7b2be2fa910195260a7c0c63a859290d4fc25e06b41a8592dc95cf2d4f41975f"},
+ // Re-reviewed for merge review S2 (2026-10-13): before it checks the frozen hosts, Publish refuses the census while any box's report carries no seed state, any box journals or gates an import, or any record's seed is unfinished (activationSeedRefusal, reads only). Its one Put, of the backfill manifest, is unchanged.
+ {"internal/hostops/backfill_publish.go", "Backfill.Publish", "nonrecord", "sel:Put", "e38e617a2f62b857cdf5c0fadf7ffe11d32e7287c15d46cc6f08f1aa812ac6bb"},
{"internal/hostops/backfill_publish.go", "PublishCensusReport", "nonrecord", "sel:Put", "5c7ae54a82c3cf47d166c3b02123b60a9aff06e52b500a34d84cd668f4e29b36"},
{"internal/hostops/control_writer.go", "ControlWriter.updateAdmission", "nonrecord", "sel:Put", "b8777d9f07582d452e6d12a4e2f417e9c1804f1b87d32b13cb7172c515854cf5"},
{"internal/hostops/create_effects.go", "ProductionAuthority.RecordEffects", "record-writer", "key:WriteRecord,sel:Put", "21550e2b7d739956fcfad7b9ad7aaaf7a3f8c98948de8417ad9fd557f666251f"},
@@ -1066,8 +1067,12 @@ var plan9B8CensusInventory = []censusEntry{
// Reviewed at the merge of carlos-run into seeded-instances (2026-10-13): the
// seeded series was built before this census reached its base. Every record
// write below goes through the typed API (CreateRecord, SetRecordSeed), every
- // record delete is a CAS on the etag read, and every door refuses a claim
- // console before it writes (seedRegionReady, importGate, finishSeed, the sweep).
+ // record delete is a CAS on the etag read, and every console door refuses a
+ // claim console before it writes (seedRegionReady, importGate, finishSeed, the
+ // sweep). On a claim-armed box the swap and the rollback are not armed and the
+ // journal pass only protects; the two writes left there are the swap's refusal
+ // (swapImport) and the no-journal finish of an aborted seed (finishAbortedSeed),
+ // both through SetRecordSeed.
// Builds the import doors' path (/api/cli/apps/.../instances/<host>/import...); it writes no record.
{"apimode.go", "importDoor", "key-site", "lit:instances", "96919f2814950abf43f4915bd8d231f73cc347a75e9ad787abae1e0434a0d7dd"},
// The import door's begin: creates the record with its seed at awaiting-upload through CreateRecord (create-only), after every check of the create door and after seedRegionReady, which refuses a claim console.
@@ -1102,7 +1107,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/seedfinish.go", "Server.finishSeedOnce", "key-site", "key:RecordKey", "2cc0f6bc838e2a09e5e5accf068fad812ed763fe75f858ed1e3b4c0d48396289"},
// The sweep's abandon of a stale seed, to aborted through SetRecordSeed; the sweep is off on a claim console.
{"internal/console/seedsweep.go", "Server.abandonSeed", "record-writer", "key:SetRecordSeed", "a1ac6747620b8d60a9d5222d593f22e1db41f7e03690882c2986cdc510a5bbfe"},
- // The box's placed recorder: SetRecordSeed (SeedPlace) after the first upload, then the snapshot's delete; wired only with claim control off.
+ // The box's placed recorder: SetRecordSeed (SeedPlace) after the first upload, then the snapshot's delete. It is assigned whatever the claim flag says. Its two callers are the plain wake's step 5b and the seed fallback's finish, and neither runs with claim control on: a claims-on wake is the claim's own (doClaimWake), and the fallback refuses under ClaimArmed before it reaches the finish. (Corrected after merge review: it is not "wired only with claim control off".)
{"internal/host/seedwiring.go", "seedPlacedHook", "record-writer", "key:SetRecordSeed", "8583e0cce0e00cd4c724162bed93f357fb326c2a0635a413e7666a71419bcf76"},
// Puts a store record back as it was, or deletes it, by CAS, under RevertIn's app mutation: a store record, not a host record.
{"internal/objstore/objstore.go", "claimRawRevertIn", "nonrecord", "sel:Put", "bfbde7d63e304a08494280d250c4cd50179e5c465b6d37015d5f56b29c32a5fc"},
@@ -1115,7 +1120,8 @@ var plan9B8CensusInventory = []censusEntry{
// An aborted seed with no journal row: deletes its snapshot, then SetRecordSeed (SeedRolledBack).
{"internal/provision/seedreconcile.go", "finishAbortedSeed", "record-writer", "key:SetRecordSeed", "c8b2d4d24cda9430cecba42cb5a93ccde50ee02ed2c0c2aef42d7e01bf4a32f0"},
// Compares each journal row's record key with the record the pass read; writes no record.
- {"internal/provision/seedreconcile.go", "journalPass", "key-site", "key:RecordKey", "2c3700df744f1df33be347d0a6a8ab8c37edbffeabed5d60227e1ea54b575e60"},
+ // Re-reviewed for merge review S2 (2026-10-13): with claim control on for the box every journaled host is protected and handled and nothing else is done: no rollback, no redone record CAS, no cleared gate. It still writes no record itself.
+ {"internal/provision/seedreconcile.go", "journalPass", "key-site", "key:RecordKey", "ef46f7c4ae634f472ac53b4754c030f0f24317aa3bd8a13598e80aa23269a528"},
// Picks the record a journal row names, by key; reads only.
{"internal/provision/seedreconcile.go", "journalRecord", "key-site", "key:RecordKey", "ed4f8cd033967157e6ba96cb84f95da7ed8a08898f18f8c66334eaeb62b97453"},
// The rollback's steps 6 to 8: the bucket cleanup, the journal row's delete, then SetRecordSeed (SeedRolledBack).
diff --git a/internal/provision/seed.go b/internal/provision/seed.go
index 854c885ac..7638466a5 100644
--- a/internal/provision/seed.go
+++ b/internal/provision/seed.go
@@ -47,6 +47,30 @@ func (s *SeedSource) Abandoned() bool {
return s != nil && (s.State == seeds.StateAborted || s.State == seeds.StateRolledBack)
}
+// Unfinished reports a seed that still needs a door, a box or the console
+// to act before it is done: the states claim activation refuses over
+// (hostops, merge review S2), because none of those acts on a claim
+// console or a claim-armed box.
+//
+// - reserving, awaiting-upload, uploaded: a door is still writing it;
+// - an import at ready, swapped or placed: not committed;
+// - aborted, rolled-back: its rollback or its finish is still owed.
+//
+// Not in the list: a --from seed at ready (it waits only for its first
+// wake) or placed, an import at committed, and refused.
+func (s *SeedSource) Unfinished() bool {
+ if s == nil {
+ return false
+ }
+ switch s.State {
+ case seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateAborted, seeds.StateRolledBack:
+ return true
+ case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced:
+ return s.Import()
+ }
+ return false
+}
+
// BlocksRun is run admission's predicate (run spec §5.1 step 9, codex
// R2-2): an import that is not committed refuses carlos run.
func (s *SeedSource) BlocksRun() bool { return s.Import() && s.State != seeds.StateCommitted }
diff --git a/internal/provision/seed_test.go b/internal/provision/seed_test.go
index 3a200c3d6..bf1242225 100644
--- a/internal/provision/seed_test.go
+++ b/internal/provision/seed_test.go
@@ -587,3 +587,34 @@ func TestSetRecordRegionRefusesAnUnplacedSeed(t *testing.T) {
t.Fatalf("a placed seed after the re-home: %+v, %v", got, err)
}
}
+
+// Merge review S2: the states claim activation refuses over, exactly.
+func TestSeedUnfinished(t *testing.T) {
+ var none *SeedSource
+ if none.Unfinished() {
+ t.Fatal("a record with no seed is unfinished")
+ }
+ for _, c := range []struct {
+ kind, state string
+ want bool
+ }{
+ {seeds.KindReplica, seeds.StateReserving, true},
+ {seeds.KindReplica, seeds.StateReady, false},
+ {seeds.KindReplica, seeds.StatePlaced, false},
+ {seeds.KindReplica, seeds.StateAborted, true},
+ {seeds.KindReplica, seeds.StateRolledBack, true},
+ {seeds.KindImport, seeds.StateAwaitingUpload, true},
+ {seeds.KindImport, seeds.StateUploaded, true},
+ {seeds.KindImport, seeds.StateReady, true},
+ {seeds.KindImport, seeds.StateSwapped, true},
+ {seeds.KindImport, seeds.StatePlaced, true},
+ {seeds.KindImport, seeds.StateCommitted, false},
+ {seeds.KindImport, seeds.StateRefused, false},
+ {seeds.KindImport, seeds.StateAborted, true},
+ {seeds.KindImport, seeds.StateRolledBack, true},
+ } {
+ if got := (&SeedSource{Kind: c.kind, State: c.state}).Unfinished(); got != c.want {
+ t.Errorf("%s %s: unfinished %v, want %v", c.kind, c.state, got, c.want)
+ }
+ }
+}
diff --git a/internal/provision/seedjournal_test.go b/internal/provision/seedjournal_test.go
index 7feb94cec..9744cc7c2 100644
--- a/internal/provision/seedjournal_test.go
+++ b/internal/provision/seedjournal_test.go
@@ -760,3 +760,96 @@ BEGIN SELECT RAISE(ABORT, 'seed_source is read-only here'); END`)
t.Fatalf("row = seed %q source %q, want it as it was", got.Seed, got.SeedSource)
}
}
+
+// Merge review S2 (b): on a box with claim control on, the journal pass
+// does nothing but protect. A journal row there is an import a claim
+// caught mid-flight; the box arms no swap or rollback and the claim
+// console refuses commit, abort and finish, so no single step is safe to
+// take. In every state the row, the journal row and the record are exactly
+// as they were: the swap's record CAS is not redone, a committed import's
+// gate is not cleared, and no rollback step runs, even with the rollback's
+// hooks wired. The removal phase leaves the row too.
+func TestJournalPassOnlyProtectsWithClaimControlOn(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ record string // the record's seed state; "" deletes the record
+ journal string // the journal row's state
+ }{
+ {"ready: the record CAS would be redone", seeds.StateReady, registry.JournalSwapped},
+ {"swapped", seeds.StateSwapped, registry.JournalSwapped},
+ {"placed", seeds.StatePlaced, registry.JournalSwapped},
+ {"committed: the gate would be cleared", seeds.StateCommitted, registry.JournalSwapped},
+ {"aborted: a rollback would start", seeds.StateAborted, registry.JournalSwapped},
+ {"record gone: a rollback would start", "", registry.JournalSwapped},
+ {"rolling-back", seeds.StateAborted, registry.JournalRollingBack},
+ {"rolling-back, record gone", "", registry.JournalRollingBack},
+ {"cleanup-pending", seeds.StateAborted, registry.JournalCleanupPending},
+ {"cleanup-pending, record gone", "", registry.JournalCleanupPending},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ state := c.record
+ if state == "" {
+ state = seeds.StateSwapped
+ }
+ x := journaledImport(t, state)
+ if c.journal != registry.JournalSwapped {
+ if err := x.reg.CloseImportGate(x.rec.Host, x.rec.Seed.ID, 7); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if c.journal == registry.JournalCleanupPending {
+ if err := x.reg.RestoreImportRoute(x.rec.Host, x.rec.Seed.ID); err != nil {
+ t.Fatal(err)
+ }
+ }
+ x.row = *mustGet(t, x.reg, x.rec.Host)
+ j, _ := x.reg.ImportJournal(x.rec.Host)
+ x.j = *j
+ key := RecordKey(x.rec.Account, x.rec.App, x.rec.Host)
+ if c.record == "" {
+ if err := x.s.Delete(ctx, key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before, _, _ := x.s.Get(ctx, key)
+ b := x.box(t)
+ x.cfg.ClaimEnabled = true
+ logged := captureLog(t)
+ for pass := 0; pass < 2; pass++ {
+ if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
+ t.Fatalf("pass %d: %v", pass, err)
+ }
+ x.untouched(t)
+ if after, _, _ := x.s.Get(ctx, key); !bytes.Equal(after, before) {
+ t.Fatalf("pass %d: the record changed:\n got %s\nwant %s", pass, after, before)
+ }
+ if len(b.steps) != 0 || len(b.retired) != 0 || !b.local || !b.replica || !b.key {
+ t.Fatalf("pass %d: the box was touched: steps %q, retired %q, local %v, replica %v, key %v",
+ pass, b.steps, b.retired, b.local, b.replica, b.key)
+ }
+ }
+ if !strings.Contains(logged.String(), "claim control is on for this box; its row is protected and nothing else is done") {
+ t.Fatalf("the protection was not logged:\n%s", logged)
+ }
+ })
+ }
+}
+
+// The same journal rows with claim control off move forward as before, so
+// the test above fails for the reason it says when the guard is removed.
+func TestJournalPassMovesForwardWithClaimControlOff(t *testing.T) {
+ ready := journaledImport(t, seeds.StateReady)
+ if err := Reconcile(ctx, ready.s, ready.reg, ready.cfg); err != nil {
+ t.Fatal(err)
+ }
+ if st := seedState(t, ready.s, ready.rec); st.State != seeds.StateSwapped {
+ t.Fatalf("record = %s, want the swap's CAS redone", st.State)
+ }
+ committed := journaledImport(t, seeds.StateCommitted)
+ if err := Reconcile(ctx, committed.s, committed.reg, committed.cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, committed.reg, committed.rec.Host); got.ImportGate != "" {
+ t.Fatalf("gate = %q, want it cleared", got.ImportGate)
+ }
+}
diff --git a/internal/provision/seedreconcile.go b/internal/provision/seedreconcile.go
index c45c8083b..4d723ead8 100644
--- a/internal/provision/seedreconcile.go
+++ b/internal/provision/seedreconcile.go
@@ -315,9 +315,11 @@ func purgeAbortedFromLocal(ctx context.Context, reg *registry.Registry, cfg Reco
//
// The journal is read whatever cfg.Seeds is (preflight M1): with seed
// hooks unarmed every journaled host is still protected and handled,
-// with no action. ok is false when the journal cannot be read. Reconcile
-// then treats the pass as a failed read for removal, and seedAction acts
-// on no import.
+// with no action. With claim control on for this box it is the same:
+// every journaled host is protected and handled, and nothing moves
+// forward (no rollback, no redone record CAS, no cleared gate). ok is
+// false when the journal cannot be read. Reconcile then treats the pass
+// as a failed read for removal, and seedAction acts on no import.
//
// A journal row at swapped is decided by the record that answers to it,
// which is only ever the record under the journal's own key
@@ -352,6 +354,21 @@ func journalPass(ctx context.Context, s release.Store, reg *registry.Registry, c
log.Printf("provision: %s: import %s is journaled, but this box cannot act on imports; its row is left alone", j.Host, j.SeedID)
continue
}
+ // With claim control on, a journal row is only protected (merge
+ // review S2). The box arms no swap and no rollback there, and the
+ // claim console refuses commit, abort and finish, so nothing this
+ // pass could do would be one step of a whole: redoing the swap's
+ // record CAS writes a record, and clearing a committed gate opens
+ // the row to ordinary traffic, each on a box whose claim has not
+ // taken the host. The host stays handled (the loop converges
+ // nothing) and journaled (the removal phase leaves its row), until
+ // the claim's own seed algorithm exists (#250). Activation refuses
+ // while a journal row exists, so this is an import that was caught
+ // by a claim that came on anyway.
+ if cfg.ClaimEnabled {
+ log.Printf("provision: %s: import %s is journaled at %s and claim control is on for this box; its row is protected and nothing else is done", j.Host, j.SeedID, j.State)
+ continue
+ }
rec, inPass, known := journalRecord(ctx, s, j, recs, readFailed, protect)
if !known {
log.Printf("provision: %s: import %s left alone this pass: its record could not be read", j.Host, j.SeedID)
diff --git a/internal/registry/importjournal.go b/internal/registry/importjournal.go
index 5dc58b389..64f930e06 100644
--- a/internal/registry/importjournal.go
+++ b/internal/registry/importjournal.go
@@ -140,6 +140,36 @@ func (r *Registry) ImportJournals() ([]ImportJournal, error) {
return out, rows.Err()
}
+// ImportJournalHosts returns the hosts the journal holds a row for, in any
+// state, ordered. It is what a box says about its imports in the claim
+// activation census (hostops.CollectCensusSeeds). Unlike the journal's
+// other methods it answers on a handle that proxies to root's registry
+// child too, so a collector that runs as root can ask.
+func (r *Registry) ImportJournalHosts() ([]string, error) {
+ if r.remote != nil {
+ out, err := r.remote.call(nil, remoteRequest{Method: "ImportJournalHosts"}, nil)
+ if err != nil {
+ return nil, err
+ }
+ // The transport drops an empty list; "none" is not "did not say".
+ return append([]string{}, out.Hosts...), nil
+ }
+ rows, err := r.sql.Query(`SELECT host FROM import_journal ORDER BY host`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var h string
+ if err := rows.Scan(&h); err != nil {
+ return nil, err
+ }
+ out = append(out, h)
+ }
+ return out, rows.Err()
+}
+
// hashImportJournal folds the journal into SnapshotFingerprint's digest:
// the journal is box-local state nothing in the bucket can rebuild, and a
// snapshot that kept a deleted cleanup-pending row would, restored cold,
diff --git a/internal/registry/importjournal_test.go b/internal/registry/importjournal_test.go
index 98145814c..ed223f516 100644
--- a/internal/registry/importjournal_test.go
+++ b/internal/registry/importjournal_test.go
@@ -889,3 +889,37 @@ func TestImportJournalEnvBeforeMigratesAndIsHashedWhenSet(t *testing.T) {
t.Fatal("a recorded env state did not move SnapshotFingerprint")
}
}
+
+// Merge review S2: a box says which hosts its import journal holds, for the
+// claim activation census. The read answers through root's registry child
+// too, where the journal's other methods refuse, so a collector that runs
+// as root is not left unable to say.
+func TestImportJournalHostsLocalAndRemote(t *testing.T) {
+ r, path := remoteTestRegistry(t)
+ if hosts, err := r.ImportJournalHosts(); err != nil || hosts == nil || len(hosts) != 0 {
+ t.Fatalf("an empty journal through the child = %v, %v; want an empty list", hosts, err)
+ }
+ local, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer local.Close()
+ old := legacyRow()
+ if err := local.Upsert(old); err != nil {
+ t.Fatal(err)
+ }
+ j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: mustRoute(t, local, old.Host), At: 1}
+ if err := local.SwapForImport(j, swapRow()); err != nil {
+ t.Fatal(err)
+ }
+ for name, reg := range map[string]*Registry{"local": local, "through the child": r} {
+ hosts, err := reg.ImportJournalHosts()
+ if err != nil || len(hosts) != 1 || hosts[0] != old.Host {
+ t.Fatalf("%s: ImportJournalHosts = %v, %v", name, hosts, err)
+ }
+ }
+ // The rest of the journal stays the agent's own.
+ if _, err := r.ImportJournals(); !errors.Is(err, errRemoteMethod) {
+ t.Fatalf("ImportJournals through the child = %v, want the remote refusal", err)
+ }
+}
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go
index 42534b1a0..a7cfc10ee 100644
--- a/internal/registry/remote_dispatch.go
+++ b/internal/registry/remote_dispatch.go
@@ -118,6 +118,7 @@ type remoteResponse struct {
Timers []Timer
Number int
Times map[string]int64
+ Hosts []string
}
func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback func(remoteResponse) error) (out remoteResponse, err error) {
@@ -186,6 +187,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback
out.Number64, out.Text, err = r.AccountBySqid(q.Sqid)
case "Accounts":
out.Accounts, err = r.Accounts()
+ case "ImportJournalHosts":
+ out.Hosts, err = r.ImportJournalHosts()
case "List":
out.Routes, err = r.List()
case "Get":