carlos / platform Public

provision: lifetime judge review fixes: corrupt bodies keep the tolerant listing complete, absent-with-slot is final, a dedicated unreadable error, doc comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5ed73da6b9cceac2100eb2f8fad1e930dccfe129 parent 81b2731
6 files changed, +100 −11
  • internal/provision/lifetime_judge.go +40 −2
  • internal/provision/lifetime_judge_test.go +42 −5
  • internal/provision/provision.go +6 −0
  • internal/provision/rootrecord_test.go +10 −2
  • internal/provision/self_serve_moves1_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv +1 −1
diff --git a/internal/provision/lifetime_judge.go b/internal/provision/lifetime_judge.go
index 1a9a6c017..ef194d510 100644
--- a/internal/provision/lifetime_judge.go
+++ b/internal/provision/lifetime_judge.go
@@ -13,6 +13,11 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// Observation is one judged read of a host's record and incarnation slot.
+// Present says whether a record body exists; Raw and ETag are the exact body
+// and version a caller may act on; Slot and SlotETag are the slot read after
+// it. Verdict is the lifetime answer and Err explains a corrupt or unreadable
+// one.
type Observation struct {
Present bool
Rec *Record
@@ -24,8 +29,16 @@ type Observation struct {
Err error
}
+// LifetimeJudge reads a record and its host incarnation slot and says whether
+// the slot still vouches for the record. A nil *LifetimeJudge is the
+// claims-on deployment: it reads no slot and judges every record valid.
type LifetimeJudge struct{}
+// ErrLifetimeUnreadable marks a lifetime that could not be judged because the
+// observation never settled: the slot kept changing under the reads.
+var ErrLifetimeUnreadable = errors.New("provision: lifetime could not be judged")
+
+// NewLifetimeJudge returns a judge for a claims-off deployment.
func NewLifetimeJudge() *LifetimeJudge { return &LifetimeJudge{} }
// Observe reads the record before the slot so a retired lifetime cannot
@@ -76,6 +89,10 @@ func (j *LifetimeJudge) ObserveWith(ctx context.Context, s release.Store, acct,
if !obs.Present && slot == nil {
return obs
}
+ if !obs.Present {
+ obs.Verdict = LifetimeStale
+ return obs
+ }
obs.Verdict = LifetimeOf(obs.Rec, obs.ETag, slot)
if obs.Verdict == LifetimeValid {
return obs
@@ -85,10 +102,13 @@ func (j *LifetimeJudge) ObserveWith(ctx context.Context, s release.Store, acct,
}
previousSlotETag = slotETag
}
- obs.Verdict, obs.Err = LifetimeUnreadable, fmt.Errorf("%s: %w", host, ErrRecordIndexRetry)
+ obs.Verdict, obs.Err = LifetimeUnreadable, fmt.Errorf("%s: %w", host, ErrLifetimeUnreadable)
return obs
}
+// observationRecord returns the record an observation vouches for, or the
+// error a reader should report: the observation's own error for a corrupt or
+// unreadable one, os.ErrNotExist for an absent, stale or retired one.
func observationRecord(obs Observation) (*Record, string, error) {
if obs.Verdict == LifetimeUnreadable || obs.Verdict == LifetimeCorrupt {
return nil, "", obs.Err
@@ -99,6 +119,8 @@ func observationRecord(obs Observation) (*Record, string, error) {
return obs.Rec, obs.ETag, nil
}
+// ReadRecordJudged is GetRecordETag under a judge: a record the slot no longer
+// vouches for reads as os.ErrNotExist. A nil judge is GetRecordETag.
func ReadRecordJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app, host string) (*Record, string, error) {
if j == nil {
return GetRecordETag(ctx, s, acct, app, host)
@@ -106,11 +128,16 @@ func ReadRecordJudged(ctx context.Context, s release.Store, j *LifetimeJudge, ac
return observationRecord(j.Observe(ctx, s, acct, app, host))
}
+// ListRecordsJudged is ListRecords under a judge: stale records are dropped
+// silently. A nil judge is ListRecords.
func ListRecordsJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, truncated bool, err error) {
recs, truncated, _, err = ListRecordsCompleteJudged(ctx, s, j, acct, app, max)
return recs, truncated, err
}
+// ListRecordsCompleteJudged is ListRecordsComplete under a judge: stale
+// records are dropped silently and unreadable observations are reported in
+// skipped. A nil judge is ListRecordsComplete.
func ListRecordsCompleteJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, truncated bool, skipped []string, err error) {
if j == nil {
return ListRecordsComplete(ctx, s, acct, app, max)
@@ -144,6 +171,9 @@ func ListRecordsCompleteJudged(ctx context.Context, s release.Store, j *Lifetime
return recs, truncated, skipped, nil
}
+// 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.
func ListRecordsStrictJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, unreadable map[string]bool, err error) {
if j == nil {
return ListRecordsStrict(ctx, s, acct, app, max)
@@ -180,6 +210,8 @@ func ListRecordsStrictJudged(ctx context.Context, s release.Store, j *LifetimeJu
return recs, unreadable, nil
}
+// ListAllRecordsStrictJudged is ListAllRecordsStrict under a judge. A nil
+// judge is ListAllRecordsStrict.
func ListAllRecordsStrictJudged(ctx context.Context, s release.Store, j *LifetimeJudge) (recs []Record, unreadable map[string]bool, err error) {
if j == nil {
return ListAllRecordsStrict(ctx, s)
@@ -188,6 +220,10 @@ func ListAllRecordsStrictJudged(ctx context.Context, s release.Store, j *Lifetim
return recs, unreadable, err
}
+// ListAllRecordsTolerantJudged is ListAllRecordsTolerant under a judge: stale
+// records are dropped silently, a corrupt body leaves complete true, and an
+// unreadable observation makes it false. A nil judge is
+// ListAllRecordsTolerant.
func ListAllRecordsTolerantJudged(ctx context.Context, s release.Store, j *LifetimeJudge) (recs []Record, complete bool, err error) {
if j == nil {
return ListAllRecordsTolerant(ctx, s)
@@ -196,6 +232,9 @@ func ListAllRecordsTolerantJudged(ctx context.Context, s release.Store, j *Lifet
return recs, complete, err
}
+// readAllRecordsJudged is the judged sibling of readAllRecords. A corrupt body
+// goes in unreadable and leaves complete true, as it does there; complete is
+// false only for an unreadable observation or a partial listing.
func readAllRecordsJudged(ctx context.Context, s release.Store, j *LifetimeJudge, strict bool) (recs []Record, complete bool, unreadable map[string]bool, err error) {
complete = true
unreadable = map[string]bool{}
@@ -243,7 +282,6 @@ func readAllRecordsJudged(ctx context.Context, s release.Store, j *LifetimeJudge
if obs.Verdict == LifetimeCorrupt {
log.Printf("provision: skipping record %s: malformed: %v", key, obs.Err)
unreadable[host] = true
- complete = false
continue
}
if !obs.Present || obs.Verdict == LifetimeUnreadable {
diff --git a/internal/provision/lifetime_judge_test.go b/internal/provision/lifetime_judge_test.go
index 6609b9d52..ed0224990 100644
--- a/internal/provision/lifetime_judge_test.go
+++ b/internal/provision/lifetime_judge_test.go
@@ -210,7 +210,7 @@ func TestJudgeAcrossConcurrentLifetimeChanges(t *testing.T) {
}
}}
obs := judgeObserve(s, NewLifetimeJudge())
- if obs.Verdict != LifetimeUnreadable || obs.Err == nil || !strings.Contains(obs.Err.Error(), judgeHost) || count != 3 || !obs.Present || !bytes.Equal(obs.Raw, raw) || obs.ETag != tag || obs.SlotETag != lastTag {
+ if obs.Verdict != LifetimeUnreadable || !errors.Is(obs.Err, ErrLifetimeUnreadable) || errors.Is(obs.Err, ErrRecordIndexRetry) || !strings.Contains(obs.Err.Error(), judgeHost) || count != 3 || !obs.Present || !bytes.Equal(obs.Raw, raw) || obs.ETag != tag || obs.SlotETag != lastTag {
t.Fatalf("exhaustion %+v rounds %d", obs, count)
}
})
@@ -530,7 +530,7 @@ func TestJudgedListingsDropStaleKeepValid(t *testing.T) {
}
recs, complete, err := ListAllRecordsTolerantJudged(ctx, s, j)
check(recs)
- if err != nil || complete != (bad == "none") {
+ if err != nil || complete != (bad != "unreadable") {
t.Fatalf("tolerant %v %v", complete, err)
}
})
@@ -630,14 +630,15 @@ func TestJudgeAbsentCorruptAndStableStale(t *testing.T) {
s := &judgeStore{Store: base, beforeGet: func(k string) { reads++ }}
obs := judgeObserve(s, NewLifetimeJudge())
verdict := LifetimeStale
- rounds := 2
+ rounds := 1
+ if mode == "stale" || mode == "tombstone" {
+ rounds = 2
+ }
if mode == "absent-no-slot" {
verdict = LifetimeValid
- rounds = 1
}
if mode == "corrupt" || mode == "mismatch" {
verdict = LifetimeCorrupt
- rounds = 1
}
if obs.Present != !strings.HasPrefix(mode, "absent") || obs.Verdict != verdict || !bytes.Equal(obs.Raw, raw) || obs.ETag != tag || obs.SlotETag != slotTag || reads != 2*rounds {
t.Fatalf("observation %+v reads %d", obs, reads)
@@ -758,3 +759,39 @@ func TestJudgedListingCapCountsKeys(t *testing.T) {
}
}
}
+
+func TestJudgedTolerantListingCorruptBodyKeepsCompleteTrue(t *testing.T) {
+ for _, mode := range []string{"corrupt", "unreadable"} {
+ t.Run(mode, func(t *testing.T) {
+ base := release.NewDirStore(t.TempDir())
+ rec := judgeRecord()
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ judgePut(t, base, key, rec)
+ judgePut(t, base, HostIncarnationKey(rec.Host), judgeSlot(1, SlotLive))
+ if mode == "corrupt" {
+ putReplace(t, base, key, []byte("broken"))
+ }
+ s := &judgeStore{Store: base, getError: func(k string) error {
+ if mode == "unreadable" && k == HostIncarnationKey(rec.Host) {
+ return os.ErrPermission
+ }
+ return nil
+ }}
+ ctx := context.Background()
+ recs, complete, unreadable, err := readAllRecordsJudged(ctx, s, NewLifetimeJudge(), false)
+ if err != nil || len(recs) != 0 {
+ t.Fatalf("recs %+v err %v", recs, err)
+ }
+ if mode == "corrupt" && (!complete || !unreadable[rec.Host]) {
+ t.Fatalf("corrupt body: complete %v unreadable %v", complete, unreadable)
+ }
+ if mode == "unreadable" && (complete || unreadable[rec.Host]) {
+ t.Fatalf("unreadable observation: complete %v unreadable %v", complete, unreadable)
+ }
+ _, got, err := ListAllRecordsTolerantJudged(ctx, s, NewLifetimeJudge())
+ if err != nil || got != (mode == "corrupt") {
+ t.Fatalf("tolerant %v %v", got, err)
+ }
+ })
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 61887cb63..7629d53c9 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -952,15 +952,21 @@ func updateRecord(ctx context.Context, s release.Store, acct, app, host string,
type lifetimeJudgeKey struct{}
+// WithJudge returns a context whose record updates act on the judge's
+// observation instead of a bare read. A nil judge is recorded as none.
func WithJudge(ctx context.Context, j *LifetimeJudge) context.Context {
return context.WithValue(ctx, lifetimeJudgeKey{}, j)
}
+// JudgeFrom returns the judge WithJudge stored in ctx, or nil.
func JudgeFrom(ctx context.Context) *LifetimeJudge {
j, _ := ctx.Value(lifetimeJudgeKey{}).(*LifetimeJudge)
return j
}
+// WithLifetimes arms a claims-off context: the judge for record reads and
+// updates, and the record sequence for writes. A nil judge returns ctx
+// unchanged.
func WithLifetimes(ctx context.Context, j *LifetimeJudge) context.Context {
if j == nil {
return ctx
diff --git a/internal/provision/rootrecord_test.go b/internal/provision/rootrecord_test.go
index bec092534..4f56f7cdc 100644
--- a/internal/provision/rootrecord_test.go
+++ b/internal/provision/rootrecord_test.go
@@ -53,6 +53,10 @@ func TestPlan14ProofRecordLookup(t *testing.T) {
}
}
+// errCandidateRead is a candidate read failure distinct from the ambiguity
+// error Find returns for two live candidates.
+var errCandidateRead = errors.New("candidate read failed")
+
func TestIndexFindJudgesCandidates(t *testing.T) {
for _, mode := range []string{"successor", "two-legacy", "corrupt", "mismatch", "unreadable", "all-stale", "nil-ambiguous"} {
t.Run(mode, func(t *testing.T) {
@@ -87,7 +91,7 @@ func TestIndexFindJudgesCandidates(t *testing.T) {
}
s := &judgeStore{Store: base, getError: func(key string) error {
if mode == "unreadable" && key == RecordKey("bab", "shop", judgeHost) {
- return os.ErrPermission
+ return errCandidateRead
}
return nil
}}
@@ -102,10 +106,14 @@ func TestIndexFindJudgesCandidates(t *testing.T) {
if err != nil || rec.App != next.App || gotTag != tag {
t.Fatalf("successor %+v %s %v", rec, gotTag, err)
}
- case "two-legacy", "nil-ambiguous", "unreadable":
+ case "two-legacy", "nil-ambiguous":
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("got %v", err)
}
+ case "unreadable":
+ if !errors.Is(err, errCandidateRead) || errors.Is(err, os.ErrPermission) {
+ t.Fatalf("got %v, want the candidate's own error", err)
+ }
case "corrupt", "mismatch":
if !errors.Is(err, ErrRecordCorrupt) {
t.Fatalf("got %v", err)
diff --git a/internal/provision/self_serve_moves1_census_test.go b/internal/provision/self_serve_moves1_census_test.go
index 6f87d8718..67c0286e8 100644
--- a/internal/provision/self_serve_moves1_census_test.go
+++ b/internal/provision/self_serve_moves1_census_test.go
@@ -44,7 +44,7 @@ func plan14SelfServeMoves1WriteCensusInventory() []censusEntry {
{"internal/provision/rootrecord.go", "IndexRecords", "key-site", "key:ParseRecordKey,lit:instances", "e44ac431865bf38baa4105f66c9cb23014cc3c7ffcd41d5385244cc76fc4c0b9"},
{"internal/provision/lifetime_judge.go", "ListRecordsCompleteJudged", "key-site", "key:RecordsPrefix", "d851c7d8d04be82fbda88e257bc02825aff67c41b8a1f029f1a8c49f6fe4516c"},
{"internal/provision/lifetime_judge.go", "ListRecordsStrictJudged", "key-site", "key:RecordKey,key:RecordsPrefix", "95eb5de787af1cabccdc2c3ae56da5226ee876f203d253a2e8335322b20a5d5b"},
- {"internal/provision/lifetime_judge.go", "readAllRecordsJudged", "key-site", "key:ParseRecordKey,lit:instances", "b7be8d74718cfaa96220d2a070b619266167086e69881baa621c4af4c7413aa7"},
+ {"internal/provision/lifetime_judge.go", "readAllRecordsJudged", "key-site", "key:ParseRecordKey,lit:instances", "e56e43bb89e7a013445033b390cd57bf6eda16a539f5a398d1dd26597fe30ca4"},
{"internal/provision/lifetime.go", "GetRecordRaw", "key-site", "key:RecordKey", "1e893cadc5dedac4687a6877457254c8e07c328a91b2c5923ef87fd3c41ff1fc"},
{"internal/provision/lifetime.go", "WriteHostIncarnation", "nonrecord", "sel:Put", "c5f2719a53447c0477706600081a9846444071f39b85759a463d1ee757dc760e"},
}
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
index dbe8a80ea..f4e9db3db 100644
--- a/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv
+++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv
@@ -1,5 +1,5 @@
# 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 7ab4023fb302f730f830f24ecb5a5095bfbc8b26edf6482b9acc69d6e4596338
+@coverage internal/provision 5763793801381eae1dca8368722fd98acbe7bf95857a7bf2a70bad3d76f520e1
# Task 2 and fix round 1: rev3=PROPOSED-self-serve-moves-1 throughout; all dispositions below await the human census ruling. Console SQL and randomness run as carlos-console under console.service; no root decision is added. Existing legacy rows are re-frozen at this boundary, and the two constructors lose their own read sites to insertAppRow.
# The H3 removal syntax requires the census-ruling- prefix; PROPOSED in those tokens is deliberate and does not claim approval.
@legacy 296610cb0201ccb67b21152a50191b8b819f41e1d39fd43ca8b8d35cd75a2601 internal/console/lifetimes_store.go Store.BeginAllocation legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts one pending allocation in the console SQLite store, carrying the account, app and app lifetime; no bucket or root action file:Exec@132 7c7cb202595de014e2a549ebb001ae2c0afd800fc7959a6b5e145f5c1bc9c8ef