carlos / platform Public

docs: P7 plan rev 3: one privilege, fewer mechanisms; round-2 findings unedited; round-3 brief

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4db75572a0c7cb47ca85440d9985d18a9d37469b parent ed1c5fa
3 files changed, +184 −37
  • docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md +91 −37
  • docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR-R2.md +68 −0
  • docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R3.md +25 −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
index 113ea1941..478be9866 100644
--- 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
@@ -1,10 +1,25 @@
-# Instance moves P7: the edge-local door — Implementation Plan (rev 2)
+# Instance moves P7: the edge-local door — Implementation Plan (rev 3)
> **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 at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a monotonic revision and a 72-hour expiry, and re-signed every 12 hours. Every edge mirrors the projections on their own 30-second loop, verifies them against `CARLOS_RELEASE_PUBKEY`, and keeps a persisted revision high-water mark, so neither a forged nor a replayed record admits a key. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, normalizes X-Forwarded-For, marks the request as a door caller and hands it to `Edge.ServeHTTP`. `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve, after the login re-resolution. A host served elsewhere is backhauled only to a box that advertises door support, with the door scope bound into the hop MAC, and the receiving edge enforces the same rule on its own final route.
+**Architecture:** A signed-in person mints a door key at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a revision and a 72-hour expiry, re-signed every 12 hours, and written by one serialized, compare-and-swap publisher that always rebuilds from the database. Every edge mirrors the projections on their own 30-second loop and verifies them against `CARLOS_RELEASE_PUBKEY`. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, replaces X-Forwarded-For with one validated address, marks the request as a door caller and hands it to `Edge.ServeHTTP`. For a host this box serves, `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve. For a host served elsewhere, the door checks the owner the home box published, which a box publishes only for a host whose every route has one owner, and then backhauls it as an ordinary hop.
+
+## Security model (rev 3)
+
+**The door grants exactly one privilege over the public door: the caller's claim of the visitor's address is believed.** Everything else a door request can do (reach a host, wake an instance, meet the password gate, be backhauled), a request from the internet to the same URL can already do. This is checked against the code, not assumed: hop trust (`hopVerified`) is read in exactly three places, `setForwarded` (`homemap.go:914`), `clientIP` (`analytics.go:77`) and the one-hop refusal (`edge.go:2617`), and a client address feeds only the gate's rate-limit key (`gate/gate.go:231`) and the visitor hash (`analytics.go:86`). Nothing in `internal/edge`, `internal/activator` or `internal/gate` trusts a loopback or other special address.
+
+So the door's authorization exists to confine that one privilege to the calling app's own routes, and every residual gap below costs at most a misattributed visitor address on some app's traffic, never access, data or a write:
+
+| residual | bound | why accepted |
+|---|---|---|
+| a bucket writer (#171) forges or replays a door-key record | a replayed record is admitted until its own `expires_at`, at most 72 h after it was signed | the same writer can already rewrite instance records and edges files; #171 is the fix |
+| a bucket writer forges an edges-file owner | misattribution on the forged host until the next honest publish | same class; edges files are unsigned today |
+| ownership changes (a host moves between apps) | up to one home-map poll (30 s) of the old owner's attribution | the edge's existing staleness contract |
+| the console's database is restored from backup | keys minted after the backup vanish; keys revoked after it return | the same holds for every console credential (`cli_tokens`); a restore is an incident with its own runbook |
+
+Round 2's findings 1–3 (door-hop verification failure, the unauthenticated capability flag, reserved handlers on the receiving box) all concerned carrying a door scope across a hop. Rev 3 carries none: a backhauled door request is an ordinary verified hop, and the receiving edge treats it exactly as it treats any hop today. Findings 6 and 7 concerned box-side revision persistence, which rev 3 drops.
**Tech Stack:** Go standard library (`net/http`, `crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's SQLite store, `cliRoute`/`svcrole`, the CLI's `apiSession`.
@@ -25,6 +40,18 @@
| 9 | wrong file and fixture references | **Accepted:** `runInstances` is in `cli.go:284`; fixture is `fx.ts`/`fx.pub`, helpers in `apiinstances_test.go`; catchall publisher `edgesfile.go:363` and the alias-versus-route fold covered; docs go to `docs/site/cli.md` (the only reference page). |
| 10 | root signing acceptable; add a domain prefix | **Accepted:** signatures cover `"carlos door-keys v1\n" + payload`; cross-kind rejection tests (Task 1). |
+## Revision 3: Astra round 2 dispositions
+
+| # | finding | disposition |
+|---|---|---|
+| R2-1 | failed door-hop verification drops the scope | **Superseded:** rev 3 carries no door scope across a hop (Security model). |
+| R2-2 | `door: true` is unauthenticated; selection race | **Superseded:** no capability flag. The remote check reads owner and dial target from one `Owner` call on one snapshot (Task 5). |
+| R2-3 | receiving edge reaches reserved handlers first | **Superseded:** a backhauled door request is an ordinary hop; the reserved handlers it could reach are the ones any internet client reaches. Local door callers are still excluded from them (Task 5). |
+| R2-4 | `Put(..., "")` is create-only | **Accepted:** publication reads the current ETag and writes with it, retrying on conflict with a fresh rebuild from the database (Task 2). |
+| R2-5 | concurrent publishers overwrite a revocation | **Accepted:** one per-app publication mutex in the console process; keys and revision read in one transaction inside it; a CAS retry rebuilds from the database; the publisher never writes a revision lower than the verified one in the bucket (Task 2). The console is one process per deployment. |
+| R2-6 | a restored database reuses revisions | **Accepted in part:** if the bucket's verified revision is at or above the database's, the publisher first advances the database's counter past it, so revisions stay monotonic across a restore. That a restore brings back keys revoked after the backup is stated as a residual (Security model). |
+| R2-7 | high-water persistence contract | **Superseded:** no box-side persistence. |
+
## 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 are a second check, not the identity.
@@ -45,15 +72,16 @@
| key minted | admitted within one mirror interval (30 s) after the serial bump |
| key revoked, bucket healthy | refused within 30 s |
| key revoked, bucket list failing | refused at the latest 10 minutes after the last successful list, when the whole map is cleared |
-| old record replayed into the bucket | refused on any box that has seen a newer revision (persisted high-water mark); a box that never saw the newer revision accepts it until the replayed record's own `expires_at`, at most 72 h after it was signed |
+| a lower revision appears than the one this process last admitted | kept out for the life of the process (in-memory only); after a restart, admitted until its own `expires_at` (Security model residual) |
+| old record replayed into the bucket | admitted until its own `expires_at`, at most 72 h after it was signed (Security model residual) |
| console down | records expire 72 h after their last re-sign; the door refuses every key of an expired record |
## Review Focus
-1. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door`) never gains hop trust or a different scope; the door deletes all three (Task 6 test).
-2. **A path-prefix hostname whose prefixes belong to different apps**, locally and behind a backhaul: app A's key never reaches app B's prefix (Tasks 5 and 6 tests).
+1. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) never gains hop trust; the door deletes both (Task 6 test).
+2. **A path-prefix hostname whose prefixes belong to different apps**: locally, app A's key never reaches app B's prefix; remotely, the host publishes no owner and the door refuses it (Tasks 4 and 5 tests).
3. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test).
-4. **A replayed older record** after a revocation, before and after a verifier restart (Task 4 test).
+4. **A revoke racing a renewal** never leaves the older record in the bucket (Task 2 test).
5. **An X-Forwarded-For that is absent, empty, multi-line or not an IP** leaves exactly one valid address in the header the app and analytics see (Task 6 test).
---
@@ -68,10 +96,9 @@
| `internal/console/authcli.go` (modify, beside `instance-token`, ~line 637) | Routes, person-only. |
| `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. |
| `cli.go` (modify, `runInstances` at ~line 284) | Dispatch `door-key`. |
-| `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363, top-level `Door`) | Publish per-host `account`/`app` and the box's `door: true`. |
-| `internal/edge/homemap.go` (modify) | Fold owner and door support with the winning entry; `Owner`; `acceptHop` door scope; `setForwarded` trust. |
-| `internal/edge/mesh.go` (modify) | `signHopDoor`/`verifyHopDoorEitherKey`: the MAC with the scope bound in. |
-| `internal/edge/doorkeys.go` (new) | The key mirror: its own loop, freshness contract, high-water state file. |
+| `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363) | Publish per-host `account`/`app` for single-owner hostnames. |
+| `internal/edge/homemap.go` (modify) | Fold the owner with the winning entry; `Owner`; `setForwarded` trust. |
+| `internal/edge/doorkeys.go` (new) | The key mirror: its own loop and freshness contract. |
| `internal/edge/door.go` (new) | Door mark, `serveDoor`, `doorOwnsRoute`, reserved-name refusal, the listener. |
| `internal/edge/edge.go` (modify: `ServeHTTP`, `backhaul`/`backhaulProxy`, `RunWithActivatorReady`, shutdown) | Enforcement points and lifecycle. |
| `internal/edge/analytics.go` (modify `clientIP`) | Trust the door's normalized address. |
@@ -421,7 +448,19 @@ func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) {
- `func (st *Store) DoorKeyRevoke(accountID int64, app, id string) (revision uint64, err error)` — `os.ErrNotExist` for an unknown or already revoked id; bumps the revision
- `func (st *Store) DoorKeysLive(accountID int64, app string) (keys []doorkey.Entry, revision uint64, err error)`
- `func (st *Store) DoorKeyApps() ([]struct{ AccountID int64; Sqid, App string }, error)` — every app with a revision row
- - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — builds the record from `DoorKeysLive` (never from the bucket), `SignedAt = now`, `ExpiresAt = now + doorkey.Lifetime`, signs, and `Put`s unconditionally (no etag: the database is the authority, and overwriting a tampered object is the point)
+ - `func (st *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error)` — sets the revision to `past+1` if it is at or below `past`; returns the resulting revision
+ - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — the one publisher, protocol below
+ - `s.doorPublish` — `map[string]*sync.Mutex` keyed `"<sqid>/<app>"`, guarded by its own mutex, one lock per app
+
+**Publication protocol** (round 2, findings 4–6). `release.Store.Put(ctx, key, body, etag)` is create-only with `etag == ""` and compare-and-swap otherwise (`internal/release/store.go:37`), so:
+
+1. Take the app's publication mutex; hold it to the end.
+2. `wire, etag, err := s.cfg.Releases.Get(ctx, key)`. Not found: `etag = ""`, `bucketRev = 0`. Found: `rec, verr := doorkey.Verify(pub, wire, time.Time{})`. Pass the zero time so an expired record still yields its revision. If `Verify` refuses the zero time, add `doorkey.VerifyRevision(pub, wire) (uint64, error)`, which checks the signature and shape only. `bucketRev` is the verified revision, or `0` for anything unverifiable (a tampered object is overwritten, never trusted).
+3. `keys, rev := DoorKeysLive(...)`, one read transaction. If `rev <= bucketRev`, then `rev = DoorKeyAdvanceRevision(..., bucketRev)` and re-read `keys`. That covers a restored database and a second writer.
+4. Build `doorkey.Record{Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now + doorkey.Lifetime}`, sign it, and `Put(ctx, key, body, etag)`.
+5. On `release.ErrCASConflict`, go back to step 2, at most three times. Every retry rebuilds from the database and never reuses signed bytes. If all three fail, return the error.
+
+Mint and revoke do the database write, then `publishDoorKeys`, then `bumpSerial`. The renewal loop calls `publishDoorKeys` with no database write, so a renewal re-publishes the current revision with a fresh expiry, or a newer one if step 3 advanced it. Because the mutex serializes a renewal against a revoke, and each builds from the database inside the lock, a renewal can never write a key set older than one already published. The console runs as one process per deployment. If that ever changes, step 2's revision check keeps a slower writer from putting back an older revision, because it re-reads under CAS.
- `func (s *Server) doorKeyResignLoop(stop <-chan struct{})` — every 12 h, `publishDoorKeys` for every `DoorKeyApps()` row; logs failures; started beside the console's other loops (find them with `grep -n "go s\..*Loop(" internal/console/server.go`), only when `len(s.cfg.Key) > 0`
- Wire: `POST /api/cli/apps/{account}/{app}/door-keys` `{"label"}` → 201 `{"key","id","account","app"}`; `GET .../door-keys` → 200 `{"keys":[{"id","label","created_at"}]}`; `POST .../door-keys/{id}/revoke` → 200 `{"revoked":id}`, 404 unknown id; 409 on a full set; 503 with the read-only sentence when `len(s.cfg.Key) == 0`. All person-only.
@@ -499,6 +538,22 @@ func TestDoorKeysRefuseServiceCredentialsAndReadOnlyConsoles(t *testing.T) { /*
func TestDoorKeysRefuseANinthKey(t *testing.T) { /* eight mints 201, the ninth 409, the record still has eight */ }
func TestDoorKeyResignRefreshesExpiry(t *testing.T) { /* mint; call publishDoorKeys again after advancing the store's clock seam (or compare SignedAt before/after a second call); revision unchanged, ExpiresAt later */ }
+
+// Round 2 finding 5: a renewal that read revision N must not land after a
+// revoke published N+1. Drive it deterministically: wrap fx.rel in a store
+// whose Put blocks on a channel for the FIRST call only; start a renewal
+// (publishDoorKeys) in a goroutine so it blocks inside the lock; start a
+// revoke in another goroutine; release the renewal; wait for both. The
+// bucket must hold the revoke's revision and not admit the revoked key.
+func TestDoorKeyRevokeIsNotOverwrittenByARacingRenewal(t *testing.T) { /* as described */ }
+
+// Round 2 finding 6: a database whose counter is behind the bucket (a
+// restore) publishes above the bucket's revision, never at or below it.
+func TestDoorKeyPublishAdvancesPastTheBucketRevision(t *testing.T) { /* publish revision 1; plant a validly signed revision 7 in the bucket; mint; the new record's revision is 8 and the database's counter is 8 */ }
+
+// Round 2 finding 4: replacing an existing object works, and a tampered
+// object is overwritten rather than trusted.
+func TestDoorKeyPublishReplacesExistingAndTamperedObjects(t *testing.T) { /* mint twice (second Put replaces); then write garbage bytes at the key and revoke: the revoke publishes a verifiable record */ }
```
(`fx.key`: if the fixture exposes the private key under another name, use that; `grep -n "ed25519.GenerateKey" internal/console/server_test.go`.) Write out the three stubbed tests in full in the same style before running.
@@ -533,49 +588,50 @@ func TestDoorKeyResignRefreshesExpiry(t *testing.T) { /* mint; call publishDoorK
---
-### Task 4: Edges files publish owners and door support; the edge mirrors door keys
+### Task 4: Edges files publish single owners; the edge mirrors door keys
**Files:** Modify `internal/host/edgesfile.go`, `internal/edge/homemap.go`. Create `internal/edge/doorkeys.go`. Tests in `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go`, `internal/edge/doorkeys_test.go`.
**Interfaces:**
-- Edges file: `edgeHost` gains `Account string \`json:"account,omitempty"\``, `App string \`json:"app,omitempty"\``, set for every role-`home` route from `release.RouteAccount(rt)`/`rt.App`, for aliases from their primary's row, and for the served catchall at `edgesfile.go:363` from the wildcard claim's account/app. `edgesFile` gains `Door bool \`json:"door,omitempty"\``, always true from this build on. It is a capability ("this edge verifies door hops and enforces their scope"), not a statement that the box runs a door of its own: a box receiving backhauled door traffic needs only the binary. An older publisher omits it, and door traffic is never backhauled to it.
-- Fold: `edgesHostView` and `homeEntry` gain `account`, `app`; `edgesFileView` gains `Door`; `homeEntry` gains `door bool` from its file. When `foldHomes` lets a route replace an alias (~`homemap.go:581`), owner and door come from the winner, never merged.
-- `func (m *homeMap) Owner(host string) (account, app string, door, ok bool)` — same lookup as `Home`, including the one-label wildcard; `ok` false when the winning entry carries no owner. Nil receiver returns zeros.
-- `type doorKeyMirror struct{ ... }` in `doorkeys.go` with `func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey, stateFile string) *doorKeyMirror`, `func (d *doorKeyMirror) Refresh(ctx context.Context) error`, `func (d *doorKeyMirror) run(stop <-chan struct{})` (30 s ticker, 10 s per-refresh budget), `func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool)` (nil-safe; also refuses a record whose `ExpiresAt` has passed at read time).
+- Edges file: `edgeHost` gains `Account string \`json:"account,omitempty"\`` and `App string \`json:"app,omitempty"\``. **They are published only when every route this box serves under that hostname (the row, every path-prefix row that shares it, and for an alias, its primary's rows) has one account and app.** A mixed hostname publishes neither, and the door refuses it remotely. Set for role-`home` routes from `release.RouteAccount(rt)`/`rt.App`, for aliases from their primary, and for the served catchall at `edgesfile.go:363` from the wildcard claim's account/app. No capability flag.
+- Fold: `edgesHostView` and `homeEntry` gain `account`, `app`. When `foldHomes` lets a route replace an alias (~`homemap.go:581`), the owner comes from the winner, never merged.
+- `func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool)`: one read of one snapshot answers both whom to check and where to dial, so the check and the dial cannot disagree. It uses the same lookup as `Home`, including the one-label wildcard. `ok` is false when the winning entry has no owner or is not a dial target. A nil receiver returns zeros.
+- `type doorKeyMirror struct{ ... }` in `doorkeys.go`:
+ - `func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror`
+ - `func (d *doorKeyMirror) Refresh(ctx context.Context) error`
+ - `func (d *doorKeyMirror) run(stop <-chan struct{})`, with a 30 s ticker and a 10 s budget per refresh
+ - `func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool)`, which is nil-safe and also refuses a record whose `ExpiresAt` has passed at read time.
Mirror rules, each with its own test:
- list succeeds: records absent from the list are dropped immediately;
- list fails: the previous map is kept, but once 10 minutes have passed since the last successful list the whole map is cleared;
- a GET fails for one key: its previous record is kept under the same 10-minute bound, measured from that record's last successful read;
- verification fails (signature, shape, expiry), or the payload's account/app differ from the path's: that record is dropped immediately;
-- revision below the high-water mark for that account/app: the stored newer record is kept (if still unexpired) and the replay is logged once per revision;
-- the high-water marks are persisted to `stateFile` (JSON map `"<account>/<app>": revision`, written atomically via temp file and rename, 0600) after any refresh that raised one, and loaded in `newDoorKeyMirror`.
+- a lower revision than the one this process last admitted for that account/app: the newer record is kept while unexpired and the lower one is logged once. This is in memory only; the Security model states what a restart allows.
-- [ ] **Step 1: Failing tests** — publisher: owner on a home row, on an alias (primary's), on the served catchall; `door: true` on every file this build writes. Fold: owner through exact and wildcard lookup; missing owner gives `ok == false`; a route replacing an alias carries the route's owner and door. Mirror: each rule above, plus **replay across restart**: publish revision 2 (key revoked), refresh, then put revision 1 (key present) back, refresh → not admitted; construct a new mirror over the same `stateFile`, put revision 1, refresh → still not admitted.
+- [ ] **Step 1: Failing tests** — publisher: owner on a home row, on an alias (the primary's), and on the served catchall; **no owner on a hostname whose path-prefix rows belong to two apps**. Fold: the owner through exact and wildcard lookup; a missing owner gives `ok == false`; a route replacing an alias carries the route's owner; `Owner`'s `eip`/`box` equal `Home`'s for the same host. Mirror: each rule above.
- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile|Fold' -count=1` → FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** both packages whole.
-- [ ] **Step 5: Commit** `edges files publish owners and door support; the edge mirrors signed door keys (#175 P7)`.
+- [ ] **Step 5: Commit** `edges files publish single owners; the edge mirrors signed door keys (#175 P7)`.
---
-### Task 5: Enforcement inside `ServeHTTP` and across the hop
+### Task 5: Enforcement inside `ServeHTTP`
-**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`, `backhaulProxy`), `internal/edge/homemap.go` (`acceptHop`, `setForwarded`), `internal/edge/mesh.go`, `internal/edge/analytics.go` (`clientIP`). Tests in `internal/edge/door_test.go`, `internal/edge/mesh_test.go`.
+**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`), `internal/edge/homemap.go` (`setForwarded`), `internal/edge/analytics.go` (`clientIP`). Tests in `internal/edge/door_test.go`.
**Interfaces:**
- `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`; `func doorCallerFrom(ctx context.Context) (doorCaller, bool)`
-- `const hopDoorHeader = "X-Carlos-Hop-Door"` (value `<account>/<app>`)
-- `func signHopDoor(key []byte, host, scope string, now time.Time) string` and `func verifyHopDoorEitherKey(current, next []byte, host, scope, header string, now time.Time) bool` — same timestamp format and skew window as `signHop`/`verifyHop`, MAC over `canonicalizeHopHost(host) + "\n" + ts + "\n" + "door " + scope`. A door hop's signature therefore never verifies as a plain hop and the reverse.
+- `func (e *Edge) backhaulTo(w http.ResponseWriter, r *http.Request, host, eip, box string) bool`: the dial half of `backhaul`, split out so the door dials exactly the target `Owner` returned. `backhaul` becomes `Home` plus `backhaulTo`, and its behaviour is unchanged.
- `func doorOwnsRoute(c doorCaller, rt registry.Route) bool` → `release.RouteAccount(rt) == c.Account && rt.App == c.App`
- `func (e *Edge) doorReservedHost(host string) bool` — true for the tunnel hostname and the steering probe hostname, compared after lowercasing and stripping one trailing dot (the normalization `steeringProbe` and `canonicalizeHopHost` use)
- `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || door caller present`; used by `setForwarded` and `clientIP` only. `backhaul`'s one-hop refusal keeps reading `hopVerified`.
Enforcement, in `ServeHTTP` order:
-1. **First statement after the port strip:** `door, isDoor := doorCallerFrom(r.Context())`. If `isDoor && e.doorReservedHost(host)`: `e.unknownHost(w, r); return`. If `isDoor`, skip the steering probe and tunnel branches entirely (wrap them in `if !isDoor { ... }`).
-2. `acceptHop` now also handles `X-Carlos-Hop-Door`: it deletes the header in every case; when present on a hop, it verifies with `verifyHopDoorEitherKey` instead of the plain verifier, and on success returns the request with both `hopVerifiedKey` and `doorCallerKey{scope}`. On any request without a hop header it deletes a stray `X-Carlos-Hop-Door`. So a hop-delivered door request becomes `isDoor` at the receiving edge; re-read `door, isDoor` after `acceptHop`.
-3. **Miss branch:** if `isDoor` and not hopped: `acct, app, doorOK, ok := e.homes.Load().Owner(host)`; unless `ok && doorOK && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaul`, which, for a door request, signs with `signHopDoor(key, host, door.Account+"/"+door.App, now)` and sets `X-Carlos-Hop-Door` (in `backhaulProxy`'s Rewrite, after deleting any inbound copy).
-4. **After the final `up` is chosen**, that is, after the login POST re-resolution block (~`edge.go:1416-1446`) and before the gate (`:1516`), the cache and dispatch: `if isDoor && !doorOwnsRoute(door, up.route) { e.unknownHost(w, r); return }`. The same `up` value is then used for everything after it, so authorization and dispatch cannot diverge.
+1. **First statement after the port strip:** `door, isDoor := doorCallerFrom(r.Context())`. If `isDoor && e.doorReservedHost(host)`: `e.unknownHost(w, r); return`. If `isDoor`, skip the steering probe and tunnel branches entirely (wrap them in `if !isDoor { ... }`). The mark is only ever set by `serveDoor` on this box. Nothing carries it across a hop, and `acceptHop` is unchanged.
+2. **Miss branch:** if `isDoor`, call `acct, app, eip, box, ok := e.homes.Load().Owner(host)`. Unless `ok && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaulTo(w, r, host, eip, box)`. The receiving edge sees an ordinary verified hop, as it does for public backhaul today. It believes the forwarded chain, which the door has already reduced to one validated address. This is the whole of what crosses, and a mismatch on the far side costs at most attribution (Security model).
+3. **After the final `up` is chosen**, that is, after the login POST re-resolution block (~`edge.go:1416-1446`) and before the gate (`:1516`), the cache and dispatch: `if isDoor && !doorOwnsRoute(door, up.route) { e.unknownHost(w, r); return }`. The same `up` value is then used for everything after it, so authorization and dispatch cannot diverge.
- [ ] **Step 1: Failing tests** (an in-process `Edge` built the way existing `edge_test.go` tests build one with local routes and a stubbed home map; `grep -n "func newTestEdge\|func testEdge" internal/edge/*_test.go`):
1. door caller, local route of its app → served;
@@ -583,11 +639,10 @@ Enforcement, in `ServeHTTP` order:
3. path-prefix host: `/a/` owned by the caller's app, `/b/` by another → `/a/x` served, `/b/x` unknown host;
4. gate login POST to `/.carlos/gate` with `next=/b/x` (plain and percent-encoded) on that host, caller owns `/a/` only → unknown host, and the gate's password check never runs (assert via a gate stub or by a correct password not yielding a cookie);
5. the tunnel hostname and the steering probe hostname (also upper-cased and with a trailing dot), with a wildcard route owned by the caller that would otherwise match → unknown host, and neither the hub nor the probe handler runs;
- 6. remote host with published owner = caller and `door: true` → `backhaul` called with the door scope signed; published owner = caller but `door` false → unknown host; no published owner → unknown host;
- 7. **receiving side:** a request with a valid door-hop signature for scope `bab/a` to a path-prefix host whose `/b/` belongs to app `b` → unknown host; to `/a/` → served; a plain-hop signature presented with `X-Carlos-Hop-Door` → not verified (forwarded headers stripped, no door mark);
- 8. mesh: `verifyHopDoorEitherKey` refuses a plain `signHop` value and `verifyHopEitherKey` refuses a `signHopDoor` value.
+ 6. remote host whose published owner is the caller → `backhaulTo` is called with `Owner`'s own `eip` and `box`; published owner another app → unknown host; no published owner (a mixed hostname, or an older publisher) → unknown host;
+ 7. a request carrying a `doorCallerKey` mark can only come from `serveDoor`: assert that a request arriving on the public handler with any `X-Carlos-*` header never has the mark.
- [ ] **Step 2–4:** run (outside the sandbox), implement, run `go test ./internal/edge -count=1`.
-- [ ] **Step 5: Commit** `edge: enforce the door's scope on the final route, across the hop, and away from reserved handlers (#175 P7)`.
+- [ ] **Step 5: Commit** `edge: enforce the door's scope on the final route and away from reserved handlers (#175 P7)`.
---
@@ -606,7 +661,6 @@ func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) {
// The mesh's vocabulary is never a door caller's to speak.
r.Header.Del(hopHeader)
r.Header.Del(hopAuthHeader)
- r.Header.Del(hopDoorHeader)
tok, err := doorkey.ParseToken(raw)
if err != nil {
doorRefused(w)
@@ -651,7 +705,7 @@ func doorRefused(w http.ResponseWriter) {
- [ ] **Step 1: Failing tests:**
1. no key, malformed key, revoked key → 401 with `Cache-Control: no-store`, upstream never called;
2. valid key → served; the upstream sees no `X-Carlos-Door-Key` and sees `X-Tito-Auth-User` untouched;
- 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door` → none reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`;
+ 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`;
4. X-Forwarded-For cases: `203.0.113.9` → upstream sees exactly `203.0.113.9`; `garbage`, empty, absent, a trailing empty element (`203.0.113.9, `), and two header lines whose last element is `198.51.100.7` → upstream sees exactly `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `198.51.100.7`; `clientIP` returns the same value;
5. a gated route still answers with the gate's page;
6. `openDoor` on a temp path makes a 0660 socket; a real unix-socket client round-trips case 2; `closeDoor` removes the socket; `openDoor` with a nil mirror returns an error and creates nothing.
@@ -665,8 +719,8 @@ func doorRefused(w http.ResponseWriter) {
**Files:** Modify `internal/edge/edge.go` (a new `RunOption`; the home-map arming block ~line 2985; the shutdown block ~line 3110; the dev-mode branch that returns early), `internal/host/agent.go` (the options passed to `edge.RunWithActivatorReady`, ~line 510). Tests in `internal/edge/edge_test.go` (or `door_test.go`) and `internal/host/agent_test.go`.
**Interfaces:**
-- `func WithEdgeDoor(path, stateFile string) RunOption` — records both on the `Edge`.
-- In the run path, immediately after `e.homes.Store(hm)` and its `go hm.run(...)`: when a door path is set and `e.staticStore` and `e.staticPub` are non-nil, build `e.doorKeys = newDoorKeyMirror(e.staticStore, e.staticPub, stateFile)`, run one synchronous `Refresh` with a 10 s budget (log a failure, carry on: every key is refused until a refresh lands), start `go e.doorKeys.run(...)`, then `openDoor(path)` and keep `closeDoor` on the `Edge`. With no store or no public key: log that the door is off and why. In dev mode: log that the door is off in dev mode.
+- `func WithEdgeDoor(path string) RunOption` — records the path on the `Edge`.
+- In the run path, immediately after `e.homes.Store(hm)` and its `go hm.run(...)`: when a door path is set and `e.staticStore` and `e.staticPub` are non-nil, build `e.doorKeys = newDoorKeyMirror(e.staticStore, e.staticPub)`, run one synchronous `Refresh` with a 10 s budget (log a failure, carry on: every key is refused until a refresh lands), start `go e.doorKeys.run(...)`, then `openDoor(path)` and keep `closeDoor` on the `Edge`. With no store or no public key: log that the door is off and why. In dev mode: log that the door is off in dev mode.
- Shutdown calls `closeDoor` before the HTTP servers' `Shutdown`.
- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p, filepath.Join(dataDir, "door-keys.state"))) }`.
@@ -689,6 +743,6 @@ func doorRefused(w http.ResponseWriter) {
## Rollout (for the handoff, not a task)
-1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners and `door: true`, and verify door hops, but open no socket).
+1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners, but open no socket).
2. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock`, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`. Boxes that home Tito instances elsewhere need only the new binary to receive door traffic; they need no door of their own.
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-FINDINGS-P7-EDGE-DOOR-R2.md b/docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR-R2.md
new file mode 100644
index 000000000..6c5aaa947
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR-R2.md
@@ -0,0 +1,68 @@
+| Round-1 finding | Disposition |
+|---|---|
+| 1. Console re-signs another app’s record | **Closed.** Building records exclusively from the console database removes the copied-record signing attack. |
+| 2. Remote authorization loses app scope | **Partly.** The signed scope protects successfully verified hops, but verification failure still removes the restriction; advertised support is also untrusted. |
+| 3. Login re-resolution and lookup race | **Closed.** Authorization now checks the final `up` used for gate processing and dispatch. |
+| 4. Reserved handlers override ownership | **Partly.** Local door requests are excluded, but receiving edges reach reserved handlers before discovering the door mark. |
+| 5. Replay restores revoked keys | **Partly.** Expiry and persisted revisions bound ordinary replay, including the stated 72-hour fresh-box allowance. Counter rollback and persistence failures remain unresolved. |
+| 6. Malformed XFF remains authoritative | **Closed.** Replacing the header with one parsed address or loopback fixes forwarding and analytics. |
+| 7. Unbounded stale authorization | **Closed.** The separate refresh budget, retention bounds and read-time expiry establish a bounded failure contract. |
+| 8. Listener precedes mirror readiness | **Closed.** Task 7 initializes the mirror before opening the listener, defines empty-mirror behavior and retains shutdown handling. |
+| 9. Incorrect interfaces and missing integration cases | **Closed for the original findings.** Dispatch, fixtures, catchall publication, fold ownership and documentation destinations are corrected. A newly introduced store-interface mismatch appears below. |
+| 10. Signing-domain separation | **Closed.** The signed domain prefix and cross-kind rejection tests address the recommendation. |
+
+1. **P1 — Failed door-hop verification silently removes app authorization.**
+
+ Task 5 marks a request as door traffic only after successful verification; its negative test explicitly expects “no door mark” ([plan:576](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:576), [plan:587](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:587)). The existing verifier strips forwarded headers and continues as an ordinary client on failure ([homemap.go:793](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:793)).
+
+ This happens during legitimate mesh-key propagation: the sender starts signing with `.next` before the receiver mirrors it. A door request scoped to A, targeting B’s prefix behind A’s front hostname, then reaches B without the ownership check.
+
+ **Fix:** Treat attempted door hops as a distinct protocol outcome. Missing, malformed or unverifiable scope/signature must terminate the request, never downgrade it to public traffic. Test two edges with different mirrored key generations and assert that B’s handler is never called.
+
+2. **P1 — `door: true` does not prove the destination enforces door scope.**
+
+ The plan makes an edges-file boolean the compatibility gate ([plan:541](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:541)), but edges files are unsigned JSON ([homemap.go:478](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:478)). A bucket writer can advertise support for an old edge; stale metadata after rollback can do the same. That edge rejects the new MAC as a plain-hop signature, then serves the request without scope enforcement.
+
+ There is also a selection race: Task 5 checks `Owner(host)`, then `backhaul` independently calls `Home(host)` ([plan:577](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:577), [edge.go:2621](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:2621)). A refresh can switch the destination to an unsupported box between those reads.
+
+ **Fix:** Require authenticated support from the actual receiving endpoint before sending application traffic, through a protocol old receivers cannot process as ordinary requests. Select owner, capability and dial target from one snapshot. Test forged capability metadata, rollback and a refresh between selection and dialing.
+
+3. **P1 — Re-reading the door mark happens after reserved handlers have already returned.**
+
+ Task 5 checks `isDoor` before the steering and tunnel branches, but only discovers a received door hop afterward ([plan:575](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:575)). Current execution reaches steering, tunnel registration and the store door before `acceptHop` ([edge.go:1329](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1329), [edge.go:1366](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1366)).
+
+ A signed door hop targeting a hostname reserved only on the receiving box reaches that handler while `isDoor` is still false. Re-reading the mark cannot undo the response.
+
+ **Fix:** Authenticate or reject attempted door hops before any reserved-handler dispatch, then apply the reserved-name exclusion. Add receiving-side tests for all three handlers, including overlapping wildcard routes and normalized hostname variants.
+
+4. **P1 — The specified unconditional publication operation does not exist.**
+
+ Task 2 requires `Put` “unconditionally (no etag)” ([plan:424](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:424)). However, `release.Store.Put(..., "")` is explicitly create-only; a nonempty argument requires the current ETag ([store.go:37](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/release/store.go:37)).
+
+ Following the specified call permits the first publication but rejects every revoke, subsequent mint and renewal. Retrying the same call cannot repair it.
+
+ **Fix:** Specify a supported publication operation. One option is a conditional-write retry using the bucket only for its ETag, with record contents always rebuilt from the database. Alternatively, explicitly add an overwrite interface and its implementations. Test replacement of existing and tampered objects.
+
+5. **P1 — Concurrent publishers can overwrite a completed revocation with an older record.**
+
+ Database mutation followed by an independent read/sign/write is not an ordered publication protocol ([plan:428](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:428)). A renewal reads revision 4 containing key K and pauses. A revoke publishes revision 5 without K and returns success. The renewal then overwrites it with revision 4.
+
+ Boxes that missed revision 5 continue admitting K even with a healthy bucket. The scheduled repair may be 12 hours away.
+
+ **Fix:** Serialize per-app publication across mutations and renewals, including every console permitted to publish. Read keys and revision in one consistent database snapshot. Conditional-write retries must rebuild from current database state rather than reuse stale signed bytes. Add a deterministic delayed-publisher test proving revision 4 cannot replace revision 5 after successful revocation.
+
+6. **P1 — Restoring the console database can reuse revisions with different authorization.**
+
+ Revisions come solely from the database, while mirrors reject only values *below* their high-water mark ([plan:419](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:419), [plan:551](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:551)).
+
+ Suppose revision 10 revoked K. Restoring revision 9 restores K; another mutation creates revision 10 containing K again. Even boxes that observed the revocation accept that equal revision. Periodic re-signing can extend this authorization indefinitely. Conversely, resetting the counter far below existing marks prevents legitimate replacement keys from working.
+
+ **Fix:** Define a restore-safe authority-generation and recovery protocol before building. Recovery must invalidate uncertain credentials and advance verifier trust explicitly. Also reject conflicting key sets at the same revision while allowing expiry-only renewal. Test database rollback, counter reset and equal-revision conflicts.
+
+7. **P2 — High-water persistence has no failure or crash contract.**
+
+ Task 4 specifies atomic rename after a refresh raises a mark, but not persistence-before-activation, durable synchronization, load errors or retry after a failed write ([plan:552](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:552)). If the in-memory mark advances but writing fails, subsequent refreshes need not “raise” it again. Restart then loses revocation knowledge.
+
+ **Fix:** Persist accepted advances before exposing the corresponding authorization state, retain pending persistence work after failure, and define refusal behavior for unreadable or corrupt state. Explicitly classify missing state after data loss under the documented fresh-box allowance. Test write/rename failure, restart and corrupt-state loading.
+
+The plan is **not ready to build** until these authorization, publication and recovery contracts are corrected.
\ No newline at end of file
diff --git a/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R3.md b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R3.md
new file mode 100644
index 000000000..bb93db3ce
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R3.md
@@ -0,0 +1,25 @@
+# Brief: P7 edge-door plan, review round 3
+
+Rounds 1 and 2 are `CODEX-FINDINGS-P7-EDGE-DOOR.md` and `-R2.md` beside the
+plan. The plan is now revision 3. It opens with a disposition table for
+round 2 and a new **Security model** section.
+
+Revision 3 changes the approach instead of adding machinery. Its claim is
+that **the door grants exactly one privilege over the public door: the
+caller's claim of the visitor's address is believed.** Everything else a door
+request can do, an internet request to the same URL can already do. Given
+that, it drops the door-scoped hop and the box-side revision persistence, and
+accepts a set of residuals that each cost at most misattribution.
+
+1. **Try to falsify the claim first.** Look for anything a door request can reach or cause that an internet request to the same host and path cannot:
+ - any handler, header, context mark or address the edge, the activator, the gate, the cache, the purge path, the sidecar marker or analytics treats differently;
+ - anything an app behind the edge could reasonably rely on, such as an app that trusts `X-Forwarded-For` for authorization;
+ - what the door does to `RemoteAddr` and the forwarded headers on a local serve and on a backhaul.
+
+ If the claim is false, say exactly where. That is the most important finding this round could produce.
+2. **If the claim holds, judge the residuals table.** Is each bound stated correctly? Is any residual worse than misattribution?
+3. **Disposition table** for round 2's seven findings: closed / partly / not closed / correctly superseded.
+4. **Then only new findings,** ranked P1/P2/P3. Look hardest at the publication protocol in Task 2 (mutex, the CAS retry, the revision advance) and at `Owner` returning the dial target.
+5. The rulings in the design record's §0 are fixed. Check every `path:line` you cite.
+
+End with one line saying whether the plan is ready to build. Keep it under about 1,500 words.