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>
6 files changed,
+100
−11
internal/provision/lifetime_judge.go+40 −2internal/provision/lifetime_judge_test.go+42 −5internal/provision/provision.go+6 −0internal/provision/rootrecord_test.go+10 −2internal/provision/self_serve_moves1_census_test.go+1 −1internal/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 |