carlos / platform Public

App redirects and account transfer: spec rev 1 and Astra brief (#295)

Human

Prompt

Can we take carlos/platform#295

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Design for a redirect release kind (301 to any https URL, path and
query carried) and a host-claim transfer of static apps that turns the
source into a redirect to the new hostname. Rulings agreed with Paul
2026-10-15 to 2026-10-18 are fixed in section 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3361ef5f1a47f4b6258ab922d30a1c9128278e5f parent a5003ab
2 files changed, +444 −0
  • docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md +367 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-APP-TRANSFER.md +77 −0

CI passed — run details

CI log
=== gate ===
go: downloading golang.org/x/crypto v0.52.0
go: downloading golang.org/x/net v0.55.0
go: downloading golang.org/x/text v0.37.0
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	287.851s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.750s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.296s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.710s
ok  	github.com/carlosframework/platform/internal/activator	249.076s
ok  	github.com/carlosframework/platform/internal/agentsock	0.027s
ok  	github.com/carlosframework/platform/internal/alerting	0.012s
ok  	github.com/carlosframework/platform/internal/analytics	0.025s
ok  	github.com/carlosframework/platform/internal/appconfig	2.516s
ok  	github.com/carlosframework/platform/internal/apperr	0.083s
ok  	github.com/carlosframework/platform/internal/applog	0.142s
ok  	github.com/carlosframework/platform/internal/awsinv	0.013s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.014s
ok  	github.com/carlosframework/platform/internal/boxid	0.886s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.017s
ok  	github.com/carlosframework/platform/internal/caps	0.020s
ok  	github.com/carlosframework/platform/internal/certwarm	0.009s
ok  	github.com/carlosframework/platform/internal/cgroup	0.697s
ok  	github.com/carlosframework/platform/internal/changelog	0.312s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	390.799s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.332s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.428s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.679s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.130s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/host	135.043s
ok  	github.com/carlosframework/platform/internal/hostactors	0.461s
ok  	github.com/carlosframework/platform/internal/hostbox	2.243s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.499s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	4.973s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.591s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	0.965s
ok  	github.com/carlosframework/platform/internal/hostops	1.595s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.457s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.138s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	4.178s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.717s
ok  	github.com/carlosframework/platform/internal/instancefs	0.020s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.540s
ok  	github.com/carlosframework/platform/internal/lease	9.825s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.007s
ok  	github.com/carlosframework/platform/internal/ledger	0.016s
ok  	github.com/carlosframework/platform/internal/mail	0.167s
ok  	github.com/carlosframework/platform/internal/maintenance	0.082s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.501s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.081s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	21.014s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.780s
ok  	github.com/carlosframework/platform/internal/objstore	0.176s
ok  	github.com/carlosframework/platform/internal/ops	0.172s
ok  	github.com/carlosframework/platform/internal/park	0.201s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	17.161s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.964s
ok  	github.com/carlosframework/platform/internal/relay	7.229s
ok  	github.com/carlosframework/platform/internal/release	20.267s
ok  	github.com/carlosframework/platform/internal/rollout	3.792s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/runs	0.573s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.476s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.702s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.999s
ok  	github.com/carlosframework/platform/internal/status	0.040s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.212s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.561s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46399650 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/18 10:42:26 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF99AD44ACDA42, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md b/docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md
new file mode 100644
index 000000000..166e1789a
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md
@@ -0,0 +1,367 @@
+# App redirects and account transfer (#295)
+
+Status: rev 1, draft for Astra's first read. Rulings in §1 are Paul's
+(2026-10-15 to 2026-10-18) and are fixed.
+
+## 0. Why
+
+Tito's static guide pages lived in the `tito` account and now belong in a
+`guides` account. Two had already been sent to customers as
+`https://<app>.<tito sqid>.platform.tito.io/`. Two things stop a clean move:
+
+- A host-claim console refuses to move an app to another account
+ (`claimTransferUnsupportedMsg`, `internal/console/claim_retire_helpers.go:119`,
+ raised at `appsettings.go:1337`). The move-any-app spec left transfer
+ refused on purpose (`2026-10-02-move-any-app-design.md` §0.5 P6).
+- The hostname contains the account sqid, so even a supported move would
+ break every link already sent. Nothing on the platform can answer a 301
+ for an app host today; the only host-level redirect is the console's own
+ `RedirectHosts`.
+
+Tito's fix in the meantime is a copy in each account and a double deploy for
+every change.
+
+The non-claim repair path (`carlos accounts migrate` then `console
+migrate-app`) is no model to follow: it keeps the old hostname, breaks bound
+secrets (sealed to (account, app) as GCM associated data,
+`internal/appconfig/crypto.go:8`), and leaves the store IAM user, custom
+domain claims and mail sender domains with the old account.
+
+## 1. Rulings (fixed)
+
+1. **Two parts, one spec.** Part A is a redirect; Part B is a transfer that
+ uses it. Part A ships first and is useful on its own.
+2. **A redirect is a release kind** (`redirect`), served by the existing
+ `static` route. It is not a new route kind and not a standalone host
+ record.
+3. **A redirect may point at any `https://` URL.** Path and query carry
+ over.
+4. **A transfer turns its source into a redirect** to the new hostname in
+ the same operation. `--no-redirect` retires the source instead.
+5. **Transfer is for static apps only.** An app with bound secrets, an
+ object store, a custom domain, mail senders or the temporary flag is
+ refused up front, every reason at once.
+6. **Who may transfer:** an owner of the source account who may also create
+ apps in the destination account.
+7. **Billing has no special case.** A redirect app counts as an app-month
+ for its account and its requests are metered like any other.
+
+## 2. Part A: redirect releases
+
+### 2.1 Manifest
+
+`release.Manifest` (`internal/release/release.go:47`) gains one additive
+field, omitted when empty, like `Label`:
+
+```go
+// Redirect is set only on a kind "redirect" release: every request to a
+// host serving it answers 301 to To + the request's path and query.
+Redirect *Redirect `json:"redirect,omitempty"`
+
+type Redirect struct {
+ To string `json:"to"`
+}
+```
+
+`validateManifest` (`release.go:321`) changes:
+
+- `Kind` may be `binary`, `static` or `redirect`.
+- A `redirect` manifest has **no artifacts** and a non-nil `Redirect`; any
+ other kind with a `Redirect` is refused, and the "no artifacts" check
+ applies only to `binary` and `static`.
+- `Redirect.To` must parse with `url.Parse`, be absolute, have scheme
+ `https`, a non-empty host, no userinfo, no query and no fragment, and be
+ at most 2048 bytes. A path is allowed; a trailing `/` on it is dropped
+ before joining (§2.4), so `https://x/docs` and `https://x/docs/` behave
+ the same.
+
+The manifest is content-addressed and reached through a signed channel
+pointer exactly as today, so the target is covered by the same signature
+and digest checks as a static site's bytes. Nothing new is signed.
+
+Every old binary rejects a `redirect` manifest at `validateManifest` (kind
+and no-artifacts checks), which is why §4 gates the ship behind a switch.
+
+### 2.2 CLI
+
+`carlos deploy --kind redirect --to <url>` with no directory argument.
+
+- `--to` is required with `--kind redirect` and refused with any other
+ kind. A directory argument, `--build`, `--program-change` and the
+ capability flags are refused with `--kind redirect` rather than ignored.
+- `--kind` is never inferred as `redirect` (`deploy.go:101`, `:258`).
+- If the app has no host yet, the existing `mintPlatformHost` path
+ (`deploy.go:916`) creates the static apex, exactly as a first static
+ deploy does.
+- The CLI validates `--to` with the same rules as §2.1 before calling the
+ console, then ships a manifest with `kind: "redirect"` and no artifact
+ part, and promotes as today. The version is `r-<first 10 hex of
+ sha256(To)>-<unix seconds>`, so it passes `ValidVersion` and two
+ redirects to the same target stay distinct releases.
+
+Other commands only need to tolerate the kind:
+
+- `carlos rollback` already moves a channel pointer, so rolling back a
+ redirect restores the site it replaced. No change.
+- Canary channels (`canary.go:499`, `:574`): a redirect may be shipped to a
+ canary channel. The kind checks that today say "static" accept
+ `redirect` where they are deciding "edge-served, no box process".
+- `carlos releases` and the console's release list show a redirect's
+ target in place of an artifact size.
+
+### 2.3 Console
+
+`handleAPIShip` (`internal/console/apicli.go:1798`):
+
+- Accepts `kind: "redirect"` only when `CARLOS_REDIRECT_RELEASES` is on
+ (§4). Off, it answers the same refusal as any unknown kind.
+- A `redirect` ship carries the meta part only; any file part is refused.
+- Temporary apps stay static-only (`apicli.go:1933`): a redirect ship to a
+ temporary app is refused.
+- A `redirect` release on an app that has a non-static route is refused:
+ the box path (`release/materialize.go:112`, `apiinstances.go:1799`)
+ never serves one.
+
+### 2.4 Edge
+
+The static handler (`internal/edge/static.go`) learns one case. Today
+`ensureExtracted` refuses a manifest whose kind is not `static`
+(`static.go:283`). After the change, `currentDir`'s resolution yields
+either an extracted site directory or a redirect target, cached the same
+way (the 60-second pointer re-check, the stale-on-error rule).
+
+For a redirect target, the handler answers every method with:
+
+- status `301`;
+- `Location: <To without trailing slash><request path><?query if any>`,
+ where the request path is the raw escaped path as received (no
+ re-escaping, no cleaning), so `/guide?x=1` on `https://new.example/docs`
+ goes to `https://new.example/docs/guide?x=1`;
+- `Cache-Control: max-age=3600`, so a changed or rolled-back redirect
+ reaches returning visitors within an hour (a bare 301 is cached
+ heuristically, often for good);
+- an empty body.
+
+The usage meter records the request against the route's account as for any
+response (`edge.go:1641`).
+
+### 2.5 Strings for copy review
+
+All new user-facing strings go through copy review before they land:
+the `--to` and `--kind redirect` refusals, the deploy success line, the
+release-list rendering of a target, the console ship refusals, and the
+`docs/site/cli.md` entry.
+
+## 3. Part B: transfer
+
+### 3.1 Surface
+
+- CLI: `carlos apps transfer <app> --to <account> [--no-redirect]`,
+ `carlos apps transfer <app> --status`, and
+ `carlos apps transfer <app> --abandon`.
+- API: `POST /api/cli/apps/<from>/<app>/transfer` (body `{"to": "<sqid>",
+ "redirect": true}`) answers `202` with the operation id once the
+ operation is open; `GET` on the same path answers the operation's phase
+ and last error; `DELETE` abandons it (§3.6).
+- The browser `/move` door (`appsettings.go:1311`) calls the same
+ operation on a host-claim console instead of answering
+ `claimTransferUnsupportedMsg`. On a non-claim console it keeps today's
+ behaviour.
+- Both doors are off unless `CARLOS_APP_TRANSFER` is on (§4). Off, they
+ answer today's refusal.
+
+The CLI polls `GET` every 2 seconds and prints one line per phase until the
+operation finishes or fails. The operation does not depend on the CLI: a
+closed terminal or a console restart leaves it to finish under §3.5.
+
+### 3.2 Authority
+
+The actor must be an owner of the source account and hold a role that may
+create apps in the destination account (the same check as app create
+there). A service token can never transfer.
+
+### 3.3 Eligibility
+
+Checked before anything is written. Every failing check is reported in one
+answer, each with its own fix:
+
+1. Every host record of the app is a `static` route. Any `instance` or
+ `service` route refuses.
+2. The current release on every channel is `static`. A `redirect` source is
+ refused (transferring a redirect is not a use case).
+3. No bound secrets: no `Var` of kind `secret` in `config.json`, any
+ `environments/<n>.json` or any `instances-config/<host>.json` whose
+ decoded envelope starts `CSB2`. Unbound (v1) secrets are copied as they
+ are; they open anywhere.
+4. No object store (`objstore.Get` / `StoreEnvironments`).
+5. No custom-domain claim naming the app (`s.claims.List`, filtered on
+ account and app as `apidomains.go:1108` does), and no host other than
+ the apex and static canary hosts.
+6. No mail senders under `apps/<from>/<app>/email/senders/`.
+7. Not temporary (`AppLife(...).Temporary()`).
+8. The name is free in the destination account, including its Trash, and
+ `control/hosts/<app>.<to sqid>.<domain>.json` does not exist.
+9. Neither admission has an op open, and the source has no unsettled
+ registrations or effects.
+
+The checks run again inside the operation after step 1 of §3.4 holds the
+source; a change between the answer and the hold refuses the operation
+before any write.
+
+### 3.4 Operation
+
+Two new op kinds: `transfer-out` on the source admission and `transfer-in`
+on the destination's. Each op's intent names the other side (account, app,
+app id, op id), so either sweep can find its partner. The source keeps
+serving its current release untouched until step 6.
+
+1. **Hold the source.** `BeginExclusive` on the source with kind
+ `transfer-out`. While it is open every other mutation of the source is
+ refused, which blocks deploys for the duration.
+2. **Create the destination app.** The SQL row and `EnrollNewApp` for the
+ destination, then `BeginExclusive` with kind `transfer-in`. Recorded as
+ an effect of `transfer-out`.
+3. **Copy.** Everything under `apps/<from>/<app>/` that `planCopy`
+ (`accounts.go:506`) copies, written under the destination's permit as
+ effects of `transfer-in`:
+ - immutable objects create-only, byte for byte;
+ - channel pointers re-signed for the destination with
+ `release.RebindPointer` and the console's root key;
+ - promotion records `releases/<from>/<app>/promoted/*` copied to
+ `releases/<to>/<app>/promoted/*`, and the destination's
+ pointer-digest marker written;
+ - `production.json` declared afresh for the destination;
+ - `instances.json`, `instances/*`, `admission.json`, retention and
+ purge-request files, runs, logs and analytics **not** copied. The
+ destination's host is created in step 4; its history starts empty.
+4. **Create the destination host.** `<app>.<to sqid>.<domain>` through the
+ hostops `Controller.Attempt` / `Complete` path that
+ `beginClaimCreate` / `finishClaimCreate` use
+ (`claim_create.go:30`, `:111`), with the ownership and record effects
+ recorded on `transfer-in`'s runner. It is a new holder and a new
+ incarnation; nothing of the source's claim is reused.
+5. **Wait for the destination to serve.** Request the new host over HTTPS
+ through the edge until the answer is `200` with `X-Carlos-Version`
+ equal to the copied production version. This also waits out the
+ destination's wildcard certificate if the account had none. After 15
+ minutes the op stays at this phase with the last error in `GET`'s
+ answer and keeps retrying every 30 seconds; the person can wait or
+ abandon (§3.6).
+6. **Turn the source.** Ship and promote a `redirect` release on every
+ channel of the source, `To` = `https://<app>.<to sqid>.<domain>`.
+ With `--no-redirect` the source is retired instead through the existing
+ retire path (`claim_retire.go:33`) once `transfer-out` closes. This is
+ the point of no return.
+7. **Close both ops.** `transfer-in` first, then `transfer-out`. Audit
+ events are written for both accounts.
+
+Every write in steps 2 to 6 is an effect recorded on one op's runner before
+it is issued, so a crash leaves a receipt naming exactly what may have
+happened.
+
+### 3.5 Recovery
+
+`SweepClaimAdmissions` (`claim_runtime.go:186`) gains a case for each kind,
+in the style of `ClaimPlacementCoordinator.Resume` (`claim_placement.go:94`):
+
+- `transfer-out` resumes forward from its last receipt. If its
+ `transfer-in` partner is missing it was never created: re-issue step 2.
+- `transfer-in` with a live `transfer-out` partner does nothing on its
+ own: the source side drives. With no partner (the source was closed by
+ hand) it stays closed and alerts; it does not guess.
+- Every step is idempotent: create-only copies, CAS pointer writes,
+ `Attempt` adopting its own claim, the readiness probe, and the redirect
+ ship keyed by its deterministic `To`.
+
+`ResumeExclusive` (`control_writer.go:287`) accepts the two new kinds;
+`RecoverMutation` keeps refusing to reopen them, so a crash can never leave
+the source open with a half-made destination.
+
+### 3.6 Abandon
+
+`DELETE` / `--abandon` is accepted before step 6 starts and refused after.
+It deletes the destination through the existing purge path (row,
+admission, host claim, copied objects), then closes `transfer-out` with
+the source unchanged. Abandon is itself recorded on `transfer-out` so a
+crash mid-abandon resumes the abandon, not the transfer.
+
+### 3.7 What does not move
+
+- CLI and service tokens and door keys stay with the source, which still
+ exists as the redirect. The destination starts with none.
+- Run history, logs, errors and analytics stay with the source.
+- The source keeps its app-month and its storage until it is deleted.
+
+### 3.8 Strings for copy review
+
+Every refusal in §3.3, the phase lines in §3.4, the abandon answers, the
+browser `/move` page on a claim console, and the `docs/site/cli.md`
+entries.
+
+## 4. Rollout and compatibility
+
+- `CARLOS_REDIRECT_RELEASES` and `CARLOS_APP_TRANSFER` are console
+ switches, off by default.
+- A redirect manifest is unreadable to old binaries (§2.1), so every box
+ and edge that can serve the app must run Part A before the console's
+ switch goes on. That is the standing roll order: dev boxes, dev console,
+ check, flagship boxes, flagship console. The switch goes on after the
+ console roll.
+- An old edge that meets a redirect manifest anyway keeps serving its
+ last good extraction (stale-on-error), so a too-early switch degrades to
+ "the redirect has not happened yet", not an outage.
+- `CARLOS_APP_TRANSFER` needs `CARLOS_REDIRECT_RELEASES` on; the console
+ refuses to start with the first on and the second off.
+- Part B ships in a later release than Part A. Tito's console also needs a
+ build from on or after f0606ae1 for `carlos apps list`; one roll can
+ carry both.
+
+## 5. Testing
+
+Part A:
+
+- `validateManifest`: every rule in §2.1, including old-kind manifests
+ with a `Redirect` and redirect manifests with artifacts.
+- Edge: the 301, `Location` joins (root, prefix path, trailing slash,
+ escaped path, query, no query), `Cache-Control`, every method, the
+ 60-second pointer re-check moving static to redirect and back
+ (rollback), a forged pointer or wrong digest refused.
+- Console: switch off refuses, file part refused, temporary refused,
+ non-static app refused.
+- CLI: flag combinations, `--to` validation, version shape.
+
+Part B:
+
+- One test per eligibility reason, plus all reasons at once.
+- Crash and resume at every step boundary: kill after each recorded
+ effect, run the sweep, check the operation finishes with the same end
+ state.
+- Abandon before step 6, refusal after, crash mid-abandon.
+- End to end on a claim-mode fixture console: the source answers 301 to
+ the destination, the destination answers the copied version, and a
+ deploy to the source during the op is refused.
+
+Locally only the touched packages run, with `-run`. CI runs the full suite.
+
+## 6. Out of scope
+
+- Transfer of server apps, or of apps with bound secrets, stores, custom
+ domains or mail senders.
+- Rename within an account (still refused, P6).
+- Redirect rules per path, status codes other than 301, or redirects with
+ a fixed expiry. Ending a redirect is deleting the app.
+- Making redirect apps free.
+
+## 7. Open points for review
+
+1. §3.4 step 4 runs a host create inside an exclusive `transfer-in` op,
+ which no existing caller does: `beginClaimCreate` registers on the
+ admission (`RegisterStart`), and an exclusive op may refuse that. The
+ spec says to drive `Controller.Attempt` / `Complete` directly with the
+ effects on the op's runner; the review should confirm the hostops
+ invariants hold that way.
+2. Two admissions held at once, source then destination. `BeginExclusive`
+ never blocks (it refuses with `ErrPending`), so ordering cannot
+ deadlock, but no existing op holds two; the review should check the
+ sweep cannot close one side while the other is mid-step.
+3. Whether `Cache-Control: max-age=3600` is the right default.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-APP-TRANSFER.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-APP-TRANSFER.md
new file mode 100644
index 000000000..f8abb3f7b
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-APP-TRANSFER.md
@@ -0,0 +1,77 @@
+# Review brief: app redirects and account transfer (#295)
+
+You are an independent second reader of a design spec for the CARLOS
+platform (this repository). Read cold and adversarially.
+
+**Spec:** `docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md`
+at the commit named in the prompt.
+
+## Fixed rulings, do not re-argue
+
+§1 of the spec lists the rulings the product owner has taken. Do not
+propose alternatives to them. Report only mechanisms that **fail to
+honour** a ruling, or that break something else in the platform.
+
+## What to check
+
+Verify against the code, not the spec's description of it. Cite
+`file:line` for every claim about existing code. The places that matter:
+
+- `internal/release/release.go` (`Manifest`, `validateManifest`,
+ `CheckManifestDigest`), `internal/release/claim_app_writes.go`,
+ `internal/release/rebind.go`, `internal/release/production.go`, and
+ every reader of manifests on boxes and edges.
+- `internal/edge/static.go` (`staticSite`, `currentDir`,
+ `ensureExtracted`, stale-on-error), `internal/edge/edge.go` (`Reload`,
+ `ServeHTTP`, usage meter).
+- `internal/console/apicli.go` (`handleAPIShip`),
+ `internal/console/appsettings.go` (`handleAppMoveAccount`),
+ `internal/console/claim_create.go`, `claim_retire.go`,
+ `claim_purge.go`, `claim_runtime.go` (`SweepClaimAdmissions`),
+ `claim_placement.go`, `claim_app_admission.go`, `temprefusal.go`,
+ `apidomains.go`, `authcli.go`.
+- `internal/hostops/` (`control_writer.go`, `operations.go`,
+ `effect_runner.go`, `admission.go`, `recovery.go`,
+ `production_create.go`, `create_effects.go`).
+- `internal/hostclaim/claim.go`, `internal/appconfig/crypto.go`,
+ `internal/objstore/`, `internal/mail/`.
+- `accounts.go` (`migrationPlan`, `planCopy`, `pointerRebinder`),
+ `deploy.go`, `cli.go`, `canary.go`, `apimode.go`.
+
+Questions worth your time:
+
+1. Can a redirect manifest reach any binary that mishandles it in a way
+ worse than "not redirected yet" (a crash loop, a dropped route, a
+ pruned release, a broken status page), given the switch and roll order
+ in §4?
+2. Is the `Location` construction in §2.4 safe: can a request path or
+ host header make the edge redirect somewhere other than `To`'s origin
+ (open redirect beyond the ruling, `//evil` paths, header injection)?
+3. §3.4 and §7 points 1 and 2: can the transfer leave either app
+ unrecoverable, serving nothing, or holding a claim that blocks the
+ name for good, under any crash point, console restart, concurrent
+ deploy, concurrent delete, or concurrent transfer of the same name in
+ the other direction? Do the hostops invariants (admission ops,
+ registrations, effect receipts, `RecoverMutation`) hold for two
+ exclusive ops and a host create inside one?
+4. Does §3.3 really catch every way a static app can carry state or
+ account-bound data that the copy would break or leak (bound secrets
+ in every config location, stores, domains, mail, door keys,
+ sidecars, anything else under the app prefix)?
+5. Is the copy in §3.4 step 3 complete and correct for a static app to
+ serve, roll back and promote in the destination exactly as it did in
+ the source (pointer re-signing, promotion records, pointer-digest
+ marker, `production.json`, pipeline and ladder state)?
+6. Anything the spec relies on that does not exist, or exists with a
+ different shape.
+
+## Output
+
+A numbered list of findings, each with: severity (P1 breaks a ruling or
+loses data, P2 wrong behaviour, P3 clarity/gap), the spec section, the
+code evidence with `file:line`, and a concrete fix. Then a one-line
+verdict: accept, or revise. Do not rewrite the spec. No praise.
+
+On a later round you will be given your earlier findings files. Start
+with a disposition table (finding → fixed / declined-with-reason
+acceptable / not fixed), then only new findings.