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>
2 files changed,
+444
−0
docs/superpowers/specs/2026-10-18-app-redirect-and-transfer-design.md+367 −0docs/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. |