carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 54521e81307ddd1d1bf6d36d035afe8626467749 parent ee35dee
2 files changed, +322 −0
  • docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md +280 −0
  • docs/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.