carlos / platform Public

provision: census overlays for the lifetime change, restore the frozen base inventory, review fixes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 60e45985b31313e3514f045d786d7062d5558427 parent 220552c
8 files changed, +91 −11
  • docs/superpowers/notes/census-proposal-self-serve-moves-1.md +5 −0
  • internal/provision/lifetime.go +1 −1
  • internal/provision/lifetime_test.go +32 −4
  • internal/provision/plan14_root_reads_test.go +7 −0
  • internal/provision/plan9_b8_census_test.go +2 −5
  • internal/provision/provision.go +1 −1
  • internal/provision/self_serve_moves1_census_test.go +41 −0
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv +2 −0
diff --git a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md
new file mode 100644
index 000000000..e53c96c8b
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md
@@ -0,0 +1,5 @@
+# Self-serve moves plan 1: census proposal
+
+Status: proposed, marked PROPOSED-self-serve-moves-1. Not an accepted ruling; the census rulings on this plan are Paul's to make.
+
+Task 1 adds four fields to `provision.Record` (`incarnation`, `incarnation_migrating`, `move_seq`, `retired`) and a new file, `internal/provision/lifetime.go`, holding the host incarnation slot, the lifetime verdict, the tombstone body and `GetRecordRaw`. The root-read overlay `internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv` re-pins the `internal/provision` source envelope and nothing else: no new root read, class or task, no accepted-legacy row, and the seal stands. The registry schema line is untouched, since `Record` is not a registry struct. The record-write overlay is `plan14SelfServeMoves1WriteCensusInventory` in `internal/provision/self_serve_moves1_census_test.go`, built over the roster-binding inventory: it removes `GetRecordETag` (it no longer builds a record key; it reads through `GetRecordRaw`), adds `GetRecordRaw` as a read-only key-site, and adds `WriteHostIncarnation` as nonrecord (it Puts only `control/host-incarnations/<host>.json`, conditionally). Later tasks of the plan will extend both overlays.
diff --git a/internal/provision/lifetime.go b/internal/provision/lifetime.go
index 9e1d4848f..11cde9a7c 100644
--- a/internal/provision/lifetime.go
+++ b/internal/provision/lifetime.go
@@ -70,7 +70,7 @@ func ReadHostIncarnation(ctx context.Context, s release.Store, host string) (*Ho
}
var slot HostIncarnation
if err := json.Unmarshal(raw, &slot); err != nil {
- return nil, "", fmt.Errorf("%w: %s: %v", ErrSlotMalformed, key, err)
+ return nil, "", fmt.Errorf("%w: %s: %w", ErrSlotMalformed, key, err)
}
if !slot.Valid() {
return nil, "", fmt.Errorf("%w: %s: failed validation", ErrSlotMalformed, key)
diff --git a/internal/provision/lifetime_test.go b/internal/provision/lifetime_test.go
index 81db68557..06f5e9ca5 100644
--- a/internal/provision/lifetime_test.go
+++ b/internal/provision/lifetime_test.go
@@ -92,7 +92,7 @@ func TestHostIncarnationWrites(t *testing.T) {
}
retired := slot
retired.State = SlotRetired
- etag2, err := WriteHostIncarnation(ctx, s, host, retired, etag1)
+ _, err = WriteHostIncarnation(ctx, s, host, retired, etag1)
if err != nil {
t.Fatalf("If-Match with the returned ETag: %v", err)
}
@@ -102,7 +102,6 @@ func TestHostIncarnationWrites(t *testing.T) {
if got, _, _ := ReadHostIncarnation(ctx, s, host); got == nil || got.State != SlotRetired {
t.Fatalf("stale write changed the slot: %+v", got)
}
- _ = etag2
for _, bad := range []HostIncarnation{
{State: SlotLive, Account: "bab", App: "shop"},
@@ -110,8 +109,8 @@ func TestHostIncarnationWrites(t *testing.T) {
{Incarnation: 1, State: SlotLive, App: "shop"},
{Incarnation: 1, State: SlotLive, Account: "bab"},
} {
- if _, err := WriteHostIncarnation(ctx, s, "other.example.com", bad, ""); err == nil {
- t.Errorf("invalid slot %+v was written", bad)
+ if _, err := WriteHostIncarnation(ctx, s, "other.example.com", bad, ""); !errors.Is(err, ErrSlotMalformed) {
+ t.Errorf("invalid slot %+v: err = %v, want ErrSlotMalformed", bad, err)
}
}
if keys, _ := s.List(ctx, "control/host-incarnations/other"); len(keys) != 0 {
@@ -180,6 +179,35 @@ func TestTombstoneBodyKeepsTheSequence(t *testing.T) {
t.Errorf("written tombstone = %s, want record_seq 8 and retired", raw)
}
+ // A record with an incarnation but no sequence: six keys, and writing the
+ // tombstone grows no sequence.
+ noSeq := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","region":"ie","incarnation":2,"move_seq":3}`)
+ noSeqKey := RecordKey("bab", "shop", "other.example.com")
+ noSeqETag, err := s.Put(ctx, noSeqKey, noSeq, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err = TombstoneBody(noSeq)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fields = nil
+ if err := json.Unmarshal(body, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if len(fields) != 6 || string(fields["incarnation"]) != "2" || string(fields["retired"]) != "true" {
+ t.Errorf("tombstone without a sequence = %s, want six keys with incarnation 2", body)
+ }
+ if _, ok := fields["record_seq"]; ok {
+ t.Errorf("tombstone grew a sequence: %s", body)
+ }
+ if _, seq, err := WriteRecord(ctx, s, noSeqKey, noSeq, body, noSeqETag); err != nil || seq != 0 {
+ t.Errorf("WriteRecord of a sequenceless tombstone = seq %d, err %v, want 0, nil", seq, err)
+ }
+ if raw, _, err := s.Get(ctx, noSeqKey); err != nil || strings.Contains(string(raw), "record_seq") {
+ t.Errorf("written tombstone = %s %v, want no record_seq", raw, err)
+ }
+
legacy := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","region":"ie"}`)
body, err = TombstoneBody(legacy)
if err != nil {
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index e0524ad72..d89e888e4 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1255,6 +1255,13 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-merge-main-4.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay self-serve-moves-1 (lifetime fields on provision.Record
+ // and internal/provision/lifetime.go, self-serve moves plan 1 task 1),
+ // loaded last: envelope only, internal/provision re-pinned. Marked
+ // rev3=PROPOSED-self-serve-moves-1, not an accepted ruling; the seal stands.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index fb59372d7..da2572358 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14ClaimRosterBindingWriteCensusInventory()
+ inventory := plan14SelfServeMoves1WriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
@@ -984,10 +984,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/provision/provision.go", "DeleteRecord", "key-site", "key:RecordKey", "67e8f77d784d3e84626f2856bd45d219759ba4b603bc3daebbb9e315131c4945"},
// Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's body plus one refusal: a change to the record's seed is ErrSeedProtected, as in updateRecordSeeding; the write is still WriteRecord.
{"internal/provision/provision.go", "claimRawUpdateRecord", "record-writer", "key:RecordKey,key:WriteRecord,key:claimRawUpdateRecord", "9ecb91710e7460a8a91ad6299b3082ace6a8a38ba21b2edfef0199e6ab05d3e4"},
- // GetRecordETag no longer builds the key itself: it reads through GetRecordRaw (lifetime.go) and hides a tombstone.
- {"internal/provision/lifetime.go", "GetRecordRaw", "key-site", "key:RecordKey", "e4c422472e601a14496af407c4f9d0535a48809964d6dfbf6b06a7c2e125f077"},
- // Writes control/host-incarnations/<host>.json by CAS (create-only or If-Match); the slot is not a record key.
- {"internal/provision/lifetime.go", "WriteHostIncarnation", "nonrecord", "sel:Put", "c5f2719a53447c0477706600081a9846444071f39b85759a463d1ee757dc760e"},
+ {"internal/provision/provision.go", "GetRecordETag", "key-site", "key:RecordKey", "d9f71e5a52091dce7ea6b6aa3e5d725456d5944efa180727674e7df0006d4fa5"},
// Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): the host-list loop is factored into recordHosts, shared with ListRecordsStrict; reads only.
{"internal/provision/provision.go", "ListRecordsComplete", "key-site", "key:RecordsPrefix", "3231a8ccb6a058d899b296c9bf7420575e91b73875dc2daf2c1405ce21969487"},
{"internal/provision/provision.go", "InstanceDir", "key-site", "lit:instances", "4defc7f996c83afe8d2d2efddd31f4de79d94a5c84e7ee99e8d639ca03d7bf81"},
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 57c8fa916..4c618c355 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -186,7 +186,7 @@ type Record struct {
// MoveSeq counts completed moves of the hostname inside one lifetime.
MoveSeq uint64 `json:"move_seq,omitempty"`
// Retired marks a tombstone: the lifetime is over and the record keeps
- // only its identity and counters. GetRecord hides it; GetRecordRaw sees it.
+ // only its identity, incarnation and record_seq. GetRecord hides it; GetRecordRaw sees it.
Retired bool `json:"retired,omitempty"`
// --- replica fields (carlos/platform discussion #162, edge checkout) ---
diff --git a/internal/provision/self_serve_moves1_census_test.go b/internal/provision/self_serve_moves1_census_test.go
new file mode 100644
index 000000000..602cd25f5
--- /dev/null
+++ b/internal/provision/self_serve_moves1_census_test.go
@@ -0,0 +1,41 @@
+package provision
+
+// plan14SelfServeMoves1WriteCensusInventory is the record-write inventory for
+// the self-serve moves plan 1 (lifetimes and order), built over the roster
+// binding inventory without editing it. Marked PROPOSED-self-serve-moves-1
+// (docs/superpowers/notes/census-proposal-self-serve-moves-1.md); not an
+// accepted ruling. Later tasks of the plan extend this function.
+//
+// Task 1 (record lifetime fields, the host incarnation slot):
+// - provision.go:GetRecordETag is removed: it no longer builds a record key.
+// It reads through GetRecordRaw and hides a tombstone, and it writes nothing.
+// - lifetime.go:GetRecordRaw is a key-site (RecordKey): the raw reader that
+// sees a tombstone and a corrupt body. A read only.
+// - lifetime.go:WriteHostIncarnation is nonrecord: it Puts only
+// control/host-incarnations/<host>.json, conditionally (create-only or
+// If-Match). The key is not an instance or sidecar record key, and the
+// unit names no record-key constructor or record literal.
+func plan14SelfServeMoves1WriteCensusInventory() []censusEntry {
+ removed := []struct{ file, unit string }{
+ {"internal/provision/provision.go", "GetRecordETag"},
+ }
+ added := []censusEntry{
+ {"internal/provision/lifetime.go", "GetRecordRaw", "key-site", "key:RecordKey", "e4c422472e601a14496af407c4f9d0535a48809964d6dfbf6b06a7c2e125f077"},
+ {"internal/provision/lifetime.go", "WriteHostIncarnation", "nonrecord", "sel:Put", "c5f2719a53447c0477706600081a9846444071f39b85759a463d1ee757dc760e"},
+ }
+ entries := plan14ClaimRosterBindingWriteCensusInventory()
+ for _, r := range removed {
+ found := false
+ for i, entry := range entries {
+ if entry.File == r.file && entry.Unit == r.unit {
+ entries = append(entries[:i], entries[i+1:]...)
+ found = true
+ break
+ }
+ }
+ if !found {
+ panic("self-serve moves 1 removal of an unlisted unit " + r.file + ":" + r.unit)
+ }
+ }
+ return append(entries, added...)
+}
diff --git a/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv b/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv
new file mode 100644
index 000000000..6958f14ca
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv
@@ -0,0 +1,2 @@
+# Census overlay self-serve-moves-1, marked rev3=PROPOSED-self-serve-moves-1 (not an accepted ruling; docs/superpowers/notes/census-proposal-self-serve-moves-1.md). Task 1 adds the lifetime fields to provision.Record (incarnation, incarnation_migrating, move_seq, retired) and internal/provision/lifetime.go (the host incarnation slot, the lifetime verdict, the tombstone body, GetRecordRaw); GetRecordETag now hides a tombstone. Envelope only: no new root read, class or task, and the registry schema (@fields) is unchanged because Record is not a registry struct. internal/provision is re-pinned over the new code. The seal stands.
+@coverage internal/provision 01ddbf636836b689030613d46749eb548fab2f144eeeacdca26cae80e7559fbd