spec: instance-bound tokens (#155 half 1), draft rev 1, and the Astra review brief
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+322
−0
docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md+280 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-INSTANCE-BOUND-TOKENS.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 286.222s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.911s
ok github.com/carlosframework/platform/cmd/moveprobe 92.271s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 249.196s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.024s
ok github.com/carlosframework/platform/internal/appconfig 2.497s
ok github.com/carlosframework/platform/internal/apperr 0.088s
ok github.com/carlosframework/platform/internal/applog 0.157s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.676s
? 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.009s
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.681s
ok github.com/carlosframework/platform/internal/changelog 0.303s
ok github.com/carlosframework/platform/internal/clamd 0.062s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 388.518s
? 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.586s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
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.004s
ok github.com/carlosframework/platform/internal/domains 2.402s
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.694s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.127s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/host 161.748s
ok github.com/carlosframework/platform/internal/hostactors 0.358s
ok github.com/carlosframework/platform/internal/hostbox 2.603s
ok github.com/carlosframework/platform/internal/hostclaim 0.483s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.960s
ok github.com/carlosframework/platform/internal/hostfence 0.035s
ok github.com/carlosframework/platform/internal/hostfiles 10.564s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 1.035s
ok github.com/carlosframework/platform/internal/hostops 1.587s
? 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.445s
ok github.com/carlosframework/platform/internal/hostresolve 0.156s
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.164s
ok github.com/carlosframework/platform/internal/instanceexec 2.725s
ok github.com/carlosframework/platform/internal/instancefs 0.019s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.018s
ok github.com/carlosframework/platform/internal/layout 1.530s
ok github.com/carlosframework/platform/internal/lease 9.830s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.167s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.499s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.057s
ok github.com/carlosframework/platform/internal/moves/archive 0.800s
ok github.com/carlosframework/platform/internal/objstore 0.170s
ok github.com/carlosframework/platform/internal/ops 0.180s
ok github.com/carlosframework/platform/internal/park 0.207s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 17.138s
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.943s
ok github.com/carlosframework/platform/internal/relay 7.221s
ok github.com/carlosframework/platform/internal/release 19.965s
ok github.com/carlosframework/platform/internal/rollout 3.779s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
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.580s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.518s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.595s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.978s
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.129s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
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.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.569s
=== 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: 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/16 02:46:24 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEE28A1FAAA982, 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-16-instance-bound-tokens-design.md b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md| new file mode 100644 |
| index 000000000..7f5f77f6e |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md |
| @@ -0,0 +1,280 @@ |
| +# Instance-bound tokens (#155, half 1) |
| + |
| +Status: draft rev 1, 2026-10-16. Discussion: amadan carlos/platform #155. |
| + |
| +## 1. The problem |
| + |
| +`CARLOS_INSTANCE_TOKEN` is a `cli_tokens` row of kind `app`, bound to |
| +(account, app). `CLIPrincipal` resolves it to `Role: svcrole.Instance` |
| +(`internal/console/svcprincipal.go`), which reaches instance create, GET, |
| +PATCH, DELETE, the move doors, import, sidecars, domains and the app-wide |
| +reads (logs, errors, availability, analytics, geo). The documented delivery |
| +is the app's default config bundle, so every instance of a per-customer app |
| +holds a credential that can act on every sibling. |
| + |
| +Three doors were closed to every service credential to avoid widening that |
| +hole further, each marked "until #155 step 6": the update settings doors |
| +(`GET/PUT …/instances/{host}/updates`, `POST …/updates/install`). The |
| +instance update settings spec (2026-10-07, §3.3, §5.1) needs an instance to |
| +change the settings named in its app's `instance_control` "by calling the |
| +console API with an instance-bound credential … minted by the console and |
| +checked by the console." |
| + |
| +## 2. Paul's rulings (2026-10-16) |
| + |
| +1. Delivery will use the same name, `CARLOS_INSTANCE_TOKEN`, written by |
| + the platform into the instance config layer |
| + (`apps/<acct>/<app>/instances-config/<host>.json`), opt-in per app, so |
| + it shadows the app-wide token inside each instance. |
| +2. An instance-bound token reaches **only its own host**: GET and PATCH |
| + itself, read and set its own update settings within `instance_control`, |
| + and read its own move status. Not create, delete, move mutations, |
| + import, sidecars, domains, app-wide reads or the apps list. Any other |
| + host answers the stranger's byte-identical 404. |
| +3. Delivery is refused for any route that shares a uid with a sibling. |
| +4. Build in two halves. **Half 1 (this spec):** the token kind, the |
| + own-host gate, in-app update settings writes, a person-only mint door |
| + and a CLI verb. Nothing is delivered automatically. **Half 2 (later):** |
| + per-app opt-in delivery through the instance layer, gated on the box's |
| + per-route uid report (plan 5's `RouteUIDs`, on the unmerged |
| + `worktree-instance-senders`) and on #126 giving exec instances their |
| + own uids. |
| + |
| +Half 1 does not close #155's hole by itself: an app that delivers its |
| +app-wide token through the default bundle stays exposed until half 2 lets |
| +it stop. Half 1 makes the credential and its gate real and tested, so half |
| +2 is delivery and nothing else. |
| + |
| +## 3. The credential |
| + |
| +### 3.1 Storage |
| + |
| +A new kind, `cliKindInstance = "instance"`, and one new column: |
| + |
| +```sql |
| +ALTER TABLE cli_tokens ADD COLUMN host TEXT NOT NULL DEFAULT ''; |
| +ALTER TABLE cli_tokens ADD COLUMN host_born INTEGER NOT NULL DEFAULT 0; |
| +``` |
| + |
| +A row of kind `instance` carries `account_id`, `app`, `host`, `host_born`, |
| +`address` (the minting member) and `label`. `role`, `name` and `rungs` are |
| +empty. `expires_at` is 0 (never), like the app token: revocation is the |
| +control, and the instance's life bounds it (§3.4). |
| + |
| +`host_born` is the instance record's `CreatedAt` in Unix nanoseconds at |
| +mint time. It binds the token to one incarnation of the host, not to the |
| +name (§3.4). |
| + |
| +A new kind rather than a column on kind `app` is deliberate: a console |
| +rolled back to a binary that predates this resolves an unknown kind to |
| +nothing (`CLIPrincipal`'s `default:` branch), so a rollback refuses these |
| +tokens instead of reading them as app-wide. |
| + |
| +### 3.2 Resolution |
| + |
| +`CLIPrincipal` gains a branch: |
| + |
| +```go |
| +case cliKindInstance: |
| + if accountID == 0 || app == "" || !provision.ValidHostShape(host) || born == 0 || |
| + role != "" || name != "" || rungs != "" { |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, |
| + App: app, Role: svcrole.Self, Instance: host, InstanceBorn: born} |
| +``` |
| + |
| +`Principal` gains `Instance string` and `InstanceBorn int64`. Both are |
| +empty for every other kind. |
| + |
| +### 3.3 The role |
| + |
| +`svcrole.Self` (`"self"`) is a role that **is not in the catalogue**: |
| +`All()` does not list it and `Parse("self")` is false. So no service |
| +credential can be minted with it, the service mint door and form never |
| +offer it, and a `kind='service'` row naming it fails to resolve. It is |
| +what a `kind='instance'` row resolves to and nothing else. `Allow(Self)` |
| +is accepted explicitly (Allow's unknown-role panic checks the catalogue |
| +plus Self). Admin is added to every Allow set as today, which is harmless: |
| +an Admin principal is app-wide and passes no host check (§4.1). |
| + |
| +`Self` is never `Instance`. `TestInstanceRoleMatchesTodaysAppTokenReach` |
| +keeps pinning the app token's reach unchanged. |
| + |
| +### 3.4 Lifetime |
| + |
| +A token is bound to one incarnation of its host: |
| + |
| +- **Every Self door compares `rec.CreatedAt.UnixNano()` with |
| + `p.InstanceBorn`** after reading the record and answers the canonical |
| + 404 on a mismatch. A host that is deleted and created again, by any |
| + path, gets a new `CreatedAt`, so the old tenant's token dies with the |
| + old record even if no revoke ran. This is the guarantee; the revokes |
| + below are tidiness. |
| +- `handleAPIInstanceDelete` revokes every live `kind='instance'` row for |
| + (account, app, host) **before** deleting the record. If the revoke |
| + fails, the delete answers 500 and deletes nothing. A delete that fails |
| + after the revoke leaves a live instance with no token, which a member |
| + fixes by minting again. |
| +- App trash/purge (`claim_app_identity.go`, `store.go:1736`) and the |
| + account-move and rename statements (`store.go:1928`, `:1970`, `:2074`, |
| + `:2089`) add `cliKindInstance` to their `kind IN (…)` lists, so an |
| + instance token follows the same rules as the app's other credentials. |
| +- A record with a zero `CreatedAt` cannot be minted for (§5): there is |
| + nothing to bind to. |
| + |
| +### 3.5 Every place that branches on Name |
| + |
| +`principalKey` and `auditActor` read `p.Name == ""` as "legacy app row". |
| +An instance row also has an empty name, so both gain an `Instance` branch |
| +**before** the Name check: |
| + |
| +- `principalKey`: `"instance:<account id>/<app>/<host>"`. Per host, so one |
| + customer's instance cannot spend a sibling's budget. |
| +- `auditActor`: `"instance:<sqid>/<app>/<host>"`. |
| + |
| +`serviceBinding` already accepts a principal with an app. Every other |
| +`p.Name == ""` and `p.App == ""` read in `internal/console` is audited in |
| +the plan, and each one is listed with its answer for an instance row. |
| + |
| +## 4. The gate |
| + |
| +### 4.1 cliRoute |
| + |
| +After the role gate, `cliRoute` applies the host check for any principal |
| +with `Instance != ""`: |
| + |
| +```go |
| +if p.Instance != "" && r.PathValue("host") != p.Instance { |
| + http.NotFound(w, r) |
| + return |
| +} |
| +``` |
| + |
| +It reads only the path and the caller's own binding, never the database, |
| +so like the role gate it is a constant function of door and caller and |
| +leaks nothing. The answer is `http.NotFound`, byte-identical to what the |
| +instance doors give a stranger (`canonicalNotFound` in the tests). |
| + |
| +The comparison is exact. A non-canonical spelling of the token's own host |
| +(upper case, a trailing dot) is a 404, which fails closed. |
| + |
| +Registration invariant, enforced at registration (panic) and by test: |
| +**every pattern whose Allow set permits Self contains `{host}`, and that |
| +`{host}` names an instance.** A Self door without `{host}` would pass the |
| +check with `PathValue("host") == ""` against a non-empty binding, which is |
| +a 404 for everything, so the invariant is about intent rather than safety, |
| +but a door that can never answer is a bug worth catching at start. |
| + |
| +### 4.2 The Self doors |
| + |
| +Exactly these, pinned by a new `TestSelfRoleDoors`: |
| + |
| +| Door | Allow set (before → after) | Self may | |
| +|---|---|---| |
| +| `GET …/instances/{host}` | Instance → Instance, Self | read its own record | |
| +| `PATCH …/instances/{host}` | Instance → Instance, Self | repoint its own upstreams and probe | |
| +| `GET …/instances/{host}/move` | Instance → Instance, Self | read its own move status | |
| +| `GET …/instances/{host}/updates` | closed → Self | read its own settings | |
| +| `PUT …/instances/{host}/updates` | closed → Self | set settings named in `instance_control` | |
| +| `POST …/instances/{host}/updates/install` | closed → Self | install now, if `updates` is in `instance_control`; never fast track | |
| + |
| +The update doors stay closed to `Instance`: the app-wide token still |
| +cannot set a sibling's pin, which is why they were closed. |
| + |
| +With `CARLOS_MOVES_PHASE1` on, the move mutations are operator-only and |
| +Self never reaches them; the status read stays as above. |
| + |
| +### 4.3 Handler checks |
| + |
| +Each Self door, after `instanceGate` and after reading the record: |
| + |
| +1. `p.Instance != ""` and `rec.CreatedAt.UnixNano() != p.InstanceBorn` → |
| + `http.NotFound`. |
| +2. PUT updates: every key in the request that changes a setting must be |
| + in the app policy's `instance_control`; otherwise 403 with the approved |
| + string `settings.console.app-keeps` (`"%s is kept by the app for |
| + itself. Ask someone who can change the app's settings"`), naming the |
| + first such key in request order. Keys sent with the record's current |
| + value are not changes and are not refused, so a client can PUT back |
| + what it read. |
| +3. POST install: refused with the same string naming `updates` unless |
| + `updates` is in `instance_control`. `fast_track: true` from Self is |
| + refused with the existing `settings.console.fast-track-owner`, before |
| + `s.store.Role` is consulted (that call takes an actor string and must |
| + never be asked about an instance actor). |
| +4. `channel` stays refused on the PUT for everyone |
| + (`settings.console.channel-elsewhere`); the channel door is not a Self |
| + door in half 1 (§8). |
| + |
| +Audit lines name the actor `instance:<sqid>/<app>/<host>` through |
| +`auditActor`, unchanged otherwise. |
| + |
| +## 5. Minting |
| + |
| +`POST /api/cli/apps/{account}/{app}/instances/{host}/token`, registered |
| +with the zero Set (people only, the mint door's reason: a credential never |
| +mints a credential). Gated by `apiPerson`, `apiAccountApp` (member) and |
| +`allowWrite`, the same as the app-token mint. |
| + |
| +- Reads the record through the provision store. Absent → the canonical |
| + 404. `CreatedAt` zero → 409 (a new string, copy review). |
| +- Body `{"label": "..."}`, cleaned by `cleanLabel`; default label |
| + `"<host> instance token"` (copy review). |
| +- Inserts the row, audits `instance-token` with the host, answers 201 |
| + `{"token", "account", "app", "host"}`. Shown once. |
| +- Several live tokens per host are allowed, as with app tokens, so a |
| + rotation is mint, deliver, revoke. |
| + |
| +Listing and revoking: `ServicePrincipals` adds `cliKindInstance` to its |
| +kind list so instance tokens appear on the account's credentials pane, and |
| +`RevokeAccountToken` already revokes any row by id. `scanCLITokenRows` |
| +renders an instance row's role as `self` and its scope as the host; the |
| +pane's wording for that is copy review. |
| + |
| +CLI: `carlos instances token <host> [--label <text>]` prints the token |
| +once with one line saying it is bound to that instance. Help text and the |
| +docs page section are copy review. |
| + |
| +## 6. Rollback and compatibility |
| + |
| +- Rolled-back console: kind `instance` does not resolve, so every |
| + instance token is a 401. Nothing widens. |
| +- Old CLIs never send these tokens. The app token's reach is unchanged. |
| +- The two `ALTER`s join authstore's tolerant list (a duplicate-column |
| + error is ignored), and both default so old rows read unchanged. |
| + |
| +## 7. Tests |
| + |
| +- `CLIPrincipal`: an instance row resolves to Self with host and born; a |
| + row with an empty app, a bad host, a zero born, or any role, name or |
| + rungs does not resolve; a revoked row does not. |
| +- `svcrole`: `Parse("self")` is false, `All()` omits Self, `Allow(Self)` |
| + does not panic, the service mint door and form refuse role `self`. |
| +- Containment, one table over every door registered in the console: for |
| + an instance token bound to host A, every door outside §4.2 answers its |
| + role refusal (403), every §4.2 door answers the canonical 404 for |
| + sibling B and for an unknown host, and answers for A. |
| +- Incarnation: delete A and create A again; the old token gets the |
| + canonical 404 on every §4.2 door, including when the revoke on delete |
| + is skipped (test hook). |
| +- Delete revokes; a revoke failure aborts the delete. |
| +- Purge, rename and account-move delete or carry instance rows the way |
| + they do app rows. |
| +- Update settings: Self may change exactly `instance_control`'s keys; the |
| + refusal names the key; a no-op PUT of current values passes; install |
| + needs `updates`; fast track is refused without a role lookup. |
| +- Rate limit and audit keys are per host and never collapse onto the |
| + `app:` key. |
| +- Mint door: people only (403 for every role, 401 inner wall), |
| + members only, absent host 404, zero `CreatedAt` 409, token shown once. |
| + |
| +## 8. Out of scope, and what half 2 needs |
| + |
| +- Delivery (ruling 1 and 3), and the per-app opt-in. |
| +- The channel door for Self, even with `channel` in `instance_control`. |
| + It is closed for the manifest door's reason and opening it is its own |
| + decision. |
| +- A host filter on logs, errors and analytics so an instance can read its |
| + own lines. |
| +- An in-app helper in rastrillo. Its shape follows delivery. |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-INSTANCE-BOUND-TOKENS.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-INSTANCE-BOUND-TOKENS.md| new file mode 100644 |
| index 000000000..091b9d708 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-INSTANCE-BOUND-TOKENS.md |
| @@ -0,0 +1,42 @@ |
| +# Review brief: instance-bound tokens (#155, half 1) |
| + |
| +You are reviewing `docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md` |
| +on branch `instance-bound-tokens-155` in this repository, against the code |
| +at the same commit. Read-only: do not edit any file. |
| + |
| +The spec adds a `cli_tokens` kind bound to one instance host, a role |
| +`svcrole.Self` outside the mintable catalogue, a host check in `cliRoute`, |
| +an incarnation check (`host_born` against the record's `CreatedAt`) in the |
| +handlers, six doors that admit it, and a person-only mint door. |
| + |
| +Section 2 records the owner's rulings. Do not reopen them (scope of reach, |
| +two halves, no delivery in half 1); review whether the design delivers |
| +them safely. |
| + |
| +Find, with file:line evidence from the code: |
| + |
| +1. Any way a token bound to host A can read or change anything about host |
| + B, about the app as a whole, or about another app or account. Include |
| + path normalisation, `PathValue` behaviour on patterns without `{host}`, |
| + the `Admin`-in-every-Set rule, and any handler that takes a host from |
| + the body or query rather than the path. |
| +2. Any code path that branches on `p.Name == ""`, `p.App`, `p.Role == |
| + svcrole.Instance`, or the token kind, and would misread an instance row |
| + (the spec names `principalKey` and `auditActor`; find the others). |
| +3. Any way an instance token outlives its instance, or comes back to life |
| + for a different tenant on a reused host name: deletion paths other than |
| + the console delete door, moves, seeds, imports, retirement, app rename, |
| + account move, purge, and whether `CreatedAt` really changes on every |
| + re-creation and never changes otherwise. |
| +4. Rollback and mixed-version hazards between console versions sharing one |
| + database. |
| +5. Anything in §4.3's update-settings rules that lets Self change a |
| + setting outside `instance_control`, or trips over existing behaviour in |
| + `internal/console/apiupdates.go` and `updatescore.go`. |
| +6. Tests in §7 that would pass while the property they name is false. |
| + |
| +For each finding: severity (P0 = cross-tenant or widening, P1 = wrong |
| +behaviour, P2 = gap or fragility), the scenario, the evidence, and a fix. |
| +Say plainly when an area is sound. Skip style and wording. |
| + |
| +Build cache: leave GOCACHE unset. Do not run the full test suite. |