domains: the catch-all label counts are judged, so a stale body neither shares a label nor reserves one (final review F3)
InstanceLabelCounts counted instance record keys with no lifetime, so a late stale body beside a retired lifetime removed a live instance's derived alias (ReconcileAliases) and reserved an unproven catch-all label at the console's attach door. domains.InstanceLabelCountsJudged counts only records the judge vouches for and reports a label with an unjudgeable record as unknown: the alias pass keeps that label's row and derives none, and the reservation stays. With no judge it is today's count. internal/domains cannot import internal/provision, so domains declares an InstanceObserver interface and context key; provision.WithJudge records the same judge there, so the box's fastConvergeLoop and instancesLoop (provision.WithLifetimes) and the console's recordCtx carry it with no new wiring. The console passes provision.InstanceObserver(JudgeFrom(ctx)). internal/domains joins TestNoUnjudgedRecordReads, InstanceLabelCounts is forbidden there, and only the judged counter's nil branch is allowlisted. Census: ReconcileAliases and deriveInstanceAliases re-frozen (the seal moves); instanceHosts replaces InstanceLabelCounts as a key-site; the console, domains and provision envelopes refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 files changed,
+572
−23
docs/superpowers/notes/census-proposal-self-serve-moves-1.md+1 −1internal/console/domaincatchall.go+7 −2internal/console/domaincatchall_test.go+68 −0internal/console/lifetimes_audit_test.go+18 −5internal/domains/aliases.go+25 −4internal/domains/claims.go+99 −5internal/domains/instancecatchall_judged_test.go+139 −0internal/domains/instancecatchall_lifetimes_test.go+109 −0internal/provision/lifetime_judge.go+24 −0internal/provision/lifetime_judge_test.go+55 −0internal/provision/plan14_root_reads_test.go+5 −2internal/provision/provision.go+6 −1internal/provision/self_serve_moves1_census_test.go+8 −0internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv+8 −3
diff --git a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md| index a32f2451e..f271cc8ec 100644 |
| --- a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md |
| +++ b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md |
| @@ -42,6 +42,6 @@ Task 9 adds the plan's end-to-end witnesses, in `internal/host/movesoffline_life |
| Task 9 fix round 1 corrects Task 3's create door. When a delayed create's SQLite commit is refused because recovery already committed its allocation, `Server.allocRecordLocked` now asks `Server.committedElsewhere`. That function reads the allocation row and the record through `readRecord` (judged) and writes nothing. When the row is committed and the record is valid at the allocation's incarnation, the door answers success; anything else stays pending. `Server.allocRecordLocked` stays a record-writer through `WriteRecord` and is re-pinned in place in the record-write inventory. The `internal/console` envelope is refreshed in place. No root read, class, legacy row or seal changes, and `TestNoUnjudgedRecordReads` stays green. |
| -Final review fix round. F1 changes the cutover door: it skips the box floor only once a validated marker body has been read or written by the door, never on the gate's state alone. A failed marker read still closes admission. `Server.handleAPILifetimesCutover` stays nonrecord and is re-pinned in place in the record-write inventory. F2 narrows the create's orphan rule: a live slot of another owner is retired only when no record exists under that owner's key, and a present body of any verdict refuses the create and is left untouched. `Server.planCreateLocked` is not a census unit, so only the envelope moves. The `internal/console` envelope is refreshed in place after each finding. No root read, class, legacy row or seal changes. |
| +Final review fix round. F1 changes the cutover door: it skips the box floor only once a validated marker body has been read or written by the door, never on the gate's state alone. A failed marker read still closes admission. `Server.handleAPILifetimesCutover` stays nonrecord and is re-pinned in place in the record-write inventory. F2 narrows the create's orphan rule: a live slot of another owner is retired only when no record exists under that owner's key, and a present body of any verdict refuses the create and is left untouched. `Server.planCreateLocked` is not a census unit, so only the envelope moves. F3 judges the catch-all label counts. `internal/domains` cannot import `internal/provision`, so it declares an `InstanceObserver` interface and its own context key, and `provision.WithJudge` records the same judge there (nil records none). `domains.InstanceLabelCountsJudged` counts only what the judge vouches for and reports a label with an unjudgeable record as unknown; with no observer it is `InstanceLabelCounts`. `ReconcileAliases` and `deriveInstanceAliases` (accepted legacy, rev3=J1) are re-frozen with reviewed bases: a stale body no longer makes a label shared, and an unknown label keeps its existing alias row and derives none, which adds four read sites on that row (`registry:Account`, `registry:Alias`, `registry:App` and the whole `RouteAlias`). That moves the seal. In the record-write inventory, `InstanceLabelCounts` leaves and `instanceHosts`, the key listing it now shares with the judged counter, takes its place as a key-site. `Server.catchallInstanceName` counts through the judged counter with the request's judge. The `internal/domains` and `internal/provision` envelopes are refreshed in place. The `internal/console` envelope is refreshed in place after each finding. No root read, class or task is added. |
| Taken together, the rows marked `PROPOSED-self-serve-moves-1` are the census of one change. Claims-off instance records gain a lifetime: the host incarnation slot, written only through `WriteHostIncarnation` by the console's create, retirement and migration protocols. Every claims-off consumer judges a record against its slot before it acts on it. Every record write stays on `WriteRecord` with the sequence discipline. The per-host `move_seq` and each row's `(incarnation, serving_seq)` pair order the publications an edge folds. Each row is one of five kinds: a write site added or re-pinned in the record-write inventory (each through `WriteRecord`, a `DeleteIfMatch` on an observed ETag, or a Put of a control key that is not a record); a read made judged at its existing class; a console SQLite unit or a legacy fingerprint with a reviewed basis; the `@fields` schema row, which pins the registry fields Task 7 adds (`Route.Incarnation`, `Route.ServingSeq`, and the remote request's `Pair` and `ExpectPair`); or a source envelope refreshed over the new code. No row adds a raw record write, a root-read class or a task. The two registry read sites added in Task 8 are named in its paragraph. These rows are one proposal, for Paul to rule on once, together, after the plan's final review. Until then every row stays PROPOSED, and a green census means only that the rows match the code, not that they are accepted. |
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go| index 578a96ced..552bfb359 100644 |
| --- a/internal/console/domaincatchall.go |
| +++ b/internal/console/domaincatchall.go |
| @@ -192,12 +192,17 @@ func (s *Server) catchallInstanceName(ctx context.Context, host, sqid, app strin |
| if s.catchallProven(ctx, parent, c.Account) { |
| return true, nil |
| } |
| - counts, err := domains.InstanceLabelCounts(ctx, s.cfg.Releases, c.Account, c.App) |
| + // Judged under a unit admitted on (recordCtx): a stale body beside a |
| + // retired lifetime is no instance, and a record that cannot be judged |
| + // keeps the reservation. Before the cutover, or claims on, the judge is |
| + // nil and this is the key count. |
| + counts, unknown, err := domains.InstanceLabelCountsJudged(ctx, s.cfg.Releases, |
| + provision.InstanceObserver(provision.JudgeFrom(ctx)), c.Account, c.App, 1) |
| if err != nil { |
| return false, err |
| } |
| label, _, _ := strings.Cut(host, ".") |
| - return counts[label] > 0, nil |
| + return counts[label] > 0 || unknown[label], nil |
| } |
| // catchallProven reports whether parent's DNS carries account's catch-all |
diff --git a/internal/console/domaincatchall_test.go b/internal/console/domaincatchall_test.go| index 418ecb083..b657a70a5 100644 |
| --- a/internal/console/domaincatchall_test.go |
| +++ b/internal/console/domaincatchall_test.go |
| @@ -372,3 +372,71 @@ func TestInstanceCatchallGuardReservations(t *testing.T) { |
| t.Fatalf("proof naming another account = %d %s, want 200", code, out) |
| } |
| } |
| + |
| +// An unproved catch-all reserves a label only for an instance its lifetime |
| +// vouches for (final review F3): a stale body beside a retired lifetime |
| +// reserves nothing once lifetimes are on, a body that cannot be judged keeps |
| +// the reservation, and with no judge (before the cutover) it is today's key |
| +// count. |
| +func TestInstanceCatchallReservationIsJudged(t *testing.T) { |
| + ctx := context.Background() |
| + put := func(t *testing.T, l ltOn, key string, v any) { |
| + t.Helper() |
| + body, ok := v.([]byte) |
| + if !ok { |
| + var err error |
| + if body, err = json.Marshal(v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := l.fx.rel.Put(ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + world := func(t *testing.T) ltOn { |
| + t.Helper() |
| + l := newLtOn(t) |
| + noCatchallProof(l.fx) |
| + put(t, l, domains.ClaimKey("shop.example.com"), domains.Claim{Schema: 1, Host: "shop.example.com", Account: "bab", App: "site"}) |
| + put(t, l, domains.ClaimKey("*.shop.example.com"), domains.Claim{Schema: 1, Host: "*.shop.example.com", Account: "bab", App: ltApp, Instances: true}) |
| + return l |
| + } |
| + record := func(host string, inc uint64) provision.Record { |
| + return provision.Record{Schema: 1, Host: host, Slug: "team", App: ltApp, Account: "bab", Channel: "stable", Incarnation: inc, CreatedAt: time.Now().UTC()} |
| + } |
| + reserved := func(t *testing.T, l ltOn, c context.Context) bool { |
| + t.Helper() |
| + held, err := l.fx.srv.catchallInstanceName(c, "team.shop.example.com", "cdef", "widgets") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return held |
| + } |
| + |
| + t.Run("a stale body reserves nothing", func(t *testing.T) { |
| + l := world(t) |
| + writeSlot(t, l.fx.rel, "team.old.example.net", provision.HostIncarnation{Incarnation: 1, State: provision.SlotRetired, Account: "bab", App: ltApp}) |
| + put(t, l, recKey(ltApp, "team.old.example.net"), record("team.old.example.net", 1)) |
| + if reserved(t, l, onCtx(t, l.fx.srv)) { |
| + t.Fatal("judged, a stale body reserved the label") |
| + } |
| + if !reserved(t, l, ctx) { |
| + t.Fatal("with no judge the key reserves the label, as today") |
| + } |
| + }) |
| + t.Run("a live instance reserves its label", func(t *testing.T) { |
| + l := world(t) |
| + writeSlot(t, l.fx.rel, "team.new.example.net", provision.HostIncarnation{Incarnation: 1, State: provision.SlotLive, Account: "bab", App: ltApp}) |
| + put(t, l, recKey(ltApp, "team.new.example.net"), record("team.new.example.net", 1)) |
| + if !reserved(t, l, onCtx(t, l.fx.srv)) { |
| + t.Fatal("judged, a live instance did not reserve its label") |
| + } |
| + }) |
| + t.Run("a body that cannot be judged keeps the reservation", func(t *testing.T) { |
| + l := world(t) |
| + put(t, l, recKey(ltApp, "team.bad.example.net"), []byte("{not a record")) |
| + if !reserved(t, l, onCtx(t, l.fx.srv)) { |
| + t.Fatal("judged, a corrupt body released the reservation") |
| + } |
| + }) |
| +} |
diff --git a/internal/console/lifetimes_audit_test.go b/internal/console/lifetimes_audit_test.go| index 7548bbb76..bd8bba95a 100644 |
| --- a/internal/console/lifetimes_audit_test.go |
| +++ b/internal/console/lifetimes_audit_test.go |
| @@ -16,15 +16,17 @@ import ( |
| ) |
| // TestNoUnjudgedRecordReads is the audit of self-serve moves plan 1, Task 6: |
| -// no non-test code in internal/console, internal/host or internal/provision |
| -// reads an instance record raw, except inside the functions listed in |
| -// unjudgedReadPermitted. Every claims-off consumer goes through a judged |
| +// no non-test code in internal/console, internal/host, internal/provision or |
| +// internal/domains reads an instance record raw, except inside the functions |
| +// listed in unjudgedReadPermitted. Every claims-off consumer goes through a judged |
| // reader (ReadRecordJudged, Observe, ObserveWith, the *Judged listings, |
| // FindRecordByHost, (*RecordIndex).Find), which is the unjudged reader under |
| // a nil judge, so a claims-on caller is unchanged. |
| // |
| // Forbidden, resolved by symbol: the raw provision getters and listings, |
| -// hostops.ReadRecord, a release.Store Get whose key is provision.RecordKey(...) |
| +// hostops.ReadRecord, domains.InstanceLabelCounts (a count of instance record |
| +// keys, read without a lifetime: final review F3), a release.Store Get whose |
| +// key is provision.RecordKey(...) |
| // (directly or through a local assigned from it), and provision.DecodeRecord |
| // of bytes read from the store in the same function. A use that is not a call |
| // (a function value) counts too. |
| @@ -44,7 +46,7 @@ func TestNoUnjudgedRecordReads(t *testing.T) { |
| Env: append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0"), |
| Overlay: map[string][]byte{fixture: []byte(unjudgedReadsFixture)}, |
| } |
| - pkgs, err := packages.Load(cfg, "./internal/console", "./internal/host", "./internal/provision") |
| + pkgs, err := packages.Load(cfg, "./internal/console", "./internal/host", "./internal/provision", "./internal/domains") |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -88,6 +90,7 @@ func TestNoUnjudgedRecordReads(t *testing.T) { |
| c + "auditFixtureHostops -> " + pv + "DecodeRecord of bytes read from the store", |
| c + "auditFixtureKeyVar -> " + pv + "DecodeRecord of bytes read from the store", |
| c + "auditFixtureKeyVar -> release.Store Get of provision.RecordKey(...)", |
| + c + "auditFixtureLabelCounts -> " + pkgDomains + ".InstanceLabelCounts", |
| c + "auditFixtureListing -> " + pv + "ListAllRecordsTolerant", |
| } |
| sort.Strings(fixed) |
| @@ -139,12 +142,16 @@ var unjudgedReadPermitted = map[string]string{ |
| "(*github.com/carlosframework/platform/internal/console.Server).refreshClaimRetireRecord": "claims-on: refreshes the record a retire claim holds", |
| "(*github.com/carlosframework/platform/internal/console.Server).claimAliasUnbacked": "claims-on: the alias path's presence check of a record key", |
| "github.com/carlosframework/platform/internal/host.relocationRecord": "claims-on: region-HA relocation reads the record a claim holder names", |
| + // The catch-all label count: under a nil observer (claims on, or before |
| + // the cutover) the judged counter is the key count. |
| + "github.com/carlosframework/platform/internal/domains.InstanceLabelCountsJudged": "judged counter; a nil observer is InstanceLabelCounts", |
| } |
| const ( |
| pkgProvision = "github.com/carlosframework/platform/internal/provision" |
| pkgHostops = "github.com/carlosframework/platform/internal/hostops" |
| pkgRelease = "github.com/carlosframework/platform/internal/release" |
| + pkgDomains = "github.com/carlosframework/platform/internal/domains" |
| ) |
| // unjudgedForbidden are the raw readers, by full name. |
| @@ -159,6 +166,7 @@ var unjudgedForbidden = []string{ |
| pkgProvision + ".ListAllRecordsStrict", |
| pkgProvision + ".ListAllRecordsTolerant", |
| pkgHostops + ".ReadRecord", |
| + pkgDomains + ".InstanceLabelCounts", |
| } |
| type unjudgedFinding struct { |
| @@ -405,6 +413,7 @@ const unjudgedReadsFixture = `package console |
| import ( |
| "context" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -444,4 +453,8 @@ func (s *Server) auditFixtureFuncValue() any { |
| func (s *Server) auditFixtureListing(ctx context.Context) { |
| _, _, _ = provision.ListAllRecordsTolerant(ctx, s.cfg.Releases) |
| } |
| + |
| +func (s *Server) auditFixtureLabelCounts(ctx context.Context) { |
| + _, _ = domains.InstanceLabelCounts(ctx, s.cfg.Releases, "bab", "shop") |
| +} |
| ` |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 599a705e9..ba8be39d2 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -116,7 +116,13 @@ func sayInstanceCatchallOnce(host, reason string) { |
| // instance it could name. If it fails, the catch-all's existing rows are |
| // kept as they are for this pass rather than dropped: a listing blip must |
| // not take every team's name down. |
| -func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, error), reg *registry.Registry, now func() time.Time) { |
| +// |
| +// With lifetimes on (a judge in the pass's context), labelHosts counts only |
| +// the records their lifetimes vouch for, so a stale body left beside a |
| +// retired lifetime does not make a label shared. A label with a record that |
| +// could not be judged (unknown) keeps its existing row as it is and derives |
| +// no new one, unless the judged records alone already share it. |
| +func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, map[string]bool, error), reg *registry.Registry, now func() time.Time) { |
| claimed := make(map[string]Claim, len(claims)) |
| for _, c := range claims { |
| claimed[c.Host] = c |
| @@ -150,6 +156,7 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi |
| continue |
| } |
| var hostsOf map[string]int |
| + var unknownOf map[string]bool |
| for _, rt := range routes { |
| if !rt.Provisioned || rt.IsSidecar() || rt.Replica || rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| continue |
| @@ -159,7 +166,7 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi |
| continue |
| } |
| if hostsOf == nil { |
| - counts, err := labelHosts(c.Account, c.App) |
| + counts, unknown, err := labelHosts(c.Account, c.App) |
| if err != nil { |
| log.Printf("domains: instance catch-all %s: list the app's instances: %v — keeping its names as they are this pass", c.Host, err) |
| for _, a := range existing { |
| @@ -170,12 +177,22 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi |
| } |
| break |
| } |
| - hostsOf = counts |
| + hostsOf, unknownOf = counts, unknown |
| } |
| if hostsOf[label] > 1 { |
| sayInstanceCatchallOnce(c.Host, "label "+label+" is shared by "+strconv.Itoa(hostsOf[label])+" instances of "+c.App+" (its instance domain was changed); "+label+"."+parent+" serves none of them until one is deleted") |
| continue |
| } |
| + if unknownOf[label] { |
| + // A record under this label could not be judged: whether |
| + // the label is shared is unknown, so its existing row is |
| + // kept as it is this pass and no new one is derived. |
| + if a, ok := have[label+"."+parent]; ok && a.Account == c.Account && a.App == c.App { |
| + desired[a.Alias] = true |
| + } |
| + sayInstanceCatchallOnce(c.Host, "a record of label "+label+" could not be judged; its name is kept as it is until it can be") |
| + continue |
| + } |
| name := label + "." + parent |
| if n, ok := NormalizeAliasHost(name); !ok || n != name { |
| continue // too long once joined to parent |
| @@ -534,7 +551,11 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| } |
| deriveInstanceAliases(claims, listed, routes, byHost, existing, deploymentRoutes, desired, |
| - func(account, app string) (map[string]int, error) { return InstanceLabelCounts(ctx, s, account, app) }, reg, now) |
| + func(account, app string) (map[string]int, map[string]bool, error) { |
| + // The judge rides in the pass's context (provision.WithLifetimes |
| + // records it here too); none is today's key count. |
| + return InstanceLabelCountsJudged(ctx, s, InstanceObserverFrom(ctx), account, app, 2) |
| + }, reg, now) |
| for _, a := range existing { |
| if !desired[a.Alias] { |
| if err := reg.DeleteAlias(a.Alias); err != nil { |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index 1de2720dc..520382875 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -144,23 +144,117 @@ func instanceRecordsPrefix(account, app string) string { |
| // deployment-wide view of which labels the app's instances use, whatever |
| // box serves them and whatever instance domain each was created under. |
| // Replica records are counted too; they have labels of their own, so they |
| -// never inflate another instance's count. |
| +// never inflate another instance's count. It reads no lifetime: every |
| +// consumer counts through InstanceLabelCountsJudged, which is this count |
| +// when no judge is armed. |
| func InstanceLabelCounts(ctx context.Context, s release.Store, account, app string) (map[string]int, error) { |
| + hosts, err := instanceHosts(ctx, s, account, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + counts := map[string]int{} |
| + for _, host := range hosts { |
| + counts[hostLabel(host)]++ |
| + } |
| + return counts, nil |
| +} |
| + |
| +// instanceHosts is the hosts of the app's instance record keys, from one |
| +// listing. |
| +func instanceHosts(ctx context.Context, s release.Store, account, app string) ([]string, error) { |
| prefix := instanceRecordsPrefix(account, app) |
| keys, err := s.List(ctx, prefix) |
| if err != nil { |
| return nil, err |
| } |
| - counts := map[string]int{} |
| + var hosts []string |
| for _, key := range keys { |
| host, ok := strings.CutSuffix(strings.TrimPrefix(key, prefix), ".json") |
| if !ok || host == "" || strings.Contains(host, "/") { |
| continue |
| } |
| - label, _, _ := strings.Cut(host, ".") |
| - counts[label]++ |
| + hosts = append(hosts, host) |
| } |
| - return counts, nil |
| + return hosts, nil |
| +} |
| + |
| +func hostLabel(host string) string { |
| + label, _, _ := strings.Cut(host, ".") |
| + return label |
| +} |
| + |
| +// InstanceVerdict is how an InstanceObserver judges one listed instance |
| +// record key. |
| +type InstanceVerdict int |
| + |
| +const ( |
| + // InstanceLive is a record its lifetime vouches for: it is counted. |
| + InstanceLive InstanceVerdict = iota |
| + // InstanceGone is a key absent by the time it is read, a stale body |
| + // beside a later lifetime, or a tombstone: it is not counted. |
| + InstanceGone |
| + // InstanceUnknown is a record that could not be read or judged, or a |
| + // corrupt body: its label's count is unknown. |
| + InstanceUnknown |
| +) |
| + |
| +// InstanceObserver judges an instance record against its lifetime. It is |
| +// provision's lifetime judge seen through an interface, because |
| +// internal/provision imports this package. |
| +type InstanceObserver interface { |
| + ObserveInstance(ctx context.Context, s release.Store, account, app, host string) InstanceVerdict |
| +} |
| + |
| +type instanceObserverKey struct{} |
| + |
| +// WithInstanceObserver records o for InstanceObserverFrom. provision.WithJudge |
| +// calls it with the same judge it records, so a context armed for lifetimes |
| +// carries both, and a nil judge records none. |
| +func WithInstanceObserver(ctx context.Context, o InstanceObserver) context.Context { |
| + return context.WithValue(ctx, instanceObserverKey{}, o) |
| +} |
| + |
| +// InstanceObserverFrom is the observer WithInstanceObserver recorded in ctx, |
| +// or nil. |
| +func InstanceObserverFrom(ctx context.Context) InstanceObserver { |
| + o, _ := ctx.Value(instanceObserverKey{}).(InstanceObserver) |
| + return o |
| +} |
| + |
| +// InstanceLabelCountsJudged is InstanceLabelCounts with each key's lifetime |
| +// observed through o: a key o judges gone is not counted, and a label with a |
| +// key o cannot judge is in unknown (its count is then only a lower bound). |
| +// A label listed fewer than minListed times is counted from the listing |
| +// without a read: ReconcileAliases passes 2, since one key can never make a |
| +// label shared. A nil o is InstanceLabelCounts exactly, with no unknowns. |
| +func InstanceLabelCountsJudged(ctx context.Context, s release.Store, o InstanceObserver, account, app string, minListed int) (counts map[string]int, unknown map[string]bool, err error) { |
| + if o == nil { |
| + counts, err = InstanceLabelCounts(ctx, s, account, app) |
| + return counts, nil, err |
| + } |
| + hosts, err := instanceHosts(ctx, s, account, app) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + listed := map[string]int{} |
| + for _, host := range hosts { |
| + listed[hostLabel(host)]++ |
| + } |
| + counts, unknown = map[string]int{}, map[string]bool{} |
| + for _, host := range hosts { |
| + label := hostLabel(host) |
| + if listed[label] < minListed { |
| + counts[label]++ |
| + continue |
| + } |
| + switch o.ObserveInstance(ctx, s, account, app, host) { |
| + case InstanceLive: |
| + counts[label]++ |
| + case InstanceUnknown: |
| + unknown[label] = true |
| + } |
| + } |
| + return counts, unknown, nil |
| } |
| // CatchallProofName and CatchallProofValue are the TXT record that proves an |
diff --git a/internal/domains/instancecatchall_judged_test.go b/internal/domains/instancecatchall_judged_test.go| new file mode 100644 |
| index 000000000..bcbe2a8ee |
| --- /dev/null |
| +++ b/internal/domains/instancecatchall_judged_test.go |
| @@ -0,0 +1,139 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// fakeObserver judges instance keys by host; a host it does not name is |
| +// live. reads counts what it was asked. |
| +type fakeObserver struct { |
| + verdicts map[string]InstanceVerdict |
| + reads []string |
| +} |
| + |
| +func (f *fakeObserver) ObserveInstance(_ context.Context, _ release.Store, _, _, host string) InstanceVerdict { |
| + f.reads = append(f.reads, host) |
| + return f.verdicts[host] |
| +} |
| + |
| +// shopCatchall is account bab holding shop.example.com and the instance |
| +// catch-all *.shop.example.com for its app shop. |
| +func shopCatchall(t *testing.T) release.Store { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + putClaim(t, s, Claim{Host: "shop.example.com", Account: "bab", App: "site"}) |
| + putClaim(t, s, Claim{Host: "*.shop.example.com", Account: "bab", App: "shop", Instances: true}) |
| + return s |
| +} |
| + |
| +func TestInstanceLabelCountsJudged(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + putRecordKey(t, s, "bab", "shop", "team.new.example.com") |
| + putRecordKey(t, s, "bab", "shop", "team.old.example.com") |
| + putRecordKey(t, s, "bab", "shop", "solo.new.example.com") |
| + putRecordKey(t, s, "bab", "shop", "lost.new.example.com") |
| + putRecordKey(t, s, "bab", "shop", "lost.old.example.com") |
| + |
| + // A nil observer is InstanceLabelCounts exactly. |
| + got, unknown, err := InstanceLabelCountsJudged(ctx, s, nil, "bab", "shop", 2) |
| + want, _ := InstanceLabelCounts(ctx, s, "bab", "shop") |
| + if err != nil || unknown != nil || len(got) != len(want) || got["team"] != 2 || got["solo"] != 1 || got["lost"] != 2 { |
| + t.Fatalf("nil observer = %v %v %v, want %v", got, unknown, err, want) |
| + } |
| + |
| + o := &fakeObserver{verdicts: map[string]InstanceVerdict{ |
| + "team.old.example.com": InstanceGone, |
| + "lost.old.example.com": InstanceUnknown, |
| + "solo.new.example.com": InstanceGone, |
| + }} |
| + got, unknown, err = InstanceLabelCountsJudged(ctx, s, o, "bab", "shop", 2) |
| + if err != nil || got["team"] != 1 || got["lost"] != 1 || !unknown["lost"] || unknown["team"] { |
| + t.Fatalf("judged = %v %v %v, want team 1, lost 1 and unknown", got, unknown, err) |
| + } |
| + // solo is listed once: under minListed 2 it is counted without a read. |
| + if got["solo"] != 1 { |
| + t.Fatalf("solo = %d, want 1 from the listing", got["solo"]) |
| + } |
| + for _, h := range o.reads { |
| + if h == "solo.new.example.com" { |
| + t.Fatal("a label listed once was read under minListed 2") |
| + } |
| + } |
| + got, _, _ = InstanceLabelCountsJudged(ctx, s, o, "bab", "shop", 1) |
| + if got["solo"] != 0 { |
| + t.Fatalf("solo under minListed 1 = %d, want 0 (judged gone)", got["solo"]) |
| + } |
| +} |
| + |
| +// A stale body beside a retired lifetime does not make a label shared: an |
| +// existing derived name stays (final review F3). A record that cannot be |
| +// judged keeps the label's name as it is. A nil observer is today's count. |
| +func TestInstanceCatchallJudgesTheLabelCount(t *testing.T) { |
| + setup := func(t *testing.T) (release.Store, func(ctx context.Context) map[string]string) { |
| + s := shopCatchall(t) |
| + putRecordKey(t, s, "bab", "shop", "team.new.example.com") |
| + reg := aliasRegWith(t) |
| + addInstance(t, reg, "team.new.example.com", "team", "bab", "shop") |
| + pass := func(ctx context.Context) map[string]string { |
| + t.Helper() |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for k, a := range aliasMap(t, reg) { |
| + out[k] = a.Host |
| + } |
| + return out |
| + } |
| + if got := pass(context.Background()); got["team.shop.example.com"] != "team.new.example.com" { |
| + t.Fatalf("precondition: the name derives: %v", got) |
| + } |
| + putRecordKey(t, s, "bab", "shop", "team.old.example.com") |
| + return s, pass |
| + } |
| + t.Run("valid and stale", func(t *testing.T) { |
| + _, pass := setup(t) |
| + o := &fakeObserver{verdicts: map[string]InstanceVerdict{"team.old.example.com": InstanceGone}} |
| + if got := pass(WithInstanceObserver(context.Background(), o)); got["team.shop.example.com"] != "team.new.example.com" { |
| + t.Fatalf("a stale body beside the live instance removed its name: %v", got) |
| + } |
| + }) |
| + t.Run("unreadable evidence", func(t *testing.T) { |
| + _, pass := setup(t) |
| + o := &fakeObserver{verdicts: map[string]InstanceVerdict{"team.old.example.com": InstanceUnknown}} |
| + if got := pass(WithInstanceObserver(context.Background(), o)); got["team.shop.example.com"] != "team.new.example.com" { |
| + t.Fatalf("an unjudged record removed the label's name: %v", got) |
| + } |
| + }) |
| + t.Run("unreadable evidence derives nothing new", func(t *testing.T) { |
| + s := shopCatchall(t) |
| + putRecordKey(t, s, "bab", "shop", "team.new.example.com") |
| + putRecordKey(t, s, "bab", "shop", "team.old.example.com") |
| + reg := aliasRegWith(t) |
| + addInstance(t, reg, "team.new.example.com", "team", "bab", "shop") |
| + o := &fakeObserver{verdicts: map[string]InstanceVerdict{"team.old.example.com": InstanceUnknown}} |
| + if err := ReconcileAliases(WithInstanceObserver(context.Background(), o), s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := aliasMap(t, reg); len(got) != 0 { |
| + t.Fatalf("an unjudged label derived a new name: %+v", got) |
| + } |
| + }) |
| + t.Run("two live records still share the label", func(t *testing.T) { |
| + _, pass := setup(t) |
| + o := &fakeObserver{verdicts: map[string]InstanceVerdict{}} |
| + if got := pass(WithInstanceObserver(context.Background(), o)); len(got) != 0 { |
| + t.Fatalf("a label two live instances share kept a name: %v", got) |
| + } |
| + }) |
| + t.Run("no observer is today", func(t *testing.T) { |
| + _, pass := setup(t) |
| + if got := pass(WithInstanceObserver(context.Background(), nil)); len(got) != 0 { |
| + t.Fatalf("with no judge the raw count of two removes the name, as today: %v", got) |
| + } |
| + }) |
| +} |
diff --git a/internal/domains/instancecatchall_lifetimes_test.go b/internal/domains/instancecatchall_lifetimes_test.go| new file mode 100644 |
| index 000000000..59e71ebf2 |
| --- /dev/null |
| +++ b/internal/domains/instancecatchall_lifetimes_test.go |
| @@ -0,0 +1,109 @@ |
| +package domains_test |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The catch-all alias pass under the box's real wiring: the lifetime judge |
| +// rides in provision.WithLifetimes, as fastConvergeLoop and instancesLoop |
| +// arm it, and ReconcileAliases reads it back through |
| +// domains.InstanceObserverFrom (final review F3). |
| +func TestInstanceCatchallAliasesUnderTheLifetimeJudge(t *testing.T) { |
| + put := func(t *testing.T, s release.Store, key string, v any) { |
| + t.Helper() |
| + body, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + slot := func(t *testing.T, s release.Store, host, state string) { |
| + t.Helper() |
| + if _, err := provision.WriteHostIncarnation(ctx, s, host, provision.HostIncarnation{Incarnation: 1, State: state, Account: "bab", App: "shop"}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + record := func(t *testing.T, s release.Store, host string) { |
| + t.Helper() |
| + put(t, s, provision.RecordKey("bab", "shop", host), provision.Record{Schema: 1, Host: host, Slug: "team", |
| + App: "shop", Account: "bab", Channel: "stable", Incarnation: 1, CreatedAt: time.Now().UTC()}) |
| + } |
| + world := func(t *testing.T) (release.Store, *registry.Registry) { |
| + t.Helper() |
| + s := dirStore(t) |
| + put(t, s, domains.ClaimKey("shop.example.com"), domains.Claim{Schema: 1, Host: "shop.example.com", Account: "bab", App: "site"}) |
| + put(t, s, domains.ClaimKey("*.shop.example.com"), domains.Claim{Schema: 1, Host: "*.shop.example.com", Account: "bab", App: "shop", Instances: true}) |
| + slot(t, s, "team.new.example.com", provision.SlotLive) |
| + record(t, s, "team.new.example.com") |
| + reg := regWith(t, registry.Route{Host: "team.new.example.com", Slug: "team", App: "shop", Account: "bab", Kind: "instance", Status: "ready"}) |
| + if err := reg.SetProvisioned("team.new.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, reg |
| + } |
| + alias := func(t *testing.T, reg *registry.Registry) string { |
| + t.Helper() |
| + as, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range as { |
| + if a.Alias == "team.shop.example.com" { |
| + return a.Host |
| + } |
| + } |
| + return "" |
| + } |
| + pass := func(t *testing.T, judged bool, s release.Store, reg *registry.Registry) { |
| + t.Helper() |
| + c := ctx |
| + if judged { |
| + c = provision.WithLifetimes(ctx, provision.NewLifetimeJudge()) |
| + } |
| + if err := domains.ReconcileAliases(c, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + t.Run("a stale body keeps the live instance's name", func(t *testing.T) { |
| + s, reg := world(t) |
| + pass(t, true, s, reg) |
| + if got := alias(t, reg); got != "team.new.example.com" { |
| + t.Fatalf("precondition: the name derives, got %q", got) |
| + } |
| + // The old lifetime of team.old.example.com is retired; its body is |
| + // the late stale body the plan permits. |
| + slot(t, s, "team.old.example.com", provision.SlotRetired) |
| + record(t, s, "team.old.example.com") |
| + pass(t, true, s, reg) |
| + if got := alias(t, reg); got != "team.new.example.com" { |
| + t.Fatalf("judged, a stale body removed the name: alias -> %q", got) |
| + } |
| + // With no judge (claims on, or before the cutover) it is today's |
| + // key count: two keys share the label and the name goes. |
| + pass(t, false, s, reg) |
| + if got := alias(t, reg); got != "" { |
| + t.Fatalf("unjudged, the shared label kept its name: alias -> %q", got) |
| + } |
| + }) |
| + t.Run("a corrupt body keeps the name as it is", func(t *testing.T) { |
| + s, reg := world(t) |
| + pass(t, true, s, reg) |
| + if _, err := s.Put(ctx, provision.RecordKey("bab", "shop", "team.old.example.com"), []byte("{not a record"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + pass(t, true, s, reg) |
| + if got := alias(t, reg); got != "team.new.example.com" { |
| + t.Fatalf("judged, a corrupt body removed the name: alias -> %q", got) |
| + } |
| + }) |
| +} |
diff --git a/internal/provision/lifetime_judge.go b/internal/provision/lifetime_judge.go| index 49fa96525..6e2c4fc18 100644 |
| --- a/internal/provision/lifetime_judge.go |
| +++ b/internal/provision/lifetime_judge.go |
| @@ -10,6 +10,7 @@ import ( |
| "sort" |
| "sync" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -106,6 +107,29 @@ func (j *LifetimeJudge) ObserveWith(ctx context.Context, s release.Store, acct, |
| return obs |
| } |
| +// ObserveInstance is the judge as a domains.InstanceObserver, for the |
| +// catch-all label counts: a record the slot vouches for is live; absent, |
| +// stale or a tombstone is gone; corrupt or unreadable is unknown. |
| +func (j *LifetimeJudge) ObserveInstance(ctx context.Context, s release.Store, acct, app, host string) domains.InstanceVerdict { |
| + obs := j.Observe(ctx, s, acct, app, host) |
| + switch { |
| + case obs.Verdict == LifetimeUnreadable || obs.Verdict == LifetimeCorrupt: |
| + return domains.InstanceUnknown |
| + case !obs.Present || obs.Verdict != LifetimeValid || obs.Rec == nil || obs.Rec.Retired: |
| + return domains.InstanceGone |
| + } |
| + return domains.InstanceLive |
| +} |
| + |
| +// InstanceObserver is j as a domains.InstanceObserver: nil for a nil judge, |
| +// never a non-nil interface holding a nil judge. |
| +func InstanceObserver(j *LifetimeJudge) domains.InstanceObserver { |
| + if j == nil { |
| + return nil |
| + } |
| + return j |
| +} |
| + |
| // 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. |
diff --git a/internal/provision/lifetime_judge_test.go b/internal/provision/lifetime_judge_test.go| index 11121c7aa..ef3a1caa7 100644 |
| --- a/internal/provision/lifetime_judge_test.go |
| +++ b/internal/provision/lifetime_judge_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "strings" |
| "testing" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -925,3 +926,57 @@ func TestListRecordReplicasJudged(t *testing.T) { |
| t.Fatalf("nil judge %v (%v), unjudged %v", hosts(nilJudged), err, hosts(plain)) |
| } |
| } |
| + |
| +// The catch-all label counts (internal/domains) read the judge through |
| +// domains.InstanceObserverFrom, which WithJudge records beside JudgeFrom: the |
| +// two agree, a nil judge overrides an outer one, and no context ever holds a |
| +// non-nil observer wrapping a nil judge (final review F3). |
| +func TestInstanceObserverFollowsTheJudge(t *testing.T) { |
| + ctx := context.Background() |
| + if domains.InstanceObserverFrom(ctx) != nil || InstanceObserver(nil) != nil { |
| + t.Fatal("an unarmed context, or a nil judge, has an observer") |
| + } |
| + j := NewLifetimeJudge() |
| + on := WithLifetimes(ctx, j) |
| + if o, ok := domains.InstanceObserverFrom(on).(*LifetimeJudge); !ok || o != j || JudgeFrom(on) != j { |
| + t.Fatalf("armed: observer %v, judge %v, want both %p", domains.InstanceObserverFrom(on), JudgeFrom(on), j) |
| + } |
| + off := WithJudge(on, nil) |
| + if domains.InstanceObserverFrom(off) != nil || JudgeFrom(off) != nil { |
| + t.Fatal("a nil judge did not override the outer one for both readers") |
| + } |
| + |
| + s := release.NewDirStore(t.TempDir()) |
| + verdict := func(host string) domains.InstanceVerdict { |
| + return j.ObserveInstance(ctx, s, "bab", "shop", host) |
| + } |
| + put := func(host string, body []byte) { |
| + if _, err := s.Put(ctx, RecordKey("bab", "shop", host), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rec := func(host string, inc uint64) []byte { |
| + b, _ := json.Marshal(Record{Schema: 1, Host: host, App: "shop", Account: "bab", Channel: "stable", Incarnation: inc}) |
| + return b |
| + } |
| + slot := func(host, state string) { |
| + if _, err := WriteHostIncarnation(ctx, s, host, HostIncarnation{Incarnation: 1, State: state, Account: "bab", App: "shop"}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + slot("live.example.com", SlotLive) |
| + put("live.example.com", rec("live.example.com", 1)) |
| + slot("old.example.com", SlotRetired) |
| + put("old.example.com", rec("old.example.com", 1)) |
| + put("bad.example.com", []byte("{not a record")) |
| + for host, want := range map[string]domains.InstanceVerdict{ |
| + "live.example.com": domains.InstanceLive, |
| + "old.example.com": domains.InstanceGone, |
| + "gone.example.com": domains.InstanceGone, |
| + "bad.example.com": domains.InstanceUnknown, |
| + } { |
| + if got := verdict(host); got != want { |
| + t.Errorf("%s = %v, want %v", host, got, want) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 4e8964763..ad15ac310 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1286,8 +1286,11 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| // re-pins the two offline release units in place (claims on releases |
| // through today's ReleaseHold), which moves it again. Task 8 (the pair |
| // and target_host in the edges file) re-freezes edgesFileLoop, which |
| - // takes the box's lifetimes switch, and moves it once more. |
| - seal = "49b0bd1e34f165fe37c0bc2953172559c93af366425e83e60f454d237c11b0f5" |
| + // takes the box's lifetimes switch, and moves it once more. The final |
| + // review's fix round (F3) re-freezes ReconcileAliases and |
| + // deriveInstanceAliases, whose catch-all label counts are judged, and |
| + // moves it again. |
| + seal = "09fc7839a7111d1e945ac07f37a1b28659634149b90537ae4b533af1d98de290" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 5b78f7939..35b0faaf8 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -25,6 +25,7 @@ import ( |
| "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/controlsock" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "golang.org/x/crypto/bcrypt" |
| @@ -953,8 +954,12 @@ 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. |
| +// observation instead of a bare read. A nil judge is recorded as none. The |
| +// same judge is recorded for domains.InstanceObserverFrom, so the catch-all |
| +// label counts (internal/domains, which cannot import this package) judge |
| +// exactly when this context's record reads do. |
| func WithJudge(ctx context.Context, j *LifetimeJudge) context.Context { |
| + ctx = domains.WithInstanceObserver(ctx, InstanceObserver(j)) |
| return context.WithValue(ctx, lifetimeJudgeKey{}, j) |
| } |
diff --git a/internal/provision/self_serve_moves1_census_test.go b/internal/provision/self_serve_moves1_census_test.go| index 8033a1476..1db6e0dd8 100644 |
| --- a/internal/provision/self_serve_moves1_census_test.go |
| +++ b/internal/provision/self_serve_moves1_census_test.go |
| @@ -232,6 +232,12 @@ package provision |
| // as before, re-pinned in place. The box floor is skipped only once a |
| // validated marker body was read (or written by the door), never on the |
| // gate's state alone; the marker Put is unchanged. |
| +// - F3: domains/claims.go:InstanceLabelCounts leaves the inventory and |
| +// instanceHosts takes its place: the listing of the app's instance |
| +// record keys moved into that helper, which InstanceLabelCounts and |
| +// InstanceLabelCountsJudged share. key-site as before: it lists keys and |
| +// writes nothing. The judged counter observes each key through the |
| +// context's judge (provision's ObserveInstance) and writes nothing. |
| func plan14SelfServeMoves1WriteCensusInventory() []censusEntry { |
| removed := []struct{ file, unit string }{ |
| {"internal/provision/provision.go", "GetRecordETag"}, |
| @@ -279,6 +285,7 @@ func plan14SelfServeMoves1WriteCensusInventory() []censusEntry { |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel"}, |
| {"internal/console/fleets.go", "Server.placeAppTarget"}, |
| {"internal/provision/reconcile.go", "mintHeld"}, |
| + {"internal/domains/claims.go", "InstanceLabelCounts"}, |
| } |
| added := []censusEntry{ |
| {"internal/provision/provision.go", "updateRecordSeeding", "record-writer", "key:RecordKey,key:WriteRecord", "0bd38721d7cc97f37dbe8f37803f89d3e6efdaf29f9d6542a1d1b4d8eb370a3e"}, |
| @@ -345,6 +352,7 @@ func plan14SelfServeMoves1WriteCensusInventory() []censusEntry { |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "94d0979010cbe5bed720c16d5e96bfa7dc000711c836709fbc44681aab4ce3f7"}, |
| {"internal/console/fleets.go", "Server.placeAppTarget", "record-writer", "key:RecordsPrefix,key:SetRecordRegion", "c7afc71427abb8e831670d7ea9cf0d42f870aca7e132f1655d1ce968254bea19"}, |
| {"internal/provision/reconcile.go", "mintHeld", "key-site", "key:RecordKey", "0c11b996b5e773011047c1bfefb959dd694080abd614b67709290f50f2093e85"}, |
| + {"internal/domains/claims.go", "instanceHosts", "key-site", "key:instanceRecordsPrefix", "0552090e94107e58bfc06c8f69a827b88d0d063d4ca889252080b32b463e57ff"}, |
| } |
| entries := plan14ClaimRosterBindingWriteCensusInventory() |
| for _, r := range removed { |
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 20821a897..2cd3e0f3c 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 68e9c5f7b5f6e9f994007e3f7de9149ddbced3155512583215ec444fd708ee18 |
| +@coverage internal/provision d3de4ce79f1ca7ea8af3e59de0769de53427f0392821252b695ae347971559d1 |
| # 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 |
| @@ -45,7 +45,7 @@ internal/console/store_gate.go claimSQLCensus legacy accepted-legacy accepted-le |
| @remove internal/console/tempstore.go Store.CreateTemporaryAppInRegion |
| @removed internal/console/tempstore.go Store.CreateTemporaryAppInRegion H3 census-ruling-PROPOSED-self-serve-moves-1;rev3=PROPOSED-self-serve-moves-1;basis=constructor remains declared but has no direct read; app insertion and lifetime minting moved to insertAppRow, classified above; proposed removal only |
| # Task 2 envelopes, rev3=PROPOSED-self-serve-moves-1: internal/console covers the lifetime store, app constructors, lifetime projection and table-scoped gate labels; internal/provision (above) adds only ErrLifetimeExhausted beyond Task 1. Registry fields are unchanged. |
| -@coverage internal/console 3b006c37c57938e97b34a1407fea006eb83d273b963f7e8b0fb747ab9c8ac12e |
| +@coverage internal/console 6a7239960c7ef46a0d16e3a0c01df6c74d68ea9f5c93a49bce7dcf45e9ec0b77 |
| # Task 6a: rev3=PROPOSED-self-serve-moves-1; reviewed provision envelope now includes bounded record-then-slot observations, judged read/list/update gates and signed-counter exhaustion at slot read. No removal-loop, host or console change; registry fields and the legacy seal stand. |
| internal/provision/rootrecord.go IndexRecords authority B3,C rev3=PROPOSED-self-serve-moves-1;basis=retain every discovered candidate owner key per host for fresh lifetime judgement at Find; partial discovery still retries and no verdict or body is cached e44ac431865bf38baa4105f66c9cb23014cc3c7ffcd41d5385244cc76fc4c0b9 |
| internal/provision/rootrecord.go RecordIndex.Find authority B3,C rev3=PROPOSED-self-serve-moves-1;basis=observe each candidate through the context judge, refuse corruption and read failures, require one valid owner and validate its schema and shape; nil-judge duplicates retain permission refusal 760eed59cbd6a8ddab1098ce3a5f7a4272ecda81c17cc079dc135aba3a85a8f8 |
| @@ -73,7 +73,7 @@ internal/console/lifetimes.go newLifetimeOpID legacy accepted-legacy accepted-le |
| internal/console/lifetimes_store.go Store.ConvertRetirementKind legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: converts one pending normal SQLite retirement intent to kind cleanup with the corrupt body's ETag and refuses any other row file:Exec@215 400ffadd8d2210de9016fd288e8350307c7ecf7ba3f40dc08b9dacf63a6234b1 |
| @legacy 1fd9608bc83e53fa767adb28b116414434d5bd87a480b15181a12d3c763f95ec internal/domains/reconcile.go Reconcile legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop: the seeded-instances body (a trashed app's route kept while an import on its host is journaled, deleted only if the row is still the one listed); self-serve moves 1 Task 4 removes the claims-off 1d auto-purge, so a claims-off box destroys no app objects and a host-claim box only requests the claim's purge as before; the registry reads are unchanged registry:Account@5579,registry:Account@5775,registry:App@10116,registry:App@11452,registry:App@13691,registry:App@5380,registry:App@5528,registry:App@7191,registry:App@7248,registry:App@7449,registry:App@7662,registry:App@7900,registry:App@8090,registry:Canary@10060,registry:Canary@12738,registry:Canary@13582,registry:Channel@11507,registry:Channel@12715,registry:Channel@13613,registry:Channel@13700,registry:Channel@14003,registry:Host@10023,registry:Host@10702,registry:Host@13738,registry:Host@13823,registry:Host@13994,registry:Host@5650,registry:Host@5736,registry:Host@7298,registry:Host@7440,registry:Host@7653,registry:Host@7891,registry:Host@8081,registry:Kind@12381,registry:Platform@10044,registry:Platform@13567,whole:Route@10005,whole:Route@10111,whole:Route@10172,whole:Route@10378,whole:Route@10394,whole:Route@10520,whole:Route@10548,whole:Route@10714,whole:Route@10730,whole:Route@10738,whole:Route@11437,whole:Route@11490,whole:Route@11647,whole:Route@11663,whole:Route@12708,whole:Route@12778,whole:Route@12947,whole:Route@12963,whole:Route@12971,whole:Route@13522,whole:Route@13553,whole:Route@13686,whole:Route@13841,whole:Route@13855,whole:Route@13861,whole:Route@14033,whole:Route@14047,whole:Route@14053,whole:Route@14061,whole:Route@14070,whole:Route@5366,whole:Route@5380,whole:Route@5510,whole:Route@5528,whole:Route@5579,whole:Route@5650,whole:Route@5736,whole:Route@5775,whole:Route@7144,whole:Route@7176,whole:Route@7243,whole:Route@7469,whole:Route@7487,whole:Route@7497,whole:Route@7563,whole:Route@7680,whole:Route@7698,whole:Route@7708,whole:Route@7913,whole:Route@7931,whole:Route@7941,whole:Route@8119,whole:Route@8137,whole:Route@8147,whole:Route@8157,whole:Route@8166 9ee4958e2acd7313769f764102349ba0e6435c5dd895e9ac8e6c0f794734a7a4 |
| internal/domains/reconcile.go Reconcile legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop: the seeded-instances body (a trashed app's route kept while an import on its host is journaled, deleted only if the row is still the one listed); self-serve moves 1 Task 4 removes the claims-off 1d auto-purge, so a claims-off box destroys no app objects and a host-claim box only requests the claim's purge as before; the registry reads are unchanged registry:Account@5579,registry:Account@5775,registry:App@10116,registry:App@11452,registry:App@13691,registry:App@5380,registry:App@5528,registry:App@7191,registry:App@7248,registry:App@7449,registry:App@7662,registry:App@7900,registry:App@8090,registry:Canary@10060,registry:Canary@12738,registry:Canary@13582,registry:Channel@11507,registry:Channel@12715,registry:Channel@13613,registry:Channel@13700,registry:Channel@14003,registry:Host@10023,registry:Host@10702,registry:Host@13738,registry:Host@13823,registry:Host@13994,registry:Host@5650,registry:Host@5736,registry:Host@7298,registry:Host@7440,registry:Host@7653,registry:Host@7891,registry:Host@8081,registry:Kind@12381,registry:Platform@10044,registry:Platform@13567,whole:Route@10005,whole:Route@10111,whole:Route@10172,whole:Route@10378,whole:Route@10394,whole:Route@10520,whole:Route@10548,whole:Route@10714,whole:Route@10730,whole:Route@10738,whole:Route@11437,whole:Route@11490,whole:Route@11647,whole:Route@11663,whole:Route@12708,whole:Route@12778,whole:Route@12947,whole:Route@12963,whole:Route@12971,whole:Route@13522,whole:Route@13553,whole:Route@13686,whole:Route@13841,whole:Route@13855,whole:Route@13861,whole:Route@14033,whole:Route@14047,whole:Route@14053,whole:Route@14061,whole:Route@14070,whole:Route@5366,whole:Route@5380,whole:Route@5510,whole:Route@5528,whole:Route@5579,whole:Route@5650,whole:Route@5736,whole:Route@5775,whole:Route@7144,whole:Route@7176,whole:Route@7243,whole:Route@7469,whole:Route@7487,whole:Route@7497,whole:Route@7563,whole:Route@7680,whole:Route@7698,whole:Route@7708,whole:Route@7913,whole:Route@7931,whole:Route@7941,whole:Route@8119,whole:Route@8137,whole:Route@8147,whole:Route@8157,whole:Route@8166 9ee4958e2acd7313769f764102349ba0e6435c5dd895e9ac8e6c0f794734a7a4 |
| -@coverage internal/domains f0f68ddee74e459f4416359b4b5d8c911d6c0850d70032bd7346c6b82f1a0885 |
| +@coverage internal/domains 78f7cb6c185da532240d402d3444b9750b9a81f86f1d204d26851309299e3e41 |
| @coverage internal/host c520fb14e1584aebc16285652da59f50b945dc2fc9159919d4c5b3d7ff628169 |
| @coverage internal/release 99ef7ef9b58b82e6fb4b22a6e378a04d8fd9cccafe1f8726710b8181058ec065 |
| # Task 6b (consumers observe the lifetime: the provision reconcile and import journal, the host wiring and readers), rev3=PROPOSED-self-serve-moves-1: every changed unit re-pinned at its class and task with a reviewed basis; legacy units keep accepted-legacy and take a new frozen fingerprint (the seal moves); the four units with no read left re-pinned at their declarations; seedrollback.go journalRecordJudged added as accepted-legacy beside journalRecordByKey. All rows await the human census ruling. |
| @@ -194,3 +194,8 @@ internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 rev3=PROPOSED-sel |
| internal/host/edgesfile.go edgesFileLoop legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): census-ruling-moves-offline's body, which also takes the box's lifetimes switch (runAgent's boxJudge != nil) and hands it to every edgesFileWriteClaims pass; read site unchanged, offset moved; a report, not a decision; self-serve moves 1 Task 8 file:Read@2757 65c1d42c39f7f69035ccc6c2a6ce92f155f26d7c7af4b82b4633e215557615e7 |
| # Task 9 fix round 1 (a delayed create whose replay already committed answers success), rev3=PROPOSED-self-serve-moves-1: allocRecordLocked asks committedElsewhere when its SQLite commit is refused (one SQLite read of its own allocation row, one judged record read through readRecord; no write). No root read, class, legacy row or seal changes; the internal/console envelope is refreshed in place above. |
| # Final review fix round, rev3=PROPOSED-self-serve-moves-1: F1 makes the cutover door run the box floor unless a validated marker was read (no new read site; the floor reads are the door's as before). F2 makes a create refuse another owner's live slot whenever any record is present under that owner's key (one fewer path to a retirement; no read site added). No root read, class, legacy row or seal changes; the internal/console envelope is refreshed in place above. |
| +# Final review fix round F3 (catch-all label counts judged), rev3=PROPOSED-self-serve-moves-1: ReconcileAliases (accepted legacy) now hands deriveInstanceAliases a judged counter (domains.InstanceLabelCountsJudged with the context's observer, which provision.WithJudge records beside the judge; nil is today's key count); its read sites are unchanged, offsets moved. deriveInstanceAliases (accepted legacy) skips stale bodies in the shared-label count and, for a label with a record that cannot be judged, keeps its existing alias row (four new sites on that row: registry:Account, registry:Alias, registry:App and the whole RouteAlias) and derives none. Both are re-frozen with reviewed bases, which moves the seal. The internal/console (catchallInstanceName judged), internal/domains and internal/provision (ObserveInstance, InstanceObserver, WithJudge) envelopes are refreshed in place. No class or task is added; registry fields stand. |
| +@legacy bfee05191142ea2c7985d11be04b944c38dccb6e6eed9f5eb14e3fd5f788047f internal/domains/aliases.go ReconcileAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its fast-converge and instances loops: rev3=J1's body, which now counts the catch-all's labels through domains.InstanceLabelCountsJudged with the pass context's observer (provision.WithLifetimes on a claims-off box; none on a claims-on box, which is today's key count); registry read sites unchanged, offsets moved registry:Account@15249,registry:Account@15262,registry:Alias@14864,registry:Alias@14904,registry:Alias@14974,registry:App@11952,registry:App@12152,registry:App@12308,registry:App@12509,registry:App@15275,registry:App@15284,registry:App@2026,registry:App@2222,registry:App@5823,registry:App@6022,registry:Host@15234,registry:Host@15523,registry:Host@15576,registry:Host@15648,registry:Host@16160,registry:Host@16252,registry:Host@16311,registry:Host@16392,registry:Host@406,registry:Prefix@16168,registry:Prefix@16266,registry:Prefix@16319,registry:Prefix@16400,registry:Status@8686,whole:Route@11921,whole:Route@11996,whole:Route@12143,whole:Route@12205,whole:Route@12348,whole:Route@12504,whole:Route@13400,whole:Route@1369,whole:Route@14469,whole:Route@14477,whole:Route@2066,whole:Route@2217,whole:Route@2273,whole:Route@359,whole:Route@388,whole:Route@399,whole:Route@417,whole:Route@5169,whole:Route@5867,whole:Route@6013,whole:Route@8016,whole:Route@8468,whole:Route@8630,whole:RouteAlias@14485,whole:RouteAlias@14839,whole:RoutePath@10213,whole:RoutePath@16053,whole:RoutePath@16095,whole:RoutePath@16209,whole:RoutePath@16239,whole:WildcardDomain@12720,whole:WildcardDomain@15164,whole:WildcardDomain@15213,whole:WildcardDomain@15376,whole:WildcardDomain@15482,whole:WildcardDomain@15511 f0cb08a90be8c8d6c97c24be3cf73720abb54cb8444793fa25ba982e9525e9f9 |
| +internal/domains/aliases.go ReconcileAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its fast-converge and instances loops: rev3=J1's body, which now counts the catch-all's labels through domains.InstanceLabelCountsJudged with the pass context's observer (provision.WithLifetimes on a claims-off box; none on a claims-on box, which is today's key count); registry read sites unchanged, offsets moved registry:Account@15249,registry:Account@15262,registry:Alias@14864,registry:Alias@14904,registry:Alias@14974,registry:App@11952,registry:App@12152,registry:App@12308,registry:App@12509,registry:App@15275,registry:App@15284,registry:App@2026,registry:App@2222,registry:App@5823,registry:App@6022,registry:Host@15234,registry:Host@15523,registry:Host@15576,registry:Host@15648,registry:Host@16160,registry:Host@16252,registry:Host@16311,registry:Host@16392,registry:Host@406,registry:Prefix@16168,registry:Prefix@16266,registry:Prefix@16319,registry:Prefix@16400,registry:Status@8686,whole:Route@11921,whole:Route@11996,whole:Route@12143,whole:Route@12205,whole:Route@12348,whole:Route@12504,whole:Route@13400,whole:Route@1369,whole:Route@14469,whole:Route@14477,whole:Route@2066,whole:Route@2217,whole:Route@2273,whole:Route@359,whole:Route@388,whole:Route@399,whole:Route@417,whole:Route@5169,whole:Route@5867,whole:Route@6013,whole:Route@8016,whole:Route@8468,whole:Route@8630,whole:RouteAlias@14485,whole:RouteAlias@14839,whole:RoutePath@10213,whole:RoutePath@16053,whole:RoutePath@16095,whole:RoutePath@16209,whole:RoutePath@16239,whole:WildcardDomain@12720,whole:WildcardDomain@15164,whole:WildcardDomain@15213,whole:WildcardDomain@15376,whole:WildcardDomain@15482,whole:WildcardDomain@15511 f0cb08a90be8c8d6c97c24be3cf73720abb54cb8444793fa25ba982e9525e9f9 |
| +@legacy a37ea03438815852c187e4b5e8d83641253f23e8cbd48898abd4e2c72820c65e internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): rev3=J1's body; a stale body under a retired lifetime no longer makes a label shared, and a label with a record that cannot be judged keeps its existing alias row of the catch-all's own account and app (reading that row's Account, App and Alias) and derives no new one; a report-only conservative keep, never a new route registry:Account@2163,registry:Account@2950,registry:Account@3913,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@3006,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@2976,registry:App@3939,registry:Host@1843,registry:Host@3892,registry:Host@3902,registry:Host@4098,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3502,whole:RouteAlias@2143,whole:RouteAlias@2920,whole:RouteAlias@3874,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 888a5a32dbbcd9619b0bf513c0dc746686ad7c4e5681bf82248022e8f13b2512 |
| +internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): rev3=J1's body; a stale body under a retired lifetime no longer makes a label shared, and a label with a record that cannot be judged keeps its existing alias row of the catch-all's own account and app (reading that row's Account, App and Alias) and derives no new one; a report-only conservative keep, never a new route registry:Account@2163,registry:Account@2950,registry:Account@3913,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@3006,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@2976,registry:App@3939,registry:Host@1843,registry:Host@3892,registry:Host@3902,registry:Host@4098,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3502,whole:RouteAlias@2143,whole:RouteAlias@2920,whole:RouteAlias@3874,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 888a5a32dbbcd9619b0bf513c0dc746686ad7c4e5681bf82248022e8f13b2512 |