carlos / platform Public

provision: record lifetime fields, the owned host incarnation slot, the tombstone form and the lifetime verdict

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 267ea4fc8634dd64168cebb397331c01ecd57b60 parent 494b515
5 files changed, +489 −9
  • internal/provision/lifetime.go +178 −0
  • internal/provision/lifetime_test.go +265 −0
  • internal/provision/plan9_b8_census_test.go +4 −1
  • internal/provision/provision.go +21 −8
  • internal/provision/provision_test.go +21 −0
diff --git a/internal/provision/lifetime.go b/internal/provision/lifetime.go
new file mode 100644
index 000000000..9e1d4848f
--- /dev/null
+++ b/internal/provision/lifetime.go
@@ -0,0 +1,178 @@
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The host incarnation slot is the durable lifetime of a hostname on a
+// claims-off deployment: control/host-incarnations/<host>.json names the
+// record key (account and app) that owns the hostname and the incarnation
+// number of that lifetime. A record is trusted only while LifetimeOf says
+// the slot still vouches for it. The slot is written only by conditional
+// writes, so two creators of one hostname cannot both win.
+
+const (
+ // SlotLive is a lifetime in progress; SlotRetired is one that ended.
+ SlotLive = "live"
+ SlotRetired = "retired"
+)
+
+// HostIncarnation is the body of a host incarnation slot. Account and App
+// are the record key the lifetime belongs to. RecordETag is set only by a
+// migration slot, binding it to the one record body it was written for.
+type HostIncarnation struct {
+ Incarnation uint64 `json:"incarnation"`
+ State string `json:"state"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Intent string `json:"intent,omitempty"`
+ RecordETag string `json:"record_etag,omitempty"`
+}
+
+// Valid reports whether the slot is a well-formed body.
+func (h HostIncarnation) Valid() bool {
+ return h.Incarnation > 0 && (h.State == SlotLive || h.State == SlotRetired) &&
+ h.Account != "" && h.App != ""
+}
+
+// HostIncarnationKey is the slot's bucket key, or "" for a host that fails
+// ValidHostShape.
+func HostIncarnationKey(host string) string {
+ if !ValidHostShape(host) {
+ return ""
+ }
+ return "control/host-incarnations/" + host + ".json"
+}
+
+// ErrSlotMalformed marks a slot body that does not decode or is not Valid.
+var ErrSlotMalformed = errors.New("provision: host incarnation slot is malformed")
+
+// ReadHostIncarnation returns the slot and the ETag it was read under, or
+// nil, "", nil when no slot exists.
+func ReadHostIncarnation(ctx context.Context, s release.Store, host string) (*HostIncarnation, string, error) {
+ key := HostIncarnationKey(host)
+ if key == "" {
+ return nil, "", fmt.Errorf("provision: host incarnation slot: invalid host %q", host)
+ }
+ raw, etag, err := s.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ return nil, "", err
+ }
+ var slot HostIncarnation
+ if err := json.Unmarshal(raw, &slot); err != nil {
+ return nil, "", fmt.Errorf("%w: %s: %v", ErrSlotMalformed, key, err)
+ }
+ if !slot.Valid() {
+ return nil, "", fmt.Errorf("%w: %s: failed validation", ErrSlotMalformed, key)
+ }
+ return &slot, etag, nil
+}
+
+// WriteHostIncarnation writes the slot conditionally and returns the new
+// ETag. ifMatch == "" is create-only (a lost race is release.ErrCASConflict);
+// otherwise the write is guarded by If-Match.
+func WriteHostIncarnation(ctx context.Context, s release.Store, host string, slot HostIncarnation, ifMatch string) (string, error) {
+ key := HostIncarnationKey(host)
+ if key == "" {
+ return "", fmt.Errorf("provision: host incarnation slot: invalid host %q", host)
+ }
+ if !slot.Valid() {
+ return "", fmt.Errorf("%w: refusing to write an invalid slot for %s", ErrSlotMalformed, host)
+ }
+ body, err := json.Marshal(slot)
+ if err != nil {
+ return "", err
+ }
+ return s.Put(ctx, key, body, ifMatch)
+}
+
+// LifetimeVerdict is LifetimeOf's answer. LifetimeCorrupt and
+// LifetimeUnreadable are for callers that could not read the record or the
+// slot; LifetimeOf itself answers only valid or stale.
+type LifetimeVerdict int
+
+const (
+ LifetimeValid LifetimeVerdict = iota
+ LifetimeStale
+ LifetimeCorrupt
+ LifetimeUnreadable
+)
+
+// LifetimeOf decides whether the slot vouches for the record read under
+// recordETag. Pure: it reads nothing.
+func LifetimeOf(rec *Record, recordETag string, slot *HostIncarnation) LifetimeVerdict {
+ if rec == nil || rec.Retired {
+ return LifetimeStale
+ }
+ owned := slot != nil && slot.Account == rec.Account && slot.App == rec.App
+ switch {
+ case rec.Incarnation == 0 && rec.IncarnationMigrating == "":
+ if slot == nil {
+ return LifetimeValid
+ }
+ case rec.IncarnationMigrating != "":
+ if slot == nil {
+ return LifetimeValid
+ }
+ if slot.Incarnation == 1 && slot.State == SlotLive && owned &&
+ slot.Intent == "migration "+rec.IncarnationMigrating && slot.RecordETag == recordETag {
+ return LifetimeValid
+ }
+ default:
+ if owned && slot.State == SlotLive && slot.Incarnation == rec.Incarnation {
+ return LifetimeValid
+ }
+ }
+ return LifetimeStale
+}
+
+// TombstoneBody turns a record body into its tombstone: schema, host,
+// account, app, incarnation and record_seq (the last two only when present)
+// are kept, retired is set, everything else is dropped. Write it with
+// WriteRecord against before, so record_seq advances by one.
+func TombstoneBody(before []byte) ([]byte, error) {
+ fields, _, err := RecordSequence(before)
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]json.RawMessage{}
+ for _, name := range []string{"schema", "host", "account", "app", "incarnation", "record_seq"} {
+ if v, ok := fields[name]; ok {
+ out[name] = v
+ }
+ }
+ out["retired"] = json.RawMessage(strconv.FormatBool(true))
+ return json.Marshal(out)
+}
+
+// ErrRecordCorrupt marks a record body that does not decode or disagrees
+// with its own path.
+var ErrRecordCorrupt = errors.New("provision: record is corrupt")
+
+// GetRecordRaw reads one record including a tombstone. A corrupt body
+// returns ErrRecordCorrupt with raw and etag still set, so a caller can act
+// on exactly what it observed. A store error returns no bytes.
+func GetRecordRaw(ctx context.Context, s release.Store, acct, app, host string) (rec *Record, raw []byte, etag string, err error) {
+ raw, etag, err = s.Get(ctx, RecordKey(acct, app, host))
+ if err != nil {
+ return nil, nil, "", err
+ }
+ decoded, err := DecodeRecord(raw)
+ if err != nil {
+ return nil, raw, etag, fmt.Errorf("provision: record %s/%s/%s: %w: %w", acct, app, host, ErrRecordCorrupt, err)
+ }
+ if decoded.Account != acct || decoded.App != app || decoded.Host != host {
+ return nil, raw, etag, fmt.Errorf("provision: record %s/%s/%s: %w: record disagrees with its own path", acct, app, host, ErrRecordCorrupt)
+ }
+ return &decoded, raw, etag, nil
+}
diff --git a/internal/provision/lifetime_test.go b/internal/provision/lifetime_test.go
new file mode 100644
index 000000000..81db68557
--- /dev/null
+++ b/internal/provision/lifetime_test.go
@@ -0,0 +1,265 @@
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestLifetimeOf(t *testing.T) {
+ live := func(inc uint64) *HostIncarnation {
+ return &HostIncarnation{Incarnation: inc, State: SlotLive, Account: "bab", App: "shop"}
+ }
+ migration := func(marker, etag string) *HostIncarnation {
+ return &HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "shop",
+ Intent: "migration " + marker, RecordETag: etag}
+ }
+ base := Record{Schema: 1, Host: "shop.example.com", App: "shop", Account: "bab"}
+ with := func(f func(*Record)) *Record { r := base; f(&r); return &r }
+
+ retiredSlot := live(3)
+ retiredSlot.State = SlotRetired
+ otherApp := live(3)
+ otherApp.App = "other"
+
+ cases := []struct {
+ name string
+ rec *Record
+ etag string
+ slot *HostIncarnation
+ want LifetimeVerdict
+ }{
+ {"retired record, no slot", with(func(r *Record) { r.Retired = true; r.Incarnation = 3 }), "e", nil, LifetimeStale},
+ {"retired record, live slot", with(func(r *Record) { r.Retired = true; r.Incarnation = 3 }), "e", live(3), LifetimeStale},
+ {"fieldless, no slot", &base, "e", nil, LifetimeValid},
+ {"fieldless, slot present", &base, "e", live(1), LifetimeStale},
+ {"marked, no slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", nil, LifetimeValid},
+ {"marked, matching migration slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", migration("m1", "e"), LifetimeValid},
+ {"marked, different etag", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e2", migration("m1", "e"), LifetimeStale},
+ {"marked, different marker", with(func(r *Record) { r.IncarnationMigrating = "m2" }), "e", migration("m1", "e"), LifetimeStale},
+ {"marked, plain live slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", live(1), LifetimeStale},
+ {"marked, migration slot of another app", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e",
+ &HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "other", Intent: "migration m1", RecordETag: "e"}, LifetimeStale},
+ {"incarnation, matching live slot", with(func(r *Record) { r.Incarnation = 3 }), "e", live(3), LifetimeValid},
+ {"incarnation, no slot", with(func(r *Record) { r.Incarnation = 3 }), "e", nil, LifetimeStale},
+ {"incarnation, other number", with(func(r *Record) { r.Incarnation = 3 }), "e", live(4), LifetimeStale},
+ {"incarnation, retired slot", with(func(r *Record) { r.Incarnation = 3 }), "e", retiredSlot, LifetimeStale},
+ {"incarnation, slot owned by another app", with(func(r *Record) { r.Incarnation = 3 }), "e", otherApp, LifetimeStale},
+ }
+ for _, c := range cases {
+ if got := LifetimeOf(c.rec, c.etag, c.slot); got != c.want {
+ t.Errorf("%s: LifetimeOf = %d, want %d", c.name, got, c.want)
+ }
+ }
+}
+
+func TestHostIncarnationKey(t *testing.T) {
+ if got := HostIncarnationKey("shop.example.com"); got != "control/host-incarnations/shop.example.com.json" {
+ t.Errorf("key = %q", got)
+ }
+ for _, bad := range []string{"", "../x", "a/b", "UPPER space"} {
+ if got := HostIncarnationKey(bad); got != "" {
+ t.Errorf("HostIncarnationKey(%q) = %q, want empty", bad, got)
+ }
+ }
+}
+
+func TestHostIncarnationWrites(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ const host = "shop.example.com"
+ slot := HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "shop"}
+
+ if got, etag, err := ReadHostIncarnation(ctx, s, host); got != nil || etag != "" || err != nil {
+ t.Fatalf("absent read = %v %q %v, want nil, empty, nil", got, etag, err)
+ }
+ etag1, err := WriteHostIncarnation(ctx, s, host, slot, "")
+ if err != nil || etag1 == "" {
+ t.Fatalf("create-only: %q %v", etag1, err)
+ }
+ if _, err := WriteHostIncarnation(ctx, s, host, slot, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create-only err = %v, want ErrCASConflict", err)
+ }
+ got, readTag, err := ReadHostIncarnation(ctx, s, host)
+ if err != nil || got == nil || *got != slot || readTag != etag1 {
+ t.Fatalf("read = %+v %q %v", got, readTag, err)
+ }
+ retired := slot
+ retired.State = SlotRetired
+ etag2, err := WriteHostIncarnation(ctx, s, host, retired, etag1)
+ if err != nil {
+ t.Fatalf("If-Match with the returned ETag: %v", err)
+ }
+ if _, err := WriteHostIncarnation(ctx, s, host, slot, etag1); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("stale ETag err = %v, want ErrCASConflict", err)
+ }
+ 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"},
+ {Incarnation: 1, State: "bogus", Account: "bab", App: "shop"},
+ {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 keys, _ := s.List(ctx, "control/host-incarnations/other"); len(keys) != 0 {
+ t.Errorf("an invalid slot left objects behind: %v", keys)
+ }
+
+ for _, body := range []string{`not json`, `{"incarnation":0,"state":"live","account":"bab","app":"shop"}`} {
+ if _, err := s.Put(ctx, HostIncarnationKey("bad.example.com"), []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := ReadHostIncarnation(ctx, s, "bad.example.com"); !errors.Is(err, ErrSlotMalformed) {
+ t.Errorf("body %q read err = %v, want ErrSlotMalformed", body, err)
+ }
+ if err := s.Delete(ctx, HostIncarnationKey("bad.example.com")); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestTombstoneBodyKeepsTheSequence(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := Record{Schema: 1, Host: "shop.example.com", Slug: "shop", App: "shop", Account: "bab",
+ Pool: "p", Region: "ie", Channel: "stable", Gate: "hash", Incarnation: 2, MoveSeq: 5, RecordSeq: 7,
+ Replicas: []string{"uk"}}
+ before, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := RecordKey("bab", "shop", "shop.example.com")
+ etag, err := s.Put(ctx, key, before, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := TombstoneBody(before)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(body, &fields); err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for k := range fields {
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ if got := strings.Join(names, ","); got != "account,app,host,incarnation,record_seq,retired,schema" {
+ t.Fatalf("tombstone keys = %s", got)
+ }
+ if string(fields["record_seq"]) != "7" || string(fields["retired"]) != "true" || string(fields["incarnation"]) != "2" {
+ t.Errorf("tombstone = %s", body)
+ }
+ if _, _, err := WriteRecord(ctx, s, key, before, body, etag); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var written Record
+ if err := json.Unmarshal(raw, &written); err != nil {
+ t.Fatal(err)
+ }
+ if written.RecordSeq != 8 || !written.Retired {
+ t.Errorf("written tombstone = %s, want record_seq 8 and retired", raw)
+ }
+
+ legacy := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","region":"ie"}`)
+ body, err = TombstoneBody(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fields = nil
+ if err := json.Unmarshal(body, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if len(fields) != 5 {
+ // schema, host, account, app, retired: no incarnation, no sequence.
+ t.Errorf("legacy tombstone = %s, want five keys", body)
+ }
+ if _, ok := fields["record_seq"]; ok {
+ t.Errorf("legacy tombstone grew a sequence: %s", body)
+ }
+
+ if _, err := TombstoneBody([]byte(`not json`)); err == nil {
+ t.Error("TombstoneBody accepted a body that is not JSON")
+ }
+}
+
+func TestGetRecordHidesATombstone(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ const host = "shop.example.com"
+ key := RecordKey("bab", "shop", host)
+ before := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","incarnation":2,"record_seq":3}`)
+ etag, err := s.Put(ctx, key, before, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec, _, err := GetRecordETag(ctx, s, "bab", "shop", host); err != nil || rec.Incarnation != 2 {
+ t.Fatalf("live read = %+v %v", rec, err)
+ }
+ body, err := TombstoneBody(before)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := WriteRecord(ctx, s, key, before, body, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := GetRecordETag(ctx, s, "bab", "shop", host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("GetRecordETag on a tombstone err = %v, want os.ErrNotExist", err)
+ }
+ if _, err := GetRecord(ctx, s, "bab", "shop", host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("GetRecord on a tombstone err = %v, want os.ErrNotExist", err)
+ }
+ rec, raw, tag, err := GetRecordRaw(ctx, s, "bab", "shop", host)
+ if err != nil || rec == nil || !rec.Retired || rec.Incarnation != 2 || len(raw) == 0 || tag == "" {
+ t.Errorf("GetRecordRaw = %+v %s %q %v, want the tombstone", rec, raw, tag, err)
+ }
+ if _, _, _, err := GetRecordRaw(ctx, s, "bab", "shop", "absent.example.com"); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("GetRecordRaw on an absent record err = %v, want os.ErrNotExist", err)
+ }
+}
+
+func TestGetRecordRawReportsCorruptWithTheETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ cases := map[string]string{
+ "not json": `not json`,
+ "wrong host": `{"schema":1,"host":"other.example.com","app":"shop","account":"bab"}`,
+ "wrong account": `{"schema":1,"host":"shop.example.com","app":"shop","account":"zzz"}`,
+ }
+ for name, body := range cases {
+ key := RecordKey("bab", "shop", "shop.example.com")
+ etag, err := s.Put(ctx, key, []byte(body), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, raw, tag, err := GetRecordRaw(ctx, s, "bab", "shop", "shop.example.com")
+ if !errors.Is(err, ErrRecordCorrupt) || rec != nil || string(raw) != body || tag != etag {
+ t.Errorf("%s: GetRecordRaw = %+v %q %q %v, want ErrRecordCorrupt with raw and etag", name, rec, raw, tag, err)
+ }
+ if _, _, err := GetRecordETag(ctx, s, "bab", "shop", "shop.example.com"); err == nil {
+ t.Errorf("%s: GetRecordETag accepted a corrupt record", name)
+ }
+ if err := s.Delete(ctx, key); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 094df27bd..fb59372d7 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -984,7 +984,10 @@ 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"},
- {"internal/provision/provision.go", "GetRecordETag", "key-site", "key:RecordKey", "d9f71e5a52091dce7ea6b6aa3e5d725456d5944efa180727674e7df0006d4fa5"},
+ // 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"},
// 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 a5657b0cb..57c8fa916 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -172,6 +172,23 @@ type Record struct {
// (reconcile.go) and read by the edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md.
Gate string `json:"gate,omitempty"`
+ // --- lifetime fields (self-serve moves, lifetime.go) ---
+
+ // Incarnation names which lifetime of this hostname the record belongs
+ // to; the owned slot control/host-incarnations/<host>.json carries the
+ // same number while the lifetime is live. Zero with no marker is a
+ // fieldless legacy record. A counter, never reused.
+ Incarnation uint64 `json:"incarnation,omitempty"`
+ // IncarnationMigrating is the marker a record carries while it is being
+ // moved into a slot-governed lifetime; LifetimeOf checks it against the
+ // migration slot's intent.
+ IncarnationMigrating string `json:"incarnation_migrating,omitempty"`
+ // 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.
+ Retired bool `json:"retired,omitempty"`
+
// --- replica fields (carlos/platform discussion #162, edge checkout) ---
// Role is what this instance IS within its app: "" (the default every
@@ -2775,18 +2792,14 @@ func GetRecord(ctx context.Context, s release.Store, acct, app, host string) (*R
// (UpdateRecord), not against whatever is there at write time. Same seam,
// same reason, as GetManifestETag beside PutManifestIfMatch.
func GetRecordETag(ctx context.Context, s release.Store, acct, app, host string) (*Record, string, error) {
- raw, etag, err := s.Get(ctx, RecordKey(acct, app, host))
+ rec, _, etag, err := GetRecordRaw(ctx, s, acct, app, host)
if err != nil {
return nil, "", err
}
- rec, err := DecodeRecord(raw)
- if err != nil {
- return nil, "", fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err)
- }
- if rec.Account != acct || rec.App != app || rec.Host != host {
- return nil, "", fmt.Errorf("provision: record %s/%s/%s: record disagrees with its own path", acct, app, host)
+ if rec.Retired {
+ return nil, "", fmt.Errorf("provision: record %s/%s/%s: retired: %w", acct, app, host, os.ErrNotExist)
}
- return &rec, etag, nil
+ return rec, etag, nil
}
// recordHosts picks the record hosts out of a listing of RecordsPrefix:
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 596506be4..81591bab8 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -58,6 +58,27 @@ func TestRecordWireShapeIsAdditive(t *testing.T) {
if !rec.HibernateOrDefault() {
t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today")
}
+ // The lifetime fields: a legacy record is fieldless, and a record that
+ // carries none of them marshals none of them.
+ if rec.Incarnation != 0 || rec.IncarnationMigrating != "" || rec.MoveSeq != 0 || rec.Retired {
+ t.Errorf("lifetime fields = %d %q %d %v, want all zero", rec.Incarnation, rec.IncarnationMigrating, rec.MoveSeq, rec.Retired)
+ }
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"incarnation", "incarnation_migrating", "move_seq", "retired"} {
+ if strings.Contains(string(body), `"`+key+`"`) {
+ t.Errorf("marshalled legacy record carries %q: %s", key, body)
+ }
+ }
+ var with Record
+ if err := json.Unmarshal([]byte(`{"incarnation":18446744073709551615,"incarnation_migrating":"m1","move_seq":4,"retired":true}`), &with); err != nil {
+ t.Fatal(err)
+ }
+ if with.Incarnation != ^uint64(0) || with.IncarnationMigrating != "m1" || with.MoveSeq != 4 || !with.Retired {
+ t.Errorf("lifetime fields did not round trip: %+v", with)
+ }
}
// The console rewrites records (plan 2's PUT). A rewrite must not sprout