carlos / platform Public

console: lifetime tables review fixes: census rows, the app_lifetime exemption scoped to host_allocations, read guards, state transitions

Co-Authored-By: Codex Astra <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev aac276363be8cbaf0904bd0cba824053bb60b0d0 parent e065b41
7 files changed, +318 −40
  • docs/superpowers/notes/census-proposal-self-serve-moves-1.md +2 −0
  • internal/console/lifetimes_store.go +41 −9
  • internal/console/lifetimes_store_test.go +190 −17
  • internal/console/store_gate.go +20 −9
  • internal/console/store_gate_test.go +9 −0
  • internal/provision/plan14_root_reads_test.go +9 −4
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv +47 −1
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 e53c96c8b..1bfd5616c 100644
--- a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md
+++ b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md
@@ -3,3 +3,5 @@
Status: proposed, marked PROPOSED-self-serve-moves-1. Not an accepted ruling; the census rulings on this plan are Paul's to make.
Task 1 adds four fields to `provision.Record` (`incarnation`, `incarnation_migrating`, `move_seq`, `retired`) and a new file, `internal/provision/lifetime.go`, holding the host incarnation slot, the lifetime verdict, the tombstone body and `GetRecordRaw`. The root-read overlay `internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv` re-pins the `internal/provision` source envelope and nothing else: no new root read, class or task, no accepted-legacy row, and the seal stands. The registry schema line is untouched, since `Record` is not a registry struct. The record-write overlay is `plan14SelfServeMoves1WriteCensusInventory` in `internal/provision/self_serve_moves1_census_test.go`, built over the roster-binding inventory: it removes `GetRecordETag` (it no longer builds a record key; it reads through `GetRecordRaw`), adds `GetRecordRaw` as a read-only key-site, and adds `WriteHostIncarnation` as nonrecord (it Puts only `control/host-incarnations/<host>.json`, conditionally). Later tasks of the plan will extend both overlays.
+
+Task 2 adds 15 console lifetime-store units and re-pins `migrate`, `migrateAppsKey`, `BackfillOpsMemberships` and `claimSQLCensus`. Each has a proposed legacy disposition limited to console SQL, randomness or an interface declaration, running as `carlos-console`; none adds a root decision. The two constructors `CreateAppInRegion` and `CreateTemporaryAppInRegion` now delegate their SQL to `insertAppRow`: their stale read rows are removed with explicit H3 dispositions, while the declarations remain covered by the source envelope. These account for all six changed frozen declarations, including the gate census changed in fix round 1. The removal parser requires a `census-ruling-` prefix, so those tokens say `census-ruling-PROPOSED-self-serve-moves-1`; they remain proposals. Every unit carries `rev3=PROPOSED-self-serve-moves-1` and a reviewed one-line basis. The console envelope covers the tables, constructors, lifetime projection, negative-read and transition guards, and the table-scoped `app_lifetime` exemption; the provision envelope adds `ErrLifetimeExhausted`. The existing overlay header is retained as the Task 1 record. Its provision coverage digest is refreshed in place because the loader forbids duplicate coverage rows in one overlay. Nineteen new legacy fingerprints require a new pinned seal, checked after verifying the preceding seal. Registry fields and the record-write inventory are unchanged. A green census checks these proposed rows against the code; Paul's ruling is still outstanding.
diff --git a/internal/console/lifetimes_store.go b/internal/console/lifetimes_store.go
index 7890ad2d0..19f3cfbda 100644
--- a/internal/console/lifetimes_store.go
+++ b/internal/console/lifetimes_store.go
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
+ "errors"
"fmt"
"math"
"time"
@@ -79,6 +80,16 @@ func sqlInt(incarnation uint64) (int64, error) {
return int64(incarnation), nil
}
+// storedIncarnation reads an INTEGER incarnation back. A negative value
+// cannot have been written by this package; it is refused as a counter
+// outside the range, never wrapped into a huge uint64.
+func storedIncarnation(v int64) (uint64, error) {
+ if v < 0 {
+ return 0, fmt.Errorf("%w: stored incarnation %d is negative", provision.ErrLifetimeExhausted, v)
+ }
+ return uint64(v), nil
+}
+
func (s *Store) BeginAllocation(a Allocation) error {
inc, err := sqlInt(a.Incarnation)
if err != nil {
@@ -91,14 +102,28 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
return err
}
-// setState moves one intent row to state; a missing row is an error, so a
-// recovery pass cannot believe it settled something that is not there.
+// setState moves one intent row out of pending into a terminal state. A
+// terminal row never changes again, and an unknown or pending target is
+// refused; a missing row is an error too, so a recovery pass cannot believe
+// it settled something that is not there.
func (s *Store) setState(table, opID, state string) error {
- if !lifetimeKnownState(state) {
- return fmt.Errorf("console: unknown lifetime state %q", state)
+ if lifetimeKnownState(state) && state != lifetimePending {
+ res, err := s.db.Exec(`UPDATE `+table+` SET state = ? WHERE op_id = ? AND state = ?`, state, opID, lifetimePending)
+ if err != nil {
+ return err
+ }
+ if n, err := res.RowsAffected(); err != nil || n == 1 {
+ return err
+ }
}
- res, err := s.db.Exec(`UPDATE `+table+` SET state = ? WHERE op_id = ?`, state, opID)
- return oneRow(res, err, table, opID)
+ var cur string
+ switch err := s.db.QueryRow(`SELECT state FROM `+table+` WHERE op_id = ?`, opID).Scan(&cur); {
+ case errors.Is(err, sql.ErrNoRows):
+ return fmt.Errorf("console: no %s row for op %q", table, opID)
+ case err != nil:
+ return err
+ }
+ return fmt.Errorf("console: %s op %q is %s and cannot move to %s", table, opID, cur, state)
}
func oneRow(res sql.Result, err error, table, opID string) error {
@@ -134,7 +159,9 @@ func (s *Store) allocations(where string, args ...any) ([]Allocation, error) {
if err := rows.Scan(&a.OpID, &a.Account, &a.App, &a.Host, &inc, &a.State, &a.Body, &a.PredecessorETag, &a.StaleETag, &a.AppLifetime); err != nil {
return nil, err
}
- a.Incarnation = uint64(inc)
+ if a.Incarnation, err = storedIncarnation(inc); err != nil {
+ return nil, err
+ }
out = append(out, a)
}
return out, rows.Err()
@@ -157,7 +184,10 @@ func (s *Store) LastIncarnation(host string) (uint64, error) {
var inc int64
err := s.db.QueryRow(`SELECT COALESCE(MAX(incarnation), 0) FROM host_allocations WHERE host = ? AND state <> ?`,
host, lifetimeVoid).Scan(&inc)
- return uint64(inc), err
+ if err != nil {
+ return 0, err
+ }
+ return storedIncarnation(inc)
}
func retirementKindKnown(k string) bool {
@@ -236,7 +266,9 @@ FROM record_retirements WHERE state = ? ORDER BY `+order, lifetimePending)
if err := rows.Scan(&r.OpID, &r.Kind, &r.Account, &r.App, &r.Host, &inc, &r.RecordETag, &r.SlotETag, &r.SlotIntent, &r.Cancels, &r.State); err != nil {
return nil, err
}
- r.Incarnation = uint64(inc)
+ if r.Incarnation, err = storedIncarnation(inc); err != nil {
+ return nil, err
+ }
out = append(out, r)
}
return out, rows.Err()
diff --git a/internal/console/lifetimes_store_test.go b/internal/console/lifetimes_store_test.go
index c35b1539c..eba358bec 100644
--- a/internal/console/lifetimes_store_test.go
+++ b/internal/console/lifetimes_store_test.go
@@ -3,11 +3,14 @@ package console
import (
"database/sql"
"errors"
+ "go/ast"
+ "go/parser"
+ "go/token"
"math"
- "os"
"path/filepath"
"reflect"
"regexp"
+ "strconv"
"strings"
"testing"
"time"
@@ -148,11 +151,18 @@ func TestLastIncarnationSkipsVoid(t *testing.T) {
}
// Every other state counts.
for _, st := range []string{"committed", "superseded", "refused"} {
- if err := s.SetAllocationState("op-2", st); err != nil {
+ op := "op-" + st
+ if err := s.BeginAllocation(testAllocation(op, h, 5)); err != nil {
t.Fatal(err)
}
- if got, _ := s.LastIncarnation(h); got != 2 {
- t.Fatalf("LastIncarnation with op-2 %s = %d, want 2", st, got)
+ if err := s.SetAllocationState(op, st); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := s.LastIncarnation(h); got != 5 {
+ t.Fatalf("LastIncarnation with a %s 5 = %d, want 5", st, got)
+ }
+ if _, err := s.db.Exec(`DELETE FROM host_allocations WHERE op_id = ?`, op); err != nil {
+ t.Fatal(err)
}
}
}
@@ -311,7 +321,12 @@ func appLifetimeOf(t *testing.T, s *Store, acct int64, app string) string {
func TestEveryAppInsertMintsALifetime(t *testing.T) {
// 1. No SQL in this package's non-test source inserts into apps except
- // insertAppRow and the apps_new migration copy.
+ // insertAppRow and the apps_new migration copy. The scan parses each file
+ // and reads string literals (comments do not count), attributing each to
+ // its own top-level declaration, so a package-level const or var is its
+ // own unit; literals joined by + are read as the one string they build.
+ // It cannot resolve a dynamic table name, SQL assembled across variables,
+ // or fmt.Sprintf pieces that do not themselves say INSERT ... INTO apps.
insert := regexp.MustCompile(`(?is)INSERT(\s+OR\s+(IGNORE|REPLACE))?\s+INTO\s+apps(_new)?\b`)
files, err := filepath.Glob("*.go")
if err != nil {
@@ -322,12 +337,18 @@ func TestEveryAppInsertMintsALifetime(t *testing.T) {
if strings.HasSuffix(name, "_test.go") {
continue
}
- src, err := os.ReadFile(name)
+ f, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
if err != nil {
t.Fatal(err)
}
- for _, loc := range insert.FindAllIndex(src, -1) {
- found = append(found, name+":"+enclosingFunc(string(src), loc[0]))
+ for _, decl := range f.Decls {
+ unit := declUnit(decl)
+ for _, lit := range sqlStringsIn(decl) {
+ if insert.MatchString(lit) {
+ found = append(found, name+":"+unit)
+
+ }
+ }
}
}
want := map[string]bool{"lifetimes_store.go:insertAppRow": true, "store.go:migrateAppsKey": true}
@@ -385,17 +406,60 @@ func TestEveryAppInsertMintsALifetime(t *testing.T) {
}
}
-// enclosingFunc names the top-level func whose body holds byte offset pos.
-func enclosingFunc(src string, pos int) string {
- re := regexp.MustCompile(`(?m)^func (?:\([^)]*\) )?(\w+)`)
- name := ""
- for _, m := range re.FindAllStringSubmatchIndex(src, -1) {
- if m[0] > pos {
- break
+// declUnit names a top-level declaration: a func by its name, anything
+// else by its first declared name.
+func declUnit(d ast.Decl) string {
+ switch x := d.(type) {
+ case *ast.FuncDecl:
+ return x.Name.Name
+ case *ast.GenDecl:
+ for _, sp := range x.Specs {
+ switch v := sp.(type) {
+ case *ast.ValueSpec:
+ return x.Tok.String() + " " + v.Names[0].Name
+ case *ast.TypeSpec:
+ return "type " + v.Name.Name
+ }
}
- name = src[m[2]:m[3]]
}
- return name
+ return "decl"
+}
+
+// sqlStringsIn returns the string values in a declaration, with a chain of
+// + over string literals joined into one.
+func sqlStringsIn(n ast.Node) []string {
+ var out []string
+ var flatten func(e ast.Expr) (string, bool)
+ flatten = func(e ast.Expr) (string, bool) {
+ switch x := e.(type) {
+ case *ast.BasicLit:
+ if x.Kind == token.STRING {
+ v, err := strconv.Unquote(x.Value)
+ return v, err == nil
+ }
+ case *ast.BinaryExpr:
+ if x.Op == token.ADD {
+ l, lok := flatten(x.X)
+ r, rok := flatten(x.Y)
+ return l + r, lok && rok
+ }
+ case *ast.ParenExpr:
+ return flatten(x.X)
+ }
+ return "", false
+ }
+ ast.Inspect(n, func(n ast.Node) bool {
+ e, ok := n.(ast.Expr)
+ if !ok {
+ return true
+ }
+ if v, ok := flatten(e); ok {
+ out = append(out, v)
+ return false
+ }
+ return true
+ })
+ return out
}
func TestAppLifetimeSurvivesRebuildAndBackfillsLegacyRows(t *testing.T) {
@@ -485,3 +549,112 @@ func TestInsertAppRowThroughTheGate(t *testing.T) {
t.Fatalf("lifetime = %q, want 16 hex digits", got)
}
}
+
+func TestLifetimeStateTransitionsAreGuarded(t *testing.T) {
+ s := openTestStore(t)
+ h := "shop.example.com"
+ begin := map[string]func(op string) error{
+ "host_allocations": func(op string) error { return s.BeginAllocation(testAllocation(op, h, 1)) },
+ "record_retirements": func(op string) error {
+ return s.BeginRetirement(Retirement{OpID: op, Kind: "normal", Account: "bab", App: "shop", Host: h, Incarnation: 1})
+ },
+ "record_migrations": func(op string) error {
+ return s.BeginMigration(Migration{OpID: op, Account: "bab", App: "shop", Host: h})
+ },
+ }
+ set := map[string]func(op, state string) error{
+ "host_allocations": s.SetAllocationState,
+ "record_retirements": s.SetRetirementState,
+ "record_migrations": s.SetMigrationState,
+ }
+ for table := range begin {
+ for _, first := range []string{"committed", "superseded", "void", "refused"} {
+ op := "op-" + first
+ if err := begin[table](op); err != nil {
+ t.Fatal(err)
+ }
+ if err := set[table](op, first); err != nil {
+ t.Fatalf("%s: pending -> %s: %v", table, first, err)
+ }
+ for _, next := range []string{"committed", "superseded", "void", "refused", "pending", "bogus"} {
+ err := set[table](op, next)
+ if err == nil {
+ t.Fatalf("%s: %s -> %s was accepted", table, first, next)
+ }
+ if !strings.Contains(err.Error(), first) || !strings.Contains(err.Error(), next) {
+ t.Fatalf("%s: %s -> %s error %q does not name both states", table, first, next, err)
+ }
+ }
+ var got string
+ if err := s.db.QueryRow(`SELECT state FROM `+table+` WHERE op_id = ?`, op).Scan(&got); err != nil || got != first {
+ t.Fatalf("%s: state = %q, %v, want it left at %s", table, got, err, first)
+ }
+ }
+ // A pending row cannot be set to pending or to a made-up state.
+ if err := begin[table]("op-live"); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"pending", "bogus", ""} {
+ if err := set[table]("op-live", bad); err == nil {
+ t.Fatalf("%s: pending -> %q was accepted", table, bad)
+ }
+ }
+ }
+}
+
+func TestNegativeStoredIncarnationIsRefusedNotWrapped(t *testing.T) {
+ s := openTestStore(t)
+ h := "shop.example.com"
+ if err := s.BeginAllocation(testAllocation("op-a", h, 1)); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BeginRetirement(Retirement{OpID: "op-r", Kind: "normal", Account: "bab", App: "shop", Host: h, Incarnation: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`UPDATE host_allocations SET incarnation = -1`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`UPDATE record_retirements SET incarnation = -1`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.PendingAllocations(); !errors.Is(err, provision.ErrLifetimeExhausted) {
+ t.Fatalf("PendingAllocations err = %v, want ErrLifetimeExhausted", err)
+ }
+ if _, err := s.PendingRetirements(); !errors.Is(err, provision.ErrLifetimeExhausted) {
+ t.Fatalf("PendingRetirements err = %v, want ErrLifetimeExhausted", err)
+ }
+ if got, err := s.LastIncarnation(h); !errors.Is(err, provision.ErrLifetimeExhausted) {
+ // MAX(-1) is -1: refused, never 18446744073709551615.
+ t.Fatalf("LastIncarnation = %d, %v, want ErrLifetimeExhausted", got, err)
+ }
+}
+
+func TestAppInsertScanDeclarationsAndFragments(t *testing.T) {
+ source := `package console
+func insertAppRow() { _ = "INSERT INTO apps" }
+const hidden = "INSERT " + "OR IGNORE INTO apps"
+var another = "INSERT OR REPLACE INTO apps_new" + suffix
+func elsewhere() { _ = ("INSERT INTO " + "apps") + suffix }
+`
+ f, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, decl := range f.Decls {
+ for _, sql := range sqlStringsIn(decl) {
+ if strings.Contains(sql, "INSERT") {
+ got = append(got, declUnit(decl)+":"+sql)
+ }
+ }
+ }
+ want := []string{
+ "insertAppRow:INSERT INTO apps",
+ "const hidden:INSERT OR IGNORE INTO apps",
+ "var another:INSERT OR REPLACE INTO apps_new",
+ "elsewhere:INSERT INTO apps",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("SQL declarations = %q, want %q", got, want)
+ }
+}
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go
index 7a42bbe39..8395d0f8c 100644
--- a/internal/console/store_gate.go
+++ b/internal/console/store_gate.go
@@ -161,6 +161,13 @@ type claimSQLTable struct {
// in an UPDATE of the retired incarnation's rows; "" means none.
PurgeMonotone string
+ // Labels lists columns whose names look like app references but are not
+ // one: a label the table's own rows carry, never a scope. The census skips
+ // them for this table only, and honours the field on a gated table only
+ // (a NonApp table may not excuse an app-looking column). Each label must
+ // exist.
+ Labels []string
+
// cols is the table's column list, filled in by claimSQLCensus.
cols []string
}
@@ -200,7 +207,9 @@ var claimSQLTables = map[string]claimSQLTable{
"mail_event_deliveries": {Sqid: "account", App: "app"},
// Pending lifetimes (lifetimes_store.go): intents on a claims-off host's
// incarnation slot, keyed by the account sqid and app name.
- "host_allocations": {Sqid: "account", App: "app"},
+ // app_lifetime is the apps row's lifetime id at allocation time: a label
+ // for one lifetime of the app, not a second scope, and only here.
+ "host_allocations": {Sqid: "account", App: "app", Labels: []string{"app_lifetime"}},
"record_retirements": {Sqid: "account", App: "app"},
"record_migrations": {Sqid: "account", App: "app"},
@@ -234,13 +243,6 @@ func claimQ(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""
func claimAppish(col string) bool {
c := strings.ToLower(col)
- // app_lifetime is an app row's lifetime id (host_allocations), a label for
- // one lifetime of an app, never an app name or a second scope: it cannot
- // widen what a permit covers, so it is the one app_ column the census
- // does not read as an app reference.
- if c == "app_lifetime" {
- return false
- }
return c == "app" || c == "apps" || c == "app_id" ||
strings.HasPrefix(c, "app_") || strings.HasSuffix(c, "_app") || strings.HasSuffix(c, "_app_id")
}
@@ -382,8 +384,17 @@ func claimSQLCensus(db *sql.DB, tables map[string]claimSQLTable) (map[string]cla
sort.Strings(spec.cols)
present[name] = spec
var appCols []string
+ labels := map[string]bool{}
+ if spec.NonApp == "" {
+ for _, c := range spec.Labels {
+ labels[c] = true
+ if !cols[name][c] {
+ bad("table %q: label column %q is missing", name, c)
+ }
+ }
+ }
for c := range cols[name] {
- if claimAppish(c) {
+ if claimAppish(c) && !labels[c] {
appCols = append(appCols, c)
}
}
diff --git a/internal/console/store_gate_test.go b/internal/console/store_gate_test.go
index ba41cf1d0..0733a1ef8 100644
--- a/internal/console/store_gate_test.go
+++ b/internal/console/store_gate_test.go
@@ -801,6 +801,15 @@ func TestClaimSQLCensusRefusals(t *testing.T) {
{"app column hidden in a NonApp table", ``, func(m map[string]claimSQLTable) { m["owners"] = claimSQLTable{NonApp: "claimed harmless"} }, "app column"},
{"app_id not enforced", ``, func(m map[string]claimSQLTable) { m["apps"] = claimSQLTable{Account: "account_id", App: "app"} }, "app_id"},
{"missing column", ``, func(m map[string]claimSQLTable) { m["audit"] = claimSQLTable{Account: "no_such", App: "app"} }, "no_such"},
+ {"app_lifetime in a NonApp table", `CREATE TABLE lifetime_label (x TEXT, app_lifetime TEXT)`, func(m map[string]claimSQLTable) {
+ m["lifetime_label"] = claimSQLTable{NonApp: "claimed harmless", Labels: []string{"app_lifetime"}}
+ }, "app column"},
+ {"app_lifetime in a gated table that does not list it", `CREATE TABLE lifetime_label (account TEXT, app TEXT, app_lifetime TEXT)`, func(m map[string]claimSQLTable) {
+ m["lifetime_label"] = claimSQLTable{Sqid: "account", App: "app"}
+ }, "app_lifetime"},
+ {"label column that does not exist", ``, func(m map[string]claimSQLTable) {
+ m["host_allocations"] = claimSQLTable{Sqid: "account", App: "app", Labels: []string{"app_lifetime", "no_such_label"}}
+ }, "no_such_label"},
{"empty spec", ``, func(m map[string]claimSQLTable) { m["users"] = claimSQLTable{} }, "no App column"},
}
for _, c := range cases {
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index d89e888e4..cd2c3bd60 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1255,13 +1255,18 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-merge-main-4.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
- // Census overlay self-serve-moves-1 (lifetime fields on provision.Record
- // and internal/provision/lifetime.go, self-serve moves plan 1 task 1),
- // loaded last: envelope only, internal/provision re-pinned. Marked
- // rev3=PROPOSED-self-serve-moves-1, not an accepted ruling; the seal stands.
+ // Self-serve moves plan 1, Tasks 1 and 2: proposed console lifetime
+ // store dispositions and envelopes. The two constructors transfer their
+ // read sites to insertAppRow. New and changed legacy units need a new
+ // seal; verify the preceding seal first. All rows await a human ruling
+ // under rev3=PROPOSED-self-serve-moves-1.
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ seal = "3bfd8a2df46d689def6f0728415fe23c4da3682e43546536c60a4e7c02a55378"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
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 6958f14ca..18510bd32 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,2 +1,48 @@
# 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 01ddbf636836b689030613d46749eb548fab2f144eeeacdca26cae80e7559fbd
+@coverage internal/provision a7dc8a5a3c06f69678b8e337a5cb72097070d813d8e69f2e1b4742e07ef3b5c8
+# 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
+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
+@legacy 81eb409f26072193c620602363c4bf4f11455773807e4980296a99f4702a5775 internal/console/lifetimes_store.go Store.BeginCancellingRetirement legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts an orphan retirement and voids only its pending allocation in one SQLite transaction; a failed handover rolls back both writes file:Exec@490,file:Exec@700 16ef23b049264054caac5e82acec9228b4249ceae131f16f7c93f3eeff53261e
+internal/console/lifetimes_store.go Store.BeginCancellingRetirement legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts an orphan retirement and voids only its pending allocation in one SQLite transaction; a failed handover rolls back both writes file:Exec@490,file:Exec@700 16ef23b049264054caac5e82acec9228b4249ceae131f16f7c93f3eeff53261e
+@legacy 61633641278fe8db01648aedd4451b892ffad0258230625cf866c9aefc683de5 internal/console/lifetimes_store.go Store.BeginMigration legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts a pending record-migration intent in the console SQLite store; performs no record or root write file:Exec@63 6d41a54d3a0d8fd7cd798aff7bec9b551bca841eb33862be2e428de933904ca6
+internal/console/lifetimes_store.go Store.BeginMigration legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts a pending record-migration intent in the console SQLite store; performs no record or root write file:Exec@63 6d41a54d3a0d8fd7cd798aff7bec9b551bca841eb33862be2e428de933904ca6
+@legacy 1a81e39e13cebf1f2f149e213eca45a1b0b56b7d6aadbcc178d9cca040505916 internal/console/lifetimes_store.go Store.BeginRetirement legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts a typed pending retirement in the console SQLite store and refuses cancellation outside the atomic handover file:Exec@377 4caf842cb1938a819f73ceb27a4946f0a76a6160ccb396e22ce251a41f2cda61
+internal/console/lifetimes_store.go Store.BeginRetirement legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: inserts a typed pending retirement in the console SQLite store and refuses cancellation outside the atomic handover file:Exec@377 4caf842cb1938a819f73ceb27a4946f0a76a6160ccb396e22ce251a41f2cda61
+@legacy 70451e5a1e3dc58c17378ed27f42f7386dbf1532fd7a11616d52bb490950955b internal/console/lifetimes_store.go Store.LastIncarnation legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads the maximum non-void allocation incarnation for one host from SQLite, refusing a negative stored result file:QueryRow@86 dec985de2406501f3ee395ecfa5c40d9a36f13f4d1e50227144ba0b36bdfcd80
+internal/console/lifetimes_store.go Store.LastIncarnation legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads the maximum non-void allocation incarnation for one host from SQLite, refusing a negative stored result file:QueryRow@86 dec985de2406501f3ee395ecfa5c40d9a36f13f4d1e50227144ba0b36bdfcd80
+@legacy 062168242c404ebf5734eba51f1ac0d6b72e84563d4a164f25be0023fc4267af internal/console/lifetimes_store.go Store.PendingForHost legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: counts pending rows for one host across the three intent tables, excluding the caller operation file:QueryRow@90 ed5b8c0dec9798a3433aea983e78c2816c07c73bbbe01b844b4efdf4f432d34b
+internal/console/lifetimes_store.go Store.PendingForHost legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: counts pending rows for one host across the three intent tables, excluding the caller operation file:QueryRow@90 ed5b8c0dec9798a3433aea983e78c2816c07c73bbbe01b844b4efdf4f432d34b
+@legacy 4b18a7309d263ecd743357c422d52723c928c1aadb7866476a177f90c1d2fffb internal/console/lifetimes_store.go Store.PendingMigrations legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads pending migrations from SQLite in insertion order for console recovery file:Query@73 f99e5c47a520d1fbcae505dcf5f3ae5ebf1dc2926ebcd8414e3bbfe656f0e062
+internal/console/lifetimes_store.go Store.PendingMigrations legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads pending migrations from SQLite in insertion order for console recovery file:Query@73 f99e5c47a520d1fbcae505dcf5f3ae5ebf1dc2926ebcd8414e3bbfe656f0e062
+@legacy 555e62c99bb56b9fcef54a9aa570ec7ce73f9ef1352f110141c500fed50dbebb internal/console/lifetimes_store.go Store.SetMigrationMarkedETag legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: updates the marked record ETag on one SQLite migration intent and refuses a missing operation file:Exec@79 557778aa186eef380ddedca7edf855e2cb5e95998a947f0fc87b10c4acd96d10
+internal/console/lifetimes_store.go Store.SetMigrationMarkedETag legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: updates the marked record ETag on one SQLite migration intent and refuses a missing operation file:Exec@79 557778aa186eef380ddedca7edf855e2cb5e95998a947f0fc87b10c4acd96d10
+@legacy 0c277c23cd1f2dbc73ab9ac54c41f4b3ebac357c43bd54d769d5e0d64cb405dc internal/console/lifetimes_store.go Store.allocations legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads selected SQLite allocation intents, refusing negative incarnations before unsigned conversion file:Query@93 966309cdf0fb0f16414b9f45894c7d442c38fe4665062557632e0d9319a8ed6b
+internal/console/lifetimes_store.go Store.allocations legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads selected SQLite allocation intents, refusing negative incarnations before unsigned conversion file:Query@93 966309cdf0fb0f16414b9f45894c7d442c38fe4665062557632e0d9319a8ed6b
+@legacy 730ef89c865af956e4fb613fc622ff654cf793089541f9452164103c01d5aff9 internal/console/lifetimes_store.go Store.backfillAppLifetimes legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads apps lacking a lifetime and fills only those rows with independently minted ids in one boot-time SQLite transaction file:Exec@633,file:Query@140 ea01fe927443bff4b489f848370eeb67af6e1c53227fa6c3f3b7c40dc324a75b
+internal/console/lifetimes_store.go Store.backfillAppLifetimes legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads apps lacking a lifetime and fills only those rows with independently minted ids in one boot-time SQLite transaction file:Exec@633,file:Query@140 ea01fe927443bff4b489f848370eeb67af6e1c53227fa6c3f3b7c40dc324a75b
+@legacy 296cf9ebb259a1bc5922dc9c54d6b0dcdec6ee0b4bde27880a07efa168b80282 internal/console/lifetimes_store.go Store.pendingRetirements legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads pending SQLite retirements in recovery order, refusing negative incarnations before unsigned conversion file:Query@87 5497fd926619c814e20a5f7e57521a924dc6cb0441bbf34715d67a4e06ce5a5f
+internal/console/lifetimes_store.go Store.pendingRetirements legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads pending SQLite retirements in recovery order, refusing negative incarnations before unsigned conversion file:Query@87 5497fd926619c814e20a5f7e57521a924dc6cb0441bbf34715d67a4e06ce5a5f
+@legacy 305ac444a9608142fc363980694d99c914452b56bd2258853b7f152646008a4c internal/console/lifetimes_store.go Store.setState legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: conditionally updates only pending SQLite intents to a known terminal state; terminal and invalid transitions are refused with both states file:Exec@134,file:QueryRow@384 ca4357f909812e1bcfa3fb1914c67c06589110a71e61026f29c0b8988ae531f1
+internal/console/lifetimes_store.go Store.setState legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: conditionally updates only pending SQLite intents to a known terminal state; terminal and invalid transitions are refused with both states file:Exec@134,file:QueryRow@384 ca4357f909812e1bcfa3fb1914c67c06589110a71e61026f29c0b8988ae531f1
+@legacy 5ca1ae47b2d8e54104986877e14db39410711145b3d850d091ec4a8a47a73e71 internal/console/lifetimes_store.go insertAppRow legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: mints an app lifetime and performs the shared SQLite app insert, preserving conflict and ops-backfill ownership checks file:Exec@204 47738c07462b5fabe1a29ad68ae9250e050647efc2296f31f8cce9bbf4670481
+internal/console/lifetimes_store.go insertAppRow legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: mints an app lifetime and performs the shared SQLite app insert, preserving conflict and ops-backfill ownership checks file:Exec@204 47738c07462b5fabe1a29ad68ae9250e050647efc2296f31f8cce9bbf4670481
+@legacy 6926cc1599bdd8538d561ec013b825ff54a555e9a254f771c1e249b49f3ab301 internal/console/lifetimes_store.go mintLifetime legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads crypto/rand for an independent 16-hex app lifetime; randomness grants no root authority file:Read@67 afb1f969bb0c70ca0982194d04387d642ec0c7860410d21544fcfafe995f0c9f
+internal/console/lifetimes_store.go mintLifetime legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads crypto/rand for an independent 16-hex app lifetime; randomness grants no root authority file:Read@67 afb1f969bb0c70ca0982194d04387d642ec0c7860410d21544fcfafe995f0c9f
+@legacy 949985e2c34c8bb12a1b2e34580421493cc76b78e1fa7ad67d01c1f1882730bb internal/console/lifetimes_store.go type appExecer legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: declares the Exec interface used by the shared app-row insert; a declaration alone performs no IO file:Exec@28 474e417dac16b289dfac524eaa8b09a867ed140a6d43b8341162d2c970e60416
+internal/console/lifetimes_store.go type appExecer legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: declares the Exec interface used by the shared app-row insert; a declaration alone performs no IO file:Exec@28 474e417dac16b289dfac524eaa8b09a867ed140a6d43b8341162d2c970e60416
+@legacy d7dfc34d3a2dac648deb6e829c7209b86c02a886bc7beb4857dbdf53ea9311fb internal/console/store.go Store.BackfillOpsMemberships legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads unclaimed ops candidates before calling the shared lifetime-minting insert, which repeats the unclaimed-everywhere check atomically file:Exec@177,file:Exec@528,file:Query@2609,file:Query@3496,file:Query@4336 3d31113685a1b4892667ab48a138580c06922364238079037fb23710f60100c2
+internal/console/store.go Store.BackfillOpsMemberships legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: reads unclaimed ops candidates before calling the shared lifetime-minting insert, which repeats the unclaimed-everywhere check atomically file:Exec@177,file:Exec@528,file:Query@2609,file:Query@3496,file:Query@4336 3d31113685a1b4892667ab48a138580c06922364238079037fb23710f60100c2
+@legacy 5a62caa5a71b3cbe80e376c380e81a92e392e491f04636aed2184de628ffd54f internal/console/store.go Store.migrate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: adds the three account/app-scoped lifetime intent tables and apps.lifetime at unarmed store boot, then backfills missing lifetimes file:Exec@13226,file:Exec@19282 3694b4a7b6fb5fb8449104ecfa53ab5c568e47cf32c611945bb542e655e96a72
+internal/console/store.go Store.migrate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: adds the three account/app-scoped lifetime intent tables and apps.lifetime at unarmed store boot, then backfills missing lifetimes file:Exec@13226,file:Exec@19282 3694b4a7b6fb5fb8449104ecfa53ab5c568e47cf32c611945bb542e655e96a72
+@legacy c7d052c17b41322555a7d3578a7fa3d163e36d2388eb2e1144ce1cb45e51e9b0 internal/console/store.go Store.migrateAppsKey legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: copies apps into the composite-key schema while preserving existing lifetimes and minting only missing ids file:Exec@2336,file:QueryRow@65 52af07da0e304586f325d926b70d12ae453a1451ecf3c6aefd38668b381d3f65
+internal/console/store.go Store.migrateAppsKey legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: copies apps into the composite-key schema while preserving existing lifetimes and minting only missing ids file:Exec@2336,file:QueryRow@65 52af07da0e304586f325d926b70d12ae453a1451ecf3c6aefd38668b381d3f65
+@legacy c1fc01ca7bd83c9388979149ad1ee0fbf686bd9f01da89c81cf7317fbe8323db internal/console/store_gate.go claimSQLCensus legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: permits declared label columns only on their own gated table, requires those columns to exist and still refuses app_lifetime on NonApp or undeclared gated tables file:Query@1970,file:Query@2336,file:Query@301,file:Query@694 a9fbbffe55eb5ae682d6a3163c211cd70bc148ba81446c2e86c3eeac6788407f
+internal/console/store_gate.go claimSQLCensus legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=console.service user carlos-console, J4 root-input: permits declared label columns only on their own gated table, requires those columns to exist and still refuses app_lifetime on NonApp or undeclared gated tables file:Query@1970,file:Query@2336,file:Query@301,file:Query@694 a9fbbffe55eb5ae682d6a3163c211cd70bc148ba81446c2e86c3eeac6788407f
+@remove internal/console/store.go Store.CreateAppInRegion
+@removed internal/console/store.go Store.CreateAppInRegion 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
+@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 fdb47efcb501addb7b7b0bfc756247c64494153e6cbc20d50e5873e4e011ac87