console: a create retires another owner's live slot only when no record exists under its key (final review F2)
The orphan rule protected the foreign record only when it was valid, so a present corrupt or stale-looking body under another owner's live slot let the create retire that slot and delete the bytes. Any present body now refuses the create with the held 409 and touches nothing; only an absent body reaches retireSlotOrphanLocked. Census: internal/console envelope refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+59
−6
docs/superpowers/notes/census-proposal-self-serve-moves-1.md+1 −1internal/console/lifetimes_create.go+8 −3internal/console/lifetimes_create_test.go+48 −0internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv+2 −2
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 ca3a4cbc8..a32f2451e 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, and the `internal/console` envelope is refreshed in place. 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. The `internal/console` envelope is refreshed in place after each finding. No root read, class, legacy row or seal changes. |
| 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/lifetimes_create.go b/internal/console/lifetimes_create.go| index be68cd66b..97463a82c 100644 |
| --- a/internal/console/lifetimes_create.go |
| +++ b/internal/console/lifetimes_create.go |
| @@ -154,8 +154,9 @@ func (s *Server) lifetimeAppCheck(ctx context.Context, sqid, app, lifetime strin |
| // plan treats as outside its guarantees): it is retired first and the create |
| // continues from the retired slot. Admission (step 1) ran under the same |
| // hostname lock, so no operation is pending for the hostname here. A live |
| -// slot owned by another key with a valid record, or a holder hostHolder |
| -// finds, refuses the create. |
| +// slot owned by another key with any record present under that key (valid, |
| +// stale-looking or corrupt), or a holder hostHolder finds, refuses the |
| +// create: another owner's slot is an orphan only when no record exists. |
| func (s *Server) planCreateLocked(ctx context.Context, acct *Account, rec provision.Record) (createPlan, error) { |
| host := rec.Host |
| for round := 0; ; round++ { |
| @@ -174,7 +175,11 @@ func (s *Server) planCreateLocked(ctx context.Context, acct *Account, rec provis |
| if err != nil { |
| return createPlan{}, err |
| } |
| - if validRecord(other) { |
| + // Only an absent record under the owner's key makes its |
| + // live slot an orphan. A present body of any verdict |
| + // (valid, stale-looking or corrupt) is the owner's to |
| + // settle: the create is refused and touches nothing. |
| + if other.Present { |
| hold := &apexHold{Holder: slot.App} |
| if slot.Account != rec.Account { |
| hold.OtherAccount = slot.Account |
diff --git a/internal/console/lifetimes_create_test.go b/internal/console/lifetimes_create_test.go| index 142552c62..ebfcbbf8b 100644 |
| --- a/internal/console/lifetimes_create_test.go |
| +++ b/internal/console/lifetimes_create_test.go |
| @@ -628,6 +628,54 @@ func TestCreateRefusesALiveSlotOfAnotherApp(t *testing.T) { |
| t.Fatalf("create = %d %s, want 409 routed for acme", code, out) |
| } |
| }) |
| + // A present body of any verdict under another owner's live slot is not |
| + // an orphan: only an absent record is (final review F2). The create is |
| + // refused and touches nothing. |
| + for name, body := range map[string][]byte{ |
| + "a malformed record of another account": []byte("{not a record"), |
| + "a stale-looking record of another account": func() []byte { |
| + b, _ := json.Marshal(provision.Record{Schema: 1, Host: ltOne, App: "web", Account: "acme", Channel: "stable", Incarnation: 2, CreatedAt: time.Now().UTC()}) |
| + return b |
| + }(), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + l := newLtOn(t) |
| + writeSlot(t, l.fx.rel, ltOne, provision.HostIncarnation{Incarnation: 3, State: provision.SlotLive, Account: "acme", App: "web", Intent: "create x"}) |
| + key := provision.RecordKey("acme", "web", ltOne) |
| + if _, err := l.fx.rel.Put(ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, recETag, err := l.fx.rel.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, slotETag := slotOf(t, l.fx.rel, ltOne) |
| + bucket := cutoverBucket(t, l.fx.rel) |
| + code, out := l.create(t, ltOne) |
| + if code != http.StatusConflict || ltErr(out) != hostHeldSentence(ltOne, &apexHold{Holder: "web", OtherAccount: "acme"}) { |
| + t.Fatalf("create = %d %s, want 409 routed for acme", code, out) |
| + } |
| + got, gotETag, err := l.fx.rel.Get(ctx, key) |
| + if err != nil || !bytes.Equal(got, body) || gotETag != recETag { |
| + t.Fatalf("acme's record after the refusal = %q %s (%v), want its bytes on %s", got, gotETag, err, recETag) |
| + } |
| + if _, e := slotOf(t, l.fx.rel, ltOne); e != slotETag { |
| + t.Fatal("the refused create wrote the slot") |
| + } |
| + if n, a := len(retireRows(t, l.fx.st, ltOne)), len(allocRows(t, l.fx.st, ltOne)); n != 0 || a != 0 { |
| + t.Fatalf("the refused create left %d retirement and %d allocation rows", n, a) |
| + } |
| + after := cutoverBucket(t, l.fx.rel) |
| + for k, e := range bucket { |
| + if after[k] != e { |
| + t.Fatalf("the refused create changed %s", k) |
| + } |
| + } |
| + if len(after) != len(bucket) { |
| + t.Fatalf("the refused create changed the bucket: %d keys, was %d", len(after), len(bucket)) |
| + } |
| + }) |
| + } |
| } |
| func TestCreateReclaimsAnOrphanSlot(t *testing.T) { |
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 892349ddf..20821a897 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 |
| @@ -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 7b2a80cf76ef16487ddb81ed28dfe079c11630b2c4d40227e848cb4fe6292715 |
| +@coverage internal/console 3b006c37c57938e97b34a1407fea006eb83d273b963f7e8b0fb747ab9c8ac12e |
| # 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 |
| @@ -193,4 +193,4 @@ internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 rev3=PROPOSED-sel |
| @legacy 5ca6674843515dd4b3587ac65b3d8d380f9aa1bfa0a7d5b50b02f01ad18e90c2 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 |
| 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). 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. |