carlos / platform Public

provision: RetireRef states its expectation, and the judged record evidence and host listing the purge and restore settlement read

RetireExpectation{Absent, ETag, Incarnation} rides on RetireRef.Expect for
the claims-off lifetime retirement; the claims-on chain and the legacy
delete ignore it. RecordEvidenceJudged is the three-valued judged sibling
of ListRecordsComplete (valid records, and unknown for an unreadable or
corrupt key or more keys than the cap; a stale body, a tombstone or a key
gone since the listing counts for nothing). ListRecordHosts and
IsInstanceRecordKeyOf name an app's instance record keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c354c191a49f6903fca025bd23051c9b250cbe85 parent 766641c
4 files changed, +228 −0
  • internal/provision/lifetime_judge.go +58 −0
  • internal/provision/lifetime_judge_test.go +96 −0
  • internal/provision/retirement.go +38 −0
  • internal/provision/retirement_test.go +36 −0
diff --git a/internal/provision/lifetime_judge.go b/internal/provision/lifetime_judge.go
index ef194d510..49fa96525 100644
--- a/internal/provision/lifetime_judge.go
+++ b/internal/provision/lifetime_judge.go
@@ -171,6 +171,64 @@ func ListRecordsCompleteJudged(ctx context.Context, s release.Store, j *Lifetime
return recs, truncated, skipped, nil
}
+// RecordEvidenceJudged is the judged sibling of ListRecordsComplete for a
+// caller deciding whether an app still lives from its instance records
+// (self-serve moves plan 1, Task 4): the records the slot vouches for, and
+// unknown when any key could not be judged. Three values per key: a valid
+// record is evidence; a stale body, a tombstone or a key gone since the
+// listing contributes nothing; an unreadable or corrupt observation, or more
+// keys than max, makes the whole answer unknown. A failed listing is an
+// error. j must not be nil.
+func RecordEvidenceJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, unknown bool, err error) {
+ if j == nil {
+ return nil, false, errors.New("provision: record evidence needs a lifetime judge")
+ }
+ if max < 0 {
+ max = 0
+ }
+ prefix := RecordsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ hosts := recordHosts(keys, prefix)
+ sort.Strings(hosts)
+ if len(hosts) > max {
+ unknown = true
+ hosts = hosts[:max]
+ }
+ for _, host := range hosts {
+ obs := j.Observe(ctx, s, acct, app, host)
+ switch {
+ case obs.Verdict == LifetimeUnreadable || obs.Verdict == LifetimeCorrupt:
+ unknown = true
+ case !obs.Present || obs.Verdict == LifetimeStale || obs.Rec == nil || obs.Rec.Retired:
+ default:
+ recs = append(recs, *obs.Rec)
+ }
+ }
+ return recs, unknown, nil
+}
+
+// ListRecordHosts lists the hosts that have an instance record key under
+// acct/app, sorted. It reads no body. IsInstanceRecordKeyOf is its key test.
+func ListRecordHosts(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
+ prefix := RecordsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ hosts := recordHosts(keys, prefix)
+ sort.Strings(hosts)
+ return hosts, nil
+}
+
+// IsInstanceRecordKeyOf reports whether key is an instance record key of
+// acct/app: <RecordsPrefix>/<host>.json, nothing nested.
+func IsInstanceRecordKeyOf(key, acct, app string) bool {
+ return len(recordHosts([]string{key}, RecordsPrefix(acct, app))) == 1
+}
+
// ListRecordsStrictJudged is ListRecordsStrict under a judge: stale records
// are dropped silently, a corrupt body is reported in unreadable, and an
// unreadable observation fails the listing. A nil judge is ListRecordsStrict.
diff --git a/internal/provision/lifetime_judge_test.go b/internal/provision/lifetime_judge_test.go
index ed0224990..24fd37f79 100644
--- a/internal/provision/lifetime_judge_test.go
+++ b/internal/provision/lifetime_judge_test.go
@@ -795,3 +795,99 @@ func TestJudgedTolerantListingCorruptBodyKeepsCompleteTrue(t *testing.T) {
})
}
}
+
+// Restore evidence from instance records is three-valued (self-serve moves
+// plan 1, Task 4): a valid record is evidence, a stale body, a tombstone or
+// a key gone since the listing is nothing, and a corrupt or unreadable one,
+// or more keys than the cap, is unknown.
+func TestRecordEvidenceJudgedIsThreeValued(t *testing.T) {
+ ctx := context.Background()
+ j := NewLifetimeJudge()
+ put := func(t *testing.T, s release.Store, host string, slot *HostIncarnation, body any) {
+ t.Helper()
+ rec := judgeRecord()
+ rec.Host = host
+ if body == nil {
+ judgePut(t, s, RecordKey("bab", "shop", host), rec)
+ } else if _, err := s.Put(ctx, RecordKey("bab", "shop", host), body.([]byte), ""); err != nil {
+ t.Fatal(err)
+ }
+ if slot != nil {
+ judgePut(t, s, HostIncarnationKey(host), *slot)
+ }
+ }
+ live, retired := judgeSlot(1, SlotLive), judgeSlot(1, SlotRetired)
+ cases := []struct {
+ name string
+ arrange func(t *testing.T, s release.Store)
+ getErr string
+ max int
+ valid int
+ unknown bool
+ }{
+ {"a valid record", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &live, nil) }, "", 10, 1, false},
+ {"a stale body", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &retired, nil) }, "", 10, 0, false},
+ {"a tombstone", func(t *testing.T, s release.Store) {
+ rec := judgeRecord()
+ rec.Host = "a.example.com"
+ raw, _ := json.Marshal(rec)
+ tomb, _ := TombstoneBody(raw)
+ put(t, s, "a.example.com", &live, tomb)
+ }, "", 10, 0, false},
+ {"a corrupt body", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", nil, []byte("{")) }, "", 10, 0, true},
+ {"an unreadable key", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &live, nil) }, "a.example.com", 10, 0, true},
+ {"more keys than the cap", func(t *testing.T, s release.Store) {
+ put(t, s, "a.example.com", &live, nil)
+ put(t, s, "b.example.com", &retired, nil)
+ }, "", 1, 1, true},
+ {"a valid record beside a stale one", func(t *testing.T, s release.Store) {
+ put(t, s, "a.example.com", &live, nil)
+ put(t, s, "b.example.com", &retired, nil)
+ }, "", 10, 1, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ base := release.NewDirStore(t.TempDir())
+ c.arrange(t, base)
+ s := &judgeStore{Store: base, getError: func(k string) error {
+ if c.getErr != "" && k == RecordKey("bab", "shop", c.getErr) {
+ return os.ErrPermission
+ }
+ return nil
+ }}
+ recs, unknown, err := RecordEvidenceJudged(ctx, s, j, "bab", "shop", c.max)
+ if err != nil || len(recs) != c.valid || unknown != c.unknown {
+ t.Fatalf("evidence = %d records, unknown %v, %v; want %d, %v", len(recs), unknown, err, c.valid, c.unknown)
+ }
+ })
+ }
+ t.Run("a key gone since the listing", func(t *testing.T) {
+ base := release.NewDirStore(t.TempDir())
+ put(t, base, "a.example.com", &live, nil)
+ s := &judgeStore{Store: base, afterList: func(string) {
+ _ = base.Delete(ctx, RecordKey("bab", "shop", "a.example.com"))
+ }}
+ recs, unknown, err := RecordEvidenceJudged(ctx, s, j, "bab", "shop", 10)
+ if err != nil || len(recs) != 0 || unknown {
+ t.Fatalf("evidence = %d, unknown %v, %v; want nothing, known", len(recs), unknown, err)
+ }
+ })
+ if _, _, err := RecordEvidenceJudged(ctx, release.NewDirStore(t.TempDir()), nil, "bab", "shop", 10); err == nil {
+ t.Fatal("a nil judge gave evidence")
+ }
+}
+
+func TestInstanceRecordKeyOfIsTheRecordKeyShape(t *testing.T) {
+ for key, want := range map[string]bool{
+ RecordKey("bab", "shop", "a.example.com"): true,
+ "apps/bab/shop/instances.json": false,
+ "apps/bab/shop/instances/x/y.json": false,
+ RecordKey("bab", "web", "a.example.com"): false,
+ "apps/bab/shop/sidecars/job.json": false,
+ "apps/bab/shop/channels/stable.json": false,
+ } {
+ if got := IsInstanceRecordKeyOf(key, "bab", "shop"); got != want {
+ t.Fatalf("IsInstanceRecordKeyOf(%q) = %v, want %v", key, got, want)
+ }
+ }
+}
diff --git a/internal/provision/retirement.go b/internal/provision/retirement.go
index aab7a2564..38baba68c 100644
--- a/internal/provision/retirement.go
+++ b/internal/provision/retirement.go
@@ -12,7 +12,45 @@ type RetireRef struct {
Family, Account, App, Host, Name, By string
// Required by rollbacks: never retire a successor to the failed attempt.
Incarnation string
+ // Expect is what a claims-off retirement admitted with lifetimes on
+ // requires of the record before it acts (self-serve moves plan 1, Task
+ // 4). The claims-on branch and the legacy delete ignore it.
+ Expect RetireExpectation
}
+
+// RetireExpectation is a claims-off caller's statement of which record it
+// means to retire. At most one field is set; the zero value is "whatever is
+// current" (a detach).
+//
+// - Absent: the caller saw no record. A record present now is someone
+// else's and is left untouched; an absent key still has its owned live
+// slot retired.
+// - ETag: the caller read the record under this ETag. Any other body, or
+// none, is a CAS conflict.
+// - Incarnation: the caller made this lifetime. A record of any other
+// lifetime, or none, is left untouched.
+type RetireExpectation struct {
+ Absent bool
+ ETag string
+ Incarnation uint64
+}
+
+// Set reports how many of the expectation's fields are set; more than one
+// is a programming error.
+func (e RetireExpectation) Set() int {
+ n := 0
+ if e.Absent {
+ n++
+ }
+ if e.ETag != "" {
+ n++
+ }
+ if e.Incarnation != 0 {
+ n++
+ }
+ return n
+}
+
type ClaimRetirer interface {
RetireRecord(context.Context, RetireRef) error
}
diff --git a/internal/provision/retirement_test.go b/internal/provision/retirement_test.go
index e0a4f45f4..a31dee3ac 100644
--- a/internal/provision/retirement_test.go
+++ b/internal/provision/retirement_test.go
@@ -39,3 +39,39 @@ func TestRetireRecordFlagOffAndRequiredProvider(t *testing.T) {
t.Fatal("claim path not required")
}
}
+
+// RetireExpectation states at most one thing; the claims-on and legacy
+// branches carry it without reading it (self-serve moves plan 1, Task 4).
+func TestRetireExpectationSetCountsEachField(t *testing.T) {
+ for _, c := range []struct {
+ e RetireExpectation
+ want int
+ }{
+ {RetireExpectation{}, 0},
+ {RetireExpectation{Absent: true}, 1},
+ {RetireExpectation{ETag: "e"}, 1},
+ {RetireExpectation{Incarnation: 2}, 1},
+ {RetireExpectation{Absent: true, ETag: "e"}, 2},
+ {RetireExpectation{Absent: true, ETag: "e", Incarnation: 3}, 3},
+ } {
+ if got := c.e.Set(); got != c.want {
+ t.Fatalf("%+v.Set() = %d, want %d", c.e, got, c.want)
+ }
+ }
+}
+
+func TestRetireRecordLegacyIgnoresTheExpectation(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ rec := Record{Schema: 1, Account: "bab", App: "shop", Host: "one.shop.example.com", Channel: "stable"}
+ if err := CreateRecord(ctx, store, rec); err != nil {
+ t.Fatal(err)
+ }
+ ref := RetireRef{Family: "instance", Account: "bab", App: "shop", Host: rec.Host, Expect: RetireExpectation{Incarnation: 9}}
+ if err := RetireRecord(ctx, store, ref, RetireOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := GetRecord(ctx, store, "bab", "shop", rec.Host); err == nil {
+ t.Fatal("the legacy delete read the expectation and kept the record")
+ }
+}