carlos / platform Public

plan: claim Plan 7 rev 1, the pull door (O1/O2 bracket, signed exact-version handles)

Inert behind CARLOS_HOST_CLAIM. Refuses a non-empty holder.generation
until the moves spec's generation selector (§5.1) exists as a shared
component; activation is gated on it as well as on Plan 8.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 139378b2fd55177356de77f29642656c9eefdce9 parent f9b7911
1 file changed, +1599 −0
  • docs/superpowers/plans/2026-10-05-host-lifecycle-claim-7-pull-door.md +1599 −0

CI cancelled — superseded by a newer push — run details

diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-7-pull-door.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-7-pull-door.md
new file mode 100644
index 000000000..978ab6f67
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-7-pull-door.md
@@ -0,0 +1,1599 @@
+# Host Lifecycle Claim, Plan 7: the pull door
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** bracket replica listings with fresh claim observations and deliver each selected file through a console-signed handle that names its exact object version. A stalled or resumed download cannot read a replacement incarnation's bytes.
+
+**Architecture:**
+
+- Keep the current membership, token budget, instance-class and residency gates. Under the startup flag, `hostclaim.Read` supplies O1, the holder supplies the location, and `lease.VersionedStore` supplies current object versions. Read O2 before returning anything and compare the complete holder and operation.
+- Extend the existing JSON protocol additively with `protocol: 2` and per-file `handle`. Keep the coordinate URLs and carry the opaque handle in `X-Carlos-DB-Handle`; the successful response echoes it. The CLI remembers both the handle and its listed metadata. Existing flag-off requests and new clients talking to old consoles retain protocol 0.
+- The door has no existing handle signature. Derive a domain-separated HMAC-SHA256 key from the console's existing persisted P-256 `Store.SigningKey`, already loaded in `NewServer`; do not use the optional release signing key or introduce a new secret. Authentication remains required for every request.
+- Adapt exact-version streams to the existing `replicaReader` path so concurrency limits, idle cancellation, write deadlines and audit records remain in one place. Range requests skip and bound the selected version locally; they never issue a current-key GET.
+- The console uses its box's IAM role and refuses other regions before S3. Plan 2 already added both required grants. Make their read-only purpose explicit by splitting its combined version read/delete statement, preserving every existing permission and resource.
+
+**Tech Stack:** Go 1.26; standard-library HMAC, JSON and HTTP; existing `hostclaim`, `lease.VersionedStore`, `leasetest.VersionedBucket`, `dbdoor`, Litestream v0.5.14 and LTX. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev 6.14, §2, C4, C6, §6.5, §9 and §10; `docs/superpowers/specs/2026-09-02-carlos-db-design.md`, §6. Source interfaces were read from `origin/main` and `feat/hostclaim-library`, rather than this older planning checkout. The actual S3 layout is `<location>/%04x/<ltx filename>`; Litestream's file replica uses `ltx/<decimal level>` and is not the S3 path helper.
+
+**Depends on:** Plans 1 and 2. Cut `feat/hostclaim-door` from `feat/hostclaim-library`, then merge `origin/main` (Plan 2, `3ca43d3`). The commands below are for implementation; authoring this plan changes only this Markdown file, without staging or committing.
+
+## Global Constraints
+
+- **Inert by default (R1):** read `CARLOS_HOST_CLAIM` once in `serve`, pass `Config.HostClaim` down, and construct the versioned client only when enabled and a bucket is configured. Any nonblank value enables the flag, including `0`; unset or whitespace disables it. No package-global flag, no environment reads inside handlers, no automatic enabling in infrastructure.
+- **Authorization (C4):** match O1's instance holder account, app, exact record key, incarnation and creation time to the authenticated route and freshly read record. Preserve the existing service-credential refusal, byte-identical non-member 404, class/pool/fleet/residency rails, rate limits and missing-bucket refusal. No cross-region lookup or IAM widening.
+- **Read failures (C6):** an absent claim is a distinct retryable 409, `host not adopted`. Free/deleting/parking/purging claims refuse. Claim or version-list failures return no files. No fall-through to the legacy reader, no adoption, no writes to claims in this plan.
+- **O1/O2:** compare the complete `Holder` and `Op`, even if a broken writer reused `Gen`. §6.5 step 5 explicitly limits the relevant generation change to holder/op, so a chore-only revision may pass when both are unchanged. Do not compare only incarnation, location or op kind. A conservative extra refusal from nil-versus-empty content is safe.
+- **Selection:** list only `<O1.holder.location>/<hex level>/`; accept current (`IsLatest`) non-delete-marker versions and exact canonical LTX filenames. Never descend into child incarnations, other generations or metadata files. Empty and `null` version IDs refuse: a null version can be replaced if versioning is suspended. Empty valid levels still return `files: []`.
+- **Handles:** canonical ordered JSON, HMAC-SHA256 with domain separation, constant-time MAC comparison, 15-minute expiry, maximum encoded length 8192, and signed host/location/key/version/incarnation/account/app/size. Verify host and coordinate binding on GET. Read exactly that version or fail; a missing version maps to 404, never current-key fallback. An admitted open may finish after a claim transition; a new request rechecks the claim.
+- **Wire compatibility:** omitted new fields preserve flag-off JSON. An old CLI against an enabled console cannot supply a handle and is refused; Plan 8 must account for client rollout. A new CLI rejects a successful file response that does not echo its handle, including an old console reached after a v2 listing. New CLI → old console and old CLI → new flag-off console remain supported.
+- **Generation dependency:** Plans 1 and 2 do not implement the moves spec's generation-aware restore selection, including sealed versions and unfinished-admission baselines. A nonempty `holder.Generation` therefore refuses with 409 before listing or opening data. `@root` is an obligation sentinel, not an allowed holder generation. This affects ordinary cold admissions after adoption/create, not just moves. **Plan 8 must not enable this door until that selection contract is integrated and tested.** Serving stale root files would violate C3a. This is a concrete rollout blocker, not an assertion that root-only selection completes generation restores.
+- **IAM:** the two own-region role policies already contain `s3:GetObjectVersion` and `s3:ListBucketVersions` after Plan 2. Task 4 separates the read grant from the pre-existing deletion grant; it adds no permission and touches no sibling `moves/*` grant. No apply or production experiment is authorized by this plan.
+- **Gate:** use the default Go cache. If this sandbox makes it read-only, use the existing shared on-disk `$TMPDIR/gocache`; never put caches under `/tmp`, and never create a cache per task. Run Linux checks plus Darwin, Windows and Linux/arm64 builds.
+
+## Review Focus
+
+1. **A stale handle reads a later incarnation.** Authorize, pause, change the holder and overwrite the same legacy key, then open. It must read the authorized version or fail (Task 2).
+2. **The O1/O2 race.** Holder/op changes, equal-Gen corruption, read failure, malformed claim and disappearance refuse; an unrelated chore completion passes (Task 2).
+3. **Legacy location.** Use exactly `instances/<host>` from the holder, with bounded hex-level selection; do not append an incarnation, derive the prefix from `HibernatePrefix`, or collect child incarnations (Task 2).
+4. **Flag-off regressions.** Existing tests remain unchanged. Check both wire compatibility directions and a v2-to-v0 switch mid-pull (Tasks 1–3).
+5. **Forgery and expiry.** Wrong key, modified body/signature, expired token, wrong host/coordinate and later incarnation all fail before an exact-version open (Task 2).
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/dbdoor/coords.go` | Add optional protocol and handle fields |
+| `internal/dbdoor/client.go`, `client_handles.go` | Carry handles, preserve metadata, enforce successful-response acknowledgement |
+| `internal/dbdoor/client_handles_test.go` | Old/new protocol compatibility, downgrade and pinning tests |
+| `internal/console/apidbclaim.go` | Claim gate, signed handles, bracketed listing and exact-version range adapter |
+| `internal/console/apidb.go` | Branch into claim mode while retaining the existing stream/audit path |
+| `internal/console/server.go` | Config dependencies and derived signing secret |
+| `internal/console/apidbclaim_test.go` | Deterministic claims, version races, failure and handle tests |
+| `internal/console/apidbclaim_http_test.go` | Real restore, old-client behavior and authorization regression |
+| `cmd/console/main.go`, `hostclaim_test.go` | One-time env flag and own-region versioned-client wiring |
+| `infra/modules/region-host/main.tf`, `infra/modules/region-remote-host/main.tf` | Explicit read-only door grants, preserving Plan 2 deletion grants |
+
+`db.go`, `apimode.go`, existing door tests and `dbdoortest` remain unchanged: the CLI already forwards arbitrary request headers, and its restore/error path consumes the same `dbdoor.Client`. No new CLI command or documentation heading is required.
+
+---
+
+### Task 1: Add the compatible handle protocol to the client
+
+**Files:**
+- Modify: `internal/dbdoor/coords.go`, `internal/dbdoor/client.go`
+- Create: `internal/dbdoor/client_handles.go`, `internal/dbdoor/client_handles_test.go`
+
+**Interfaces:**
+- Produces: optional `ListResponse.Protocol`, `LTXFile.Handle`, `HandleHeader`, per-client synchronized handle/metadata storage.
+- Consumes: existing `Doer`, unchanged coordinate paths, Range headers and `DoorError`.
+
+- [ ] **Step 1: Create the implementation branch and merge Plan 2**
+
+```bash
+git switch -c feat/hostclaim-door feat/hostclaim-library
+git merge --no-edit origin/main
+git merge-base --is-ancestor 3ca43d3 HEAD
+git status --short
+```
+
+Expected: branch `feat/hostclaim-door` contains the library and Plan 2; the ancestry check exits zero and status is clean after resolving any merge conflict. These commands assume the implementation checkout has no unrelated edits. Do not run them in this planning worktree.
+
+- [ ] **Step 2: Write `client_handles_test.go`**
+
+```go
+package dbdoor
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/superfly/ltx"
+)
+
+func TestHandleProtocolCompatibility(t *testing.T) {
+ for _, mode := range []string{"legacy", "pinned", "downgraded-get"} {
+ t.Run(mode, func(t *testing.T) {
+ c := NewClient(func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
+ out := &http.Response{StatusCode: 200, Header: http.Header{}}
+ if tail == ListTail(0) {
+ list := ListResponse{Files: []LTXFile{{Level: 0, MinTXID: 1, MaxTXID: 1, Size: 3}}}
+ if mode != "legacy" {
+ list.Protocol = 2
+ list.Files[0].Handle = "opaque"
+ }
+ b, _ := json.Marshal(list)
+ out.Body = io.NopCloser(strings.NewReader(string(b)))
+ } else {
+ want := ""
+ if mode != "legacy" {
+ want = "opaque"
+ }
+ if h.Get(HandleHeader) != want || h.Get("Range") != "bytes=1-2" {
+ t.Fatal(tail, h)
+ }
+ if mode == "pinned" {
+ out.Header.Set(HandleHeader, want)
+ }
+ out.Body = io.NopCloser(strings.NewReader("bc"))
+ }
+ return out, nil
+ })
+ it, err := c.LTXFiles(t.Context(), 0, 0, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ it.Close()
+ rc, err := c.OpenLTXFile(t.Context(), 0, ltx.TXID(1), ltx.TXID(1), 1, 2)
+ if mode == "downgraded-get" {
+ if err == nil {
+ rc.Close()
+ t.Fatal("accepted unpinned body")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := io.ReadAll(rc)
+ rc.Close()
+ if err != nil || string(b) != "bc" {
+ t.Fatal(string(b), err)
+ }
+ })
+ }
+}
+
+func TestHandleProtocolNeverFallsBack(t *testing.T) {
+ c := NewClient(nil)
+ first := ListResponse{Protocol: 2, Files: []LTXFile{{Level: 0, MinTXID: 1, MaxTXID: 1, Size: 3, Handle: "first"}}}
+ if _, err := c.rememberFiles(0, first); err != nil {
+ t.Fatal(err)
+ }
+ again := ListResponse{Protocol: 2, Files: []LTXFile{{Level: 0, MinTXID: 1, MaxTXID: 1, Size: 3, Handle: "second"}}}
+ files, err := c.rememberFiles(0, again)
+ if err != nil || files[0].Handle != "first" {
+ t.Fatal(files, err)
+ }
+ again.Files[0].Size = 4
+ if _, err := c.rememberFiles(0, again); err == nil {
+ t.Fatal("metadata changed")
+ }
+ if _, err := c.rememberFiles(0, ListResponse{}); err == nil {
+ t.Fatal("listing downgraded")
+ }
+ if _, err := c.fileHandle(0, 2, 2); err == nil {
+ t.Fatal("missing handle fell back")
+ }
+ for _, bad := range []ListResponse{{Protocol: 3}, {Protocol: 2, Files: []LTXFile{{Level: 0, MinTXID: 1, MaxTXID: 1}}}} {
+ if _, err := NewClient(nil).rememberFiles(0, bad); err == nil {
+ t.Fatal("bad listing", bad)
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Run the failing protocol tests**
+
+```bash
+go test ./internal/dbdoor -run '^TestHandleProtocol' -count=1
+```
+
+Expected: compile failure naming `HandleHeader`, `Protocol`, `Handle` or `rememberFiles`. The test double is an old console when it returns protocol 0 and accepts a coordinate without a handle; the same new client restores that old wire shape.
+
+- [ ] **Step 4: Write `client_handles.go`**
+
+```go
+package dbdoor
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+)
+
+const HandleHeader = "X-Carlos-DB-Handle"
+
+type dbHandleState struct {
+ mu sync.Mutex
+ pinned bool
+ files map[string]LTXFile
+}
+
+func (c *Client) rememberFiles(level int, list ListResponse) ([]LTXFile, error) {
+ c.handles.mu.Lock()
+ defer c.handles.mu.Unlock()
+ if list.Protocol != 0 && list.Protocol != 2 {
+ return nil, errors.New("dbdoor: unsupported listing protocol")
+ }
+ if c.handles.pinned && list.Protocol != 2 {
+ return nil, errors.New("dbdoor: pinned listing protocol disappeared")
+ }
+ if list.Protocol == 0 {
+ return list.Files, nil
+ }
+ files := make(map[string]LTXFile, len(c.handles.files)+len(list.Files))
+ for k, v := range c.handles.files {
+ files[k] = v
+ }
+ seen := map[string]bool{}
+ out := make([]LTXFile, 0, len(list.Files))
+ for _, f := range list.Files {
+ k := GetTail(level, f.MinTXID, f.MaxTXID)
+ if f.Level != level || f.MinTXID == 0 || f.MaxTXID < f.MinTXID || f.Handle == "" || f.Size < 0 || seen[k] {
+ return nil, errors.New("dbdoor: invalid pinned listing")
+ }
+ seen[k] = true
+ if old, ok := files[k]; ok {
+ if old.Size != f.Size {
+ return nil, errors.New("dbdoor: pinned file size changed; retry the database pull")
+ }
+ f = old // Preserve first handle and its metadata through retries/resumes.
+ }
+ files[k] = f
+ out = append(out, f)
+ }
+ c.handles.files = files
+ c.handles.pinned = true
+ return out, nil
+}
+
+func (c *Client) fileHandle(level int, lo, hi uint64) (string, error) {
+ c.handles.mu.Lock()
+ defer c.handles.mu.Unlock()
+ if !c.handles.pinned {
+ return "", nil
+ }
+ if f, ok := c.handles.files[GetTail(level, lo, hi)]; ok {
+ return f.Handle, nil
+ }
+ return "", fmt.Errorf("dbdoor: coordinate has no pinned handle: %d %s", level, Span(lo, hi))
+}
+```
+
+- [ ] **Step 5: Apply the exact wire/client edits**
+
+The script asserts each edit anchor occurs once. Stop on a changed anchor and reconcile with the implementation branch; do not apply an approximate replacement.
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+
+def source(p):
+ return Path(p).read_text()
+
+def put(p, text):
+ Path(p).write_text(text)
+
+def replace(text, old, new):
+ assert text.count(old) == 1, (old, text.count(old))
+ return text.replace(old, new)
+
+p='internal/dbdoor/coords.go';s=source(p)
+s=replace(s,'type LTXFile struct {','type LTXFile struct {\n\tHandle string `json:"handle,omitempty"`')
+s=replace(s,'type ListResponse struct {','type ListResponse struct {\n\tProtocol int `json:"protocol,omitempty"`')
+put(p,s)
+p='internal/dbdoor/client.go';s=source(p)
+s=replace(s,'type Client struct {','type Client struct {\n\thandles dbHandleState')
+s=replace(s,'infos := make([]*ltx.FileInfo, 0, len(list.Files))\n\tfor _, f := range list.Files {','files, err := c.rememberFiles(level, list)\n\tif err != nil { return nil, err }\n\tinfos := make([]*ltx.FileInfo, 0, len(files))\n\tfor _, f := range files {')
+s=replace(s,'h := http.Header{}\n\th.Set("Range", RangeHeader(offset, size))','h := http.Header{}\n\thandle, err := c.fileHandle(level, uint64(minTXID), uint64(maxTXID))\n\tif err != nil { return nil, err }\n\tif handle != "" { h.Set(HandleHeader, handle) }\n\th.Set("Range", RangeHeader(offset, size))')
+s=replace(s,'case http.StatusOK, http.StatusPartialContent:\n\t\treturn res.Body, nil','case http.StatusOK, http.StatusPartialContent:\n\t\tif handle != "" && res.Header.Get(HandleHeader) != handle {\n\t\t\tres.Body.Close()\n\t\t\treturn nil, errors.New("dbdoor: console did not enforce the pinned handle")\n\t\t}\n\t\treturn res.Body, nil')
+put(p,s)
+PY
+```
+
+- [ ] **Step 6: Run the client checks**
+
+```bash
+gofmt -w internal/dbdoor/coords.go internal/dbdoor/client.go internal/dbdoor/client_handles.go internal/dbdoor/client_handles_test.go
+go test -race ./internal/dbdoor -count=1
+```
+
+Expected: `ok`. A successful pinned GET without the echoed handle is rejected before its body reaches restore. Once the client sees v2, neither a downgraded listing nor a coordinate without a stored handle can select the old protocol. Metadata and the first handle remain together for this client/restore attempt; an expired or vanished first handle fails safely rather than silently substituting bytes.
+
+---
+
+### Task 2: Bracket listings and stream signed exact versions
+
+**Files:**
+- Create: `internal/console/apidbclaim.go`, `internal/console/apidbclaim_test.go`, `internal/console/apidbclaim_http_test.go`
+- Modify: `internal/console/apidb.go`, `internal/console/server.go`
+
+**Interfaces:**
+- Consumes: `hostclaim.Read(ctx, release.Store, host) (Observed, error)`; `Observed.Claim.Gen`, `.Holder`, `.Op`; `lease.VersionedStore.ListVersions` and `.OpenVersion`; `leasetest.NewVersionedBucket`, `.AddBucket`, `.Put`, `.BareDelete`, `.PageSize`, `.FailListAfterPages`.
+- Produces: `Config.HostClaim`, `Config.DBVersions`, private derived `Server.dbHandleKey`, `dbRequest.claim`, `dbVersionReader` implementing only the existing read interface.
+- Reuses: `Store.SigningKey()` (`*ecdsa.PrivateKey`), loaded by `NewServer`, independent of optional `Config.Key`.
+
+- [ ] **Step 1: Write `apidbclaim_test.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type doorVersionHook struct {
+ lease.VersionedStore
+ after func()
+}
+
+func (v doorVersionHook) ListVersions(ctx context.Context, b, p string) ([]lease.ObjectVersion, error) {
+ out, err := v.VersionedStore.ListVersions(ctx, b, p)
+ if v.after != nil {
+ v.after()
+ }
+ return out, err
+}
+
+type doorReadFault struct {
+ release.Store
+ err error
+}
+
+func (s *doorReadFault) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if s.err != nil {
+ return nil, "", s.err
+ }
+ return s.Store.Get(ctx, key)
+}
+
+func doorPlanFixture(t *testing.T, legacy bool) (*Server, dbRequest, provision.Record, *leasetest.VersionedBucket) {
+ t.Helper()
+ rec := provision.Record{Host: "shop.example", Account: "bab", App: "jam"}
+ inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ loc := "instances/" + rec.Host
+ if !legacy {
+ loc += "/" + inc
+ }
+ claim := hostclaim.Claim{V: 2, Host: rec.Host, Gen: 1, Holder: &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(rec.Account, rec.App, rec.Host), Account: rec.Account, App: rec.App, Incarnation: inc, Location: loc}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ store := release.NewDirStore(t.TempDir())
+ data, _ := json.Marshal(claim)
+ key, _ := hostclaim.Key(rec.Host)
+ if _, err := store.Put(t.Context(), key, data, ""); err != nil {
+ t.Fatal(err)
+ }
+ model := leasetest.NewVersionedBucket()
+ model.AddBucket("data")
+ s := &Server{cfg: Config{HostClaim: true, Releases: store, HibernateBucket: "data", DBVersions: model}, dbHandleKey: []byte(strings.Repeat("s", 32))}
+ g := dbRequest{acct: &Account{Sqid: "bab"}, app: "jam", host: rec.Host}
+ g.claim, err = s.dbClaim(t.Context(), g, rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s, g, rec, model
+}
+
+func writeDoorClaim(t *testing.T, s *Server, g dbRequest, c *hostclaim.Claim) {
+ t.Helper()
+ key, _ := hostclaim.Key(g.host)
+ _, etag, err := s.cfg.Releases.Get(t.Context(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = s.cfg.Releases.Put(t.Context(), key, b, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+func cloneDoorClaim(c *hostclaim.Claim) *hostclaim.Claim {
+ b, _ := json.Marshal(c)
+ var out hostclaim.Claim
+ _ = json.Unmarshal(b, &out)
+ return &out
+}
+
+func TestDoorPlanHandles(t *testing.T) {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ secret := dbHandleSecret(key)
+ h := dbFileHandle{V: 1, Host: "shop.example", Location: "instances/shop.example/inc", Key: "instances/shop.example/inc/0000/a", VersionID: "v+/?", Incarnation: "inc", Expiry: 200, Account: "bab", App: "jam", Size: 6}
+ token, err := signDBHandle(secret, h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := verifyDBHandle(dbHandleSecret(key), token, time.Unix(199, 0))
+ if err != nil || got != h {
+ t.Fatal(got, err)
+ }
+ for _, bad := range []string{"", token + "x", "x" + token, strings.Repeat("x", 8193)} {
+ if _, err := verifyDBHandle(secret, bad, time.Unix(199, 0)); !errors.Is(err, errDBHandle) {
+ t.Fatal(bad, err)
+ }
+ }
+ if _, err := verifyDBHandle([]byte(strings.Repeat("x", 32)), token, time.Unix(199, 0)); !errors.Is(err, errDBHandle) {
+ t.Fatal(err)
+ }
+ if _, err := verifyDBHandle(secret, token, time.Unix(200, 0)); !errors.Is(err, errDBHandleExpired) {
+ t.Fatal(err)
+ }
+}
+
+func TestDoorPlanListingAndPinnedOpen(t *testing.T) {
+ for _, legacy := range []bool{false, true} {
+ t.Run(map[bool]string{true: "legacy", false: "scoped"}[legacy], func(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, legacy)
+ loc := g.claim.Claim.Holder.Location
+ key := dbVersionKey(loc, 0, 1, 1)
+ m.Put("data", key, []byte("older"))
+ id := m.Put("data", key, []byte("original"))
+ deleted := dbVersionKey(loc, 0, 2, 2)
+ m.Put("data", deleted, []byte("hidden"))
+ m.BareDelete("data", deleted)
+ m.Put("data", loc+"/lock.json", []byte("secret"))
+ m.Put("data", dbVersionKey(loc+"/other-incarnation", 0, 3, 3), []byte("other"))
+ m.Put("data", dbVersionKey(loc+"-sibling", 0, 4, 4), []byte("sibling"))
+ m.Put("data", dbVersionKey(loc+"/g-old", 0, 5, 5), []byte("generation"))
+ m.PageSize = 1
+ out, err := s.listDBVersions(t.Context(), g, 0)
+ if err != nil || out.Protocol != 2 || len(out.Files) != 1 {
+ t.Fatalf("%+v %v", out, err)
+ }
+ token := out.Files[0].Handle
+ h, err := verifyDBHandle(s.dbHandleKey, token, time.Now())
+ if err != nil || h.VersionID != id || h.Location != loc {
+ t.Fatal(h, err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set(dbdoor.HandleHeader, token)
+ rd, err := s.pinnedDBReader(req, g, 0, 1, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Stall after admission, then overwrite the identical legacy key and
+ // advance the holder to a new incarnation before the physical open.
+ m.Put("data", key, []byte("replacement"))
+ next := cloneDoorClaim(g.claim.Claim)
+ next.Gen++
+ next.Holder.CreatedAt = time.Unix(1, 0)
+ next.Holder.Incarnation, _ = hostclaim.Incarnation("bab", "jam", g.host, next.Holder.CreatedAt)
+ next.Holder.Location = "instances/" + g.host + "/" + next.Holder.Incarnation
+ writeDoorClaim(t, s, g, next)
+ rc, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 2, 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := io.ReadAll(rc)
+ rc.Close()
+ if err != nil || string(body) != "igi" {
+ t.Fatalf("%q %v", body, err)
+ }
+ g2 := g
+ g2.claim.Claim = next
+ if _, err := s.pinnedDBReader(req, g2, 0, 1, 1); err == nil {
+ t.Fatal("new incarnation accepted old handle")
+ }
+ bad := g
+ bad.host = "other.example"
+ if _, err := s.pinnedDBReader(req, bad, 0, 1, 1); err == nil {
+ t.Fatal("cross host")
+ }
+ if _, err := s.pinnedDBReader(req, g, 0, 2, 2); err == nil {
+ t.Fatal("cross coordinate")
+ }
+ if err := m.DeleteVersion(t.Context(), "data", key, id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 0, 0); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestDoorPlanBracket(t *testing.T) {
+ for _, change := range []string{"holder", "op", "same-gen-op", "chore", "absent", "malformed", "read-error"} {
+ t.Run(change, func(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, false)
+ m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1), []byte("old"))
+ fault := &doorReadFault{Store: s.cfg.Releases}
+ s.cfg.Releases = fault
+ s.cfg.DBVersions = doorVersionHook{VersionedStore: m, after: func() {
+ next := cloneDoorClaim(g.claim.Claim)
+ next.Gen++
+ switch change {
+ case "holder":
+ next.Holder.Stamp = next.Gen
+ case "op":
+ next.Op = hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "delete"}
+ case "same-gen-op":
+ next.Gen--
+ next.Op.Exec = "another-box"
+ case "chore":
+ next.Touched.Complete = true
+ case "absent":
+ fault.err = os.ErrNotExist
+ return
+ case "read-error":
+ fault.err = errors.New("offline")
+ return
+ case "malformed":
+ next.V = 999
+ }
+ writeDoorClaim(t, s, g, next)
+ }}
+ out, err := s.listDBVersions(t.Context(), g, 0)
+ if change == "chore" {
+ if err != nil || len(out.Files) != 1 {
+ t.Fatal(out, err)
+ }
+ } else if err == nil || len(out.Files) != 0 {
+ t.Fatalf("partial/success: %+v %v", out, err)
+ }
+ })
+ }
+}
+
+func TestDoorPlanClaimRefusals(t *testing.T) {
+ for _, change := range []string{"absent", "free", "deleting", "parking", "purging", "owner", "incarnation", "location", "generation", "malformed", "read-error"} {
+ t.Run(change, func(t *testing.T) {
+ s, g, rec, m := doorPlanFixture(t, true)
+ m.FailOn = func(op, key string) error { t.Fatalf("refusal reached data bucket: %s %s", op, key); return nil }
+ c := cloneDoorClaim(g.claim.Claim)
+ want := errDBClaimClosed
+ switch change {
+ case "absent":
+ s.cfg.Releases = release.NewDirStore(t.TempDir())
+ want = errDBNotAdopted
+ case "free":
+ c.Holder = nil
+ c.Op = hostclaim.Op{}
+ case "deleting", "parking", "purging":
+ c.Op = hostclaim.Op{Kind: change, ID: "x"}
+ case "owner":
+ g.app = "other"
+ want = os.ErrNotExist
+ case "incarnation":
+ rec.CreatedAt = time.Unix(1, 0)
+ want = os.ErrNotExist
+ case "location":
+ c.Holder.Location = ""
+ want = hostclaim.ErrMalformed
+ case "generation":
+ c.Holder.Generation = "g-current"
+ want = errDBGeneration
+ case "malformed":
+ c.V = 99
+ want = hostclaim.ErrMalformed
+ case "read-error":
+ want = errors.New("offline")
+ s.cfg.Releases = &doorReadFault{Store: s.cfg.Releases, err: want}
+ }
+ if change != "absent" && change != "read-error" {
+ writeDoorClaim(t, s, dbRequest{host: rec.Host}, c)
+ }
+ if _, err := s.dbClaim(t.Context(), g, rec); !errors.Is(err, want) {
+ t.Fatalf("%v want %v", err, want)
+ }
+ })
+ }
+}
+
+func TestDoorPlanListingFailureAndRanges(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, true)
+ for _, lo := range []uint64{1, 2, 3} {
+ m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, lo, lo), []byte("sixsix"))
+ }
+ m.PageSize = 1
+ m.FailListAfterPages = 1
+ if out, err := s.listDBVersions(t.Context(), g, 0); err == nil || len(out.Files) != 0 {
+ t.Fatal(out, err)
+ }
+ for _, tc := range []struct {
+ offset, size, want int64
+ ok bool
+ }{{0, 0, 6, true}, {2, 0, 4, true}, {2, 2, 2, true}, {2, 100, 4, true}, {6, 0, 0, false}, {-1, 0, 0, false}, {0, -1, 0, false}} {
+ n, err := dbVersionRange(6, tc.offset, tc.size)
+ if (err == nil) != tc.ok || n != tc.want {
+ t.Fatal(tc, n, err)
+ }
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set("Range", "bytes=2-3")
+ r.Header.Set(dbdoor.HandleHeader, "signed")
+ if err := dbVersionHeaders(w, r, 6, 2, 2); err != nil {
+ t.Fatal(err)
+ }
+ if w.Header().Get("Content-Length") != "2" || w.Header().Get("Content-Range") != "bytes 2-3/6" || w.Header().Get(dbdoor.HandleHeader) != "signed" {
+ t.Fatal(w.Header())
+ }
+}
+
+type doorOpenHook struct {
+ lease.VersionedStore
+ open func(context.Context, string, string, string) (io.ReadCloser, error)
+}
+
+func (v doorOpenHook) OpenVersion(ctx context.Context, b, k, id string) (io.ReadCloser, error) {
+ return v.open(ctx, b, k, id)
+}
+
+type doorTrackedReader struct {
+ io.Reader
+ closed bool
+}
+
+func (r *doorTrackedReader) Close() error { r.closed = true; return nil }
+
+type doorCancelledReader struct{ ctx context.Context }
+
+func (r doorCancelledReader) Read([]byte) (int, error) { return 0, r.ctx.Err() }
+
+func TestDoorPlanRangeReaderCloses(t *testing.T) {
+ for _, mode := range []string{"complete", "short-skip", "cancelled-skip"} {
+ t.Run(mode, func(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, true)
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ tracked := &doorTrackedReader{Reader: strings.NewReader("abcdef")}
+ if mode == "short-skip" {
+ tracked.Reader = strings.NewReader("a")
+ }
+ if mode == "cancelled-skip" {
+ cancel()
+ tracked.Reader = doorCancelledReader{ctx: ctx}
+ }
+ key := dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1)
+ s.cfg.DBVersions = doorOpenHook{VersionedStore: m, open: func(ctx context.Context, b, k, id string) (io.ReadCloser, error) {
+ if b != "data" || k != key || id != "selected" {
+ t.Fatal(b, k, id)
+ }
+ return tracked, nil
+ }}
+ rd := &dbVersionReader{store: s.cfg.DBVersions, bucket: "data", handle: dbFileHandle{Location: g.claim.Claim.Holder.Location, Key: key, VersionID: "selected", Size: 6}}
+ rc, err := rd.OpenLTXFile(ctx, 0, 1, 1, 2, 2)
+ if mode != "complete" {
+ if err == nil || !tracked.closed {
+ t.Fatal(err, tracked.closed)
+ }
+ if mode == "cancelled-skip" && !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := io.ReadAll(rc)
+ if err != nil || string(b) != "cd" {
+ t.Fatal(string(b), err)
+ }
+ if tracked.closed {
+ t.Fatal("closed before caller finished")
+ }
+ rc.Close()
+ if !tracked.closed {
+ t.Fatal("reader leaked")
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Write `apidbclaim_http_test.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+ "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func enableDoorPlan(t *testing.T, fx *dbFx) *leasetest.VersionedBucket {
+ t.Helper()
+ rec, err := provision.GetRecord(t.Context(), fx.rel, "bab", "titogo", dbTestHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inc, err := hostclaim.Incarnation("bab", "titogo", dbTestHost, rec.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := hostclaim.Claim{V: 2, Host: dbTestHost, Gen: 1, Holder: &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "titogo", dbTestHost), Account: "bab", App: "titogo", CreatedAt: rec.CreatedAt, Incarnation: inc, Location: "instances/" + dbTestHost}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ raw, _ := json.Marshal(c)
+ key, _ := hostclaim.Key(dbTestHost)
+ if _, err := fx.rel.Put(t.Context(), key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ m := leasetest.NewVersionedBucket()
+ m.AddBucket("data-test")
+ fx.srv.cfg.HostClaim = true
+ fx.srv.cfg.DBVersions = m
+ secret, err := fx.st.SigningKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.dbHandleKey = dbHandleSecret(secret)
+ return m
+}
+
+func TestDoorPlanHTTPCompatibilityAndRestore(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ t.Run(fmt.Sprint(enabled), func(t *testing.T) {
+ fx := dbFixture(t)
+ fx.replicate(t, dbTestHost, 3)
+ if enabled {
+ model := enableDoorPlan(t, fx)
+ root := filepath.Join(fx.root, "instances", dbTestHost, "ltx")
+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ rel, err := filepath.Rel(root, p)
+ if err != nil {
+ return err
+ }
+ parts := strings.Split(filepath.ToSlash(rel), "/")
+ if len(parts) != 2 {
+ return fmt.Errorf("unexpected file: %s", rel)
+ }
+ level, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return err
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ model.Put("data-test", dbLevelPrefix("instances/"+dbTestHost, level)+parts[1], b)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ do := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
+ r, err := http.NewRequestWithContext(ctx, "GET", fx.url(dbdoor.Base("bab", "titogo", dbTestHost)+tail), nil)
+ if err != nil {
+ return nil, err
+ }
+ r.Header = h.Clone()
+ r.Header.Set("Authorization", "Bearer "+fx.member)
+ return http.DefaultClient.Do(r)
+ }
+ got, err := dbdoor.RestoreTo(t.Context(), dbdoor.NewClient(do), t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(got.Dir)
+ db, err := sql.Open("sqlite", "file:"+got.Path+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ var rows int
+ if err := db.QueryRow("SELECT count(*) FROM t").Scan(&rows); err != nil {
+ t.Fatal(err)
+ }
+ if rows != 3*dbdoortest.Rows {
+ t.Fatal(rows)
+ }
+ // The old CLI ignores additive fields and sends no handle.
+ res, err := do(t.Context(), dbdoor.ListTail(0), http.Header{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var list dbdoor.ListResponse
+ if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if len(list.Files) == 0 {
+ t.Fatal("empty L0")
+ }
+ f := list.Files[0]
+ res, err = do(t.Context(), dbdoor.GetTail(f.Level, f.MinTXID, f.MaxTXID), http.Header{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ want := http.StatusOK
+ if enabled {
+ want = http.StatusBadRequest
+ }
+ if res.StatusCode != want {
+ t.Fatalf("old CLI: %d want %d", res.StatusCode, want)
+ }
+ if !enabled && list.Protocol != 0 {
+ t.Fatal("flag-off wire changed")
+ }
+ if enabled && fx.opens.Load() != 0 {
+ t.Fatal("pinned door opened unversioned replica")
+ }
+ })
+ }
+}
+
+func TestDoorPlanHTTPAuthorizationAndMissingClaim(t *testing.T) {
+ fx := dbFixture(t)
+ model := enableDoorPlan(t, fx)
+ model.FailOn = func(op, key string) error { t.Fatalf("unauthorized data access %s %s", op, key); return nil }
+ stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "door-plan-stranger")
+ status, h, body := rawReq(t, "GET", fx.url(dbdoor.ListPath("bab", "titogo", dbTestHost, 0)), stranger, "")
+ assertNotFound(t, "stranger", canonicalNotFound(t), status, h, body)
+ admin := serviceToken(t, fx.st, OpsAccountID, "door-plan-admin", "titogo", svcrole.Admin)
+ status, _, _ = rawReq(t, "GET", fx.url(dbdoor.ListPath("bab", "titogo", dbTestHost, 0)), admin, "")
+ if status != http.StatusForbidden {
+ t.Fatal(status)
+ }
+ // A second existing record with no claim must not auto-adopt here.
+ other := "other.go.tito.io"
+ putDBRecord(t, fx.consoleFixture, provision.Record{Host: other})
+ status, h, body = rawReq(t, "GET", fx.url(dbdoor.ListPath("bab", "titogo", other, 0)), fx.member, "")
+ if status != 409 || h.Get("Retry-After") != "1" || !strings.Contains(body, "host not adopted") {
+ t.Fatal(status, h, body)
+ }
+}
+
+func TestDoorPlanHTTPTruncationResumeAndFlagOff(t *testing.T) {
+ fx := dbFixture(t)
+ model := enableDoorPlan(t, fx)
+ key := dbVersionKey("instances/"+dbTestHost, 0, 1, 1)
+ model.Put("data-test", key, []byte("original"))
+ var opens atomic.Int32
+ fx.srv.cfg.DBVersions = doorOpenHook{VersionedStore: model, open: func(ctx context.Context, b, k, id string) (io.ReadCloser, error) {
+ rc, err := model.OpenVersion(ctx, b, k, id)
+ if err != nil {
+ return nil, err
+ }
+ if opens.Add(1) == 1 {
+ return &dbRangeReader{Reader: io.LimitReader(rc, 3), Closer: rc}, nil
+ }
+ return rc, nil
+ }}
+ do := func(tail string, h http.Header) *http.Response {
+ t.Helper()
+ r, err := http.NewRequest("GET", fx.url(dbdoor.Base("bab", "titogo", dbTestHost)+tail), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.Header = h.Clone()
+ r.Header.Set("Authorization", "Bearer "+fx.member)
+ res, err := http.DefaultClient.Do(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res
+ }
+ res := do(dbdoor.ListTail(0), http.Header{})
+ var list dbdoor.ListResponse
+ if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if len(list.Files) != 1 {
+ t.Fatal(list)
+ }
+ f := list.Files[0]
+ h := http.Header{}
+ h.Set(dbdoor.HandleHeader, f.Handle)
+ res = do(dbdoor.GetTail(0, 1, 1), h)
+ first, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if !errors.Is(err, io.ErrUnexpectedEOF) || string(first) != "ori" || res.Header.Get(dbdoor.HandleHeader) != f.Handle {
+ t.Fatal(string(first), err, res.Header)
+ }
+ model.Put("data-test", key, []byte("replacement"))
+ h.Set("Range", "bytes=3-")
+ res = do(dbdoor.GetTail(0, 1, 1), h)
+ rest, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil || res.StatusCode != 206 || string(append(first, rest...)) != "original" {
+ t.Fatal(string(rest), err, res.StatusCode)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ranged := false
+ for _, e := range events {
+ if e.Action == dbAuditAction && e.Target == dbTestHost && strings.Contains(e.Detail, "byte 3") {
+ ranged = true
+ }
+ }
+ if !ranged {
+ t.Fatal("missing pinned range audit", events)
+ }
+ // A different flag-off console refuses a previously issued v2 handle.
+ // Keep startup configuration immutable while either server is running.
+ legacy := dbFixture(t)
+ req, err := http.NewRequest("GET", legacy.url(dbdoor.GetPath("bab", "titogo", dbTestHost, 0, 1, 1)), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header = h.Clone()
+ req.Header.Set("Authorization", "Bearer "+legacy.member)
+ res, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ if res.StatusCode != 409 {
+ t.Fatal("flag-off accepted pinned request", res.StatusCode)
+ }
+}
+```
+
+The HTTP test imports a real `dbdoortest.Replicate` replica into the version model. Its explicit file-to-S3 layout conversion prevents the file replica's different layout from making a false-positive server test.
+
+- [ ] **Step 3: Run the failing console tests**
+
+```bash
+go test ./internal/console -run '^TestDoorPlan' -count=1
+```
+
+Expected: compile failure naming the new config fields, handle helpers and version-reader methods. Do not modify the existing `apidb_test.go` assertions to make them pass.
+
+- [ ] **Step 4: Write `apidbclaim.go`**
+
+```go
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdsa"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/superfly/ltx"
+)
+
+const dbHandleTTL = 15 * time.Minute
+
+var (
+ errDBNotAdopted = errors.New("host not adopted")
+ errDBClaimChanged = errors.New("host claim changed; retry the database pull")
+ errDBClaimClosed = errors.New("host is not accepting database reads")
+ errDBGeneration = errors.New("database generation requires a generation-aware reader")
+ errDBHandle = errors.New("invalid database file handle")
+ errDBHandleExpired = errors.New("database file handle expired; retry the database pull")
+)
+
+// dbFileHandle is encoded as this ordered JSON struct, never as a map.
+// Account and App bind the capability to the authenticated route as well.
+type dbFileHandle struct {
+ V int `json:"v"`
+ Host string `json:"host"`
+ Location string `json:"location"`
+ Key string `json:"key"`
+ VersionID string `json:"version_id"`
+ Incarnation string `json:"incarnation"`
+ Expiry int64 `json:"expiry"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Size int64 `json:"size"`
+}
+
+func dbHandleSecret(key *ecdsa.PrivateKey) []byte {
+ mac := hmac.New(sha256.New, key.D.FillBytes(make([]byte, 32)))
+ mac.Write([]byte("carlos-db-handle-key-v1\x00"))
+ return mac.Sum(nil)
+}
+
+func dbHandleMAC(secret, body []byte) []byte {
+ mac := hmac.New(sha256.New, secret)
+ mac.Write([]byte("carlos-db-file-v1\x00"))
+ mac.Write(body)
+ return mac.Sum(nil)
+}
+
+func signDBHandle(secret []byte, h dbFileHandle) (string, error) {
+ if len(secret) != sha256.Size {
+ return "", errDBHandle
+ }
+ body, err := json.Marshal(h)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(dbHandleMAC(secret, body)), nil
+}
+
+func verifyDBHandle(secret []byte, token string, now time.Time) (dbFileHandle, error) {
+ var h dbFileHandle
+ if len(secret) != sha256.Size || len(token) > 8192 {
+ return h, errDBHandle
+ }
+ a, b, ok := strings.Cut(token, ".")
+ if !ok {
+ return h, errDBHandle
+ }
+ body, err := base64.RawURLEncoding.DecodeString(a)
+ if err != nil {
+ return h, errDBHandle
+ }
+ sig, err := base64.RawURLEncoding.DecodeString(b)
+ if err != nil || !hmac.Equal(sig, dbHandleMAC(secret, body)) {
+ return h, errDBHandle
+ }
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&h); err != nil {
+ return dbFileHandle{}, errDBHandle
+ }
+ canonical, err := json.Marshal(h)
+ if err != nil || !bytes.Equal(body, canonical) || h.V != 1 || h.VersionID == "" || h.VersionID == "null" || h.Size < 0 {
+ return dbFileHandle{}, errDBHandle
+ }
+ if now.Unix() >= h.Expiry {
+ return dbFileHandle{}, errDBHandleExpired
+ }
+ return h, nil
+}
+
+func dbLevelPrefix(location string, level int) string {
+ // litestream/s3 uses hex level directories, unlike its file replica.
+ return fmt.Sprintf("%s/%04x/", location, level)
+}
+
+func dbVersionKey(location string, level int, lo, hi uint64) string {
+ return dbLevelPrefix(location, level) + ltx.FormatFilename(ltx.TXID(lo), ltx.TXID(hi))
+}
+
+func (s *Server) dbClaim(ctx context.Context, g dbRequest, rec provision.Record) (hostclaim.Observed, error) {
+ obs, err := hostclaim.Read(ctx, s.cfg.Releases, g.host)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ if obs.Absent {
+ return obs, errDBNotAdopted
+ }
+ h := obs.Claim.Holder
+ if h == nil {
+ return obs, errDBClaimClosed
+ }
+ inc, err := hostclaim.Incarnation(g.acct.Sqid, g.app, g.host, rec.CreatedAt)
+ if err != nil {
+ return obs, err
+ }
+ if h.Family != "instance" || h.Account != g.acct.Sqid || h.App != g.app || h.Key != provision.RecordKey(g.acct.Sqid, g.app, g.host) || h.Incarnation != inc || !h.CreatedAt.Equal(rec.CreatedAt) {
+ return obs, os.ErrNotExist
+ }
+ switch obs.Claim.Op.Kind {
+ case hostclaim.KindDeleting, hostclaim.KindParking, hostclaim.KindPurging:
+ return obs, errDBClaimClosed
+ }
+ if h.Location == "" {
+ return obs, errDBClaimClosed
+ }
+ // The moves generation restore contract is not provided by Plans 1/2.
+ // Never present root files as the installed generation's database.
+ if h.Generation != "" {
+ return obs, errDBGeneration
+ }
+ return obs, nil
+}
+
+func sameDBAdmission(a, b hostclaim.Observed) bool {
+ if a.Absent || b.Absent || a.Claim == nil || b.Claim == nil {
+ return false
+ }
+ // §6.5 permits unrelated chore revisions; compare all holder/op content,
+ // including stamp, session, admissions, journal and execution ownership.
+ return a.Host == b.Host && reflect.DeepEqual(a.Claim.Holder, b.Claim.Holder) && reflect.DeepEqual(a.Claim.Op, b.Claim.Op)
+}
+
+func (s *Server) dbClaimFailure(w http.ResponseWriter, r *http.Request, g dbRequest, err error) {
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ http.NotFound(w, r)
+ case errors.Is(err, errDBNotAdopted), errors.Is(err, errDBClaimChanged), errors.Is(err, errDBClaimClosed), errors.Is(err, errDBGeneration):
+ w.Header().Set("Retry-After", "1")
+ jsonError(w, http.StatusConflict, err.Error())
+ case errors.Is(err, errDBHandleExpired):
+ jsonError(w, http.StatusGone, err.Error())
+ case errors.Is(err, errDBHandle):
+ jsonError(w, http.StatusBadRequest, err.Error())
+ default:
+ s.dbStoreFailure(w, g, "claim/version read", err)
+ }
+}
+
+func (s *Server) listDBVersions(ctx context.Context, g dbRequest, level int) (dbdoor.ListResponse, error) {
+ out := dbdoor.ListResponse{Protocol: 2, Files: []dbdoor.LTXFile{}}
+ h := g.claim.Claim.Holder
+ prefix := dbLevelPrefix(h.Location, level)
+ versions, err := s.cfg.DBVersions.ListVersions(ctx, s.cfg.HibernateBucket, prefix)
+ if err != nil {
+ return dbdoor.ListResponse{}, err
+ }
+ seen := map[string]bool{}
+ for _, v := range versions {
+ if !strings.HasPrefix(v.Key, prefix) {
+ return dbdoor.ListResponse{}, errors.New("db: malformed version listing")
+ }
+ if !v.IsLatest || v.DeleteMarker {
+ continue
+ }
+ name := strings.TrimPrefix(v.Key, prefix)
+ lo, hi, err := ltx.ParseFilename(name)
+ if err != nil || lo == 0 || hi < lo || v.Key != dbVersionKey(h.Location, level, uint64(lo), uint64(hi)) {
+ continue
+ }
+ if v.VersionID == "" || v.VersionID == "null" || v.Size < 0 || seen[v.Key] {
+ return dbdoor.ListResponse{}, errors.New("db: malformed version listing")
+ }
+ seen[v.Key] = true
+ token, err := signDBHandle(s.dbHandleKey, dbFileHandle{V: 1, Host: g.host, Location: h.Location, Key: v.Key, VersionID: v.VersionID, Incarnation: h.Incarnation, Expiry: time.Now().Add(dbHandleTTL).Unix(), Account: g.acct.Sqid, App: g.app, Size: v.Size})
+ if err != nil {
+ return dbdoor.ListResponse{}, err
+ }
+ out.Files = append(out.Files, dbdoor.LTXFile{Level: level, MinTXID: uint64(lo), MaxTXID: uint64(hi), Size: v.Size, CreatedAt: v.LastModified, Handle: token})
+ }
+ o2, err := hostclaim.Read(ctx, s.cfg.Releases, g.host)
+ if err != nil {
+ return dbdoor.ListResponse{}, err
+ }
+ if !sameDBAdmission(g.claim, o2) {
+ return dbdoor.ListResponse{}, errDBClaimChanged
+ }
+ sort.Slice(out.Files, func(i, j int) bool {
+ if out.Files[i].MinTXID == out.Files[j].MinTXID {
+ return out.Files[i].MaxTXID < out.Files[j].MaxTXID
+ }
+ return out.Files[i].MinTXID < out.Files[j].MinTXID
+ })
+ return out, nil
+}
+
+func (s *Server) pinnedDBReader(r *http.Request, g dbRequest, level int, lo, hi uint64) (*dbVersionReader, error) {
+ h, err := verifyDBHandle(s.dbHandleKey, r.Header.Get(dbdoor.HandleHeader), time.Now())
+ if err != nil {
+ return nil, err
+ }
+ holder := g.claim.Claim.Holder
+ if h.Host != g.host || h.Account != g.acct.Sqid || h.App != g.app || h.Location != holder.Location || h.Incarnation != holder.Incarnation || h.Key != dbVersionKey(h.Location, level, lo, hi) {
+ return nil, errDBHandle
+ }
+ return &dbVersionReader{store: s.cfg.DBVersions, bucket: s.cfg.HibernateBucket, handle: h}, nil
+}
+
+// This adapter keeps the existing stream budget, watchdog and audit path.
+// Its only object read names the signed version, including on every resume.
+type dbVersionReader struct {
+ store lease.VersionedStore
+ bucket string
+ handle dbFileHandle
+}
+
+func (v *dbVersionReader) LTXFiles(context.Context, int, ltx.TXID, bool) (ltx.FileIterator, error) {
+ return nil, errDBHandle
+}
+func (v *dbVersionReader) OpenLTXFile(ctx context.Context, level int, lo, hi ltx.TXID, offset, size int64) (io.ReadCloser, error) {
+ if v.handle.Key != dbVersionKey(v.handle.Location, level, uint64(lo), uint64(hi)) {
+ return nil, errDBHandle
+ }
+ n, err := dbVersionRange(v.handle.Size, offset, size)
+ if err != nil {
+ return nil, err
+ }
+ rc, err := v.store.OpenVersion(ctx, v.bucket, v.handle.Key, v.handle.VersionID)
+ if errors.Is(err, lease.ErrMirrorNotFound) {
+ return nil, os.ErrNotExist
+ }
+ if err != nil {
+ return nil, err
+ }
+ if _, err = io.CopyN(io.Discard, rc, offset); err != nil {
+ rc.Close()
+ return nil, err
+ }
+ return &dbRangeReader{Reader: io.LimitReader(rc, n), Closer: rc}, nil
+}
+
+type dbRangeReader struct {
+ io.Reader
+ io.Closer
+}
+
+func dbVersionRange(total, offset, size int64) (int64, error) {
+ if total < 0 || offset < 0 || size < 0 || offset > total || (offset == total && total != 0) {
+ return 0, errDBHandle
+ }
+ n := total - offset
+ if size != 0 && size < n {
+ n = size
+ }
+ return n, nil
+}
+func dbVersionHeaders(w http.ResponseWriter, r *http.Request, total, offset, size int64) error {
+ n, err := dbVersionRange(total, offset, size)
+ if err != nil {
+ return err
+ }
+ w.Header().Set("Content-Length", strconv.FormatInt(n, 10))
+ w.Header().Set(dbdoor.HandleHeader, r.Header.Get(dbdoor.HandleHeader))
+ if r.Header.Get("Range") != "" && n > 0 {
+ w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+n-1, total))
+ }
+ return nil
+}
+```
+
+- [ ] **Step 5: Apply the complete server and handler edits**
+
+Keep `resolveReplica` for its pre-S3 class/residency validation, but ignore the legacy-derived prefix when the flag is enabled. The holder is the sole source of the actual version-list prefix.
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+
+def source(p):
+ return Path(p).read_text()
+
+def put(p, text):
+ Path(p).write_text(text)
+
+def replace(text, old, new):
+ assert text.count(old) == 1, (old, text.count(old))
+ return text.replace(old, new)
+
+p='internal/console/server.go';s=source(p)
+s=replace(s,'type Config struct {','type Config struct {\n\tHostClaim bool\n\tDBVersions lease.VersionedStore')
+s=replace(s,'type Server struct {','type Server struct {\n\tdbHandleKey []byte')
+# lease already imported?
+if '"github.com/carlosframework/platform/internal/lease"' not in s:s=replace(s,'import (','import (\n\t"github.com/carlosframework/platform/internal/lease"')
+s=replace(s,'s.dbReplica = s.cachedReadReplica','if cfg.HostClaim {\n\t\ts.dbHandleKey = dbHandleSecret(key)\n\t}\n\ts.dbReplica = s.cachedReadReplica')
+put(p,s)
+p='internal/console/apidb.go';s=source(p)
+s=replace(s,'"github.com/carlosframework/platform/internal/dbdoor"','"github.com/carlosframework/platform/internal/dbdoor"\n\t"github.com/carlosframework/platform/internal/hostclaim"')
+s=replace(s,'type dbRequest struct {','type dbRequest struct {\n\tclaim hostclaim.Observed')
+s=replace(s,'rd, err := s.dbReplica(ctx, prefix)','if s.cfg.HostClaim {\n\t\tg := dbRequest{acct: acct, actor: actor, app: app, host: host}\n\t\tif s.cfg.DBVersions == nil {\n\t\t\tjsonError(w, http.StatusServiceUnavailable, "this console has no versioned database reader")\n\t\t\treturn dbRequest{}, false\n\t\t}\n\t\tg.claim, err = s.dbClaim(ctx, g, *rec)\n\t\tif err != nil { s.dbClaimFailure(w, r, g, err); return dbRequest{}, false }\n\t\tg.prefix = g.claim.Claim.Holder.Location\n\t\treturn g, true\n\t}\n\trd, err := s.dbReplica(ctx, prefix)')
+s=replace(s,'itr, err := g.rd.LTXFiles(r.Context(), level, 0, false)','if s.cfg.HostClaim {\n\t\tout, err := s.listDBVersions(r.Context(), g, level)\n\t\tif err != nil { s.dbClaimFailure(w, r, g, err); return }\n\t\tw.Header().Set("Cache-Control", "no-store")\n\t\twriteJSON(w, http.StatusOK, out)\n\t\treturn\n\t}\n\titr, err := g.rd.LTXFiles(r.Context(), level, 0, false)')
+s=replace(s,'rangeHdr := r.Header.Get("Range")','var pinned *dbVersionReader\n\tif s.cfg.HostClaim {\n\t\tpinned, err = s.pinnedDBReader(r, g, level, minTXID, maxTXID)\n\t\tif err != nil { s.dbClaimFailure(w, r, g, err); return }\n\t\tg.rd = pinned\n\t} else if r.Header.Get(dbdoor.HandleHeader) != "" {\n\t\tjsonError(w, http.StatusConflict, "this console cannot enforce a pinned database read")\n\t\treturn\n\t}\n\trangeHdr := r.Header.Get("Range")')
+s=replace(s,'if !s.dbStreams.acquire(g.actor) {','if pinned != nil {\n\t\tif _, err := dbVersionRange(pinned.handle.Size, offset, size); err != nil {\n\t\t\tjsonError(w, http.StatusRequestedRangeNotSatisfiable, "unsupported range")\n\t\t\treturn\n\t\t}\n\t}\n\tif !s.dbStreams.acquire(g.actor) {')
+s=replace(s,'w.Header().Set("Content-Type", "application/octet-stream")','if pinned != nil {\n\t\tif err := dbVersionHeaders(w, r, pinned.handle.Size, offset, size); err != nil {\n\t\t\tjsonError(w, http.StatusRequestedRangeNotSatisfiable, "unsupported range")\n\t\t\treturn\n\t\t}\n\t}\n\tw.Header().Set("Content-Type", "application/octet-stream")')
+put(p,s)
+PY
+```
+
+- [ ] **Step 6: Run the console tests and unchanged door regressions**
+
+```bash
+gofmt -w internal/console/apidbclaim.go internal/console/apidbclaim_test.go internal/console/apidbclaim_http_test.go internal/console/apidb.go internal/console/server.go
+go test -race ./internal/console -run 'TestDoorPlan|TestAPIDB|TestResolveReplica|TestCachedReadReplica' -count=1
+```
+
+Expected: `ok`. Both flag values restore a real replica with the new client. The old coordinate-only client succeeds with the flag off and is refused with it on. The existing audit/ranged-audit, stream-cap and idle-deadline tests remain green without assertion changes. The new range adapter closes its underlying version reader on both discard failure and normal close; header size and handle acknowledgement are set only after a successful open. A declared Content-Length makes a truncated successful stream detectable by the HTTP client.
+
+---
+
+### Task 3: Wire the startup flag and own-region version store
+
+**Files:**
+- Modify: `cmd/console/main.go`
+- Create: `cmd/console/hostclaim_test.go`
+
+**Interfaces:**
+- Produces: `hostClaimEnabled(getenv func(string) string) bool`, called once in `serve`.
+- Consumes: `lease.NewVersionedS3(ctx, map[bucket]region) (lease.VersionedStore, error)` and the existing `dbDoorWiring` tuple. No SDK client or credential lookup is added when the flag is unset.
+
+- [ ] **Step 1: Write `hostclaim_test.go`**
+
+```go
+package main
+
+import "testing"
+
+func TestHostClaimEnabled(t *testing.T) {
+ for _, v := range []string{"", " ", "1", "true", "0"} {
+ calls := 0
+ got := hostClaimEnabled(func(k string) string {
+ calls++
+ if k != "CARLOS_HOST_CLAIM" {
+ t.Fatal(k)
+ }
+ return v
+ })
+ want := v != "" && v != " "
+ if got != want || calls != 1 {
+ t.Fatalf("%q: %v calls %d", v, got, calls)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the failing startup test**
+
+```bash
+go test ./cmd/console -run '^TestHostClaimEnabled$' -count=1
+```
+
+Expected: `undefined: hostClaimEnabled`.
+
+- [ ] **Step 3: Apply the exact startup edits**
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+
+def source(p):
+ return Path(p).read_text()
+
+def put(p, text):
+ Path(p).write_text(text)
+
+def replace(text, old, new):
+ assert text.count(old) == 1, (old, text.count(old))
+ return text.replace(old, new)
+
+p='cmd/console/main.go';s=source(p)
+if '"github.com/carlosframework/platform/internal/lease"' not in s:s=replace(s,'import (','import (\n\t"github.com/carlosframework/platform/internal/lease"')
+s=replace(s,'dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring()','dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring()\n\thostClaim := hostClaimEnabled(os.Getenv)\n\tvar dbVersions lease.VersionedStore\n\tif hostClaim && dbBucket != "" {\n\t\tdbVersions, err = lease.NewVersionedS3(context.Background(), map[string]string{dbBucket: dbRegion})\n\t\tif err != nil { log.Fatalf("console: versioned database reader: %v", err) }\n\t}')
+s=replace(s,'HibernateBucket: dbBucket,','HostClaim: hostClaim,\n\t\tDBVersions: dbVersions,\n\t\tHibernateBucket: dbBucket,')
+s+='\nfunc hostClaimEnabled(getenv func(string) string) bool {\n\treturn strings.TrimSpace(getenv("CARLOS_HOST_CLAIM")) != ""\n}\n'
+put(p,s)
+PY
+```
+
+An absent bucket retains the existing 503 configuration refusal. A constructor failure with the flag on stops console startup; it must not silently revert to unversioned reads. `NewVersionedS3` receives only this console's configured data bucket and actual AWS region, not a placement name.
+
+- [ ] **Step 4: Run the startup and combined checks**
+
+```bash
+gofmt -w cmd/console/main.go cmd/console/hostclaim_test.go
+go test ./cmd/console -count=1
+go test -race ./internal/dbdoor ./internal/console -run 'TestHandleProtocol|TestDoorPlan|TestAPIDB' -count=1
+```
+
+Expected: all report `ok`; flag tests confirm exactly one environment read. No environment setting or live deployment changes in this task.
+
+---
+
+### Task 4: Identify the console's exact version-read grants
+
+**Files:**
+- Modify: `infra/modules/region-host/main.tf`, `infra/modules/region-remote-host/main.tf`
+
+**Interfaces:**
+- Consumes: Plan 2 Task 6's own-data-bucket grants on the host role, which the console uses through the default credential chain.
+- Produces: explicit `PullDoorObjectVersions` and `PullDoorVersionList` statements, scoped respectively to the own bucket's object ARN and bucket ARN. Existing `DeleteObjectVersion` remains in its own separate statement for parking workers.
+
+- [ ] **Step 1: Run this failing source-policy check**
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+import re
+
+for filename, resource in [
+ ("infra/modules/region-host/main.tf", "var.data_bucket_arn"),
+ ("infra/modules/region-remote-host/main.tf", "aws_s3_bucket.data.arn"),
+]:
+ text = Path(filename).read_text()
+ for sid, action, arn in [
+ ("PullDoorObjectVersions", "s3:GetObjectVersion", '"${' + resource + '}/*"'),
+ ("PullDoorVersionList", "s3:ListBucketVersions", resource),
+ ]:
+ pattern = (r'Sid\s*=\s*"' + sid + r'"\s*'
+ r'Effect\s*=\s*"Allow"\s*'
+ r'Action\s*=\s*\["' + action + r'"\]\s*'
+ r'Resource\s*=\s*\[' + re.escape(arn) + r'\]')
+ assert len(re.findall(pattern, text)) == 1, (filename, sid)
+print("own-region door grants: PASS")
+PY
+```
+
+Expected before the edit: `AssertionError` naming `PullDoorObjectVersions`. The underlying permissions already exist; this test fails because their read-only identity and isolation are not yet explicit.
+
+- [ ] **Step 2: Split only the already-granted version actions**
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+
+for filename, resource in [
+ ("infra/modules/region-host/main.tf", "var.data_bucket_arn"),
+ ("infra/modules/region-remote-host/main.tf", "aws_s3_bucket.data.arn"),
+]:
+ p = Path(filename)
+ text = p.read_text()
+ objects = '"${' + resource + '}/*"'
+ old = (' {\n'
+ ' Effect = "Allow"\n'
+ ' Action = ["s3:GetObjectVersion", "s3:DeleteObjectVersion"]\n'
+ ' Resource = [' + objects + ']\n'
+ ' },')
+ new = (' {\n'
+ ' Sid = "PullDoorObjectVersions"\n'
+ ' Effect = "Allow"\n'
+ ' Action = ["s3:GetObjectVersion"]\n'
+ ' Resource = [' + objects + ']\n'
+ ' },\n'
+ ' {\n'
+ ' Effect = "Allow"\n'
+ ' Action = ["s3:DeleteObjectVersion"]\n'
+ ' Resource = [' + objects + ']\n'
+ ' },')
+ assert text.count(old) == 1, filename
+ text = text.replace(old, new)
+ old_list = (' {\n'
+ ' Effect = "Allow"\n'
+ ' Action = ["s3:ListBucketVersions"]\n'
+ ' Resource = [' + resource + ']\n'
+ ' },')
+ new_list = old_list.replace(' {\n', ' {\n Sid = "PullDoorVersionList"\n', 1)
+ assert text.count(old_list) == 1, filename
+ p.write_text(text.replace(old_list, new_list))
+PY
+```
+
+- [ ] **Step 3: Repeat Step 1, then check the infrastructure diff**
+
+```bash
+tofu fmt -check infra/modules/region-host/main.tf infra/modules/region-remote-host/main.tf
+git diff --check
+git diff -- infra/modules/region-host/main.tf infra/modules/region-remote-host/main.tf
+```
+
+Expected: the source-policy check prints `own-region door grants: PASS`; format and whitespace checks exit zero. The diff splits an existing action list and names the read/list statements, with no net authority change. Neither control-bucket resources nor sibling-region `moves/*` statements change. The door still refuses remote/fleet instances before any data access, so there is no remote data bucket to grant. Do not run `tofu apply`.
+
+---
+
+### Task 5: Run the repository gate and review the branch
+
+**Files:** no new files. Fix defects in the preceding tasks only.
+
+**Interfaces:** checks both protocol modes, cross-platform builds and the final authority boundary.
+
+- [ ] **Step 1: Run the focused tests**
+
+```bash
+go test -race ./internal/dbdoor ./internal/console ./cmd/console -run 'TestHandleProtocol|TestDoorPlan|TestHostClaimEnabled|TestAPIDB|TestResolveReplica|TestCachedReadReplica' -count=1
+```
+
+Expected: every package reports `ok`. The HTTP tests require permission to bind loopback sockets. A sandbox refusal is an unverified runtime check, not a passing result; run the same tests in the normal development/CI environment.
+
+- [ ] **Step 2: Run the full repository and cross-platform gate**
+
+```bash
+go build ./...
+go vet ./...
+go test ./... -count=1
+gofmt -l .
+GOOS=darwin go build ./...
+GOOS=windows go build ./...
+GOOS=linux GOARCH=arm64 go build ./...
+git diff --check
+```
+
+Expected: every command exits zero, build/vet/whitespace checks are silent, tests pass, and `gofmt -l .` prints nothing. The client and handle code have no Unix-only dependencies.
+
+- [ ] **Step 3: Review scope, protocol and authority**
+
+```bash
+rg -n 'CARLOS_HOST_CLAIM|HostClaim|DBVersions|dbHandleKey|OpenVersion|ListVersions' cmd/console internal/console internal/dbdoor
+git diff --stat
+git diff -- internal/console/apidb.go internal/console/server.go cmd/console/main.go infra/modules/region-host/main.tf infra/modules/region-remote-host/main.tf
+```
+
+Expected: one production environment read, explicit config passing, no new bare-key GET, and unchanged legacy handler/stream bodies except the documented guarded branches. Have the repository's authority-review model review the complete branch; if Opus is unavailable, explicitly name the supported equivalent.
+
+- [ ] **Step 4: Record validation and land only through the reviewed branch**
+
+Keep implementation iterations on `feat/hostclaim-door`. The implementation follows AGENTS.md: describe the amadan branch, squash to one change commit, push, wait for green CI on that exact tip, and land with `amadan branch merge -expect`. Read main's CI afterward. This planning task makes no commit, push, merge or infrastructure change.
+
+---
+
+## Rollout note
+
+Plans 5–7 remain inert behind an unset `CARLOS_HOST_CLAIM` until Plan 8 publishes the shared protocol floor. Task 4 only restates grants already present from Plan 2; it does not deploy an IAM change. Ship the compatible CLI before enabling the door because old clients cannot supply the signed handles.
+
+Plan 5 owns delete step 1's coordinated stop of new door requests on every console and the console operation/adoption entry points. This plan only refuses claims whose operation is deleting, parking or purging. It does not drain handlers, revoke claims, manufacture stop evidence, or write adoption state. R2's explicit unadopted-host refusal supersedes §9's general statement about doors calling adoption within this branch; Plan 5's shared adoption routine must complete adoption before this door can admit that host.
+
+Plan 8 owns the floor, fleet/console rollout and activation. **Activation also waits for the moves-aware generation selection contract.** The current Plan 1 library admits cold writers into a nonempty generation even for ordinary create/adoption, while Plans 1 and 2 expose no selector for its sealed objects or predecessor baseline. The guard in Task 2 refuses those claims instead of serving stale root data. Before enabling, integrate that selector with the same O1/O2 bracket and signed exact-version handles, and add live-generation/predecessor tests against the moves implementation. Merely enabling the flag when the floor is published would leave normal cold-admitted hosts unable to pull; this plan explicitly does not call that rollout ready.
+
+A handle is valid only on the console that shares its persisted signing key, under current membership and a compatible holder, for 15 minutes. Rebalancing to a console with another secret, key rotation, expiry, record deletion or incarnation replacement causes refusal. A handler already admitted may still stream its original exact version after a transition; there is no current-key fallback, even if that version has been parked or deleted.
+
+## Self-review
+
+| Binding ruling or requirement | Covered here |
+|---|---|
+| R1, inert flag, startup read, no global | Tasks 2–3; flag-off existing tests, startup dependency construction |
+| R2, O1 and holder authorization | Task 2 `dbClaim`; same account/app/key/creation identity plus existing gates |
+| R2, present holder, allowed op, nonempty location | Task 2 claim refusals; malformed location is rejected by real `hostclaim.Read` |
+| R2, scoped/legacy location, current versions, no markers | Task 2 `listDBVersions`, exact hex-level prefix and canonical filename; both location fixtures |
+| R2, O2 and retryable 409 | Task 2 `sameDBAdmission`; full holder/op comparison even with equal Gen; Retry-After 1 |
+| R2, unrelated chore Gen bump | Task 2 bracket test; permitted by §6.5's holder/op-specific comparison |
+| R2, read error and distinct absent claim | Task 2 pure and HTTP tests; no silent adoption or legacy fallback |
+| R3, canonical HMAC and existing secret | Task 2 handle code, persisted `Store.SigningKey`, two domain separators and `hmac.Equal` |
+| R3, host/location/key/version/incarnation/expiry | Task 2 signed struct, bound GET, 15-minute TTL, signature/expiry/cross-host tests |
+| R3, exact open after stall | Task 2 paused-admission test overwrites same key and changes holder before open; old version only |
+| R4, both CLI modes | Task 1 old-console stub; Task 2 real restore in both modes and old coordinate-only GET |
+| R4, no unnoticed protocol downgrade | Task 1 successful-response echo check, missing-handle and downgraded-list refusal |
+| R4, root CLI source | `db.go`/`apimode.go` already pass headers and use the client; unchanged |
+| R5, exact version read/list rights | Task 4 both role modules, own bucket ARN, no net new permission or cross-region grant |
+| R6, delete admission shutdown belongs to Plan 5 | Rollout note; this branch only reacts to the claim's operation |
+| R7, branch ancestry | Task 1 exact switch and merge commands, `3ca43d3` ancestor check |
+| R8, Go cache and workstation platforms | Global Constraints and Task 5; no `/tmp` cache |
+| §6.5 step 1, read O1 | Task 2 `dbClaim` calls real `hostclaim.Read` |
+| §6.5 step 2, authorize O1 holder | Task 2 authenticated route/record/holder equality and op gate |
+| §6.5 step 3, select under location | Task 2 `ListVersions` with bounded level prefix; latest nonmarkers only |
+| §6.5 step 4, read O2 | Task 2 fresh direct `hostclaim.Read` after whole version listing |
+| §6.5 step 5, compare holder/op | Task 2 `sameDBAdmission`; absence/read errors refuse before response |
+| §6.5 signed handles and stalled handlers | Tasks 1–2 exact OpenVersion, acknowledged handles, old-version overwrite witness |
+| §6.5 delete step 1 | Plan 5; deliberately not implemented by Plan 7 |
+| §2 generation and C3a | Task 2 fail-closed nonempty-generation guard; hard activation dependency stated above |
+| C4/C6 | Fresh GET/list admission, record identity, deterministic error tests and no unsafe fallback |
+| §9 legacy and adoption | Legacy holder prefix covered; unadopted host refuses under binding R2; shared adoption stays with Plan 5 |
+| §10 floor and inert landing | Plan 8 activation/floor; no enablement or live resource changes here |
+
+Plan-author verification: the complete Go blocks and all three source-edit scripts were extracted from this document and checked against the compiled overlay. The overlay used `origin/main` plus the real Plan 1 package, without creating implementation files in this repository. All added test files compiled; deterministic claim/handle/protocol/range/flag tests passed with `-race`. The test-only overlay without implementation failed with the expected missing fields/functions. Full `go build ./...` passed on Linux, Darwin, Windows and Linux/arm64. The IAM check failed before the edit and passed afterward; `tofu fmt -check` passed on both edited module files. The default Go cache was read-only, so verification used the existing shared on-disk `$TMPDIR/gocache`. Go also emitted a nonfatal module-stat-cache write warning; the reported build commands still exited zero.
+
+The HTTP restore/authorization/truncation tests compiled but could not execute: the sandbox refused `httptest`'s loopback listener with `socket: operation not permitted`. They and the unchanged flag-off HTTP regressions remain runtime checks for the implementation environment. Vet and the full repository test gate were not claimed as passing; overlay tests used `-vet=off` because overlay-only package directories are not physical working directories for vet. A separate `gpt-6-astra` authority review (Opus unavailable) checked the complete plan and the added range/truncation tests. Its handle-acknowledgement, generation-selection, metadata-pinning and test-config race findings were addressed. Implementation-branch full gates and final authority review remain required.
+