carlos / platform Public

spec rev 4 and plan: answer Astra round 3 (limiter key per incarnation, orphan rows unlisted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a14da1b25e21b18de18552bd5ed4699c9a9d393d parent 5471efd
3 files changed, +89 −16
  • docs/superpowers/plans/2026-10-16-instance-bound-tokens.md +16 −10
  • docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md +31 −6
  • docs/superpowers/specs/CODEX-FINDINGS-INSTANCE-BOUND-TOKENS-R3.md +42 −0

CI passed — run details

CI log
=== gate ===
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	285.319s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.996s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.274s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.710s
ok  	github.com/carlosframework/platform/internal/activator	249.560s
ok  	github.com/carlosframework/platform/internal/agentsock	0.023s
ok  	github.com/carlosframework/platform/internal/alerting	0.006s
ok  	github.com/carlosframework/platform/internal/analytics	0.024s
ok  	github.com/carlosframework/platform/internal/appconfig	2.493s
ok  	github.com/carlosframework/platform/internal/apperr	0.087s
ok  	github.com/carlosframework/platform/internal/applog	0.173s
ok  	github.com/carlosframework/platform/internal/awsinv	0.010s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.665s
?   	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.012s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.715s
ok  	github.com/carlosframework/platform/internal/changelog	0.322s
ok  	github.com/carlosframework/platform/internal/clamd	0.067s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	386.807s
?   	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	5.911s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.013s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.006s
ok  	github.com/carlosframework/platform/internal/domains	2.434s
ok  	github.com/carlosframework/platform/internal/doorkey	0.008s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.638s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.139s
ok  	github.com/carlosframework/platform/internal/gate	1.666s
ok  	github.com/carlosframework/platform/internal/genselect	0.022s
ok  	github.com/carlosframework/platform/internal/geo	0.014s
ok  	github.com/carlosframework/platform/internal/geoip	0.032s
ok  	github.com/carlosframework/platform/internal/host	161.009s
ok  	github.com/carlosframework/platform/internal/hostactors	0.434s
ok  	github.com/carlosframework/platform/internal/hostbox	2.705s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.537s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.701s
ok  	github.com/carlosframework/platform/internal/hostfence	0.032s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.413s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.017s
ok  	github.com/carlosframework/platform/internal/hostops	1.623s
?   	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.448s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.141s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	4.153s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.715s
ok  	github.com/carlosframework/platform/internal/instancefs	0.005s
ok  	github.com/carlosframework/platform/internal/instancekey	0.004s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.008s
ok  	github.com/carlosframework/platform/internal/layout	1.528s
ok  	github.com/carlosframework/platform/internal/lease	9.794s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.015s
ok  	github.com/carlosframework/platform/internal/mail	0.163s
ok  	github.com/carlosframework/platform/internal/maintenance	0.081s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.502s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.064s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.982s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.798s
ok  	github.com/carlosframework/platform/internal/objstore	0.166s
ok  	github.com/carlosframework/platform/internal/ops	0.171s
ok  	github.com/carlosframework/platform/internal/park	0.189s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	10.911s
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.903s
ok  	github.com/carlosframework/platform/internal/relay	7.228s
ok  	github.com/carlosframework/platform/internal/release	19.562s
ok  	github.com/carlosframework/platform/internal/rollout	3.610s
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.026s
ok  	github.com/carlosframework/platform/internal/runs	0.560s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.467s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	8.406s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.320s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.199s
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.005s
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.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.558s
=== 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: 47055010 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: cached, digest verified
  mc: cached, 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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/16 03:10:17 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEE3D7C03E8CB9, 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/plans/2026-10-16-instance-bound-tokens.md b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md
index 68f498800..3cdeb5761 100644
--- a/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md
+++ b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md
@@ -8,7 +8,7 @@
**Tech Stack:** Go, SQLite (`internal/console` store, claim-gated), the release bucket store (`internal/provision`).
-**Spec:** `docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md` (rev 3). Read §2 (rulings), §3.4 (lifetime), §3.5 (the consumer table), §4.3 and §4.4 before any task.
+**Spec:** `docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md` (rev 4). Read §2 (rulings), §3.4 (lifetime), §3.5 (the consumer table), §4.3 and §4.4 before any task.
## Global Constraints
@@ -147,7 +147,7 @@ func AllowSelf(base Set) Set {
- `CLIToken.Host string`, `func (t CLIToken) IsInstance() bool`
- `func (s *Store) NewInstanceTokenContext(ctx context.Context, accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (string, error)`
- `func (s *Store) RevokeInstanceTokensContext(ctx context.Context, accountID int64, app, host string, now time.Time) error`
- - `principalKey` → `"instance:<id>/<app>/<host>"`; `auditActor` → `"instance:<sqid>/<app>/<host>"`
+ - `principalKey` → `"instance:<id>/<app>/<host>/<born>"`; `auditActor` → `"instance:<sqid>/<app>/<host>"`
- [ ] **Step 1: Write the failing tests** in `instancetoken_store_test.go`:
@@ -222,11 +222,13 @@ func TestInstanceTokenKeysArePerHost(t *testing.T) {
b := a
b.Instance = "b.example.com"
legacy := Principal{Kind: PrincipalService, AccountID: 7, App: "shop", Role: svcrole.Instance}
- if principalKey(a) != "instance:7/shop/a.example.com" {
+ if principalKey(a) != "instance:7/shop/a.example.com/1" {
t.Fatalf("principalKey(a) = %q", principalKey(a))
}
- if principalKey(a) == principalKey(b) || principalKey(a) == principalKey(legacy) {
- t.Fatal("instance principal keys collapse onto a sibling's or the app token's")
+ reborn := a
+ reborn.InstanceBorn = 2
+ if principalKey(a) == principalKey(b) || principalKey(a) == principalKey(legacy) || principalKey(a) == principalKey(reborn) {
+ t.Fatal("instance principal keys collapse onto a sibling's, a replacement's or the app token's")
}
acct := &Account{ID: 7, Sqid: "k7"}
if got := auditActor(a, acct); got != "instance:k7/shop/a.example.com" {
@@ -269,6 +271,7 @@ Add, in the same file, the lifecycle tests, each copying the setup of the existi
- `TestRenameAndMoveCarryInstanceTokens`: after `RenameAppIn` and `MoveApp`, the row's `app` / `account_id` follow.
- `TestStaleInstanceRowsDoNotBlockRenameOrMove`: insert an instance row at the destination (raw SQL, as an older console's purge would leave it); `RenameAppIn` and `MoveApp` succeed.
- `TestOldConsoleSQLLeavesHarmlessInstanceRows`: run the PREVIOUS release's purge statement literally (`DELETE FROM cli_tokens WHERE kind IN ('app','service') AND account_id = ? AND app = ?`) on a db holding an instance row; a later `RenameAppIn` into that name succeeds.
+- `TestOrphanInstanceRowsAreUnlisted`: on the gated fixture, an instance row whose app row is deleted (the old purge statement, then delete the app row as purge does) is absent from `ServicePrincipals` and `CLITokens`; create the app again and the row is listed, and revoking it through the account pane handler (`svcpage.go`'s revoke, or `serviceCredentialWrite`) revokes it.
- Run each of these against both the plain fixture and the gated one.
- [ ] **Step 2: Run, expect FAIL** (undefined `NewInstanceTokenContext`, fields).
@@ -294,7 +297,7 @@ func (t CLIToken) IsInstance() bool { return t.Kind == cliKindInstance }
`NeverExpires` becomes `(t.IsApp() || t.IsService() || t.IsInstance()) && …`.
-`scanCLITokenRows` scans one more column, `host`, after `rungs` (update BOTH queries, `CLITokens` and `ServicePrincipals`, to select `…, rungs, host`), and sets `t.Role = string(svcrole.Self)` when `t.Kind == cliKindInstance`. `ServicePrincipals`' `kind IN (?, ?)` becomes `kind IN (?, ?, ?)` with `cliKindInstance`.
+`scanCLITokenRows` scans one more column, `host`, after `rungs` (update BOTH queries, `CLITokens` and `ServicePrincipals`, to select `…, rungs, host`, and add to both WHERE clauses `AND (kind != 'instance' OR EXISTS (SELECT 1 FROM apps WHERE apps.account_id = cli_tokens.account_id AND apps.app = cli_tokens.app))`; check the apps table's column names first with `grep -n "CREATE TABLE IF NOT EXISTS apps" -A8 internal/console/store.go`: an orphan row is inert and, in claim mode, unrevocable, spec §3.4), and sets `t.Role = string(svcrole.Self)` when `t.Kind == cliKindInstance`. `ServicePrincipals`' `kind IN (?, ?)` becomes `kind IN (?, ?, ?)` with `cliKindInstance`.
`svcprincipal.go`: `Principal` gains
@@ -393,7 +396,9 @@ If the claim layer lints raw SQL outside `claimRaw…` functions (check: `grep -
```go
if p.Instance != "" {
- return "instance:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App + "/" + p.Instance
+ // Per incarnation, not per host: a dead incarnation's surviving token
+ // must not spend its replacement's budget (spec §3.5, codex R3-1).
+ return "instance:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App + "/" + p.Instance + "/" + strconv.FormatInt(p.InstanceBorn, 10)
}
```
@@ -490,7 +495,7 @@ func TestInstanceLimiterIsPerHost(t *testing.T) {
}
```
-Fill `TestInstanceLimiterIsPerHost` with the shrinking pattern that grep finds, two tokens minted for `a.example.com` and one for `b.example.com`, and a door from Task 4 is not available yet, so use `GET …/instances/{host}` registered by the real server: call it with token A1, A1, A2 (third must be 429) and B1 (must not be 429). The record need not exist; a 404 still spends the budget, since the limiter runs before the read.
+Fill `TestInstanceLimiterIsPerHost` with the shrinking pattern that grep finds, two tokens minted for `a.example.com` (born 1), one for `a.example.com` with born 2 (a replacement incarnation), and one for `b.example.com`, and a door from Task 4 is not available yet, so use `GET …/instances/{host}` registered by the real server: call it with token A1, A1, A2 (third must be 429), then the replacement-incarnation token (must not be 429) and B1 (must not be 429). The record need not exist; a 404 still spends the budget, since the limiter runs before the read.
- [ ] **Step 2: Run, expect FAIL.**
- [ ] **Step 3: Implement.**
@@ -554,8 +559,9 @@ func (b selfBinding) refuses(rec *provision.Record) bool {
```go
// Keyed on the TOKEN for every caller but an instance-bound one, whose
- // tokens for one host share one budget so that minting more of them buys
- // nothing, while a sibling's stays its own (spec §3.5).
+ // tokens for one incarnation share one budget so that minting more of
+ // them buys nothing, while a sibling's or a replacement's stays its own
+ // (spec §3.5).
key := hashToken(bearer(r))
if p, _ := principalFrom(r.Context()); p.Instance != "" {
key = principalKey(p)
diff --git a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
index 277e84c5e..c2ab4cc6e 100644
--- a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
+++ b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
@@ -1,6 +1,6 @@
# Instance-bound tokens (#155, half 1)
-Status: draft rev 3, 2026-10-16 (Astra rounds 1 and 2 answered, §9, §10). Discussion: amadan carlos/platform #155.
+Status: rev 4, 2026-10-16 (Astra rounds 1 to 3 answered, §9 to §11). Discussion: amadan carlos/platform #155.
## 1. The problem
@@ -151,8 +151,18 @@ A token is bound to one incarnation of its host:
**not** count instance rows. A leftover instance row at the destination
name, from an older console's purge (§6), is bound to a record
incarnation that no longer exists, so it can only answer 404; it must
- not block the move, and it stays visible and revocable on the account
- pane. No start-up sweep (codex R2-1, R2-3).
+ not block the move. No start-up sweep (codex R2-1, R2-3).
+- **An orphan row is inert and unlisted** (codex R3-2). A row whose
+ (account, app) has no app row at all, live or trashed, cannot pass any
+ door (`apiAccountApp` answers 404 for a missing app), and in claim mode
+ it cannot be revoked either, because SQL admission needs the app's
+ identity. So `ServicePrincipals` and `CLITokens` leave out instance rows
+ whose app row is missing (`AND (kind != 'instance' OR EXISTS (SELECT 1
+ FROM apps WHERE apps.account_id = cli_tokens.account_id AND apps.app =
+ cli_tokens.app))`), rather than show a Revoke button that cannot work.
+ If an app of that name is created again, the row reappears, is still
+ inert (no record has its incarnation), and is revocable through that
+ app's admission.
- A record with a zero `CreatedAt` cannot be minted for (§5): there is
nothing to bind to.
@@ -164,9 +174,9 @@ instance branch **before** that test:
| Place | For an instance row |
|---|---|
-| `principalKey` | `"instance:<account id>/<app>/<host>"` |
+| `principalKey` | `"instance:<account id>/<app>/<host>/<born>"`: per incarnation, so a dead incarnation's token cannot spend its replacement's budget (codex R3-1) |
| `auditActor` | `"instance:<sqid>/<app>/<host>"` |
-| `instanceGate`'s limiter (`apiinstances.go:87`) | keyed on `principalKey` for an instance principal, so every token for one host shares one budget and a sibling's is separate (codex 8); token hash for every other caller, unchanged |
+| `instanceGate`'s limiter (`apiinstances.go:87`) | keyed on `principalKey` for an instance principal, so every token for one incarnation shares one budget, and a sibling's or a replacement's is separate (codex 8, R3-1); token hash for every other caller, unchanged |
| `CLIToken` (`authstore.go`) | gains `Host`; `IsInstance()`; `NeverExpires()` true (codex 9) |
| `scanCLITokenRows` and both listing queries | read `host`; role shown as `self` |
| `serviceRowOf` / `/auth/cli/tokens/revoke` (`authcli.go:1543`, `:1618`) | takes the app-token branch: current membership of the row's account is required, then the same `RevokeCLITokenContext` deletion that branch already uses, which carries the request context and the claim admission (codex 4, R2-3). A former member cannot revoke it |
@@ -383,7 +393,13 @@ docs page section are copy review.
value is a change; an unchanged `channel` is still refused; install
needs `updates`; fast track is refused without a role lookup.
- Rate limit: two tokens for A share one budget on a real door; B's is
- separate.
+ separate; after A is re-created without revoking its old token, the old
+ token exhausting its budget leaves the new incarnation's token able to
+ pass (codex R3-1).
+- Orphans: with `OpenGated` and `HostClaim` on, an old-console purge
+ leaving an instance row; the row is absent from both listings; an app
+ of that name created again makes it listed and revocable through the
+ account pane handler (codex R3-2).
- Personal revoke: a former member who minted an instance token cannot
revoke it from `/settings`; a current member can.
- Old-console SQL: the previous release's purge, rename and move
@@ -436,3 +452,12 @@ Every finding was checked against the code and every citation held.
| R2-4 | P2 | Moot: the freshness rule that broke the fixtures is gone. |
| R1-6 (partly) | | Fixed: the round trip is GET's `explicit` object without `channel` (§4.4.3). |
| R1-10 (partly) | | See R2-2. |
+
+## 11. Astra round 3 (CODEX-FINDINGS-INSTANCE-BOUND-TOKENS-R3.md)
+
+| # | Sev | Disposition |
+|---|---|---|
+| R3-1 | P0 | Accepted. The limiter key carries the incarnation (§3.5). |
+| R3-2 | P1 | Accepted. Orphan rows are inert, so they are unlisted while their app is gone and revocable once it exists again (§3.4). |
+| R2-2 (partly) | | Declined further. The remaining gap is an existing caller changing to replay a saved record; any such change rewrites a creation call site, which is code review's to catch, and the AST test names every site a reviewer must look at. |
+| R2-3 (partly) | | See R3-2. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-INSTANCE-BOUND-TOKENS-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-INSTANCE-BOUND-TOKENS-R3.md
new file mode 100644
index 000000000..552585b6c
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-INSTANCE-BOUND-TOKENS-R3.md
@@ -0,0 +1,42 @@
+| Round 2 finding | Rev 3 closes it? | Reason |
+|---|---|---|
+| R2-1 — Startup sweep deletes restorable credentials | Closed | Removing the sweep preserves instance tokens across trash and restore. |
+| R2-2 — Freshness window permits incarnation replay | Partly | The window is gone, but freshness remains a caller convention: [CreateRecord still serializes the supplied timestamp](internal/provision/provision.go:882), and a caller-inventory test cannot detect an existing caller starting to replay it. |
+| R2-3 — Maintenance writes lack claim-gated execution paths | Partly | Reusing `RevokeCLITokenContext` covers ordinary personal revocation; rev 3’s replacement cleanup through the account pane fails for orphan rows, as detailed below. |
+| R2-4 — Freshness guard breaks historical fixtures | Closed | Removing the guard leaves those fixtures’ timestamp contract unchanged. |
+
+**Round 3 review — `6f4bb5c4`, branch `instance-bound-tokens-155`**
+
+**Changes required: one new P0 and one new P1 finding.** These concern the proposed design; the feature is not implemented at this commit. This was a static, read-only review. No files were changed, no tests were run, and `GOCACHE` was left unset. Section 2’s rulings remain fixed.
+
+**R3-1 — P0: An old incarnation’s token can exhaust its replacement tenant’s request budget**
+
+Rev 3 keys instance requests on `instance:<account id>/<app>/<host>`, omitting `InstanceBorn`: [design:167](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md:167).
+
+Consider a token whose instance disappears through a path that does not revoke credentials, followed by recreation under the same account, app and hostname. Such deletion paths exist: seed cleanup deletes the record directly at [seedfinish.go:108](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/seedfinish.go:108). The design deliberately relies on incarnation checks to make surviving tokens harmless.
+
+However, `instanceGate` charges the request budget **before** the handler reads the record: [apiinstances.go:95](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/apiinstances.go:95). For GET, that read occurs at [apiinstances.go:876](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/apiinstances.go:876). The limiter records each admitted hit without refunding a later refusal: [authcli.go:244](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/authcli.go:244).
+
+Consequently, the old token’s requests consume the replacement’s shared budget before failing the incarnation comparison. The former tenant can repeatedly exhaust that budget, causing the replacement’s valid tokens to receive 429 responses across all six doors. This crosses the incarnation boundary even though record access fails closed.
+
+**Fix:** Include `InstanceBorn` in the limiter key, preserving budget sharing among tokens for the same incarnation. Alternatively, validate incarnation before charging its budget.
+
+**Test:** Recreate A without revoking its old token, mint a replacement token, and exhaust the old token’s budget through a real endpoint. The replacement token must still succeed. The existing two-tokens-for-A test does not distinguish these incarnations.
+
+**R3-2 — P1: Orphan credentials cannot be revoked through the promised account pane with HostClaim enabled**
+
+Rev 3 removes automatic cleanup and promises that credentials left by an older console remain revocable on the account pane: [design:150](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md:150).
+
+After an old console purges the app while leaving its instance-token row, the owner can see that credential but cannot revoke it through the specified path:
+
+- The account pane invokes `serviceCredentialWrite`: [svcpage.go:520](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/svcpage.go:520).
+- That helper admits the write against the credential’s app through `withAutoAppSQL`: [claim_route_writes.go:79](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/claim_route_writes.go:79).
+- SQL admission first requires the local app identity and refuses a missing identity: [ingest.go:49](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/hostops/ingest.go:49). The console obtains it from the app row at [claim_app_admission.go:211](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/claim_app_admission.go:211).
+
+That row has been purged, so revocation fails before its SQL executes. Personal revocation offers no escape: `RevokeCLITokenContext` uses the same admission helper at [authstore.go:642](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-bound-tokens-155/internal/console/authstore.go:642).
+
+**Fix:** Specify a narrowly authorized orphan-credential cleanup or revocation path that can operate without a live app identity while retaining the SQL gate.
+
+**Test:** With `OpenGated` and `HostClaim` enabled, reproduce an old purge leaving an instance row, then revoke it through the actual account-pane handler. Assert that the row is revoked. Testing ordinary revocation and orphan listing separately will miss this failure.
+
+The direct account/app/host checks, `AllowSelf` separation, PATCH field restrictions, explicit update-value comparisons, and incarnation checks on install retries remain sound as specified.
\ No newline at end of file