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>
3 files changed,
+89
−16
docs/superpowers/plans/2026-10-16-instance-bound-tokens.md+16 −10docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md+31 −6docs/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 |