carlos / platform Public

docs: P7 edge-door plan and Astra review brief (#175)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 37f04baabdb81165652e5572c5c93c88d82a1676 parent 52f7114
2 files changed, +741 −0
  • docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md +683 −0
  • docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md +58 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md
new file mode 100644
index 000000000..f3cd0aba4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md
@@ -0,0 +1,683 @@
+# Instance moves P7: the edge-local door — Implementation Plan
+
+> **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:** An app's own processes on a box (Tito's router, relay and import-sync loop) reach any of the app's instances through one box-local unix socket, whatever box the instance lives on and whatever its socket path, authenticated by an app-scoped door key that a box verifies offline.
+
+**Architecture:** A signed-in person mints a door key for an app at the console. The console stores only the key's SHA-256, in a record under `control/door-keys/<account>/<app>.json` that it signs with the deployment's root release key. Every edge mirrors those records on its existing 30 s home-map poll and verifies them against `CARLOS_RELEASE_PUBKEY`, so a bucket writer (discussion #171) cannot mint a key. A second listener on `/run/carlos/edge-local.sock` (mode 0660, group `carlos-sockets` by the directory's setgid) authenticates the `X-Carlos-Door-Key` header, strips it, checks that the requested host belongs to the key's account and app, marks the request as a door caller, and hands it to the ordinary `Edge.ServeHTTP`. Local hosts are served (and woken) as for public traffic. Remote hosts backhaul exactly as public traffic does. Remote ownership comes from new `account`/`app` fields each box publishes in its edges file.
+
+**Tech Stack:** Go 1.x standard library (`net/http`, `crypto/ed25519`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's `cliRoute`/`svcrole`, the CLI's `apiSession`.
+
+**Spec:** `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md` on branch `docs-instance-moves-design` (§0 rulings R5, Q3, Q5; §8; §10), and Astra's rulings file `CODEX-RULINGS-INSTANCE-MOVES.md` beside it.
+
+## Global Constraints
+
+- **Q3 (ruled):** the door authenticates an app-scoped credential restricted to this door; the edge checks the requested host's account and app against the credential's, and strips the credential before forwarding. Unix permissions on the socket stay as a second check, not the identity.
+- **R5 (ruled):** one box-local door; every enrolled consumer (router, relay, import-sync loop: G18) moves to it before P9. P7 builds the door; moving Tito's processes is Tito's slice.
+- **§8:** the door is not a bypass: the password gate, the edge's XFF rules and (from P1) the hook-path refusal apply as on the public door. Headers the app sets pass through untouched.
+- **R2 (ruled, lands in P1):** hook paths are refused on the local door too. P7 adds no hook paths; P1 must add its refusal where this plan's `serveDoor` calls `ServeHTTP`.
+- **Q5 (ruled):** move mutations for other accounts are operator-only until #171 is fixed. The door moves nothing, so it is not gated by Q5, but the key record must not be forgeable by a bucket writer (signed, below).
+- **Dormant by default:** no socket is created unless `CARLOS_EDGE_DOOR` names a path. Rolling the binary changes nothing on a box until that is set.
+- **Every `carlos` command completes its job without box access** (AGENTS.md, 2026-08-08): minting, listing and revoking keys are console API plus CLI.
+- **Copy:** every new user-facing string (CLI output, API errors, docs) passes copy review before merge. No em dashes; a full stop and a new sentence, lower case, in Go error strings.
+- **Cross-compile:** CI builds darwin and windows (`GOOS=darwin go build ./... && GOOS=windows go build ./...`); anything Linux-only needs `//go:build linux` and a `!linux` stub.
+- **Tests with unix sockets** run outside the Claude sandbox with `TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache GOFLAGS=-buildvcs=false`.
+
+## Review Focus
+
+1. **A door caller sending the mesh's hop headers** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) must never gain hop trust; the door deletes both before `ServeHTTP`. Test in Task 6.
+2. **A host on a path-prefix hostname** whose prefixes belong to different apps: ownership is checked on the route `lookupUpstreamPath(host, path)` resolves, the same resolution `ServeHTTP` uses, so app A's key cannot reach app B's prefix. Test in Task 6.
+3. **A remote host published by a box that predates the owner fields** has no owner in the fold; the door answers unknown host for it (fail closed) rather than backhauling. Test in Task 5.
+4. **A revoked key** stops working within one home-map poll (30 s); a record that fails signature verification is dropped, never kept as last-good. Test in Task 4.
+5. **An X-Forwarded-For that is absent, empty or not an IP**: the door's client address falls back to `127.0.0.1`, and a garbage chain never reaches the gate's rate-limit key or analytics. Test in Task 6.
+
+---
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `internal/doorkey/doorkey.go` (new) | Token format, record schema, record key, sign/verify, constant-time admit. No I/O. |
+| `internal/doorkey/doorkey_test.go` (new) | Unit tests for the above. |
+| `internal/console/apidoorkeys.go` (new) | Console doors: create, list, revoke. |
+| `internal/console/apidoorkeys_test.go` (new) | Door tests. |
+| `internal/console/authcli.go` (modify, near line 637) | Route registration, person-only. |
+| `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. |
+| `instances_cli.go` (modify) | Dispatch `door-key` from `runInstances`. |
+| `internal/host/edgesfile.go` (modify, `edgeHost`) | Publish `account` and `app` per host. |
+| `internal/edge/homemap.go` (modify) | Fold the owner; mirror and verify door-key records; `Owner(host)`; `doorKey(acct, app)`. |
+| `internal/edge/door.go` (new) | Door context mark, `serveDoor`, `ServeDoor(path)` listener. |
+| `internal/edge/door_test.go` (new) | Door behaviour tests. |
+| `internal/edge/homemap.go` `setForwarded`, `internal/edge/analytics.go` `clientIP` (modify) | Trust the door's chain the way a verified hop's is trusted. |
+| `internal/host/agent.go` (modify, the `RunWithActivatorReady` ready hook ~line 510) | Start the door when `CARLOS_EDGE_DOOR` is set. |
+| `docs/site/instances.md` or the CLI reference (modify) | Document the door and the key verbs (copy review). |
+
+---
+
+### Task 1: The door-key package
+
+**Files:**
+- Create: `internal/doorkey/doorkey.go`
+- Test: `internal/doorkey/doorkey_test.go`
+
+**Interfaces:**
+- Produces:
+ - `const Header = "X-Carlos-Door-Key"`
+ - `func RecordKey(account, app string) string` → `"control/door-keys/" + account + "/" + app + ".json"`
+ - `const Prefix = "control/door-keys/"`
+ - `type Token struct{ Account, App, ID, Secret string }`
+ - `func NewToken(account, app string) (raw string, entry Entry, err error)`
+ - `func ParseToken(raw string) (Token, error)`
+ - `type Entry struct{ ID string; SHA256 string; Label string; CreatedAt time.Time }` (JSON: `id`, `sha256`, `label`, `created_at`)
+ - `type Record struct{ V int; Account, App string; Keys []Entry; UpdatedAt time.Time }` (JSON: `v`, `account`, `app`, `keys`, `updated_at`)
+ - `const MaxKeys = 8`
+ - `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)`
+ - `func Verify(pub ed25519.PublicKey, wire []byte) (Record, error)`
+ - `func (r Record) Admits(t Token) bool`
+
+Token wire form: `cdk1.<account>.<app>.<id>.<secret>`. `account` matches `release.ValidAccount`; `app` matches `^[a-z][a-z0-9-]{0,31}$`; `id` is 16 lowercase hex; `secret` is 43 characters of unpadded base64url (32 random bytes). The dots cannot appear in any field, so `strings.Split` into exactly five parts is unambiguous.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package doorkey
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTokenRoundTripAndAdmit(t *testing.T) {
+ raw, entry, err := NewToken("bae", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(raw, "cdk1.bae.titogo.") {
+ t.Fatalf("token %q lacks its prefix", raw)
+ }
+ tok, err := ParseToken(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tok.Account != "bae" || tok.App != "titogo" || tok.ID != entry.ID {
+ t.Fatalf("parsed %+v, entry %+v", tok, entry)
+ }
+ rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}}
+ if !rec.Admits(tok) {
+ t.Fatal("the record does not admit its own key")
+ }
+ tok.Secret = strings.Repeat("A", 43)
+ if rec.Admits(tok) {
+ t.Fatal("a wrong secret was admitted")
+ }
+}
+
+func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) {
+ raw, entry, _ := NewToken("bae", "titogo")
+ tok, _ := ParseToken(raw)
+ other := Record{V: 1, Account: "bae", App: "other", Keys: []Entry{entry}}
+ if other.Admits(tok) {
+ t.Fatal("a record for another app admitted the key")
+ }
+}
+
+func TestParseTokenRefusesMalformed(t *testing.T) {
+ for _, raw := range []string{
+ "", "cdk1", "cdk2.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43),
+ "cdk1.BAE.titogo.0123456789abcdef." + strings.Repeat("A", 43),
+ "cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43),
+ "cdk1.bae.titogo.0123456789abcdef.short",
+ "cdk1.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43) + ".extra",
+ } {
+ if _, err := ParseToken(raw); err == nil {
+ t.Errorf("ParseToken(%q) accepted a malformed token", raw)
+ }
+ }
+}
+
+func TestSignVerify(t *testing.T) {
+ pub, priv, _ := ed25519.GenerateKey(rand.Reader)
+ _, entry, _ := NewToken("bae", "titogo")
+ rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}, UpdatedAt: time.Now().UTC()}
+ wire, err := Sign(priv, rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := Verify(pub, wire)
+ if err != nil || len(got.Keys) != 1 || got.Keys[0].ID != entry.ID {
+ t.Fatalf("Verify = %+v, %v", got, err)
+ }
+ otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
+ if _, err := Verify(otherPub, wire); err == nil {
+ t.Fatal("a record signed by another key verified")
+ }
+ tampered := []byte(strings.Replace(string(wire), `"sig": "`, `"sig": "A`, 1))
+ if _, err := Verify(pub, tampered); err == nil {
+ t.Fatal("a tampered signature verified")
+ }
+}
+
+func TestSignRefusesTooManyKeys(t *testing.T) {
+ _, priv, _ := ed25519.GenerateKey(rand.Reader)
+ rec := Record{V: 1, Account: "bae", App: "titogo"}
+ for i := 0; i <= MaxKeys; i++ {
+ _, e, _ := NewToken("bae", "titogo")
+ rec.Keys = append(rec.Keys, e)
+ }
+ if _, err := Sign(priv, rec); err == nil {
+ t.Fatal("signed a record over MaxKeys")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/doorkey/`
+Expected: FAIL (package has no non-test files).
+
+- [ ] **Step 3: Implement**
+
+```go
+// Package doorkey is the credential for the edge-local door (instance-moves
+// design §8, ruling Q3): an app-scoped key that a box verifies offline. The
+// console mints it and keeps only its SHA-256, in a record it signs with the
+// deployment's root release key, so a bucket writer (discussion #171) can
+// read the record but cannot add a key to it.
+package doorkey
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ Header = "X-Carlos-Door-Key"
+ Prefix = "control/door-keys/"
+ MaxKeys = 8
+ scheme = "cdk1"
+)
+
+var (
+ appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+ idRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
+ secretRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
+)
+
+func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" }
+
+type Token struct{ Account, App, ID, Secret string }
+
+type Entry struct {
+ ID string `json:"id"`
+ SHA256 string `json:"sha256"`
+ Label string `json:"label,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type Record struct {
+ V int `json:"v"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Keys []Entry `json:"keys"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type signedWrapper struct {
+ Payload string `json:"payload"`
+ Sig string `json:"sig"`
+}
+
+func digest(secret string) string {
+ sum := sha256.Sum256([]byte(secret))
+ return hex.EncodeToString(sum[:])
+}
+
+func NewToken(account, app string) (string, Entry, error) {
+ if !release.ValidAccount(account) || !appRe.MatchString(app) {
+ return "", Entry{}, fmt.Errorf("doorkey: invalid account or app %q/%q", account, app)
+ }
+ var id [8]byte
+ var secret [32]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ return "", Entry{}, err
+ }
+ if _, err := rand.Read(secret[:]); err != nil {
+ return "", Entry{}, err
+ }
+ t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]),
+ Secret: base64.RawURLEncoding.EncodeToString(secret[:])}
+ raw := strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".")
+ return raw, Entry{ID: t.ID, SHA256: digest(t.Secret), CreatedAt: time.Now().UTC()}, nil
+}
+
+func ParseToken(raw string) (Token, error) {
+ f := strings.Split(raw, ".")
+ if len(f) != 5 || f[0] != scheme || !release.ValidAccount(f[1]) || !appRe.MatchString(f[2]) ||
+ !idRe.MatchString(f[3]) || !secretRe.MatchString(f[4]) {
+ return Token{}, errors.New("doorkey: malformed door key")
+ }
+ return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil
+}
+
+// Admits answers whether t is one of this record's keys. The account and app
+// must match the record's own, so a key can never be looked up under
+// another app's record and pass.
+func (r Record) Admits(t Token) bool {
+ if r.Account != t.Account || r.App != t.App {
+ return false
+ }
+ want := digest(t.Secret)
+ for _, e := range r.Keys {
+ if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 {
+ return true
+ }
+ }
+ return false
+}
+
+func (r Record) valid() error {
+ if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) {
+ return errors.New("doorkey: record shape")
+ }
+ if len(r.Keys) > MaxKeys {
+ return fmt.Errorf("doorkey: %d keys, at most %d", len(r.Keys), MaxKeys)
+ }
+ for _, e := range r.Keys {
+ if !idRe.MatchString(e.ID) || len(e.SHA256) != 64 {
+ return errors.New("doorkey: record entry shape")
+ }
+ }
+ return nil
+}
+
+func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) {
+ if len(priv) != ed25519.PrivateKeySize {
+ return nil, errors.New("doorkey: no signing key")
+ }
+ if err := rec.valid(); err != nil {
+ return nil, err
+ }
+ payload, err := json.Marshal(rec)
+ if err != nil {
+ return nil, err
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ return json.MarshalIndent(signedWrapper{Payload: b64,
+ Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}, "", " ")
+}
+
+func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) {
+ if len(pub) != ed25519.PublicKeySize {
+ return Record{}, errors.New("doorkey: no public key")
+ }
+ var w signedWrapper
+ if err := json.Unmarshal(wire, &w); err != nil {
+ return Record{}, err
+ }
+ sig, err := base64.StdEncoding.DecodeString(w.Sig)
+ if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(w.Payload), sig) {
+ return Record{}, errors.New("doorkey: bad signature")
+ }
+ payload, err := base64.StdEncoding.DecodeString(w.Payload)
+ if err != nil {
+ return Record{}, err
+ }
+ var rec Record
+ if err := json.Unmarshal(payload, &rec); err != nil {
+ return Record{}, err
+ }
+ return rec, rec.valid()
+}
+```
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `go test ./internal/doorkey/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/doorkey
+git commit -m "doorkey: the edge-local door's app-scoped, root-signed credential (#175 P7)"
+```
+
+---
+
+### Task 2: Console doors to mint, list and revoke
+
+**Files:**
+- Create: `internal/console/apidoorkeys.go`, `internal/console/apidoorkeys_test.go`
+- Modify: `internal/console/authcli.go` (register beside the `instance-token` route at ~line 637)
+
+**Interfaces:**
+- Consumes: `doorkey.NewToken`, `doorkey.Sign`, `doorkey.Verify`, `doorkey.RecordKey`, `doorkey.MaxKeys`; `s.cfg.Key` (the root `ed25519.PrivateKey`; nil on a read-only console); `s.cfg.Releases` (`release.Store`); `s.apiPerson`, `s.apiAccountApp`, `s.allowWrite`, `cleanLabel`, `s.audit`, `writeJSON`, `jsonError`, `readJSON` (all existing, as used by `handleAPIInstanceToken` in `apiinstances.go:1554`).
+- Produces (wire):
+ - `POST /api/cli/apps/{account}/{app}/door-keys` body `{"label": "..."}` → 201 `{"key": "<raw>", "id": "...", "account": "...", "app": "..."}`
+ - `GET /api/cli/apps/{account}/{app}/door-keys` → 200 `{"keys": [{"id","label","created_at"}]}` (never the hash)
+ - `POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke` → 200 `{"revoked": "<id>"}`; 404 for an unknown id.
+
+All three are person-only (`svcrole.Set{}` plus `s.apiPerson`), the `instance-token` rule: a door key reaches every instance of the app. A console with no signing key answers 503 with the existing read-only sentence family. Writes are a read-modify-CAS on the record's etag with one retry, like `SetRecordMove`'s `updateRecord`.
+
+- [ ] **Step 1: Write the failing tests** — mint returns a parseable key whose record verifies against the fixture's public key and admits it; list shows the id and label but no `sha256`; revoke removes it and the re-read record no longer admits it; a ninth key is refused 409; an API key (service principal of any role, including Admin) is refused; a console fixture with no signing key answers 503 and writes nothing. Use the fixture helpers `instanceFixture`/`rawReq` from `apiinstancemove_test.go`, and read the fixture's public key the way the pipeline tests do (`grep -n "cfg.Key" internal/console/*_test.go` for the existing pattern).
+
+```go
+func TestDoorKeysMintListRevoke(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys")
+ u := fx.srv.URL + "/api/cli/apps/bab/titogo/door-keys"
+ code, _, out := rawReq(t, http.MethodPost, u, token, `{"label":"router"}`)
+ if code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, out)
+ }
+ var minted struct{ Key, ID string }
+ if err := json.Unmarshal([]byte(out), &minted); err != nil {
+ t.Fatal(err)
+ }
+ tok, err := doorkey.ParseToken(minted.Key)
+ if err != nil || tok.ID != minted.ID {
+ t.Fatalf("minted key %q: %v", minted.Key, err)
+ }
+ wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, err := doorkey.Verify(fx.pub, wire)
+ if err != nil || !rec.Admits(tok) {
+ t.Fatalf("record %+v, %v: does not admit the minted key", rec, err)
+ }
+ code, _, out = rawReq(t, http.MethodGet, u, token, "")
+ if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") {
+ t.Fatalf("list = %d %s", code, out)
+ }
+ code, _, out = rawReq(t, http.MethodPost, u+"/"+minted.ID+"/revoke", token, "")
+ if code != http.StatusOK {
+ t.Fatalf("revoke = %d %s", code, out)
+ }
+ wire, _, _ = fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
+ if rec, _ := doorkey.Verify(fx.pub, wire); rec.Admits(tok) {
+ t.Fatal("a revoked key is still admitted")
+ }
+}
+```
+
+(`fx.pub` and `fx.srv`: if the fixture does not already expose the signing public key and server URL under those names, add the two fields to `consoleFixture` in this task; `instancesURL(fx, ...)` in `apiinstancemove_test.go` shows how the URL is built today.)
+
+- [ ] **Step 2: Run to verify it fails** — `go test ./internal/console -run DoorKeys -count=1` → FAIL (404 from the unregistered route).
+
+- [ ] **Step 3: Implement** `apidoorkeys.go`:
+
+```go
+package console
+
+// Door keys (instance-moves design §8, ruling Q3): the credential an app's
+// own processes present at a box's edge-local door. Person-only, like the
+// instance token: a door key reaches every instance of the app on every box.
+// The console keeps only each key's SHA-256, in a record signed with the
+// root release key, so a bucket writer cannot add one (discussion #171).
+
+func (s *Server) doorKeyUpdate(ctx context.Context, acct, app string, mut func(*doorkey.Record) error) (*doorkey.Record, error) {
+ key := doorkey.RecordKey(acct, app)
+ update := func() (*doorkey.Record, error) {
+ wire, etag, err := s.cfg.Releases.Get(ctx, key)
+ rec := doorkey.Record{V: 1, Account: acct, App: app}
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ return nil, err
+ default:
+ if rec, err = doorkey.Verify(s.cfg.Key.Public().(ed25519.PublicKey), wire); err != nil {
+ return nil, fmt.Errorf("door-key record for %s/%s does not verify: %w", acct, app, err)
+ }
+ }
+ if err := mut(&rec); err != nil {
+ return nil, err
+ }
+ rec.UpdatedAt = time.Now().UTC()
+ body, err := doorkey.Sign(s.cfg.Key, rec)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
+ return nil, err
+ }
+ return &rec, nil
+ }
+ rec, err := update()
+ if errors.Is(err, release.ErrCASConflict) {
+ return update()
+ }
+ return rec, err
+}
+```
+
+Then the three handlers, each opening with `if !s.apiPerson(w, r) { return }`, `addr, acct, app, ok := s.apiAccountApp(w, r)`, and a 503 when `len(s.cfg.Key) == 0`. Mint: `raw, entry, err := doorkey.NewToken(acct.Sqid, app)`; `entry.Label = cleanLabel(req.Label)` defaulting to `app + " door key"`; in the mutator refuse `len(rec.Keys) >= doorkey.MaxKeys` with a sentinel mapped to 409; append; `s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app)`; `s.bumpSerial(ctx)`; 201 with the raw key shown once. List: read and verify (404-free: an absent record lists `[]`), return `id`, `label`, `created_at`. Revoke: the mutator removes the entry by id, or returns a sentinel mapped to 404; audit; bump serial.
+
+Register in `authcli.go` beside `instance-token`:
+
+```go
+ // Door keys (#175 P7): person-only, the instance-token rule. A door key
+ // reaches every instance of the app on every box's edge-local door.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke)
+```
+
+- [ ] **Step 4: Run** `go test ./internal/console -run DoorKeys -count=1` → PASS; then the whole package.
+- [ ] **Step 5: Commit** `console: mint, list and revoke door keys (#175 P7)`.
+
+---
+
+### Task 3: `carlos instances door-key`
+
+**Files:**
+- Create: `doorkeys_cli.go`, `doorkeys_cli_test.go`
+- Modify: `instances_cli.go` (`runInstances` dispatch)
+
+**Interfaces:**
+- Consumes: the three console doors; `requireAPISession`, `resolvedAccount`, `resolvedApp`, `apiPath`, `s.getJSON`, `s.postJSON`, `printUsing` (existing, as `runInstancesDelete` uses them).
+- Produces: `carlos instances door-key create --app <app> [--label <text>]` (prints the key once, then one line saying it is shown once); `... list --app <app>` (id, label, created); `... revoke --app <app> <id>`.
+
+- [ ] **Step 1: Failing test** — drive the three verbs against an `httptest` console the way existing `apiSession` tests do (`grep -n "httptest.NewServer" instances_cli_test.go apimode_test.go` for the harness), asserting the request paths and that `create` prints the key.
+- [ ] **Step 2: Run** `go test . -run DoorKey -count=1` → FAIL.
+- [ ] **Step 3: Implement** the three `apiSession` wire calls (`doorKeyMint`, `doorKeyList`, `doorKeyRevoke`) in the `mail_cli.go` style and the flag parsing in the `runInstancesDelete` style; add `case "door-key":` to `runInstances`.
+- [ ] **Step 4: Run** → PASS.
+- [ ] **Step 5: Commit** `cli: carlos instances door-key create, list, revoke (#175 P7)`.
+
+---
+
+### Task 4: Edges files publish the owner; the edge mirrors door keys
+
+**Files:**
+- Modify: `internal/host/edgesfile.go` (`edgeHost`, and wherever hosts are filled, ~lines 190-280)
+- Modify: `internal/edge/homemap.go` (`edgesHostView`, `homeEntry`, `foldHomes`, `Refresh`, new `refreshDoorKeys`, new `Owner`, new `doorRecord`)
+- Test: `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go`
+
+**Interfaces:**
+- Produces:
+ - edges-file `hosts[h].account`, `hosts[h].app` (omitempty; set for role `home` routes and aliases from the row's `release.RouteAccount(rt)` and `rt.App`; an alias carries its primary's).
+ - `func (m *homeMap) Owner(host string) (account, app string, ok bool)` — from the same fold `Home` reads, including its one-label wildcard match; `ok` false when the entry has no owner (a pre-field publisher).
+ - `func (m *homeMap) doorRecord(account, app string) (doorkey.Record, bool)`
+ - `homeMap` gains `pub ed25519.PublicKey` (from `e.staticPub`, set in `newHomeMap`).
+
+`refreshDoorKeys` lists `doorkey.Prefix`, gets each object, verifies it with `m.pub`, and requires the record's own `account`/`app` to equal the key's path segments (a signed record copied to another app's path is refused). It swaps in the whole verified map on success. **Unlike the host fold, a record that fails verification is dropped, not kept as last good**, and a list failure keeps the previous map (a blip must not lock every router out). It runs inside `Refresh` beside `refreshHosts` and `refreshKeys`, joined into the same error.
+
+- [ ] **Step 1: Failing tests** — (a) the publisher emits `account`/`app` for a home row and for an alias (the primary's owner); (b) `Owner` returns them through the fold and through a one-label wildcard; (c) a file without the fields gives `ok == false`; (d) `refreshDoorKeys` admits a validly signed record, drops one signed by another key, drops one whose payload names a different app than its path, and after a revoke (record rewritten without the key) the next refresh no longer admits it.
+- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile' -count=1` → FAIL.
+- [ ] **Step 3: Implement** as specified.
+- [ ] **Step 4: Run** → PASS; then both packages whole.
+- [ ] **Step 5: Commit** `edges files publish each host's owner; the edge mirrors signed door keys (#175 P7)`.
+
+---
+
+### Task 5: The door's ownership rule
+
+**Files:**
+- Create: `internal/edge/door.go`
+- Test: `internal/edge/door_test.go`
+
+**Interfaces:**
+- Consumes: `doorkey.ParseToken`, `doorkey.Header`, `m.doorRecord`, `m.Owner`, `e.lookupUpstreamPath`, `release.RouteAccount`.
+- Produces:
+ - `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`
+ - `func doorCallerFrom(ctx context.Context) (doorCaller, bool)`
+ - `func (e *Edge) doorOwns(c doorCaller, host, path string) bool`
+
+```go
+// doorOwns is the door's whole authorization rule: the host (and, on a
+// path-prefix hostname, the prefix) resolves to a route of the caller's
+// account and app. A local route answers from the row itself, resolved the
+// way ServeHTTP will resolve it. A host this box does not serve answers from
+// the owner its home box published. No owner published is no.
+func (e *Edge) doorOwns(c doorCaller, host, path string) bool {
+ if up, ok := e.lookupUpstreamPath(host, path); ok {
+ return release.RouteAccount(up.route) == c.Account && up.route.App == c.App
+ }
+ acct, app, ok := e.homes.Load().Owner(host)
+ return ok && acct == c.Account && app == c.App
+}
+```
+
+- [ ] **Step 1: Failing tests** — local row of the caller's app: true; local row of another app: false; path-prefix hostname with prefixes owned by two apps: true only for the caller's prefix; remote host whose published owner matches: true; remote host with no owner fields: false; unknown host: false.
+- [ ] **Step 2–4:** run, implement, run.
+- [ ] **Step 5: Commit** `edge: the door's ownership rule (#175 P7)`.
+
+---
+
+### Task 6: The door handler and listener
+
+**Files:**
+- Modify: `internal/edge/door.go`, `internal/edge/homemap.go` (`setForwarded`), `internal/edge/analytics.go` (`clientIP`)
+- Test: `internal/edge/door_test.go`
+
+**Interfaces:**
+- Produces:
+ - `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)`
+ - `func (e *Edge) ServeDoor(path string) (stop func(), err error)`
+ - `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || doorCaller present`, replacing `hopVerified` in `setForwarded` and `clientIP` only. **`backhaul`'s one-hop refusal keeps reading `hopVerified`**: a door request is not a hop and must be allowed to backhaul.
+
+```go
+func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) {
+ raw := r.Header.Get(doorkey.Header)
+ r.Header.Del(doorkey.Header)
+ // The mesh's vocabulary is never a door caller's to speak: a hop mark
+ // would buy the stale-map refusal and chain trust without a mesh key.
+ r.Header.Del(hopHeader)
+ r.Header.Del(hopAuthHeader)
+ tok, err := doorkey.ParseToken(raw)
+ if err != nil {
+ doorRefused(w)
+ return
+ }
+ rec, ok := e.homes.Load().doorRecord(tok.Account, tok.App)
+ if !ok || !rec.Admits(tok) {
+ doorRefused(w)
+ return
+ }
+ host := r.Host
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ c := doorCaller{Account: tok.Account, App: tok.App}
+ if !e.doorOwns(c, host, r.URL.Path) {
+ e.unknownHost(w, r)
+ return
+ }
+ r.RemoteAddr = net.JoinHostPort(doorClientIP(r), "0")
+ e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, c)))
+}
+
+// doorClientIP is the caller's claim of who the visitor is: the last
+// X-Forwarded-For element, if it parses as an IP. Anything else is the
+// loopback address, so a malformed chain never reaches the gate's rate-limit
+// key or the visitor hash.
+func doorClientIP(r *http.Request) string {
+ chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
+ if i := strings.LastIndexByte(chain, ','); i >= 0 {
+ chain = chain[i+1:]
+ }
+ if ip := net.ParseIP(strings.TrimSpace(chain)); ip != nil {
+ return ip.String()
+ }
+ return "127.0.0.1"
+}
+
+func doorRefused(w http.ResponseWriter) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "door key refused", http.StatusUnauthorized)
+}
+```
+
+`ServeDoor(path)`: `os.Remove(path)` a stale socket, `net.Listen("unix", path)`, `os.Chmod(path, 0o660)`, serve `http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second}` in a goroutine; `stop` closes the server and removes the socket. The socket's group comes from `/run/carlos`'s setgid `carlos-sockets` (bootstrap.go:61, 86-87); do not chgrp.
+
+- [ ] **Step 1: Failing tests** (httptest with an in-process `Edge`, a local upstream, and a homeMap stub holding one signed record):
+ 1. no key → 401, upstream never called;
+ 2. a valid key for a local host of its app → upstream called; the upstream sees no `X-Carlos-Door-Key` and sees the caller's other headers (e.g. `X-Tito-Auth-User`) untouched;
+ 3. a valid key for another app's host → the unknown-host answer, upstream never called;
+ 4. a request carrying `X-Carlos-Hop` and `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to `backhaul`, not `staleHopRefusal` (Review Focus 1);
+ 5. `X-Forwarded-For: 203.0.113.9` → the upstream's `X-Forwarded-For` ends in `203.0.113.9`; `X-Forwarded-For: garbage` and none → it ends in `127.0.0.1` (Review Focus 5);
+ 6. a gated route still demands the password (the gate's refusal page, not the upstream);
+ 7. `ServeDoor` on a temp path creates a 0660 socket and a real unix-socket client round-trips case 2.
+- [ ] **Step 2–4:** run, implement, run (`go test ./internal/edge -count=1`, outside the sandbox).
+- [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`.
+
+---
+
+### Task 7: Arm the door on the box, dormant by default
+
+**Files:**
+- Modify: `internal/host/agent.go` (the ready hook passed to `edge.RunWithActivatorReady`, ~line 510)
+- Test: `internal/host/agent_test.go` (or the nearest existing ready-hook test)
+
+```go
+ // The edge-local door (#175 P7), dormant unless the box names a path.
+ // /run/carlos/edge-local.sock is the convention; the directory's
+ // setgid gives the socket group carlos-sockets, so only enrolled
+ // consumers can dial it, and the door key is the identity on top.
+ if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" {
+ if _, err := e.ServeDoor(p); err != nil {
+ log.Printf("agent: edge-local door at %s: %v (the door is off)", p, err)
+ } else {
+ log.Printf("agent: edge-local door listening at %s", p)
+ }
+ }
+```
+
+- [ ] **Step 1: Failing test** — with `CARLOS_EDGE_DOOR` unset no socket exists; set to a temp path, the socket appears. If the ready hook is not reachable from a unit test, extract `armEdgeDoor(e *edge.Edge, getenv func(string) string)` and test that.
+- [ ] **Step 2–4:** run, implement, run; `GOOS=darwin go build ./... && GOOS=windows go build ./...`.
+- [ ] **Step 5: Commit** `agent: arm the edge-local door when CARLOS_EDGE_DOOR names a path (#175 P7)`.
+
+---
+
+### Task 8: Docs and copy review
+
+**Files:**
+- Modify: `docs/site/cli.md` (the `carlos instances` section) and the instances guide page that covers app-owned routers (find with `grep -ln "socket-consumer\|carlos-sockets" docs/site`).
+
+- [ ] **Step 1:** Draft the strings into `copy-review/strings.json`: every CLI line Task 3 prints, the console errors Task 2 returns, `door key refused`, and the docs prose (what the door is, how to mint a key, how to point a router at `/run/carlos/edge-local.sock` with `Host:` and `X-Carlos-Door-Key:`, that the box must set `CARLOS_EDGE_DOOR`, that the calling process must be enrolled with `carlos bootstrap --socket-consumer <user>`).
+- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim to code and docs.
+- [ ] **Step 3:** `go test ./... ` outside the sandbox; cross-compile.
+- [ ] **Step 4: Commit** `docs: the edge-local door and door keys (#175 P7)`.
+
+---
+
+## Rollout (not a task; for the handoff)
+
+1. Consoles first (they mint the records nobody reads yet), then boxes (they publish owners and mirror keys, still dormant).
+2. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock` in the agent's environment, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`.
+3. Tito: mint a key (`carlos instances door-key create --app titogo`), deliver it to the router, relay and import-sync processes, enrol any of their users not already in `carlos-sockets`, and switch each from computed socket paths to the door. Only after that may P9 run on that box.
diff --git a/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md
new file mode 100644
index 000000000..8edfcbbf2
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md
@@ -0,0 +1,58 @@
+# Brief: review the P7 edge-door plan
+
+Review `docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md`
+against the code in this checkout (branch `instance-moves-p7`, based on
+`main` with P0 merged) before anyone builds it.
+
+The design it implements is on branch `docs-instance-moves-design`:
+`git show origin/docs-instance-moves-design:docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md`.
+Read §0 (Paul's rulings, fixed: do not re-argue them), §8 and §10. Your own
+recommendations that became those rulings are in
+`CODEX-RULINGS-INSTANCE-MOVES.md` beside it.
+
+## What was decided while planning, and why
+
+- **The credential is a console-minted random key whose SHA-256 sits in a
+ record under `control/door-keys/<account>/<app>.json`, signed with the
+ deployment's root release key.** Boxes verify it against
+ `CARLOS_RELEASE_PUBKEY`, which the edge already holds (`e.staticPub`).
+ Rejected: the relay's HKDF-per-box-master pattern (a master staged by hand
+ on every box, an infra change per box), and an unsigned hash record (any
+ bucket writer could add a key, discussion #171).
+- **Remote ownership comes from new `account`/`app` fields in each box's
+ edges file**, so the request path never reads the bucket (the edge's rule).
+ A host whose publisher predates the fields is refused.
+- **The door calls the ordinary `Edge.ServeHTTP`** after authenticating,
+ stripping the key and the hop headers, checking ownership, and setting a
+ context mark. The mark widens X-Forwarded-For trust in `setForwarded` and
+ `clientIP` only; the backhaul one-hop refusal still reads `hopVerified`.
+- **Dormant:** no socket unless `CARLOS_EDGE_DOOR` names a path.
+
+## What to look for
+
+1. **Authorization holes.** Any request a door caller can shape that reaches
+ an instance of another app, or the tunnel host, the store door, the
+ steering probe, or any other `ServeHTTP` branch that runs before the route
+ lookup. Specifically check the login POST, which `ServeHTTP` re-resolves by
+ its `next` parameter (edge.go around 1400-1446), not by `r.URL.Path`, while
+ the door checks ownership by `r.URL.Path`.
+2. **Trust leaks.** Whether rewriting `RemoteAddr` from the caller's
+ X-Forwarded-For, or the `forwardedTrusted` widening, lets a door caller
+ spoof anything the edge or the gate relies on beyond the visitor address.
+3. **The signing choice.** Is signing door-key records with the root release
+ key (the key that moves `stable`) acceptable, or does it need a scoped
+ grant (`internal/release/grant.go`)? Does any existing verifier accept a
+ root-signed wrapper of a different kind, so that a door-key record could be
+ replayed as something else, or vice versa?
+4. **Revocation and failure behaviour.** The 30 s mirror; dropping records
+ that fail verification but keeping the last map on a list failure; what a
+ console with no signing key does.
+5. **Plan quality.** Tasks whose interfaces don't line up, steps that name
+ helpers or fixture fields that don't exist (check each against the code),
+ tests that would pass without the change, and anything the rulings require
+ that no task builds.
+
+Cite code as `path:line` and check each citation. Findings first, ranked
+P1 (must fix before building) / P2 (should fix) / P3 (note), each with the
+failure scenario and a suggested fix. Then one line on whether the plan is
+ready to build once the P1s are fixed. Keep it under about 2,000 words.